From aaa7366cbb4679d8ca58331a77d2e91c7052e2ed Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 4 Sep 2026 17:43:20 +0000 Subject: [PATCH] Generated with Hive: Add Archive section to control panel with archive and restore actions --- .../org/components/ControlPanelList.test.tsx | 222 ++++++++++ .../unit/canvas/CanvasHistoryPopover.test.tsx | 21 + .../unit/services/control-panel-state.test.ts | 122 ++++++ .../control-panel/ControlPanelList.tsx | 395 ++++++++++++------ .../control-panel/useControlPanel.ts | 86 ++-- .../control-panel/useControlPanelItems.ts | 66 ++- src/services/orgs/control-panel-state.ts | 153 +++++++ 7 files changed, 910 insertions(+), 155 deletions(-) create mode 100644 src/__tests__/unit/app/org/components/ControlPanelList.test.tsx diff --git a/src/__tests__/unit/app/org/components/ControlPanelList.test.tsx b/src/__tests__/unit/app/org/components/ControlPanelList.test.tsx new file mode 100644 index 0000000000..f530f80c9f --- /dev/null +++ b/src/__tests__/unit/app/org/components/ControlPanelList.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment jsdom +/** + * RTL tests for the control-panel left bar: empty-state copy when Archive + * is populated vs empty, archive/restore actions that stop row click-through, + * and keyboard-reachable Archive rows only while the section is expanded. + */ +import React from "react"; +import { describe, expect, test, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ControlPanelList } from "@/app/org/[githubLogin]/_components/control-panel/ControlPanelList"; +import type { ControlPanelListProps } from "@/app/org/[githubLogin]/_components/control-panel/ControlPanelList"; +import type { ControlPanelItem } from "@/types/control-panel"; +import type { ControlPanelGroup, ControlPanelRow } from "@/services/orgs/control-panel-state"; + +// jsdom does not implement scrollIntoView; ControlPanelList scrolls the cursor row into view. +window.HTMLElement.prototype.scrollIntoView = vi.fn(); + +vi.mock("framer-motion", () => ({ + motion: { + div: ({ + children, + layout: _layout, + layoutDependency: _layoutDependency, + layoutScroll: _layoutScroll, + transition: _transition, + ...rest + }: { + children?: React.ReactNode; + layout?: unknown; + layoutDependency?: unknown; + layoutScroll?: unknown; + transition?: unknown; + }) => React.createElement("div", rest, children), + }, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => + asChild ? <>{children} : {children}, +})); + +// kbd.tsx uses `React.ComponentProps` / JSX without importing React, which +// blows up under vitest's transform. The footer shortcuts are not under test. +vi.mock("@/components/ui/kbd", () => ({ + Kbd: ({ children }: { children?: React.ReactNode }) => {children}, + KbdGroup: ({ children }: { children?: React.ReactNode }) => {children}, +})); + +function makeItem(overrides: Partial): ControlPanelItem { + const id = overrides.id ?? "x"; + const kind = overrides.kind ?? "chat"; + return { + key: `${kind}:${id}`, + kind, + id, + title: overrides.title ?? "Chat", + workspaceSlug: null, + workspaceId: null, + workspaceName: null, + lastActivityAt: "2026-09-04T10:00:00.000Z", + sinceYou: "", + state: "none", + unread: false, + ...overrides, + }; +} + +function emptyProps(overrides: Partial = {}): ControlPanelListProps { + return { + groups: [], + totalCount: 0, + loading: false, + query: "", + onQueryChange: vi.fn(), + expandedKeys: new Set(), + onToggleExpanded: vi.fn(), + cursorKey: null, + focusedKey: null, + onOpen: vi.fn(), + remaining: 0, + onShowMore: vi.fn(), + archivedRows: [], + archivedExpanded: false, + onToggleArchived: vi.fn(), + onArchive: vi.fn(), + onRestore: vi.fn(), + ...overrides, + }; +} + +function chatRow(item: ControlPanelItem, extra: Partial = {}): ControlPanelRow { + return { item, depth: 0, childCount: 0, latestAt: item.lastActivityAt, ...extra }; +} + +describe("ControlPanelList empty states", () => { + test("both empty shows the whole-column empty copy", () => { + render(); + expect(screen.getByText("Nothing yet. Start a Jamie chat and it lands here.")).toBeInTheDocument(); + expect(screen.queryByTestId("control-panel-archive")).not.toBeInTheDocument(); + }); + + test("active empty + Archive populated does not show Nothing yet, and still renders Archive", () => { + const archived = makeItem({ id: "archived-1", title: "Finished kickoff", archivedAt: "2026-09-03T00:00:00.000Z" }); + render( + , + ); + expect(screen.queryByText("Nothing yet. Start a Jamie chat and it lands here.")).not.toBeInTheDocument(); + expect(screen.getByTestId("control-panel-archive")).toBeInTheDocument(); + expect(screen.getByText("Finished kickoff")).toBeInTheDocument(); + }); + + test("empty Archive section shows short empty copy when expanded", () => { + const active = makeItem({ id: "c1", title: "Live chat" }); + const groups: ControlPanelGroup[] = [ + { key: "2026-09-04", label: "Today", rows: [chatRow(active)] }, + ]; + render( + , + ); + expect(screen.getByText("No archived chats")).toBeInTheDocument(); + }); +}); + +describe("ControlPanelList archive actions", () => { + test("Archive action stops propagation and does not call onOpen", () => { + const active = makeItem({ id: "c1", title: "Live chat" }); + const onOpen = vi.fn(); + const onArchive = vi.fn(); + const groups: ControlPanelGroup[] = [ + { key: "2026-09-04", label: "Today", rows: [chatRow(active)] }, + ]; + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Archive" })); + expect(onArchive).toHaveBeenCalledTimes(1); + expect(onArchive).toHaveBeenCalledWith(expect.objectContaining({ id: "c1" })); + expect(onOpen).not.toHaveBeenCalled(); + }); + + test("Restore action stops propagation and does not call onOpen", () => { + const archived = makeItem({ id: "a1", title: "Old chat", archivedAt: "2026-09-03T00:00:00.000Z" }); + const onOpen = vi.fn(); + const onRestore = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Restore" })); + expect(onRestore).toHaveBeenCalledTimes(1); + expect(onRestore).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" })); + expect(onOpen).not.toHaveBeenCalled(); + }); +}); + +describe("ControlPanelList archive keyboard rows", () => { + test("collapsed Archive does not render archived chat rows", () => { + const archived = makeItem({ id: "a1", title: "Old chat", archivedAt: "2026-09-03T00:00:00.000Z" }); + const active = makeItem({ id: "c1", title: "Live chat" }); + render( + , + ); + expect(screen.getByText("Live chat")).toBeInTheDocument(); + expect(screen.queryByText("Old chat")).not.toBeInTheDocument(); + expect(document.querySelector('[data-panel-key="chat:a1"]')).toBeNull(); + }); + + test("expanded Archive renders archived rows that Enter can open via onOpen", () => { + const archived = makeItem({ id: "a1", title: "Old chat", archivedAt: "2026-09-03T00:00:00.000Z" }); + const onOpen = vi.fn(); + render( + , + ); + const row = document.querySelector('[data-panel-key="chat:a1"]') as HTMLElement; + expect(row).not.toBeNull(); + fireEvent.keyDown(row, { key: "Enter" }); + expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" })); + }); +}); diff --git a/src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx b/src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx index b550b90ca1..d29b638c16 100644 --- a/src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx +++ b/src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx @@ -225,6 +225,27 @@ describe("CanvasHistoryPopover", () => { }); }); + it("only ever renders the conversations the history endpoint returns (active-only)", async () => { + // The GET /chat/conversations list filters archivedAt: null server-side. + // The popover must not invent archived rows or request ?archived=1. + global.fetch = buildFetch(mockItems, {}); + + render(); + fireEvent.click(screen.getByTestId("popover-trigger")); + + await waitFor(() => { + expect(screen.getByText("Planning session")).toBeInTheDocument(); + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/orgs/my-org/chat/conversations?limit=10"), + ); + const urls = (global.fetch as ReturnType).mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.includes("archived="))).toBe(false); + expect(screen.queryByText("Archive")).not.toBeInTheDocument(); + expect(screen.getByText("What are the key milestones?")).toBeInTheDocument(); + }); + it("calls startConversation with ephemeralSeedCount=messages.length and the server id on item click", async () => { global.fetch = buildFetch(mockItems, { "conv-a": mockConversationDetail }); diff --git a/src/__tests__/unit/services/control-panel-state.test.ts b/src/__tests__/unit/services/control-panel-state.test.ts index e1b51ea64b..b279923e60 100644 --- a/src/__tests__/unit/services/control-panel-state.test.ts +++ b/src/__tests__/unit/services/control-panel-state.test.ts @@ -16,12 +16,17 @@ import { describe, expect, test } from "vitest"; import type { ControlPanelItem } from "@/types/control-panel"; import { activeChatItem, + buildArchivedRows, buildControlPanelGroups, derivePlanState, matchesControlPanelQuery, + moveChatToActive, + moveChatToArchive, overlayActiveChat, previewLine, + resolveControlPanelLists, sortControlPanelItems, + visibleControlPanelItems, } from "@/services/orgs/control-panel-state"; const noMessage = null; @@ -350,3 +355,120 @@ describe("buildControlPanelGroups", () => { expect(groups[0].label).toBe("December 20, 2025"); }); }); + +describe("archive move and on-stage gating", () => { + const startedAt = "2026-09-04T09:00:00.000Z"; + const archivedAt = "2026-09-04T12:00:00.000Z"; + const chat = makeItem({ kind: "chat", id: "c1", title: "Kickoff", lastActivityAt: startedAt }); + const plan = makeItem({ + kind: "plan", + id: "p1", + title: "Spawned", + parentChatId: "c1", + lastActivityAt: "2026-09-04T10:00:00.000Z", + }); + const other = makeItem({ kind: "chat", id: "c2", title: "Other", lastActivityAt: startedAt }); + const snapshot = { + localId: "local-1", + serverId: "c1", + lastMessageAt: "2026-09-04T11:00:00.000Z", + lastReply: "Done.", + hasMessages: true, + isStreaming: false, + }; + + test("moveChatToArchive takes nested plans with the chat and inserts them at the top of Archive", () => { + const moved = moveChatToArchive([chat, plan, other], [], "c1", archivedAt); + expect(moved.items.map((i) => i.id)).toEqual(["c2"]); + expect(moved.archivedItems.map((i) => i.id)).toEqual(["c1", "p1"]); + expect(moved.archivedItems[0].archivedAt).toBe(archivedAt); + expect(moved.items.some((i) => i.id === "p1")).toBe(false); + }); + + test("moveChatToActive restores the chat and its plans to the top of Active", () => { + const archived = moveChatToArchive([chat, plan, other], [], "c1", archivedAt); + const restored = moveChatToActive(archived.items, archived.archivedItems, "c1"); + expect(restored.items.map((i) => i.id)).toEqual(["c1", "p1", "c2"]); + expect(restored.archivedItems).toEqual([]); + expect(restored.items[0].archivedAt).toBeNull(); + }); + + test("an archived on-stage chat is not injected into active displayItems; nested plans stay in Archive", () => { + const archived = moveChatToArchive([chat, plan, other], [], "c1", archivedAt); + const resolved = resolveControlPanelLists(archived.items, archived.archivedItems, snapshot, { + chatOnStage: true, + startedAt, + titleForNew: "Kickoff", + }); + expect(resolved.displayItems.map((i) => i.id)).toEqual(["c2"]); + expect(resolved.displayArchivedItems.map((i) => i.id)).toEqual(["c1", "p1"]); + expect(resolved.displayArchivedItems[0]).toMatchObject({ + id: "c1", + unread: false, + lastActivityAt: snapshot.lastMessageAt, + }); + const groups = buildControlPanelGroups(resolved.displayItems); + expect(groups.flatMap((g) => g.rows).map((r) => r.item.id)).toEqual(["c2"]); + }); + + test("a brand-new on-stage chat not in either list is prepended into Active", () => { + const fresh = { + localId: "local-new", + serverId: null, + lastMessageAt: null, + lastReply: null, + hasMessages: false, + isStreaming: false, + }; + const resolved = resolveControlPanelLists([other], [], fresh, { + chatOnStage: true, + startedAt, + titleForNew: "New chat", + }); + expect(resolved.displayItems[0]).toMatchObject({ key: "chat:local-new", title: "New chat" }); + expect(resolved.displayItems.map((i) => i.id)).toEqual(["local-new", "c2"]); + expect(resolved.displayArchivedItems).toEqual([]); + }); + + test("buildArchivedRows is a flat archivedAt-desc list with plans nested under their parent, not day-grouped", () => { + const older = makeItem({ + kind: "chat", + id: "c-old", + title: "Older", + archivedAt: "2026-09-01T00:00:00.000Z", + lastActivityAt: "2026-09-04T18:00:00.000Z", + }); + const newer = makeItem({ + kind: "chat", + id: "c-new", + title: "Newer", + archivedAt: "2026-09-03T00:00:00.000Z", + lastActivityAt: "2026-09-02T00:00:00.000Z", + }); + const nested = makeItem({ + kind: "plan", + id: "p-old", + parentChatId: "c-old", + lastActivityAt: "2026-09-04T19:00:00.000Z", + }); + const rows = buildArchivedRows([older, nested, newer]); + expect(rows.map((r) => [r.item.id, r.depth])).toEqual([ + ["c-new", 0], + ["c-old", 0], + ["p-old", 1], + ]); + expect(rows[1].childCount).toBe(1); + expect(rows[2].parentKey).toBe("chat:c-old"); + }); + + test("visibleControlPanelItems appends Archive rows only when the section is expanded", () => { + const groups = buildControlPanelGroups([other]); + const rows = buildArchivedRows([{ ...chat, archivedAt }, plan]); + const collapsed = visibleControlPanelItems(groups, rows, new Set(), false); + expect(collapsed.map((i) => i.id)).toEqual(["c2"]); + const expanded = visibleControlPanelItems(groups, rows, new Set(), true); + expect(expanded.map((i) => i.id)).toEqual(["c2", "c1"]); + const withPlans = visibleControlPanelItems(groups, rows, new Set(["chat:c1"]), true); + expect(withPlans.map((i) => i.id)).toEqual(["c2", "c1", "p1"]); + }); +}); diff --git a/src/app/org/[githubLogin]/_components/control-panel/ControlPanelList.tsx b/src/app/org/[githubLogin]/_components/control-panel/ControlPanelList.tsx index 3e46c9e11a..be8f1c3a0e 100644 --- a/src/app/org/[githubLogin]/_components/control-panel/ControlPanelList.tsx +++ b/src/app/org/[githubLogin]/_components/control-panel/ControlPanelList.tsx @@ -2,14 +2,24 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { motion } from "framer-motion"; -import { ChevronDown, ChevronRight, FileText, Loader2, MessageCircle, Search, X } from "lucide-react"; +import { + Archive, + ArchiveRestore, + ChevronDown, + ChevronRight, + FileText, + Loader2, + MessageCircle, + Search, + X, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Kbd } from "@/components/ui/kbd"; import { jamieName } from "@/lib/constants/jamie"; import { formatAge } from "@/lib/date-utils"; import { cn } from "@/lib/utils"; import type { ControlPanelItem, ControlPanelItemKind, ControlPanelItemState } from "@/types/control-panel"; -import { NEEDS_YOU_STATES, type ControlPanelGroup } from "@/services/orgs/control-panel-state"; +import { NEEDS_YOU_STATES, type ControlPanelGroup, type ControlPanelRow } from "@/services/orgs/control-panel-state"; import { ActionTip } from "../ActionTip"; import { CONTROL_PANEL_PAGE } from "./useControlPanelItems"; @@ -113,6 +123,157 @@ export interface ControlPanelListProps { /** Chats the user has beyond the ones listed. */ remaining: number; onShowMore: () => void; + /** Flat archived chats (plans nested under their parent). Outside day grouping. */ + archivedRows: ControlPanelRow[]; + archivedExpanded: boolean; + onToggleArchived: () => void; + onArchive: (item: ControlPanelItem) => void; + onRestore: (item: ControlPanelItem) => void; +} + +function PanelRow({ + item, + depth, + parentKey, + childCount, + latestAt, + lastChild, + expandedKeys, + onToggleExpanded, + cursorKey, + focusedKey, + onOpen, + rowOrder, + action, +}: { + item: ControlPanelItem; + depth: number; + parentKey?: string; + childCount?: number; + latestAt: string; + lastChild: boolean; + expandedKeys: ReadonlySet; + onToggleExpanded: (key: string) => void; + cursorKey: string | null; + focusedKey: string | null; + onOpen: (item: ControlPanelItem) => void; + rowOrder: string; + action?: { label: string; Icon: typeof Archive; onClick: (item: ControlPanelItem) => void }; +}) { + if (parentKey && !expandedKeys.has(parentKey)) return null; + const { Icon, className: kindClass } = KIND[item.kind]; + const ActionIcon = action?.Icon; + const focused = item.key === focusedKey; + const cursor = item.key === cursorKey; + const collapsible = (childCount ?? 0) > 0; + const collapsed = collapsible && !expandedKeys.has(item.key); + const meta = [item.workspaceName, item.sinceYou].filter(Boolean).join(" · "); + return ( + onOpen(item)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(item); + } + }} + aria-current={focused ? "true" : undefined} + style={{ paddingLeft: depth > 0 ? PLAN_ROW_PAD : CHAT_ROW_PAD }} + className={cn( + "group relative flex w-full cursor-pointer items-start gap-2 border-b py-2.5 pr-3 text-left outline-none transition-colors", + focused ? "bg-muted" : "hover:bg-muted/60", + cursor && "ring-1 ring-inset ring-ring", + )} + > + {/* The tree: a stem from an open chat's icon, a guide down its plans, a tick into each. */} + {collapsible && !collapsed && ( + + )} + {depth > 0 && ( + <> + + + + )} + {collapsible ? ( + + ) : ( + depth === 0 && + )} + {/* Every chat row reserves the same slot for the count, so titles line up. */} + {depth === 0 && ( + + {collapsed ? childCount : ""} + + )} + 0 ? "h-3.5 w-3.5" : "h-4 w-4", kindClass)} /> +
+
0 ? "text-[13px]" : "text-sm", + (item.unread || focused) && "font-semibold", + )} + > + {item.title} +
+
{meta}
+
+
+ {item.kind === "chat" && action && ActionIcon && ( + + + + )} +
+ + {formatAge(Date.now() - Date.parse(latestAt))} + + +
+
+
+ ); } /** @@ -120,7 +281,8 @@ export interface ControlPanelListProps { * from a chat nested under it (collapsed until opened), all grouped by the day of * their newest activity. One row per thread with a "since you" line, a * time column and a state dot; a "Show N more" at the end when the org - * has more chats than are listed. New chat lives in the chat's own + * has more chats than are listed. Archived chats live in a flat section + * at the bottom, outside day grouping. New chat lives in the chat's own * actions in the bar across the divider (`n` here does the same). */ export function ControlPanelList({ @@ -136,6 +298,11 @@ export function ControlPanelList({ onOpen, remaining, onShowMore, + archivedRows, + archivedExpanded, + onToggleArchived, + onArchive, + onRestore, }: ControlPanelListProps) { const [searchOpen, setSearchOpen] = useState(false); const searchInputRef = useRef(null); @@ -144,8 +311,20 @@ export function ControlPanelList({ () => groups.flatMap((g) => g.rows).find((r) => !(r.parentKey && !expandedKeys.has(r.parentKey))), [groups, expandedKeys], ); + const archivedChatCount = useMemo( + () => archivedRows.filter((r) => r.item.kind === "chat").length, + [archivedRows], + ); + const bothEmpty = totalCount === 0 && archivedChatCount === 0; // Rows only move when their order does; framer measures them only then. - const rowOrder = useMemo(() => groups.flatMap((g) => g.rows.map((r) => r.item.key)).join("|"), [groups]); + const rowOrder = useMemo( + () => + [ + ...groups.flatMap((g) => g.rows.map((r) => r.item.key)), + ...archivedRows.map((r) => r.item.key), + ].join("|"), + [groups, archivedRows], + ); // Real DOM focus follows the keyboard cursor, so the browser's own // focus ring never lingers on the last clicked row — unless the user @@ -170,6 +349,15 @@ export function ControlPanelList({ setSearchOpen(false); }; + const rowProps = { + expandedKeys, + onToggleExpanded, + cursorKey, + focusedKey, + onOpen, + rowOrder, + }; + return (