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:
Fusion
2026-05-02 00:45:53 -07:00
committed by gsxdsm
parent 8dadbd6dc2
commit 9ad83d021d
3 changed files with 236 additions and 3 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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;