feat(FN-3152): add Quick Chat FAB with slash-triggered menu, resizable chat
Merged branch consolidates the FN-3119 Quick Chat FAB with slash-triggered skill menu, the FN-3152 resizable chat sidebar, planning comment inputs for mission and milestone interview modals (FN-3139), and removes the legacy agent tree view (useAgentHierarchy hook and AgentsView tree styling). Also i Fusion-Task-Id: FN-3152
This commit is contained in:
@@ -8,8 +8,7 @@
|
||||
|
||||
/* Sidebar */
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -20,6 +19,36 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-sidebar-resize-handle {
|
||||
position: relative;
|
||||
width: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
touch-action: none;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-sidebar-resize-handle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: var(--space-xs);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.chat-sidebar-resize-handle:hover::before,
|
||||
.chat-sidebar-resize-handle:active::before {
|
||||
background: color-mix(in srgb, var(--todo) 30%, transparent);
|
||||
}
|
||||
|
||||
.chat-sidebar-resize-handle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.chat-sidebar-search-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -269,6 +269,10 @@ const chatMarkdownComponents: Components = {
|
||||
* of the agentId stored on the session. This ID serves as metadata only.
|
||||
*/
|
||||
const FN_AGENT_ID = "__fn_agent__";
|
||||
const CHAT_SIDEBAR_DEFAULT_WIDTH = 280;
|
||||
const CHAT_SIDEBAR_MIN_WIDTH = 180;
|
||||
const CHAT_SIDEBAR_MAX_WIDTH = 500;
|
||||
const CHAT_SIDEBAR_STORAGE_KEY = "fusion:chat-sidebar-width";
|
||||
|
||||
interface PendingAttachment {
|
||||
file: File;
|
||||
@@ -714,6 +718,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
|
||||
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
|
||||
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||
@@ -761,6 +766,20 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const mentionCursorPosRef = useRef(0);
|
||||
const mode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const rawWidth = localStorage.getItem(CHAT_SIDEBAR_STORAGE_KEY);
|
||||
if (!rawWidth) return;
|
||||
const parsedWidth = Number.parseInt(rawWidth, 10);
|
||||
if (Number.isNaN(parsedWidth)) return;
|
||||
const clampedWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, parsedWidth));
|
||||
setSidebarWidth(clampedWidth);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
enabled: isMobile && !!activeSession,
|
||||
});
|
||||
@@ -1358,6 +1377,74 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
[deleteSession, addToast],
|
||||
);
|
||||
|
||||
const persistSidebarWidth = useCallback((width: number) => {
|
||||
try {
|
||||
localStorage.setItem(CHAT_SIDEBAR_STORAGE_KEY, String(width));
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const resizeHandle = event.currentTarget;
|
||||
if (typeof resizeHandle.setPointerCapture === "function") {
|
||||
resizeHandle.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = sidebarWidth;
|
||||
let latestWidth = startWidth;
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, startWidth + deltaX));
|
||||
latestWidth = nextWidth;
|
||||
setSidebarWidth(nextWidth);
|
||||
persistSidebarWidth(nextWidth);
|
||||
};
|
||||
|
||||
const onPointerUp = (upEvent: PointerEvent) => {
|
||||
if (typeof resizeHandle.releasePointerCapture === "function") {
|
||||
resizeHandle.releasePointerCapture(upEvent.pointerId);
|
||||
}
|
||||
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
persistSidebarWidth(latestWidth);
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
}, [isMobile, persistSidebarWidth, sidebarWidth]);
|
||||
|
||||
const handleResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const step = event.shiftKey ? 50 : 10;
|
||||
const delta = event.key === "ArrowLeft" ? -step : step;
|
||||
const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, sidebarWidth + delta));
|
||||
setSidebarWidth(nextWidth);
|
||||
persistSidebarWidth(nextWidth);
|
||||
}, [isMobile, persistSidebarWidth, sidebarWidth]);
|
||||
|
||||
// Handle session click
|
||||
const handleSessionClick = useCallback(
|
||||
(id: string) => {
|
||||
@@ -1440,7 +1527,10 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
return (
|
||||
<div className="chat-view">
|
||||
{/* Sidebar */}
|
||||
<div className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}>
|
||||
<div
|
||||
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
|
||||
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
|
||||
>
|
||||
{/* Search section */}
|
||||
<div className="chat-sidebar-search">
|
||||
<div className="chat-sidebar-search-wrapper">
|
||||
@@ -1518,6 +1608,21 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMobile && sidebarVisible && (
|
||||
<div
|
||||
className="chat-sidebar-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={CHAT_SIDEBAR_MIN_WIDTH}
|
||||
aria-valuemax={CHAT_SIDEBAR_MAX_WIDTH}
|
||||
aria-valuenow={sidebarWidth}
|
||||
aria-label="Resize chat sidebar"
|
||||
tabIndex={0}
|
||||
onPointerDown={handleResizeStart}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
|
||||
@@ -2249,6 +2249,105 @@ describe("ChatView sidebar structure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizable sidebar", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders desktop resize handle with separator ARIA attributes", () => {
|
||||
const viewportSpy = mockViewportMode("desktop");
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
|
||||
expect(handle).toHaveAttribute("aria-orientation", "vertical");
|
||||
expect(handle).toHaveAttribute("aria-valuemin", "180");
|
||||
expect(handle).toHaveAttribute("aria-valuemax", "500");
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "280");
|
||||
expect(handle).toHaveAttribute("tabindex", "0");
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("updates sidebar width while dragging", () => {
|
||||
const viewportSpy = mockViewportMode("desktop");
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
|
||||
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
|
||||
|
||||
const sidebar = document.querySelector(".chat-sidebar") as HTMLElement;
|
||||
expect(sidebar.style.width).toBe("360px");
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "360");
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clamps width between min and max", () => {
|
||||
const viewportSpy = mockViewportMode("desktop");
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
|
||||
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
|
||||
fireEvent.pointerMove(document, { pointerId: 1, clientX: -1000 });
|
||||
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("180px");
|
||||
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
|
||||
fireEvent.pointerMove(document, { pointerId: 1, clientX: 2000 });
|
||||
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("500px");
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("persists width to localStorage on pointer up", () => {
|
||||
const viewportSpy = mockViewportMode("desktop");
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
|
||||
act(() => {
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
|
||||
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
|
||||
fireEvent.pointerUp(document, { pointerId: 1, clientX: 360 });
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("fusion:chat-sidebar-width")).toBe("360");
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("restores persisted width on mount", () => {
|
||||
const viewportSpy = mockViewportMode("desktop");
|
||||
localStorage.setItem("fusion:chat-sidebar-width", "350");
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("350px");
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not render resize handle on mobile", () => {
|
||||
const viewportSpy = mockViewportMode("mobile");
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView mobile behavior", () => {
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
let savedInnerHeight: number;
|
||||
|
||||
Reference in New Issue
Block a user