diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx index e632b45..5f8689f 100644 --- a/src/client/App.test.tsx +++ b/src/client/App.test.tsx @@ -54,6 +54,11 @@ beforeEach(() => { vi.mocked(api.getTask).mockResolvedValue({ task: task({ title: "Detailed task" }) }); vi.mocked(api.settings).mockResolvedValue(settings()); vi.mocked(api.providerStatus).mockResolvedValue({ providers: [providerStatus()] }); + vi.mocked(api.assistantChatHistory).mockResolvedValue({ + conversations: [], + activeConversationId: null, + messages: [], + }); vi.mocked(api.openAiAuth).mockResolvedValue({ configured: false, login: { phase: "idle" } }); vi.mocked(api.openAiAccountInfo).mockResolvedValue({ configured: false, @@ -143,6 +148,35 @@ describe("App settings view", () => { expect(document.body.querySelector('[aria-label="Search tasks"]')).toBeNull(); expect(document.body.querySelector('button[aria-label="New task"]')).toBeNull(); }); + + it("lets the sidebar Chat toggle leave settings cleanly", async () => { + await renderApp(); + + await waitFor(() => + expect( + document.body.querySelector('[role="button"][aria-label="Open task Route task"]'), + ).toBeTruthy(), + ); + await clickButtonByText("Settings"); + + await waitFor(() => { + expect(document.body.textContent).toContain("OpenAI connection"); + }); + + await clickSelector('button[aria-label="Show chat"]'); + + await waitFor(() => { + expect(document.body.textContent).toContain( + "Talk with the agent. Each conversation keeps its own saved history.", + ); + expect(document.body.textContent).toContain("Ask about priorities"); + expect(document.body.textContent).not.toContain("OpenAI connection"); + }); + expect( + Array.from(document.body.querySelectorAll('button[aria-current="page"]')) + .some((button) => button.textContent?.includes("Settings")), + ).toBe(false); + }); }); async function renderApp() { diff --git a/src/client/App.tsx b/src/client/App.tsx index 01652be..8fd1385 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -503,9 +503,7 @@ export function App() { function changeWorkspaceMode(nextMode: WorkspaceMode) { setWorkspaceMode(nextMode); - if (nextMode === "board") { - setView("board"); - } + setView("board"); } function clearBoardFilters() { diff --git a/src/client/components/Sidebar.test.tsx b/src/client/components/Sidebar.test.tsx index 6c79ab2..606ce0a 100644 --- a/src/client/components/Sidebar.test.tsx +++ b/src/client/components/Sidebar.test.tsx @@ -83,11 +83,29 @@ describe("Sidebar", () => { expect(container.textContent).not.toContain("Focus areas"); expect(container.textContent).not.toContain("Client Ops"); expect(container.querySelector('button[aria-label="New chat"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="scroll-area"]')).not.toBeNull(); + const localStatus = container.querySelector('[data-sidebar="local-status"]'); + expect(localStatus).not.toBeNull(); + expect(localStatus?.tagName).toBe("DIV"); + expect(localStatus?.getAttribute("tabindex")).toBeNull(); + expect( + Array.from(container.querySelectorAll("button")).some((button) => + button.textContent?.includes("This Mac"), + ), + ).toBe(false); expect( Array.from(container.querySelectorAll('button[data-sidebar="menu-button"]')) .some((button) => button.textContent?.trim() === "Board"), ).toBe(false); + await act(async () => { + Array.from(container.querySelectorAll('button[data-sidebar="menu-button"]')) + .find((button) => button.textContent?.includes("Settings")) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onViewChange).toHaveBeenCalledWith("settings"); + await act(async () => { container .querySelector('button[aria-label="Show board"]') @@ -148,6 +166,10 @@ describe("Sidebar", () => { ); expect(boardToggle?.getAttribute("data-state")).toBe("off"); + expect( + Array.from(container.querySelectorAll('button[aria-current="page"]')) + .find((button) => button.textContent?.includes("Settings")), + ).not.toBeUndefined(); await act(async () => { boardToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -159,6 +181,7 @@ describe("Sidebar", () => { it("turns focus areas into clickable board filters with counts", async () => { const onFocusAreaChange = vi.fn(); + const onViewChange = vi.fn(); await act(async () => { root.render( @@ -174,7 +197,7 @@ describe("Sidebar", () => { chatConversations={chatConversations()} activeConversationId="conversation-1" chatHistoryLoading={false} - onViewChange={vi.fn()} + onViewChange={onViewChange} onWorkspaceModeChange={vi.fn()} onFocusAreaChange={onFocusAreaChange} onChatConversationSelect={vi.fn()} @@ -188,6 +211,28 @@ describe("Sidebar", () => { expect(container.textContent).toContain("Focus areas"); expect(container.textContent).toContain("All work"); expect(container.textContent).toContain("Client Ops"); + expect(container.textContent).toContain("Ideas"); + expect(container.querySelector('[data-slot="scroll-area"]')).not.toBeNull(); + expect( + container.querySelector('button[aria-label="All work, 7 tasks"]') + ?.getAttribute("aria-pressed"), + ).toBe("false"); + expect( + container.querySelector('button[aria-label="Client Ops, 2 tasks"]') + ?.getAttribute("aria-pressed"), + ).toBe("true"); + expect( + container.querySelector('button[aria-label="Ideas, 0 tasks"]') + ?.getAttribute("aria-pressed"), + ).toBe("false"); + + await act(async () => { + container + .querySelector('button[aria-label="Edit focus areas"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onViewChange).toHaveBeenCalledWith("settings"); await act(async () => { Array.from(container.querySelectorAll('button[data-sidebar="menu-button"]')) @@ -244,6 +289,8 @@ describe("Sidebar", () => { ).find((button) => button.getAttribute("data-active") === "true"); expect(activeButton?.textContent).toContain("Launch note"); + expect(activeButton?.getAttribute("aria-current")).toBe("page"); + expect(container.querySelectorAll('[data-sidebar="menu-badge"]').length).toBe(1); await act(async () => { activeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -251,6 +298,50 @@ describe("Sidebar", () => { expect(onChatConversationSelect).toHaveBeenCalledWith("conversation-2"); }); + + it("keeps long conversation lists scrollable while search stays above the list", async () => { + await act(async () => { + root.render( + + + + + , + ); + }); + + const search = container.querySelector( + 'input[aria-label="Search conversations"]', + ); + const scrollArea = container.querySelector('[data-slot="scroll-area"]'); + const activeButton = container.querySelector( + 'button[aria-current="page"]', + ); + + expect(search).not.toBeNull(); + expect(scrollArea).not.toBeNull(); + expect(search?.compareDocumentPosition(scrollArea!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(container.textContent).toContain("Conversation 20"); + expect(activeButton?.textContent).toContain("Conversation 20"); + expect(container.querySelectorAll('[data-sidebar="menu-badge"]').length).toBe(1); + }); }); function counts(): Record { @@ -264,11 +355,14 @@ function counts(): Record { } function focusAreas(): FocusArea[] { - return [{ id: "client-ops", label: "Client Ops", color: "emerald" }]; + return [ + { id: "client-ops", label: "Client Ops", color: "emerald" }, + { id: "ideas", label: "Ideas", color: "violet" }, + ]; } function focusAreaCounts(): Record { - return { "client-ops": 2 }; + return { "client-ops": 2, ideas: 0 }; } function chatConversations(): AssistantChatConversation[] { @@ -318,6 +412,18 @@ function manyChatConversations(): AssistantChatConversation[] { ]; } +function twentyChatConversations(): AssistantChatConversation[] { + return Array.from({ length: 20 }, (_, index) => { + const value = index + 1; + return { + id: `conversation-${value}`, + title: `Conversation ${value}`, + createdAt: `2026-05-03T10:${String(index).padStart(2, "0")}:00.000Z`, + updatedAt: `2026-05-03T10:${String(index).padStart(2, "0")}:30.000Z`, + }; + }); +} + function setInputValue(input: HTMLInputElement, value: string) { const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; setter?.call(input, value); diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx index c9859cb..10e21da 100644 --- a/src/client/components/Sidebar.tsx +++ b/src/client/components/Sidebar.tsx @@ -13,6 +13,7 @@ import type { TaskStatus, } from "../../shared/types"; import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Sidebar as ShadcnSidebar, SidebarContent, @@ -33,6 +34,7 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/lib/utils"; import { getFocusAreaStyle } from "../focus-areas"; type View = "board" | "settings"; @@ -57,7 +59,7 @@ export function Sidebar(props: { }) { const readyNowCount = props.counts.ready + props.counts.in_progress; const attentionCount = props.counts.needs_attention; - const { isMobile, setOpenMobile } = useSidebar(); + const { isMobile, setOpenMobile, state } = useSidebar(); function selectView(view: View) { props.onViewChange(view); @@ -100,7 +102,18 @@ export function Sidebar(props: { } const activeWorkspaceMode = - props.workspaceMode === "chat" ? "chat" : props.activeView === "board" ? "board" : ""; + props.activeView === "settings" ? "" : props.workspaceMode === "chat" ? "chat" : "board"; + const settingsIsActive = props.activeView === "settings"; + const collapsedIconMode = !isMobile && state === "collapsed"; + const collapsedToggleItemStyle = collapsedIconMode + ? { + width: "32px", + height: "32px", + minWidth: "32px", + padding: 0, + borderRadius: "var(--radius-md)", + } + : undefined; const totalTaskCount = Object.values(props.counts).reduce((total, count) => total + count, 0); return ( @@ -139,14 +152,17 @@ export function Sidebar(props: { }} variant="outline" size="sm" - spacing={0} - className="w-full group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:flex-col" + spacing={collapsedIconMode ? 1 : 0} + orientation={collapsedIconMode ? "vertical" : "horizontal"} + className={cn("w-full", collapsedIconMode && "w-8 rounded-none")} aria-label="Task mode" > Board @@ -154,7 +170,9 @@ export function Sidebar(props: { Chat @@ -178,6 +196,7 @@ export function Sidebar(props: { totalTaskCount={totalTaskCount} activeFocusAreaId={props.activeFocusAreaId} onFocusAreaChange={selectFocusArea} + onManageFocusAreas={() => selectView("settings")} /> )} @@ -187,8 +206,9 @@ export function Sidebar(props: { selectView("settings")} > @@ -196,9 +216,14 @@ export function Sidebar(props: { - +
- + This Mac @@ -206,7 +231,7 @@ export function Sidebar(props: { - +
@@ -221,46 +246,62 @@ function FocusAreasGroup(props: { totalTaskCount: number; activeFocusAreaId: string | null; onFocusAreaChange: (focusAreaId: string | null) => void; + onManageFocusAreas: () => void; }) { + const allWorkIsActive = props.activeFocusAreaId === null; + return ( - + Focus areas - - - - props.onFocusAreaChange(null)} - > - - All work - - {props.totalTaskCount} - - - - {props.focusAreas.map((area) => { - const style = getFocusAreaStyle(area.color); - return ( - - props.onFocusAreaChange(area.id)} - > - - {area.label} - - {props.focusAreaCounts[area.id] ?? 0} - - - - ); - })} - + + + Edit focus areas + + + + + + props.onFocusAreaChange(null)} + > + + All work + + + + {props.focusAreas.map((area) => { + const style = getFocusAreaStyle(area.color); + const count = props.focusAreaCounts[area.id] ?? 0; + const isActive = props.activeFocusAreaId === area.id; + return ( + + props.onFocusAreaChange(area.id)} + > + + {area.label} + + + + ); + })} + + ); @@ -286,7 +327,7 @@ function ChatHistoryGroup(props: { }, [normalizedQuery, props.conversations, showSearch]); return ( - + Conversations New chat - + {showSearch && ( <> )} - - {props.loading ? ( - Array.from({ length: 4 }, (_, index) => ( - - - - )) - ) : props.conversations.length === 0 ? ( - - - - - No conversations - - Start a new chat. + + + {props.loading ? ( + Array.from({ length: 4 }, (_, index) => ( + + + + )) + ) : props.conversations.length === 0 ? ( + + + + + No conversations + + Start a new chat. + - - - - ) : visibleConversations.length === 0 ? ( - - - - - No matches - - Try another search. + + + ) : visibleConversations.length === 0 ? ( + + + + + No matches + + Try another search. + - - - - ) : ( - visibleConversations.map((conversation) => { - const isActive = conversation.id === props.activeConversationId; - return ( - - props.onConversationSelect(conversation.id)} - > - - - {conversation.title} - - {formatConversationTime(conversation.updatedAt)} + + + ) : ( + visibleConversations.map((conversation) => { + const isActive = conversation.id === props.activeConversationId; + return ( + + props.onConversationSelect(conversation.id)} + > + + + {conversation.title} + + {formatConversationTime(conversation.updatedAt)} + - - - - {isActive ? "Open" : formatConversationBadge(conversation.updatedAt)} - - - ); - }) - )} - + + {isActive && Open} + + ); + }) + )} + + ); } +function FocusAreaCountBadge(props: { count: number }) { + return ( + + {props.count} + + ); +} + +function formatFocusAreaAriaLabel(label: string, count: number): string { + return `${label}, ${count} ${count === 1 ? "task" : "tasks"}`; +} + function formatConversationTime(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { @@ -396,14 +456,3 @@ function formatConversationTime(value: string): string { minute: "2-digit", }).format(date); } - -function formatConversationBadge(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return "Saved"; - } - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - }).format(date); -}