diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 8a97fa731b9..679cbce721a 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -100,6 +100,17 @@ pnpm build-storybook These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`. +Use **Design explorations → Agent chat sidebar** to review the production secondary +agent navigation. **Components** covers selection, search, loading, empty results, +and larger teams. **Pages** puts it beside the production agent chat inside the +real app shell, including landing, conversation, plan panel, light, and mobile +views. The plus button opens the existing agent picker; adding an agent creates +one sidebar entry, and choosing them again reopens the same conversation. The +**Add and reopen** component story exercises that constraint. Agent selection +and sending use local fixtures. The live `/chats` and `/chats/:agentRef` routes +use the same sidebar, picker, landing page, and existing chat surface when the +Agent Chat experimental setting is enabled. + Use **Chat & Comments → Issue Thread Interactions → Composer Questions Auto Advance** to try the paged composer form. A single selection shows a brief checked-state animation before advancing to the next question. Reduced-motion mode advances without animation. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 6d73543da1e..9774b019ec2 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1635,7 +1635,7 @@ for persistence, migration, rendering, and integration contracts. `enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected. -`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue on first send/upload. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window. +`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue when adding a chat or on first send/upload. `GET /api/companies/:companyId/chats` lists only the current board user’s conversations in that company, subject to ordinary issue read access. The Chat navigation opens a searchable secondary sidebar with agent avatars and a picker for starting or reopening the same per-agent conversation. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window. The server owns conversation state: `waiting` plus `in_review` denotes a healthy idle conversation, and `active` denotes an unanswered or executing turn. Successful replies settle a turn; they do not finish the issue. Idle containers are excluded from execution-work counts, ordinary task lists, timer work, and recovery invocations. Failed/unanswered turns retain normal handling. Child completion never wakes or completes the conversation. Search and direct task access preserve history. diff --git a/doc/SPEC.md b/doc/SPEC.md index 93c08b93461..cecaf9435c2 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -287,7 +287,7 @@ All agent communication flows through the **task system**. There is no separate messaging or chat system. Tasks are the communication channel. This keeps all context attached to the work it relates to and creates a natural audit trail. -Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`. +Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. Chat has a searchable secondary sidebar with agent avatars; adding an agent starts or reopens their single conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`. ### Implications diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts index f24fb7f0e60..75c695c8faa 100644 --- a/server/src/__tests__/agent-conversations.test.ts +++ b/server/src/__tests__/agent-conversations.test.ts @@ -219,6 +219,15 @@ const support = await getEmbeddedPostgresTestSupport(); expect(resolved.every((response) => response.status === 200)).toBe(true); expect(resolved[0].body.id).toBe(resolved[1].body.id); expect(resolved[0].body.conversationUserId).toBe(owner); + const listPath = `/api/companies/${companyId}/chats`; + const owned = await request(app).get(listPath); + expect(owned.status).toBe(200); + expect(owned.body.map((chat: { id: string }) => chat.id)).toEqual([resolved[0].body.id]); + expect((await request(appFor(colleague)).get(listPath)).body).toEqual([]); + expect((await request(appFor(randomUUID(), false)).get(listPath)).status).toBe(403); + await instanceSettingsService(db).updateExperimental({ enableAgentChat: false }); + expect((await request(app).get(listPath)).status).toBe(404); + await instanceSettingsService(db).updateExperimental({ enableAgentChat: true }); expect( ( await request(appFor(colleague)).get( diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f93ba2cd9d5..005b4c6f792 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -17125,7 +17125,16 @@ export function issueRoutes( res.json(bundle); }); - // Resolving an unused chat is read-only. POST is used only by first send/upload. + router.get("/companies/:companyId/chats", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + if (req.actor.type !== "board" || !req.actor.userId) throw forbidden("Board user access required"); + if (!(await instanceSettings.getExperimental()).enableAgentChat) throw notFound("Agent Chat is disabled"); + const conversations = await svc.listConversations(companyId, req.actor.userId); + res.json(await filterIssuesForActor(req, conversations)); + }); + + // GET stays read-only. POST resolves the single chat on explicit add or first send/upload. for (const method of ["get", "post"] as const) { router[method]("/companies/:companyId/chats/:agentRef", async (req, res) => { const companyId = req.params.companyId as string; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 10e48af4b49..8f5ebde2c8a 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3866,6 +3866,16 @@ registry.registerPath({ // ─── Issues ────────────────────────────────────────────────────────────────── +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/chats", + tags: ["issues"], + summary: "List the current board user's agent conversations", + description: "Requires Agent Chat to be enabled. Returns accessible conversations in the company, ordered by most recent activity. Each agent has one conversation per user.", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + registry.registerPath({ method: "get", path: "/api/companies/{companyId}/issues", diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 5dc8a03a899..d2087b3da06 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -9724,6 +9724,10 @@ export function issueService(db: Db) { }); }, + listConversations: async (companyId: string, userId: string) => db.select().from(issues).where(and( + eq(issues.companyId, companyId), eq(issues.conversationUserId, userId), isNotNull(issues.conversationAgentId), + )).orderBy(desc(issues.updatedAt), asc(issues.id)), + getConversation: async (companyId: string, agentId: string, userId: string) => db.select().from(issues).where(and( eq(issues.companyId, companyId), eq(issues.conversationAgentId, agentId), eq(issues.conversationUserId, userId), )).then((rows) => rows[0] ?? null), diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts index 8f860e304f1..f087e1ad77e 100644 --- a/tests/e2e/agent-chat-sessions.spec.ts +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -250,92 +250,111 @@ test("Stop then queued /new resets unpause the conversation without losing histo } }); -test("sidebar discovery, stars, recent agents, configuration links, and drafts survive switching", async ({ +test("secondary chat navigation preserves layout, unique conversations, history, and drafts", async ({ page, request, }) => { + const originalExperimental = await json(await request.get("/api/instance/settings/experimental")); const f = await setup(request); try { + await json(await request.patch("/api/instance/settings/experimental", { + data: { enableStreamlinedUi: true }, + })); await page.goto(`/${f.company.issuePrefix}/dashboard`); - const nav = page.getByRole("navigation"); - const chatLinks = nav.locator('a[href*="/chats/"]'); - await expect(chatLinks).toHaveText(["Alpha"]); - const compose = nav.getByRole("button", { name: "Chat with an agent", exact: true }); - await compose.click(); + await page.getByRole("link", { name: "Chat", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible(); + const sidebar = page.getByRole("complementary", { name: "Chat", exact: true }); + await expect(sidebar.getByText("No chats yet", { exact: true })).toBeVisible(); + const landingBounds = await sidebar.boundingBox(); + const compose = sidebar.getByRole("button", { name: "Add chat", exact: true }); const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true }); + const chatListPath = `/api/companies/${f.company.id}/chats`; + const betaPath = `${chatListPath}/${f.agents[1].id}`; + await compose.click(); await expect(picker.getByRole("option")).toHaveCount(6); await picker.getByRole("combobox").fill("Beta"); + await expect(picker.getByRole("option", { name: /^Beta / })).toContainText("New chat"); await picker.getByRole("combobox").press("Enter"); await expect(picker).not.toBeVisible(); await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible(); - expect(await json(await request.get(`/api/companies/${f.company.id}/chats/${f.agents[1].id}`))).toBeNull(); - for (const agent of f.agents) { - await page.goto(`/${f.company.issuePrefix}/chats/${agent.id}`); - await expect(page.getByTestId("task-chat-composer-input")).toBeVisible(); - } - await expect(chatLinks).toHaveText(["Alpha", "Zeta", "Epsilon", "Delta", "Gamma"]); - const star = page.getByRole("button", { name: "Star Zeta", exact: true }); - await page.getByTestId("task-chat-composer-input").hover(); - await expect(star).toHaveCSS("opacity", "0"); - // Enter from the preceding link. Clicking the rich editor first can leave - // a pending selection update that restores editor focus; Shift+Tab there - // also cycles work modes instead of moving backwards through the sidebar. - await nav.getByRole("link", { name: "Zeta", exact: true }).focus(); - await page.keyboard.press("Tab"); - await expect(star).toBeFocused(); - await expect(star).toHaveCSS("opacity", "1"); - await star.click(); - await page.goto(f.route); - await page.getByRole("button", { name: "Star Alpha", exact: true }).click(); - await expect(nav.locator('a[href*="/chats/"]').first()).toHaveText("Alpha"); - await expect(nav.locator('a[href*="/chats/"]').nth(1)).toHaveText("Zeta"); - const editor = page - .getByTestId("task-chat-composer-input") - .locator('[contenteditable="true"]'); - await editor.fill("Unsent draft for Alpha"); - await nav.getByRole("link", { name: "Zeta", exact: true }).click(); - await expect(editor).toHaveText(""); - await nav.getByRole("link", { name: "Alpha", exact: true }).click(); - await expect(editor).toContainText("Unsent draft for Alpha"); - const recent = await page.evaluate(() => - Object.fromEntries( - Object.entries(localStorage).filter(([key]) => - key.startsWith("paperclip.recentAgentChats:"), - ), - ), - ); - await page.getByRole("link", { name: /Configure Alpha/ }).click(); - await expect(page).toHaveURL(/\/agents\/.*\/runtime/); - const backgroundPath = `/api/companies/${f.company.id}/chats/${f.agents[1].id}`; - const background = await json( - await request.post(backgroundPath, { data: {} }), - ); - await json( - await request.post(`/api/issues/${background.id}/comments`, { - data: { - body: "Background activity", - clientRequestId: "00000000-0000-4000-8000-000000000099", - }, - }), - ); - await idle(request, backgroundPath); - expect( - await page.evaluate(() => - Object.fromEntries( - Object.entries(localStorage).filter(([key]) => - key.startsWith("paperclip.recentAgentChats:"), - ), - ), - ), - ).toEqual(recent); + expect(await sidebar.boundingBox()).toEqual(landingBounds); + const beta = await json(await request.get(betaPath)); + expect(beta.conversationAgentId).toBe(f.agents[1].id); + expect(await json(await request.get(chatListPath))).toHaveLength(1); + expect(await json(await request.get(`/api/companies/${f.company.id}/heartbeat-runs`))).toHaveLength(0); + + // Explicitly adding an existing agent must reopen the same conversation. await compose.click(); await expect(picker.getByRole("combobox")).toHaveValue(""); await picker.getByRole("combobox").fill("Beta"); + await expect(picker.getByRole("option", { name: /^Beta / })).toContainText("Open chat"); + await picker.getByRole("combobox").press("Enter"); + await expect(picker).not.toBeVisible(); + expect((await json(await request.get(betaPath))).id).toBe(beta.id); + expect(await json(await request.get(chatListPath))).toHaveLength(1); + + await compose.click(); + await picker.getByRole("combobox").fill("Alpha"); + await picker.getByRole("combobox").press("Enter"); + await expect(picker).not.toBeVisible(); + await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible(); + const nav = sidebar.getByRole("navigation", { name: "Agent conversations" }); + const alphaLink = nav.getByRole("link", { name: /^Alpha / }); + const betaLink = nav.getByRole("link", { name: /^Beta / }); + await expect(nav.getByRole("link")).toHaveCount(2); + const editor = page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]'); + await editor.fill("Unsent draft for Alpha"); + await betaLink.click(); + await expect(editor).toHaveText(""); + await alphaLink.click(); + await expect(editor).toContainText("Unsent draft for Alpha"); + + const search = sidebar.getByRole("textbox", { name: "Search agents", exact: true }); + await search.fill("No matching agent"); + await expect(sidebar.getByText("No agents found", { exact: true })).toBeVisible(); + await search.fill("Beta"); + await expect(nav.getByRole("link")).toHaveCount(1); + await expect(betaLink).toBeVisible(); + await search.press("Escape"); + await expect(nav.getByRole("link")).toHaveCount(2); + await page.reload(); + await expect(editor).toContainText("Unsent draft for Alpha"); + await expect(nav.getByRole("link")).toHaveCount(2); + + await page.getByRole("link", { name: "Chat", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible(); + expect(await sidebar.boundingBox()).toEqual(landingBounds); + await alphaLink.click(); + await page.getByRole("link", { name: "Configure Alpha", exact: true }).click(); + await expect(page).toHaveURL(/\/agents\/.*\/runtime/); + await json(await request.post(`/api/issues/${beta.id}/comments`, { + data: { + body: "Background activity", + clientRequestId: "00000000-0000-4000-8000-000000000099", + }, + })); + await idle(request, betaPath); + await page.getByRole("link", { name: "Chat", exact: true }).click(); + await compose.click(); + await picker.getByRole("combobox").fill("Beta"); await picker.getByRole("combobox").press("Enter"); await expect(picker).not.toBeVisible(); await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible(); await expect(page.getByText("Background activity", { exact: true })).toBeVisible(); + expect((await json(await request.get(betaPath))).id).toBe(beta.id); + expect(await json(await request.get(chatListPath))).toHaveLength(2); + await json(await request.post(`/api/agents/${f.agents[1].id}/terminate`)); + await page.goto(`/${f.company.issuePrefix}/chats`); + await expect(betaLink).toContainText("Terminated"); + await betaLink.click(); + await expect(page).toHaveURL(new RegExp(`/chats/${f.agents[1].id}$`)); + await expect(page.getByText("Background activity", { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByText("Background activity", { exact: true })).toBeVisible(); } finally { await f.restore(); + await json(await request.patch("/api/instance/settings/experimental", { + data: { enableStreamlinedUi: originalExperimental.enableStreamlinedUi }, + })); } }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c1ba93893e5..c740d699ff2 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -36,6 +36,7 @@ import { Workspaces } from "./pages/Workspaces"; import { Issues } from "./pages/Issues"; import { Search } from "./pages/Search"; import { IssueDetail } from "./pages/IssueDetail"; +import { AgentChats } from "./pages/AgentChats"; import { AgentChat } from "./pages/AgentChat"; import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf"; import { Routines } from "./pages/Routines"; @@ -304,6 +305,7 @@ function boardRoutes(streamlinedUiEnabled: boolean) { } /> } /> } /> + } /> } /> } /> {import.meta.env.DEV ? ( diff --git a/ui/src/api/agentChats.ts b/ui/src/api/agentChats.ts index fe1c8d3bed4..0d565e73b22 100644 --- a/ui/src/api/agentChats.ts +++ b/ui/src/api/agentChats.ts @@ -1,6 +1,7 @@ import type { Issue } from "@paperclipai/shared"; import { api } from "./client"; export const agentChatsApi = { + list: (companyId: string) => api.get(`/companies/${companyId}/chats`), get: (companyId: string, agentRef: string) => api.get( `/companies/${companyId}/chats/${encodeURIComponent(agentRef)}`, diff --git a/ui/src/components/AgentChatPicker.test.tsx b/ui/src/components/AgentChatPicker.test.tsx index 2f4a5858bfb..7c7abfd2697 100644 --- a/ui/src/components/AgentChatPicker.test.tsx +++ b/ui/src/components/AgentChatPicker.test.tsx @@ -41,6 +41,19 @@ afterEach(async () => { }); describe("AgentChatPicker", () => { + it("does not close a reopened picker when an earlier selection finishes", async () => { + let finish!: () => void; + await render({ onSelect: () => new Promise(resolve => { finish = resolve; }) }); + await act(async () => options()[0].click()); + await render({ open: false }); + await render({ open: true }); + await search("designer"); + await act(async () => finish()); + expect(props.onOpenChange).not.toHaveBeenCalled(); + expect(document.querySelector("[role=combobox]")!.value).toBe("designer"); + expect(options()).toHaveLength(1); + }); + it("searches by role and selects the correct same-name agent with Enter", async () => { await render(); expect(options()).toHaveLength(2); @@ -50,10 +63,26 @@ describe("AgentChatPicker", () => { await act(async () => { document.querySelector("[role=combobox]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); - expect(props.onSelect).toHaveBeenCalledWith(agents[1]); + expect(props.onSelect).toHaveBeenCalledWith(agents[1], expect.any(AbortSignal)); expect(props.onOpenChange).toHaveBeenCalledWith(false); }); + it("distinguishes existing conversations and selects the same agent when reopened", async () => { + await render({ existingChatAgentIds: ["first"] }); + expect(options()[0].textContent).toContain("Open chat"); + expect(options()[1].textContent).toContain("New chat"); + await search("designer"); + await act(async () => options()[0].click()); + expect(props.onSelect).toHaveBeenLastCalledWith(agents[1], expect.any(AbortSignal)); + await render({ open: false }); + await render({ open: true, existingChatAgentIds: ["first", "second"] }); + await search("designer"); + expect(options()[0].textContent).toContain("Open chat"); + expect(options()[0].textContent).not.toContain("New chat"); + await act(async () => options()[0].click()); + expect(props.onSelect).toHaveBeenLastCalledWith(agents[1], expect.any(AbortSignal)); + }); + it("recovers from no results and resets search when reopened", async () => { await render(); await search("accountant"); diff --git a/ui/src/components/AgentChatPicker.tsx b/ui/src/components/AgentChatPicker.tsx index 4bdc7bc3ed1..60b27e7f5a7 100644 --- a/ui/src/components/AgentChatPicker.tsx +++ b/ui/src/components/AgentChatPicker.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import type { Agent } from "@paperclipai/shared"; import { AgentIcon } from "@/components/AgentIconPicker"; import { Button } from "@/components/ui/button"; @@ -9,10 +9,12 @@ export interface AgentChatPickerProps { agents: Agent[]; open: boolean; onOpenChange: (open: boolean) => void; - onSelect: (agent: Agent) => void; + onSelect: (agent: Agent, signal?: AbortSignal) => void | Promise; loading?: boolean; error?: Error | null; onRetry?: () => void; + existingChatAgentIds?: readonly string[]; + renderAgentIcon?: (agent: Agent) => ReactNode; } export function AgentChatPicker({ open, onOpenChange, ...props }: AgentChatPickerProps) { @@ -21,19 +23,40 @@ export function AgentChatPicker({ open, onOpenChange, ...props }: AgentChatPicke
Chat with an agent + {props.existingChatAgentIds &&

One conversation per agent. Pick up where you left off.

}
{/* The dialog unmounts its content on close, so each search starts empty. */} - { - onOpenChange(false); - props.onSelect(agent); - }} /> + onOpenChange(false)} />
); } -function AgentChatPickerResults({ agents, onSelect, loading, error, onRetry }: Omit) { +function AgentChatPickerResults({ agents, onSelect, onComplete, loading, error, onRetry, existingChatAgentIds, renderAgentIcon }: Omit & { onComplete: () => void }) { + const mounted = useRef(false); + const selection = useRef(null); + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; selection.current?.abort(); }; + }, []); const [search, setSearch] = useState(""); + const [openingId, setOpeningId] = useState(null); + const [selectionError, setSelectionError] = useState(null); + async function selectAgent(agent: Agent) { + if (openingId) return; + setOpeningId(agent.id); + setSelectionError(null); + const request = new AbortController(); + selection.current = request; + try { + await onSelect(agent, request.signal); + if (mounted.current) onComplete(); + } catch (error) { + setSelectionError(error instanceof Error ? error.message : "Couldn’t open chat. Try again."); + } finally { + setOpeningId(null); + } + } return ( + {selectionError &&

{selectionError}

} + {openingId &&

Opening conversation…

} {error ? (

Couldn’t load agents. Try again.

@@ -66,14 +91,16 @@ function AgentChatPickerResults({ agents, onSelect, loading, error, onRetry }: O key={agent.id} value={agent.id} keywords={[agent.name, agent.title ?? "", agent.role]} - onSelect={() => onSelect(agent)} + onSelect={() => { void selectAgent(agent); }} + disabled={openingId !== null} className="gap-3 px-3 py-3" > - + {renderAgentIcon ? renderAgentIcon(agent) : } {agent.name} {agent.title ?? agent.role} + {existingChatAgentIds && {existingChatAgentIds.includes(agent.id) ? "Open chat" : "New chat"}} {agent.status === "paused" && Paused} {agent.status === "terminated" && Terminated} {agent.status === "pending_approval" && Awaiting approval} diff --git a/ui/src/components/AgentConversationSidebar.tsx b/ui/src/components/AgentConversationSidebar.tsx new file mode 100644 index 00000000000..1d32fee7dad --- /dev/null +++ b/ui/src/components/AgentConversationSidebar.tsx @@ -0,0 +1,106 @@ +import { useId, useRef, useState } from "react"; +import { Plus, Search, Users, X } from "lucide-react"; +import type { Agent } from "@paperclipai/shared"; +import { AgentChatPicker } from "@/components/AgentChatPicker"; +import { AgentAvatar } from "@/components/AgentAvatar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Link, useNavigate } from "@/lib/router"; +import { agentRouteRef, cn } from "@/lib/utils"; + +export interface AgentConversationSidebarProps { + agents: Agent[]; + availableAgents?: Agent[]; + activeId?: string; + previews?: Record; + loading?: boolean; + error?: Error | null; + onRetry?: () => void; + initialSearch?: string; + onSelect?: (agent: Agent, signal?: AbortSignal) => void | Promise; + onBrowse?: () => void; + onAddChat?: (agent: Agent, signal?: AbortSignal) => void | Promise; + historyLoading?: boolean; + historyError?: Error | null; + onRetryHistory?: () => void; +} + +/** Searchable navigation for one conversation per agent. */ +export function AgentConversationSidebar({ agents, availableAgents = agents, activeId, previews = {}, loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory }: AgentConversationSidebarProps) { + const navigate = useNavigate(); + const [pickerOpen, setPickerOpen] = useState(false); + const [search, setSearch] = useState(initialSearch); + const searchRef = useRef(null); + const headingId = useId(); + const query = search.trim().toLocaleLowerCase(); + const visible = agents.filter(agent => `${agent.name} ${agent.title ?? ""} ${agent.role}`.toLocaleLowerCase().includes(query)); + function focusSearch() { + setSearch(""); + searchRef.current?.focus(); + } + return ; +} diff --git a/ui/src/components/AgentConversationsSidebar.test.tsx b/ui/src/components/AgentConversationsSidebar.test.tsx new file mode 100644 index 00000000000..9f442f39036 --- /dev/null +++ b/ui/src/components/AgentConversationsSidebar.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import { AgentConversationsSidebar } from "./AgentConversationsSidebar"; +import { queryKeys } from "@/lib/queryKeys"; +const state = vi.hoisted(() => ({ companyId: "company-a", navigate: vi.fn(), closeSidebar: vi.fn(), ensure: vi.fn(), getAgent: vi.fn() })); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId }) })); +vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: true, setSidebarOpen: state.closeSidebar }) })); +vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: true, loaded: true }) })); +vi.mock("@/lib/router", () => ({ + useLocation: () => ({ pathname: "/A/chats/alice" }), useNavigate: () => state.navigate, + Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => {children}, +})); +vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(), get: state.getAgent } })); +vi.mock("@/api/agentChats", () => ({ agentChatsApi: { list: vi.fn(), ensure: state.ensure } })); +vi.mock("@/api/auth", () => ({ authApi: { getSession: () => ({ user: { id: "user-a" } }) } })); +vi.mock("./AgentAvatar", () => ({ AgentAvatar: () => null })); +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +const roster = ["alice", "bob"].map(id => ({ id, companyId: "company-a", name: id, urlKey: id, role: "general", title: "Teammate", status: "idle" } as Agent)); +const chat = (id: string) => ({ id: `chat-${id}`, companyId: "company-a", conversationAgentId: id, conversationUserId: "user-a", updatedAt: id === "bob" ? "2026-09-02T00:00:00Z" : "2026-09-01T00:00:00Z" }); +async function render() { + await act(async () => { root.render(); }); +} +async function addChat() { await act(async () => container.querySelector('[aria-label="Add chat"]')!.click()); } +async function choose(name: string) { + await act(async () => [...document.querySelectorAll("[role=option]")].find(option => option.textContent?.startsWith(name))!.click()); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); + state.companyId = "company-a"; + state.navigate.mockReset(); state.closeSidebar.mockReset(); state.ensure.mockReset(); state.getAgent.mockReset(); + state.ensure.mockImplementation(async (_company, id) => chat(id)); + client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-a" } }); + client.setQueryData(queryKeys.agents.list("company-a"), roster); + client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat("alice")]); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); +}); +afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); }); +it("adds through the server and reopens one cached conversation without a duplicate row", async () => { + await render(); + expect(container.querySelectorAll('nav[aria-label="Agent conversations"] a')).toHaveLength(1); + await addChat(); await choose("bob"); + expect(state.ensure).toHaveBeenCalledWith("company-a", "bob"); + expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob"); + expect(state.closeSidebar).toHaveBeenCalledWith(false); + await render(); + expect(client.getQueryData(queryKeys.agentChats.detail("company-a", "user-a", "bob"))).toEqual(chat("bob")); + await addChat(); + expect([...document.querySelectorAll("[role=option]")].find(option => option.textContent?.startsWith("bob"))?.textContent).toContain("Open chat"); + await choose("bob"); await render(); + expect(container.querySelectorAll('a[href="/chats/bob"]')).toHaveLength(1); + expect(client.getQueryData(queryKeys.agentChats.list("company-a", "user-a"))).toHaveLength(2); +}); +it("keeps failures retryable in the picker and closes it on company changes", async () => { + state.ensure.mockRejectedValueOnce(new Error("Couldn’t open chat. Try again.")); + await render(); await addChat(); await choose("bob"); + expect(document.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t open chat"); + expect(state.navigate).not.toHaveBeenCalled(); + await choose("bob"); + expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob"); + await addChat(); + state.companyId = "company-b"; + client.setQueryData(queryKeys.agents.list("company-b"), []); + client.setQueryData(queryKeys.agentChats.list("company-b", "user-a"), []); + await render(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(container.textContent).not.toContain("alice"); +}); + +it("preserves recent activity order when reopening an older conversation", async () => { + client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat("bob"), chat("alice")]); + await render(); + const links = () => [...container.querySelectorAll('nav[aria-label="Agent conversations"] a')].map(link => link.getAttribute("href")); + expect(links()).toEqual(["/chats/bob", "/chats/alice"]); + await addChat(); await choose("alice"); await render(); + expect(links()).toEqual(["/chats/bob", "/chats/alice"]); +}); + +it("retains terminated agents' history by id without offering them for new chats", async () => { + const retired = { ...roster[0], id: "retired-id", name: "Retired", status: "terminated", urlKey: "alice" }; + client.setQueryData(queryKeys.agents.detail(retired.id), retired); + client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat(retired.id), chat("alice")]); + await render(); + const historyLink = container.querySelector('a[href="/chats/retired-id"]')!; + expect(historyLink.textContent).toContain("RetiredTerminated"); + await act(async () => historyLink.click()); + expect(state.navigate).toHaveBeenLastCalledWith("/chats/retired-id"); + await addChat(); + expect([...document.querySelectorAll("[role=option]")].some(option => option.textContent?.includes("Retired"))).toBe(false); +}); + +it("does not let a dismissed selection override a newer chat", async () => { + let finishAlice!: (value: ReturnType) => void; + state.ensure.mockImplementation(async (_company, id) => id === "alice" + ? new Promise(resolve => { finishAlice = resolve; }) : chat(id)); + await render(); await addChat(); await choose("alice"); + await act(async () => document.querySelector('[data-slot="dialog-close"]')!.click()); + await addChat(); await choose("bob"); + expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob"); + await act(async () => finishAlice(chat("alice"))); + expect(state.navigate).toHaveBeenCalledTimes(1); + expect(client.getQueryData(queryKeys.agentChats.list("company-a", "user-a"))).toHaveLength(2); +}); + +it("keeps healthy chats usable while a historical lookup fails and retries", async () => { + const retired = { ...roster[0], id: "retired-id", name: "Retired", status: "terminated" }; + state.getAgent.mockRejectedValueOnce(new Error("Unavailable")).mockResolvedValue(retired); + client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat(retired.id), chat("alice")]); + await render(); + await vi.waitFor(async () => { + await act(async () => {}); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Some chat history couldn’t load"); + }); + expect(container.querySelector('a[href="/chats/alice"]')).not.toBeNull(); + await act(async () => { + const input = container.querySelector('[aria-label="Search agents"]')!; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "No match"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.textContent).toContain("No agents found"); + await act(async () => container.querySelector('[aria-label="Clear search"]')!.click()); + expect(container.querySelector('a[href="/chats/alice"]')).not.toBeNull(); + await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "Retry chat history")!.click()); + await vi.waitFor(async () => { + await act(async () => {}); + expect(container.querySelector('a[href="/chats/retired-id"]')).not.toBeNull(); + }); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.querySelector('a[href="/chats/alice"]')).not.toBeNull(); +}); diff --git a/ui/src/components/AgentConversationsSidebar.tsx b/ui/src/components/AgentConversationsSidebar.tsx new file mode 100644 index 00000000000..049de33ff4f --- /dev/null +++ b/ui/src/components/AgentConversationsSidebar.tsx @@ -0,0 +1,48 @@ +import { AgentConversationSidebar } from "./AgentConversationSidebar"; +import { useAgentChatNavigation, useOpenAgentChat } from "@/hooks/useAgentChatNavigation"; +import { useLocation, useNavigate } from "@/lib/router"; +import { useSidebar } from "@/context/SidebarContext"; +import { agentRouteRef } from "@/lib/utils"; +import { useQueries } from "@tanstack/react-query"; +import { agentsApi } from "@/api/agents"; +import { queryKeys } from "@/lib/queryKeys"; + +/** Account- and company-scoped data for the secondary chat navigation. */ +export function AgentConversationsSidebar() { + const { companyId, userId, agents, chats, session } = useAgentChatNavigation(); + const openChat = useOpenAgentChat(companyId, userId); + const navigate = useNavigate(); + const { isMobile, setSidebarOpen } = useSidebar(); + const { pathname } = useLocation(); + const activeRef = pathname.split("/chats/")[1]?.split("/")[0]; + const roster = agents.data ?? []; + // The active roster excludes terminated agents, but their history remains readable. + const missingIds = agents.isSuccess ? [...new Set((chats.data ?? []) + .flatMap(chat => chat.conversationAgentId && !roster.some(agent => agent.id === chat.conversationAgentId) ? [chat.conversationAgentId] : []))] : []; + const historyAgents = useQueries({ queries: missingIds.map(id => ({ + queryKey: queryKeys.agents.detail(id), queryFn: () => agentsApi.get(id, companyId!), + })) }); + const byId = new Map(roster.map(agent => [agent.id, agent])); + for (const result of historyAgents) if (result.data) byId.set(result.data.id, result.data); + const active = [...byId.values()].find(agent => agent.id === activeRef || (agent.status !== "terminated" && encodeURIComponent(agentRouteRef(agent)) === activeRef)); + const conversations = (chats.data ?? []).flatMap(chat => { + const agent = chat.conversationAgentId ? byId.get(chat.conversationAgentId) : undefined; + return agent ? [agent] : []; + }); + if (active && !conversations.some(agent => agent.id === active.id)) conversations.unshift(active); + const previews = Object.fromEntries((chats.data ?? []).filter(chat => chat.conversationState === "active") + .map(chat => [chat.conversationAgentId!, "Working…"])); + return { void agents.refetch(); void chats.refetch(); void session.refetch(); }} + historyLoading={historyAgents.some(result => result.isPending)} + historyError={historyAgents.find(result => result.error)?.error} + onRetryHistory={() => { historyAgents.filter(result => result.isError).forEach(result => { void result.refetch(); }); }} + onAddChat={openChat} + onSelect={agent => { + navigate(`/chats/${encodeURIComponent(agent.status === "terminated" ? agent.id : agentRouteRef(agent))}`); + if (isMobile) setSidebarOpen(false); + }} />; +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a71770149cf..06507acbe87 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -27,6 +27,8 @@ import { WorktreeBanner } from "./WorktreeBanner"; import { DevRestartBanner } from "./DevRestartBanner"; import { StandaloneBrowserControls } from "./StandaloneBrowserControls"; import { RouteErrorBoundary } from "./RouteErrorBoundary"; +import { AgentConversationsSidebar } from "./AgentConversationsSidebar"; +import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled"; import { SidebarShell } from "./SidebarShell"; import { SecondarySidebar } from "./SecondarySidebar"; import { ContextualSidebarFrame } from "./ContextualSidebarFrame"; @@ -119,7 +121,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { const isCompanySettingsRoute = shellRoute.builtInContextualSurface === "settings"; const companyPathSegments = shellRoute.companySegments; const isTaskDetailRoute = shellRoute.isTaskDetail; - const useStreamlinedTaskDetailShell = streamlinedUiEnabled && isTaskDetailRoute; + const { enabled: agentChatEnabled } = useAgentChatEnabled(); + const isAgentChatRoute = agentChatEnabled && companyPathSegments[0]?.toLowerCase() === "chats"; + // Chat keeps its header beside the agent sidebar, including before an agent is selected. + const useStreamlinedTaskDetailShell = streamlinedUiEnabled && (isTaskDetailRoute || isAgentChatRoute); const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools"; const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps"; const appDetailConnectionId = @@ -203,7 +208,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { /> ) ) : null; - const secondarySidebar = shellRoute.builtInContextualSurface === "agent" && agentId ? ( + const secondarySidebar = isAgentChatRoute ? : shellRoute.builtInContextualSurface === "agent" && agentId ? ( ) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? ( @@ -212,7 +217,8 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { ) : sharedSecondarySidebar; const hasSecondarySidebar = secondarySidebar != null; const keepsPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && ( - shellRoute.builtInContextualSurface === "skills" + isAgentChatRoute + || shellRoute.builtInContextualSurface === "skills" || shellRoute.builtInContextualSurface === "agent" || shellRoute.builtInContextualSurface === "routine" || isAppsRoute diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 5bfb9a9121a..c889b612ee4 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -18,6 +18,7 @@ import { FolderOpen, Unplug, MessagesSquare, + MessageCircle, GanttChartSquare, LayoutGrid, Users, @@ -29,7 +30,6 @@ import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarAgents } from "./SidebarAgents"; import { SidebarProjects } from "./SidebarProjects"; import { SidebarStarredProjects } from "./SidebarStarredProjects"; -import { SidebarAgentChats } from "./SidebarAgentChats"; import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled"; import { SidebarRecentTasks } from "./SidebarRecentTasks"; import { useDialogActions } from "../context/DialogContext"; @@ -175,6 +175,7 @@ export function Sidebar({ children }: { children?: ReactNode }) { badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"} alert={inboxBadge.failedRuns > 0} /> + {agentChatEnabled && } {showDecisions ? ( } {streamlinedUiEnabled ? ( diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 08f041a15cf..fde36aa5d4e 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -16,6 +16,25 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider"; import { queryKeys } from "../lib/queryKeys"; describe("LiveUpdatesProvider issue invalidation", () => { + it.each([ + ["chat-1", "issue.comment_added", "agent", "agent-1", true], + ["task-1", "issue.comment_added", "agent", "agent-1", false], + ["chat-2", "issue.conversation_opened", "user", "user-1", true], + ["chat-2", "issue.conversation_opened", "user", "user-2", false], + ])("refreshes only the owner's chat list for relevant activity: %s %s %s %s", (entityId, action, actorType, actorId, refresh) => { + const client = new QueryClient(); + const key = queryKeys.agentChats.list("company-1", "user-1"); + client.setQueryData(key, [{ id: "chat-1" }]); + const invalidate = vi.spyOn(client, "invalidateQueries"); + __liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { + entityType: "issue", entityId, action, actorType, actorId, + }, { userId: "user-1", agentId: null }); + if (refresh) expect(invalidate).toHaveBeenCalledWith({ queryKey: key }); + else expect(invalidate).not.toHaveBeenCalledWith({ queryKey: key }); + expect(invalidate).not.toHaveBeenCalledWith({ queryKey: queryKeys.agentChats.list("company-1", "user-2") }); + client.clear(); + }); + it("refreshes the source task activity when a company skill is created", () => { const client = new QueryClient(); const invalidate = vi.spyOn(client, "invalidateQueries"); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 925dd6dc453..3a38ad3b479 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1289,6 +1289,11 @@ function invalidateActivityQueries( } if (entityType === "issue") { + const chatListKey = queryKeys.agentChats.list(companyId, currentActor.userId); + const knownChat = entityId && queryClient.getQueryData(chatListKey)?.some(chat => chat.id === entityId); + if (knownChat || action === "issue.conversation_opened" && ownActorActivity) { + queryClient.invalidateQueries({ queryKey: chatListKey }); + } if (action === "issue.tree_hold_created" || action === "issue.tree_hold_released" || action === "issue.updated") { // An ancestor hold or reparenting changes descendants' effective pause. queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state"] }); diff --git a/ui/src/hooks/useAgentChatNavigation.ts b/ui/src/hooks/useAgentChatNavigation.ts new file mode 100644 index 00000000000..85c63e50d88 --- /dev/null +++ b/ui/src/hooks/useAgentChatNavigation.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Agent, Issue } from "@paperclipai/shared"; +import { agentsApi } from "@/api/agents"; +import { agentChatsApi } from "@/api/agentChats"; +import { authApi } from "@/api/auth"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { useNavigate } from "@/lib/router"; +import { agentRouteRef } from "@/lib/utils"; +import { useAgentChatEnabled } from "./useAgentChatEnabled"; + +export function useAgentChatNavigation() { + const { selectedCompanyId: companyId } = useCompany(); + const { enabled, loaded } = useAgentChatEnabled(); + const session = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() }); + const userId = session.data?.user?.id ?? session.data?.session?.userId ?? null; + const agents = useQuery({ + queryKey: queryKeys.agents.list(companyId!), queryFn: () => agentsApi.list(companyId!), + enabled: enabled && !!companyId, + }); + const chats = useQuery({ + queryKey: queryKeys.agentChats.list(companyId, userId), queryFn: () => agentChatsApi.list(companyId!), + enabled: enabled && !!companyId && session.isSuccess, + }); + return { companyId, userId, enabled, loaded, agents, chats, session }; +} + +/** Explicitly adding a chat uses the existing atomic, one-per-agent resolver. */ +export function useOpenAgentChat(companyId: string | null, userId: string | null) { + const client = useQueryClient(); + const navigate = useNavigate(); + const { isMobile, setSidebarOpen } = useSidebar(); + const scope = useRef({ companyId, userId, mounted: true }); + useEffect(() => { + const current = { companyId, userId, mounted: true }; + scope.current = current; + return () => { current.mounted = false; }; + }, [companyId, userId]); + return useCallback(async (agent: Agent, signal?: AbortSignal) => { + if (!companyId || agent.companyId !== companyId) throw new Error("Choose an agent from this company."); + const current = scope.current; + const chat = await agentChatsApi.ensure(companyId, agent.id); + client.setQueryData(queryKeys.agentChats.detail(companyId, userId, agent.id), chat); + client.setQueryData(queryKeys.issues.detail(chat.id), chat); + client.setQueryData(queryKeys.agentChats.list(companyId, userId), previous => + [chat, ...(previous ?? []).filter(item => item.id !== chat.id)] + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() || b.id.localeCompare(a.id))); + if (!current.mounted || scope.current !== current || signal?.aborted) return; + navigate(`/chats/${encodeURIComponent(agentRouteRef(agent))}`); + if (isMobile) setSidebarOpen(false); + }, [client, companyId, userId, navigate, isMobile, setSidebarOpen]); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 5e3ab064b6d..9f50ea25003 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -1,5 +1,7 @@ export const queryKeys = { agentChats: { + list: (companyId: string | null, userId?: string | null) => + ["agent-chats", companyId, userId] as const, detail: (companyId: string | null, userId: string | null, agentId: string | undefined) => ["agent-chat", companyId, userId, agentId] as const, }, diff --git a/ui/src/pages/AgentChat.tsx b/ui/src/pages/AgentChat.tsx index cd4975558a9..84279b7333c 100644 --- a/ui/src/pages/AgentChat.tsx +++ b/ui/src/pages/AgentChat.tsx @@ -10,7 +10,7 @@ import { queryKeys } from "@/lib/queryKeys"; import { useParams } from "@/lib/router"; import { agentRouteRef } from "@/lib/utils"; import { TaskDetailSurface } from "./IssueDetail"; -import type { Issue } from "@paperclipai/shared"; +import { isUuidLike, type Issue } from "@paperclipai/shared"; export function AgentChat() { const { agentRef = "" } = useParams<{ agentRef: string }>(); @@ -28,9 +28,15 @@ export function AgentChat() { }); const userId = session.data?.user?.id ?? session.data?.session?.userId ?? null; - const agent = agents.data?.find( + const rosterAgent = agents.data?.find( (item) => item.id === agentRef || agentRouteRef(item) === agentRef, ); + const historyAgent = useQuery({ + queryKey: queryKeys.agents.detail(agentRef), + queryFn: () => agentsApi.get(agentRef, selectedCompanyId!), + enabled: enabled && !!selectedCompanyId && agents.isSuccess && !rosterAgent && isUuidLike(agentRef), + }); + const agent = rosterAgent ?? (historyAgent.data?.companyId === selectedCompanyId ? historyAgent.data : undefined); const chatKey = queryKeys.agentChats.detail(selectedCompanyId, userId, agent?.id); const chat = useQuery({ queryKey: chatKey, @@ -56,13 +62,14 @@ export function AgentChat() { const issue = await promise; client.setQueryData(queryKeys.issues.detail(issue.id), issue); client.setQueryData(chatKey, issue); + void client.invalidateQueries({ queryKey: queryKeys.agentChats.list(selectedCompanyId, userId) }); return issue; } catch (error) { creating.current = null; throw error; } }, [agent, selectedCompanyId, chat.data, client, userId]); - if (!loaded || agents.isPending || session.isPending) + if (!loaded || agents.isPending || session.isPending || historyAgent.isFetching && !agent) return (

Loading conversation…

); @@ -73,10 +80,10 @@ export function AgentChat() { history remains available through task links.

); - if (agents.error || chat.error) + if (agents.error || chat.error || !rosterAgent && historyAgent.error) return (

- {(agents.error ?? chat.error)?.message} + {(agents.error ?? chat.error ?? historyAgent.error)?.message}

); if (!agent) diff --git a/ui/src/pages/AgentChats.tsx b/ui/src/pages/AgentChats.tsx new file mode 100644 index 00000000000..5ec7eb6411f --- /dev/null +++ b/ui/src/pages/AgentChats.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { MessageCircle } from "lucide-react"; +import { AgentAvatar } from "@/components/AgentAvatar"; +import { Button } from "@/components/ui/button"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useAgentChatNavigation, useOpenAgentChat } from "@/hooks/useAgentChatNavigation"; +import { Link } from "@/lib/router"; + +export function AgentChats() { + const { setBreadcrumbs } = useBreadcrumbs(); + const { companyId, userId, enabled, loaded, agents, session } = useAgentChatNavigation(); + useEffect(() => setBreadcrumbs([{ label: "Chat" }]), [setBreadcrumbs]); + return ; +} + +function AgentChatsContent({ companyId, userId, enabled, loaded, agents, session }: Pick, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "session">) { + const openChat = useOpenAgentChat(companyId, userId); + const [openingId, setOpeningId] = useState(null); + const [openError, setOpenError] = useState(null); + if (!loaded) return

Loading chat…

; + if (!enabled) return

Agent Chat is disabled. Enable it in Experimental settings.

; + const error = agents.error ?? session.error; + return
+
+ +

Who would you like to talk to?

+

Ask a question, think through an idea, or plan the next step with your team.

+
+ {error ?

Couldn’t load your agents.

+ : agents.isPending || session.isPending ?

Loading agents…

+ :
+ {(agents.data ?? []).slice(0, 6).map(agent => )} +
} + {openError &&

{openError}

} + {agents.data?.length === 0 &&

Add an agent to start a conversation.

} + +
; +} diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index fa3df153927..db488b86f2a 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -9,6 +9,7 @@ import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard"; import { WebhookUrlWarning } from "@/components/routine-triggers/WebhookUrlWarning"; import { SetupWizardNavigation, SetupWizardFooter } from "../components/SetupWizard"; import { RemoteMcpDesignExample } from "@/features/connections/remote-mcp/RemoteMcpDesignExample"; +import { AgentConversationSidebar } from "@/components/AgentConversationSidebar"; import { AgentChatPicker } from "@/components/AgentChatPicker"; import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard"; import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel"; @@ -1706,6 +1707,13 @@ export function DesignGuide() { + +
+
{}} onBrowse={() => {}} />
+
+
{}} />
+
+

