Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions src/__tests__/unit/app/org/components/ControlPanelList.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div>{children}</div>,
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) =>
asChild ? <>{children}</> : <span>{children}</span>,
}));

// 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 }) => <kbd>{children}</kbd>,
KbdGroup: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
}));

function makeItem(overrides: Partial<ControlPanelItem>): 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> = {}): 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> = {}): 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(<ControlPanelList {...emptyProps()} />);
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(
<ControlPanelList
{...emptyProps({
totalCount: 0,
archivedRows: [chatRow(archived)],
archivedExpanded: true,
})}
/>,
);
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(
<ControlPanelList
{...emptyProps({
groups,
totalCount: 1,
archivedRows: [],
archivedExpanded: true,
})}
/>,
);
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(
<ControlPanelList
{...emptyProps({
groups,
totalCount: 1,
onOpen,
onArchive,
})}
/>,
);
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(
<ControlPanelList
{...emptyProps({
archivedRows: [chatRow(archived)],
archivedExpanded: true,
onOpen,
onRestore,
})}
/>,
);
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(
<ControlPanelList
{...emptyProps({
groups: [{ key: "2026-09-04", label: "Today", rows: [chatRow(active)] }],
totalCount: 1,
archivedRows: [chatRow(archived)],
archivedExpanded: false,
})}
/>,
);
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(
<ControlPanelList
{...emptyProps({
archivedRows: [chatRow(archived)],
archivedExpanded: true,
cursorKey: "chat:a1",
onOpen,
})}
/>,
);
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" }));
});
});
21 changes: 21 additions & 0 deletions src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CanvasHistoryPopover githubLogin="my-org" />);
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<typeof vi.fn>).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 });

Expand Down
122 changes: 122 additions & 0 deletions src/__tests__/unit/services/control-panel-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"]);
});
});
Loading
Loading