diff --git a/ui/storybook/.storybook/preview-head.html b/ui/storybook/.storybook/preview-head.html new file mode 100644 index 00000000000..404cd6c309a --- /dev/null +++ b/ui/storybook/.storybook/preview-head.html @@ -0,0 +1,7 @@ + diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index 6a236fdf386..0dded8f745b 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useLayoutEffect, useState, type ReactNode } from "react"; import type { Preview } from "@storybook/react-vite"; import { MINIMAL_VIEWPORTS } from "storybook/viewport"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -734,9 +734,11 @@ function applyStorybookTheme(theme: "light" | "dark") { function StorybookProviders({ children, theme, + initialViewportWidth, }: { children: ReactNode; theme: "light" | "dark"; + initialViewportWidth?: number; }) { const [queryClient] = useState( () => @@ -754,10 +756,33 @@ function StorybookProviders({ installStorybookApiFixtures(); } - useEffect(() => { + const [viewportReady, setViewportReady] = useState(() => !initialViewportWidth || window.parent === window || window.innerWidth === initialViewportWidth); + useLayoutEffect(() => { + if (viewportReady) return; + const checkViewport = () => { + if (window.innerWidth === initialViewportWidth) setViewportReady(true); + }; + checkViewport(); + window.addEventListener("resize", checkViewport); + // A custom viewport, browser zoom, or unavailable manager must never leave + // a story blank indefinitely. Mount at the actual size after a bounded wait. + const fallback = window.setTimeout(() => setViewportReady(true), 500); + return () => { window.removeEventListener("resize", checkViewport); window.clearTimeout(fallback); }; + }, [initialViewportWidth, viewportReady]); + + const [themeReady, setThemeReady] = useState(false); + useLayoutEffect(() => { applyStorybookTheme(theme); + setThemeReady(true); }, [theme]); + // ThemeProvider reads the document on mount. Prepare it before mounting any + // app providers so they cannot paint the previous story's color mode. + // Fixed-viewport page stories should mount their responsive providers after + // the manager sizes the iframe, rather than briefly showing desktop navigation. + // Standalone iframe URLs use their actual browser width immediately. + if (!themeReady || !viewportReady) return null; + return ( @@ -787,8 +812,13 @@ const preview: Preview = { decorators: [ (Story, context) => { const theme = context.globals.theme === "light" ? "light" : "dark"; + const viewport = context.globals.viewport; + const styles = context.parameters.viewport?.options?.[viewport?.value]?.styles; + const width = styles?.[viewport?.isRotated ? "height" : "width"]; + const initialViewportWidth = context.parameters.waitForViewport && typeof width === "string" && /^\d+px$/.test(width) + ? Number.parseInt(width, 10) : undefined; return ( - + ); diff --git a/ui/storybook/prototypes/agent-chat-sidebar/AgentChatSidebarReview.tsx b/ui/storybook/prototypes/agent-chat-sidebar/AgentChatSidebarReview.tsx new file mode 100644 index 00000000000..19f9ca0084d --- /dev/null +++ b/ui/storybook/prototypes/agent-chat-sidebar/AgentChatSidebarReview.tsx @@ -0,0 +1,8 @@ +import { Layout } from "@/components/Layout"; +export { AgentChats as AgentChatSidebarLanding } from "@/pages/AgentChats"; +import type { SidebarScenario } from "./fixtures"; + +/** Story fixtures feed the production navigation and chat pages. */ +export function AgentChatSidebarReviewLayout(_props: { scenario: SidebarScenario }) { + return } />; +} diff --git a/ui/storybook/prototypes/agent-chat-sidebar/AgentConversationSidebar.tsx b/ui/storybook/prototypes/agent-chat-sidebar/AgentConversationSidebar.tsx new file mode 100644 index 00000000000..930c03afddd --- /dev/null +++ b/ui/storybook/prototypes/agent-chat-sidebar/AgentConversationSidebar.tsx @@ -0,0 +1 @@ +export { AgentConversationSidebar, type AgentConversationSidebarProps } from "@/components/AgentConversationSidebar"; diff --git a/ui/storybook/prototypes/agent-chat-sidebar/fixtures.ts b/ui/storybook/prototypes/agent-chat-sidebar/fixtures.ts new file mode 100644 index 00000000000..0e6fa7d388a --- /dev/null +++ b/ui/storybook/prototypes/agent-chat-sidebar/fixtures.ts @@ -0,0 +1,34 @@ +import { appearanceForPalette, type Agent } from "@paperclipai/shared"; +import { storybookAgents } from "../../fixtures/paperclipData"; + +export type SidebarScenario = "conversation" | "landing" | "large-team"; + +// Keep the first identity compatible with the existing chat fixture controller. +export const sidebarAgents: Agent[] = [ + { id: "agent-codex", urlKey: "researcher", name: "Researcher", title: "Customer & market research", palette: "arctic-blue" }, + { id: "chat-analyst", urlKey: "analyst", name: "Analyst", title: "Feedback analysis", palette: "orchid-peach" }, + { id: "chat-writer", urlKey: "writer", name: "Writer", title: "Client updates & drafts", palette: "solar-flare" }, + { id: "chat-strategist", urlKey: "strategist", name: "Strategist", title: "Planning & priorities", palette: "lime-lagoon" }, +].map(({ palette, ...agent }) => ({ + ...storybookAgents[0], + ...agent, + role: "general", + status: "idle", + reportsTo: null, + appearance: appearanceForPalette(palette as Parameters[0]), +})); + +export function sidebarRoster(scenario: SidebarScenario = "conversation"): Agent[] { + const roles = ["Product Designer", "Support Specialist", "Growth Marketer", "Security Engineer", "Data Scientist", "Finance Analyst", "Customer Success", "Content Editor", "QA Engineer", "Operations Lead", "Community Manager", "Product Engineer", "Technical Writer", "Recruiter", "Sales Researcher", "Release Engineer"]; + return [...sidebarAgents, ...roles.slice(0, scenario === "large-team" ? roles.length : 4).map((title, index) => ({ + ...sidebarAgents[index % sidebarAgents.length], + id: `sidebar-agent-${index}`, urlKey: `teammate-${index}`, name: title, title, + }))]; +} + +export const sidebarPreviews: Record = { + "agent-codex": "Clarifying your request", + "chat-analyst": "Feedback analysis", + "chat-writer": "Client update draft", + "chat-strategist": "Weekly priorities", +}; diff --git a/ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx b/ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx index d1b5d2cd221..f5babccb6dd 100644 --- a/ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx +++ b/ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx @@ -1,7 +1,9 @@ -import { useEffect, useLayoutEffect, useState, useRef } from "react"; +import { useLayoutEffect, useState, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/queryKeys"; import { agentRouteRef } from "@/lib/utils"; import { recordAgentChatVisit } from "@/lib/recent-agent-chats"; +import { getRecentTasksStorageKey } from "@/lib/recent-tasks"; import { AgentDetail } from "@/pages/AgentDetail"; import { AgentChat } from "@/pages/AgentChat"; import { IssueDetail } from "@/pages/IssueDetail"; @@ -9,7 +11,7 @@ import { Agents, AGENT_FILTER_TABS } from "@/pages/Agents"; import { Layout } from "@/components/Layout"; import { usePanel } from "@/context/PanelContext"; import { PluginLauncherProvider } from "@/plugins/launchers"; -import { Routes, Route, useNavigate } from "@/lib/router"; +import { Routes, Route, useNavigate, useLocation } from "@/lib/router"; import type { IssueChatComment, IssueChatLinkedRun, @@ -23,10 +25,14 @@ import { } from "@/lib/task-side-panel-state"; import { storybookAgents, + storybookAuthSession, + storybookCompanies, storybookIssues, storybookIssueDocuments, } from "../../fixtures/paperclipData"; import { chatAgents, chatIdentifier } from "./AgentChatSidebar"; +import { AgentChatSidebarReviewLayout, AgentChatSidebarLanding } from "../agent-chat-sidebar/AgentChatSidebarReview"; +import { sidebarAgents, sidebarRoster, type SidebarScenario } from "../agent-chat-sidebar/fixtures"; import { ChatEntryReviewProvider, ChatEntrySidebar, ChatEntryLanding, reviewRoster, type EntryScenario } from "../chat-entry/ChatEntryReview"; const agent = storybookAgents.find((agent) => agent.id === "agent-codex")!; @@ -48,8 +54,8 @@ const issue = { labelIds: [], currentExecutionWorkspace: null, }; -function chatIssueId(agentId: string) { - const index = reviewRoster("large-team").findIndex((item) => item.id === agentId); +function chatIssueId(agentId: string, roster = reviewRoster("large-team")) { + const index = roster.findIndex((item) => item.id === agentId); return agentId === agent.id ? issue.id : `00000000-0000-4000-8000-${String(index + 2).padStart(12, "0")}`; } const child = { @@ -208,6 +214,7 @@ export interface AgentChatPrototypeProps { contextInitiallyOpen?: boolean; taskComparison?: boolean; entryScenario?: EntryScenario; + sidebarScenario?: SidebarScenario; } /** Production pages with an in-memory API. No alternate chat controller. */ @@ -216,19 +223,25 @@ export function AgentChatPrototype({ contextInitiallyOpen = true, taskComparison = false, entryScenario, + sidebarScenario, }: AgentChatPrototypeProps) { const [ready, setReady] = useState(false); const navigate = useNavigate(); + const { pathname } = useLocation(); const initialRouteSet = useRef(false); const queryClient = useQueryClient(); const { setPanelVisible } = usePanel(); - useEffect(() => { + useLayoutEffect(() => { setPanelVisible(contextInitiallyOpen); }, [contextInitiallyOpen, setPanelVisible]); useLayoutEffect(() => { const originalFetch = window.fetch; const recentKey = `paperclip.recentAgentChats:${issue.companyId}:user-board`; const previousRecents = localStorage.getItem(recentKey); + const recentTasksKey = getRecentTasksStorageKey(issue.companyId, "user-board"); + const previousRecentTasks = localStorage.getItem(recentTasksKey); + if (sidebarScenario) localStorage.setItem(recentTasksKey, "[]"); + const hasPlan = scenario !== "empty" && (!sidebarScenario || contextInitiallyOpen); if (entryScenario) localStorage.setItem(recentKey, "[]"); const chats = new Map< string, @@ -252,7 +265,7 @@ export function AgentChatPrototype({ }; let failSend = scenario === "error"; let active = scenario === "working"; - const fixtureAgents = (entryScenario ? reviewRoster(entryScenario) : chatAgents).map((a) => ({ + const fixtureAgents = (sidebarScenario ? sidebarRoster(sidebarScenario) : entryScenario ? reviewRoster(entryScenario) : chatAgents).map((a) => ({ ...a, status: scenario === "paused" && a.id === agent.id @@ -262,21 +275,30 @@ export function AgentChatPrototype({ for (const a of fixtureAgents) { const task = { ...issue, - id: chatIssueId(a.id), - identifier: entryScenario ? `PAP-${400 + fixtureAgents.findIndex((item) => item.id === a.id)}` : chatIdentifier(a.id), + id: chatIssueId(a.id, fixtureAgents), + identifier: sidebarScenario ? `PAP-${500 + fixtureAgents.findIndex((item) => item.id === a.id)}` : entryScenario ? `PAP-${400 + fixtureAgents.findIndex((item) => item.id === a.id)}` : chatIdentifier(a.id), assigneeAgentId: a.id, title: `Chat with ${a.name}`, conversationAgentId: taskComparison ? null : a.id, conversationUserId: taskComparison ? null : "user-board", conversationState: "waiting" as const, }; - if (entryScenario ? entryScenario !== "first-use" && a.id === agent.id : scenario !== "empty" || a.id !== agent.id) chats.set(a.id, task); + const hasExistingChat = sidebarScenario + ? (sidebarScenario === "large-team" || sidebarAgents.some(initial => initial.id === a.id)) && (scenario !== "empty" || a.id !== agent.id) + : entryScenario ? entryScenario !== "first-use" && a.id === agent.id : scenario !== "empty" || a.id !== agent.id; + if (hasExistingChat) chats.set(a.id, task); let history = a.id === agent.id && scenario !== "empty" ? scenario === "working" ? comments.slice(0, 1) : [...comments] : []; + if (sidebarScenario && a.id === agent.id && scenario !== "empty") { + history = [ + { ...comment("briefing-request", "Can you prepare a customer briefing for my Acme meeting on Thursday?"), issueId: task.id }, + { ...comment("briefing-response", "Absolutely. What should I focus on—renewal risks, expansion opportunities, or a general account overview?\n\nI can have a one-page briefing ready for you to review on Wednesday.", true), issueId: task.id, runId: null }, + ]; + } if (scenario === "long" && a.id === agent.id) history = [ ...Array.from({ length: 24 }, (_, i) => ({ @@ -310,12 +332,12 @@ export function AgentChatPrototype({ state: { tabs: taskComparison ? [taskPanelPropertiesTab()] - : [ - taskPanelDocumentTab("plan", "Launch plan"), + : sidebarScenario && !hasPlan ? [taskPanelSubtasksTab()] : [ + taskPanelDocumentTab("plan", sidebarScenario ? "Customer briefing" : "Launch plan"), taskPanelArtifactsTab(), taskPanelSubtasksTab(), ], - activeTabId: taskComparison ? "properties" : "document:plan", + activeTabId: taskComparison ? "properties" : sidebarScenario && !hasPlan ? "subtasks" : "document:plan", }, launcherOpen: false, userInteracted: true, @@ -343,6 +365,7 @@ export function AgentChatPrototype({ init?.body && typeof init.body === "string" ? JSON.parse(init.body) : {}; + if (/\/companies\/[^/]+\/chats$/.test(path)) return Response.json([...chats.values()]); const chatRef = path.match(/\/chats\/([^/]+)$/)?.[1]; if (chatRef) { const a = fixtureAgents.find( @@ -356,8 +379,8 @@ export function AgentChatPrototype({ conversationAgentId: a.id, conversationUserId: "user-board", conversationState: "waiting", - id: chatIssueId(a.id), - identifier: entryScenario ? `PAP-${400 + fixtureAgents.findIndex((item) => item.id === a.id)}` : chatIdentifier(a.id), + id: chatIssueId(a.id, fixtureAgents), + identifier: sidebarScenario ? `PAP-${500 + fixtureAgents.findIndex((item) => item.id === a.id)}` : entryScenario ? `PAP-${400 + fixtureAgents.findIndex((item) => item.id === a.id)}` : chatIdentifier(a.id), assigneeAgentId: a.id, title: `Chat with ${a.name}`, }); @@ -450,7 +473,7 @@ export function AgentChatPrototype({ return Response.json({ activePauseHold: null, activeHolds: [] }); if (path.endsWith("/runs")) return Response.json( - task.id === issue.id && scenario !== "empty" + task.id === issue.id && scenario !== "empty" && !sidebarScenario ? [ { ...run, @@ -495,14 +518,15 @@ export function AgentChatPrototype({ ], }, }]); + const currentPlan = sidebarScenario ? { ...plan, title: "Customer briefing", body: "# Acme customer briefing\n\nPrepare a one-page briefing for Thursday’s meeting.\n\n## Confirm the focus\nClarify whether to prioritize renewal risks, expansion opportunities, or a general account overview.\n\n## Gather context\nReview customer notes, product usage, and recent feedback. Link the sources.\n\n## Draft for review\nHave the briefing ready on Wednesday, with clear next steps for the meeting." } : plan; if (path.endsWith("/documents/plan")) - return task.id === issue.id && scenario !== "empty" - ? Response.json(plan) + return task.id === issue.id && hasPlan + ? Response.json(currentPlan) : Response.json({ error: "No plan" }, { status: 404 }); if (path.endsWith("/documents/notes")) return Response.json(notes); if (path.endsWith("/documents")) return Response.json( - task.id === issue.id && scenario !== "empty" ? [plan, notes] : [], + task.id === issue.id && hasPlan ? (sidebarScenario ? [currentPlan] : [plan, notes]) : [], ); if (/\/issues\/[^/]+$/.test(path)) return Response.json( @@ -512,7 +536,7 @@ export function AgentChatPrototype({ /\/companies\/[^/]+\/issues$/.test(path) && (url.searchParams.has("parentId") || url.searchParams.has("descendantOf")) ) - return Response.json(scenario === "empty" ? [] : [child]); + return Response.json(scenario === "empty" || sidebarScenario ? [] : [child]); if (/\/companies\/[^/]+\/agents$/.test(path)) return Response.json(fixtureAgents); if (/\/agents\/[^/]+$/.test(path)) @@ -537,9 +561,33 @@ export function AgentChatPrototype({ return Response.json([]); }; queryClient.clear(); + if (sidebarScenario) { + // These stories review a settled page, not a chain of loading screens. + // Seed the same query keys and shapes consumed by the production chat. + queryClient.setQueryData(queryKeys.agentChats.list(issue.companyId, "user-board"), [...chats.values()]); + queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession); + queryClient.setQueryData(queryKeys.companies.list("user-board"), { companies: storybookCompanies, unauthorized: false }); + queryClient.setQueryData(queryKeys.agents.list(issue.companyId), fixtureAgents); + queryClient.setQueryData(queryKeys.instance.experimentalSettings, { + enableAgentChat: scenario !== "disabled", enableStreamlinedUi: true, + enableClassicTaskInterface: false, enableExperimentalFileViewer: true, + }); + for (const a of fixtureAgents) { + const task = chats.get(a.id) ?? null; + queryClient.setQueryData(queryKeys.agentChats.detail(issue.companyId, "user-board", a.id), task); + if (task) { + queryClient.setQueryData(queryKeys.issues.detail(task.id), task); + queryClient.setQueryData(queryKeys.issues.comments(task.id), { pages: [[...(messages.get(task.id) ?? [])].reverse()], pageParams: [null] }); + } + } + } setReady(true); return () => { window.fetch = originalFetch; + if (sidebarScenario) { + if (previousRecentTasks === null) localStorage.removeItem(recentTasksKey); + else localStorage.setItem(recentTasksKey, previousRecentTasks); + } if (entryScenario) { if (previousRecents === null) localStorage.removeItem(recentKey); else localStorage.setItem(recentKey, previousRecents); @@ -547,21 +595,21 @@ export function AgentChatPrototype({ } queryClient.clear(); }; - }, [scenario, taskComparison, entryScenario, queryClient]); - useEffect(() => { + }, [scenario, taskComparison, entryScenario, sidebarScenario, contextInitiallyOpen, queryClient]); + useLayoutEffect(() => { if (ready && !initialRouteSet.current) { initialRouteSet.current = true; navigate( - `/PAP/${entryScenario === "first-use" ? "chats" : entryScenario === "paused" ? "chats/operations" : taskComparison ? `issues/${issue.id}` : "chats/agent-codex"}`, + `/PAP/${sidebarScenario === "landing" || entryScenario === "first-use" ? "chats" : entryScenario === "paused" ? "chats/operations" : taskComparison ? `issues/${issue.id}` : "chats/agent-codex"}`, { replace: true }, ); } - }, [ready, navigate, taskComparison, entryScenario]); - if (!ready) return null; + }, [ready, navigate, taskComparison, entryScenario, sidebarScenario]); + if (!ready || pathname === "/PAP/storybook") return null; const routes = ( - : undefined} />}> - } /> + : : undefined} />}> + : } /> } /> } /> } /> diff --git a/ui/storybook/stories/agent-chat-sidebar.stories.tsx b/ui/storybook/stories/agent-chat-sidebar.stories.tsx new file mode 100644 index 00000000000..ec3e14b8f7b --- /dev/null +++ b/ui/storybook/stories/agent-chat-sidebar.stories.tsx @@ -0,0 +1,31 @@ +import { userEvent, within } from "storybook/test"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AgentChatPrototype } from "../prototypes/agent-chat/AgentChatPrototype"; + +const meta = { + title: "Design explorations/Agent chat sidebar/Pages", + component: AgentChatPrototype, + parameters: { layout: "fullscreen", docs: { description: { component: + "The production chat sidebar and pages inside the real Paperclip Layout, including the existing transcript, composer, and context panel. Click Chat to see the landing page, search by name or role, or use + to start or reopen an agent conversation. Each agent has one conversation; sends stay in this Storybook's in-memory fixtures." + } } }, + args: { sidebarScenario: "conversation", scenario: "returning", contextInitiallyOpen: false }, + render: args => , +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Conversation: Story = { name: "01 · Conversation · Start here" }; +export const ChatLanding: Story = { name: "02 · Click Chat · Choose an agent", args: { sidebarScenario: "landing", scenario: "empty" } }; +export const FirstMessage: Story = { name: "03 · First conversation", args: { scenario: "empty" } }; +export const LargerTeam: Story = { name: "04 · Larger team · Scroll and search", args: { sidebarScenario: "large-team" } }; +export const ContextPanel: Story = { name: "05 · Existing plan panel", args: { contextInitiallyOpen: true } }; +export const Light: Story = { name: "06 · Light theme", globals: { theme: "light" } }; +export const Mobile: Story = { name: "07 · Mobile · Agents in navigation drawer", parameters: { waitForViewport: true }, globals: { viewport: { value: "mobile", isRotated: false } } }; +export const MobileLanding: Story = { name: "08 · Mobile · Choose an agent", args: { sidebarScenario: "landing", scenario: "empty" }, parameters: { waitForViewport: true }, globals: { viewport: { value: "mobile", isRotated: false } } }; + +export const AddChat: Story = { + name: "09 · Add chat · One per agent", + play: async ({ canvasElement }) => { + await userEvent.click(await within(canvasElement).findByRole("button", { name: "Add chat" })); + }, +}; diff --git a/ui/storybook/stories/agent-conversation-sidebar.stories.tsx b/ui/storybook/stories/agent-conversation-sidebar.stories.tsx new file mode 100644 index 00000000000..f0917a40002 --- /dev/null +++ b/ui/storybook/stories/agent-conversation-sidebar.stories.tsx @@ -0,0 +1,58 @@ +import { userEvent, within, expect } from "storybook/test"; +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AgentConversationSidebar } from "../prototypes/agent-chat-sidebar/AgentConversationSidebar"; +import { sidebarAgents, sidebarPreviews, sidebarRoster } from "../prototypes/agent-chat-sidebar/fixtures"; + +function InteractiveSidebar(args: React.ComponentProps) { + const [activeId, setActiveId] = useState(args.activeId); + const [agents, setAgents] = useState(args.agents); + const selectAgent: NonNullable = agent => { + setActiveId(agent.id); + setAgents(current => current.some(item => item.id === agent.id) ? current : [...current, agent]); + }; + return

; +} +const meta = { + title: "Design explorations/Agent chat sidebar/Components", + component: AgentConversationSidebar, + parameters: { layout: "fullscreen", docs: { description: { component: + "Secondary agent navigation at the same width as the app's other contextual sidebars. Uses production AgentAvatar, Input, and Button. The entire row is a link; selection and search are interactive. Search matches agent name, title, and role. Escape clears the query. Browse all agents links to the existing agent directory in the page stories." + } } }, + args: { agents: sidebarAgents, availableAgents: sidebarRoster(), activeId: "agent-codex", previews: sidebarPreviews }, + render: args => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = { name: "01 · Agent list · Interactive" }; +export const Search: Story = { name: "02 · Search by role", args: { initialSearch: "research" } }; +export const NoResults: Story = { name: "03 · No matches", args: { initialSearch: "accountant" } }; +export const Loading: Story = { name: "04 · Loading", args: { agents: [], availableAgents: [], loading: true } }; +export const NoAgents: Story = { name: "05 · No agents", args: { agents: [], availableAgents: [] } }; +export const LargeTeam: Story = { name: "06 · Scrollable team", args: { agents: sidebarRoster("large-team"), availableAgents: sidebarRoster("large-team") } }; +export const LongNames: Story = { name: "07 · Long names and paused agent", args: { agents: [ + { ...sidebarAgents[0], name: "Customer & Competitive Research", title: "Research across customers, markets, and competitors" }, + { ...sidebarAgents[1], status: "paused" }, + ...sidebarAgents.slice(2), +], previews: {} } }; +export const Light: Story = { name: "08 · Light theme", globals: { theme: "light" } }; +export const HistoryUnavailable: Story = { + name: "10 · Partial history failure", + args: { historyError: new Error("History unavailable") }, +}; + +export const AddAndReopenChat: Story = { + name: "09 · Add and reopen · One per agent", + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(canvas.getByRole("button", { name: "Add chat" })); + await userEvent.type(await page.findByPlaceholderText("Search by name or role…"), "Product Designer"); + await userEvent.click(page.getByRole("option", { name: /Product Designer.*New chat/ })); + await expect(await canvas.findByRole("link", { name: /Product Designer/ })).toHaveAttribute("aria-current", "page"); + await userEvent.click(canvas.getByRole("button", { name: "Add chat" })); + await userEvent.type(await page.findByPlaceholderText("Search by name or role…"), "Product Designer"); + await userEvent.click(page.getByRole("option", { name: /Product Designer.*Open chat/ })); + await expect(await canvas.findAllByRole("link", { name: /Product Designer/ })).toHaveLength(1); + }, +};