feat(ui): add secondary navigation for agent chat (#14706)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent Chat already provides a persistent conversation with each
agent.
> - Its shortcuts share the primary navigation and do not give chats a
dedicated place.
> - People need to find agents, start a chat, and switch conversations
without moving the page layout.
> - This pull request adds a secondary chat sidebar and a landing page
around the existing chat surface.
> - The same conversation, composer, history, and context panel remain
in use.

## Linked Issues or Issue Description

Refs #13283 and #13420. This extends the existing experimental Agent
Chat navigation after review of the component and page stories. It
supports the CEO Chat roadmap item through the existing task-backed
conversation model.

**Subsystem affected**

The board UI and the company-scoped conversation list API.

**Current behavior**

Chat shortcuts sit inside the primary navigation. There is no dedicated
landing page with a searchable conversation list. A separate landing
header also moves the sidebar when an agent is selected.

**Proposed behavior**

Show a Chat entry in primary navigation. Keep a searchable agent sidebar
beside the chat content. The plus button starts or reopens the current
user's single conversation with that agent. Keep the header and sidebar
in the same positions before and after selection.

**Reason and benefit**

People can find agents and return to persistent conversations without
leaving the chat area or creating duplicate chats.

**Breaking changes**

The experimental chat navigation changes. Explicitly adding a chat now
resolves its conversation immediately. Direct visits to unused agent
chat URLs remain read-only. The existing per-agent routes and message
contracts remain compatible. No database migration is required.

## What Changed

- Add an account- and company-scoped conversation list endpoint with the
existing access checks, feature gate, and OpenAPI entry.
- Add the live secondary sidebar, landing page, avatars, search, loading
states, errors, and retry controls.
- Make the agent picker wait for chat creation and display failures.
Existing agents reopen the same conversation. A dismissed selection
cannot close a reopened picker or navigate over a newer choice.
- Preserve recent-activity ordering and terminated agents’ chat history.
Scope live list refreshes to the current user’s conversation events. A
failed historical-agent lookup leaves healthy chats usable and offers a
focused retry.
- Keep the sidebar and header stable across chat routes. Keep mobile
selection in the navigation drawer.
- Use the production components in Storybook. Prepare the theme and
mobile viewport before mounting the page to avoid the startup flash.
- Update product documentation, the design guide, and navigation tests.
Replace old browser expectations for stars and recent shortcuts with
persistent conversation and layout coverage.

## Verification

- `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates` pass
after rebasing onto master.
- Focused UI tests pass, including 105 sidebar, picker, and live-update
checks after review fixes. The 33 conversation service and route tests
and 10 OpenAPI checks pass, including ownership, feature gating, and
concurrent creation.
- The full local test run passed 14,163 server tests before three
environment or timeout failures. The embedded Postgres startup,
connector socket, and native runner failures all passed direct reruns.
- Browser test-drive verification covers a real provider reply, add and
reopen, persisted history after reload, no-match search recovery, mobile
drawer dismissal, and top-aligned context panels.
- Browser measurements confirm that the sidebar has the same position
and dimensions on the landing page and an agent conversation.
- Storybook builds and its add-and-reopen interaction passes.
- The revised browser regression passes locally against a freshly built
throwaway instance. It covers stable sidebar geometry, add/reopen
uniqueness, drafts, search, history, and terminated-agent history after
reload. The full CI browser suite also passes.
- Latest commit `b323577d9523180104df4000eaceedea2772608c`: all 54
completed checks pass, including the complete server/workspace/browser
suites, aggregate verification, build/typecheck, security scans, and
canary packaging. The two Storybook jobs are skipped by their workflow
conditions. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/36714052050).
- Greptile reviewed this same commit at 5/5 with no remaining actionable
findings; all review threads are resolved.
- Reviewer path: enable Agent Chat, click Chat, use plus to choose an
agent, send a message, switch away, and reopen that agent. One
conversation must remain, with its history intact.

## Risks

- The new sidebar lists persistent conversations instead of starred and
recent shortcuts.
- Chat creation is asynchronous. Errors stay visible in the picker, and
delayed responses cannot navigate into a previous company or account.
- The shell adjustment is limited to chat routes and preserves the
existing conversation implementation.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, code execution, and browser
tools. The session does not expose the exact API model ID or
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#123` / `Refs #123` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-30 07:35:32 -05:00
1 parent d72389bee2
commit 1b48e73e0b
32 files changed
+894 -118

No files matched your search

+11
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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(
+10 -1
View File
@@ -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;
+10
View File
@@ -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",
+4
View File
@@ -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),
+85 -66
View File
@@ -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 },
}));
}
});
+2
View File
@@ -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) {
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="chats" element={<AgentChats />} />
<Route path="chats/:agentRef" element={<AgentChat />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
{import.meta.env.DEV ? (
+1
View File
@@ -1,6 +1,7 @@
import type { Issue } from "@paperclipai/shared";
import { api } from "./client";
export const agentChatsApi = {
list: (companyId: string) => api.get<Issue[]>(`/companies/${companyId}/chats`),
get: (companyId: string, agentRef: string) =>
api.get<Issue | null>(
`/companies/${companyId}/chats/${encodeURIComponent(agentRef)}`,
+30 -1
View File
@@ -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<void>(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<HTMLInputElement>("[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");
+36 -9
View File
@@ -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<void>;
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
<DialogContent aria-describedby={undefined} className="gap-0 overflow-hidden p-0 sm:max-w-md">
<div className="px-4 pt-4 pb-3">
<DialogTitle>Chat with an agent</DialogTitle>
{props.existingChatAgentIds && <p className="mt-2 text-sm text-muted-foreground">One conversation per agent. Pick up where you left off.</p>}
</div>
{/* The dialog unmounts its content on close, so each search starts empty. */}
<AgentChatPickerResults {...props} onSelect={(agent) => {
onOpenChange(false);
props.onSelect(agent);
}} />
<AgentChatPickerResults key={String(open)} {...props} onComplete={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
}
function AgentChatPickerResults({ agents, onSelect, loading, error, onRetry }: Omit<AgentChatPickerProps, "open" | "onOpenChange">) {
function AgentChatPickerResults({ agents, onSelect, onComplete, loading, error, onRetry, existingChatAgentIds, renderAgentIcon }: Omit<AgentChatPickerProps, "open" | "onOpenChange"> & { onComplete: () => void }) {
const mounted = useRef(false);
const selection = useRef<AbortController | null>(null);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; selection.current?.abort(); };
}, []);
const [search, setSearch] = useState("");
const [openingId, setOpeningId] = useState<string | null>(null);
const [selectionError, setSelectionError] = useState<string | null>(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 (
<Command>
<CommandInput
@@ -42,6 +65,8 @@ function AgentChatPickerResults({ agents, onSelect, loading, error, onRetry }: O
value={search}
onValueChange={setSearch}
/>
{selectionError && <p role="alert" className="px-4 py-3 text-sm text-destructive">{selectionError}</p>}
{openingId && <p role="status" className="sr-only">Opening conversation…</p>}
{error ? (
<div role="alert" className="flex flex-col items-start gap-2 p-4 text-sm">
<p>Couldn’t load agents. Try again.</p>
@@ -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"
>
<AgentIcon icon={agent.icon} className="size-4 shrink-0" />
{renderAgentIcon ? renderAgentIcon(agent) : <AgentIcon icon={agent.icon} className="size-4 shrink-0" />}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{agent.name}</span>
<span className="truncate text-xs text-muted-foreground">{agent.title ?? agent.role}</span>
</span>
{existingChatAgentIds && <span className="shrink-0 text-xs text-muted-foreground">{existingChatAgentIds.includes(agent.id) ? "Open chat" : "New chat"}</span>}
{agent.status === "paused" && <span className="text-xs text-(--status-agent-paused)">Paused</span>}
{agent.status === "terminated" && <span className="text-xs text-muted-foreground">Terminated</span>}
{agent.status === "pending_approval" && <span className="text-xs text-muted-foreground">Awaiting approval</span>}
@@ -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<string, string>;
loading?: boolean;
error?: Error | null;
onRetry?: () => void;
initialSearch?: string;
onSelect?: (agent: Agent, signal?: AbortSignal) => void | Promise<void>;
onBrowse?: () => void;
onAddChat?: (agent: Agent, signal?: AbortSignal) => void | Promise<void>;
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<HTMLInputElement>(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 <aside aria-labelledby={headingId} className="flex h-full min-h-0 flex-col border-r border-border bg-background">
<div className="flex shrink-0 flex-col gap-4 px-3 pb-4 pt-4">
<div className="flex items-center justify-between gap-2">
<h2 id={headingId} className="text-sm font-semibold">Chat</h2>
<Button variant="outline" size="icon-sm" aria-label="Add chat" title="Add chat" onClick={() => setPickerOpen(true)}>
<Plus className="size-4" />
</Button>
</div>
<div className="relative">
<Search aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input ref={searchRef} aria-label="Search agents" placeholder="Find an agent" value={search} onChange={event => setSearch(event.target.value)}
onKeyDown={event => { if (event.key === "Escape") { event.stopPropagation(); setSearch(""); } }}
className="h-8 pl-8 pr-8 text-xs md:text-xs" />
{search && <Button variant="ghost" size="icon-xs" aria-label="Clear search" className="absolute right-1 top-1/2 -translate-y-1/2" onClick={focusSearch}><X className="size-3" /></Button>}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-3 pb-3 scrollbar-auto-hide">
<p className="px-2 text-(length:--text-micro) font-medium uppercase tracking-widest text-muted-foreground">
{query ? "Search results" : "Teammates"}
</p>
<span role="status" className="sr-only">{loading ? "Loading agents" : `${visible.length} ${visible.length === 1 ? "agent" : "agents"}`}</span>
{historyLoading && <p role="status" className="px-2 text-xs text-muted-foreground">Loading chat history…</p>}
{historyError && <div role="alert" className="flex flex-col items-start gap-2 px-2 py-3">
<p className="text-xs text-muted-foreground">Some chat history couldn’t load.</p>
<Button variant="outline" size="sm" onClick={onRetryHistory}>Retry chat history</Button>
</div>}
{error ? <div role="alert" className="flex flex-col items-start gap-2 px-2 py-6"><p className="text-sm">Couldn’t load your chats.</p><Button variant="outline" size="sm" onClick={onRetry}>Try again</Button></div> : loading ? <div aria-hidden="true" className="flex flex-col gap-1">
{[0, 1, 2, 3].map(index => <div key={index} className="flex items-center gap-3 rounded-md px-2 py-3 motion-safe:animate-pulse">
<div className="size-8 shrink-0 rounded-lg bg-muted" />
<div className="flex flex-1 flex-col gap-2"><div className="h-3 w-2/3 rounded-sm bg-muted" /><div className="h-2 w-full rounded-sm bg-muted" /></div>
</div>)}
</div> : visible.length ? <nav aria-label="Agent conversations" className="flex flex-col gap-1">
{visible.map(agent => <Link key={agent.id} to={`/chats/${encodeURIComponent(agent.status === "terminated" ? agent.id : agentRouteRef(agent))}`}
aria-current={activeId === agent.id ? "page" : undefined}
title={`${agent.name}${agent.title ? ` · ${agent.title}` : ""}`}
onClick={event => { if (onSelect) { event.preventDefault(); onSelect(agent); } }}
className={cn("flex min-w-0 items-center gap-3 rounded-md px-2 py-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", activeId === agent.id && "bg-accent text-accent-foreground")}>
<AgentAvatar agent={agent} size={32} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{agent.name}</span>
<span className="truncate text-xs text-muted-foreground">{agent.status === "terminated" ? "Terminated" : agent.status === "paused" ? "Paused" : previews[agent.id] ?? agent.title ?? "Start a conversation"}</span>
</span>
</Link>)}
</nav> : !agents.length && (historyLoading || historyError) ? null : <div className="flex flex-col items-start gap-2 px-2 py-6">
<p className="text-sm font-medium">{agents.length ? "No agents found" : "No chats yet"}</p>
<p className="text-xs leading-relaxed text-muted-foreground">{agents.length ? "Try another name or role." : "Choose an agent to start a conversation."}</p>
{!agents.length && <Button variant="outline" size="sm" onClick={() => setPickerOpen(true)}>Choose an agent</Button>}
{agents.length > 0 && <Button variant="outline" size="sm" onClick={focusSearch}>Clear search</Button>}
</div>}
</div>
<div className="shrink-0 px-3 py-4">
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 px-2 text-muted-foreground" asChild={!onBrowse} onClick={onBrowse}>
{onBrowse ? <><Users className="size-4" />Browse all agents</> : <Link to="/agents/all"><Users className="size-4" />Browse all agents</Link>}
</Button>
</div>
<AgentChatPicker agents={availableAgents} open={pickerOpen} onOpenChange={setPickerOpen}
existingChatAgentIds={agents.map(agent => agent.id)} loading={loading} error={error} onRetry={onRetry}
renderAgentIcon={agent => <AgentAvatar agent={agent} size={32} />}
onSelect={async (agent, signal) => {
if (onAddChat) await onAddChat(agent, signal);
else if (onSelect) await onSelect(agent, signal);
else navigate(`/chats/${encodeURIComponent(agentRouteRef(agent))}`);
if (!signal?.aborted) setSearch("");
}} />
</aside>;
}
@@ -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 }) => <a href={to} {...props}>{children}</a>,
}));
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(<QueryClientProvider client={client}><AgentConversationsSidebar /></QueryClientProvider>); });
}
async function addChat() { await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Add chat"]')!.click()); }
async function choose(name: string) {
await act(async () => [...document.querySelectorAll<HTMLElement>("[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<HTMLAnchorElement>('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<typeof chat>) => 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<HTMLButtonElement>('[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<HTMLInputElement>('[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<HTMLButtonElement>('[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();
});
@@ -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 <AgentConversationSidebar key={`${companyId}:${userId}`} agents={conversations} availableAgents={roster}
activeId={active?.id} previews={previews}
loading={agents.isPending || chats.isPending || session.isPending}
error={agents.error ?? chats.error ?? session.error}
onRetry={() => { 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);
}} />;
}
+9 -3
View File
@@ -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 ? <AgentConversationsSidebar /> : shellRoute.builtInContextualSurface === "agent" && agentId ? (
<AgentContextualSidebar agentRef={agentId} />
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
<SetupWizardSidebarOutlet><RoutineContextualSidebar routineId={routineId} /></SetupWizardSidebarOutlet>
@@ -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
+2 -2
View File
@@ -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 && <SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} />}
{showDecisions ? (
<SidebarNavItem
to="/decisions"
@@ -248,7 +249,6 @@ export function Sidebar({ children }: { children?: ReactNode }) {
) : null}
{children}
{agentChatEnabled && !children && <SidebarAgentChats />}
{streamlinedUiEnabled ? (
<SidebarRecentTasks companyId={selectedCompanyId} liveIssueIds={liveIssueIds} />
@@ -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");
+5
View File
@@ -1289,6 +1289,11 @@ function invalidateActivityQueries(
}
if (entityType === "issue") {
const chatListKey = queryKeys.agentChats.list(companyId, currentActor.userId);
const knownChat = entityId && queryClient.getQueryData<Issue[]>(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"] });
+54
View File
@@ -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<Issue[]>(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]);
}
+2
View File
@@ -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,
},
+12 -5
View File
@@ -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 (
<p className="text-sm text-muted-foreground">Loading conversation…</p>
);
@@ -73,10 +80,10 @@ export function AgentChat() {
history remains available through task links.
</p>
);
if (agents.error || chat.error)
if (agents.error || chat.error || !rosterAgent && historyAgent.error)
return (
<p className="text-sm text-destructive">
{(agents.error ?? chat.error)?.message}
{(agents.error ?? chat.error ?? historyAgent.error)?.message}
</p>
);
if (!agent)
+46
View File
@@ -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 <AgentChatsContent key={`${companyId}:${userId}`} companyId={companyId} userId={userId}
enabled={enabled} loaded={loaded} agents={agents} session={session} />;
}
function AgentChatsContent({ companyId, userId, enabled, loaded, agents, session }: Pick<ReturnType<typeof useAgentChatNavigation>, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "session">) {
const openChat = useOpenAgentChat(companyId, userId);
const [openingId, setOpeningId] = useState<string | null>(null);
const [openError, setOpenError] = useState<string | null>(null);
if (!loaded) return <p role="status" className="text-sm text-muted-foreground">Loading chat…</p>;
if (!enabled) return <p className="text-sm text-muted-foreground">Agent Chat is disabled. Enable it in Experimental settings.</p>;
const error = agents.error ?? session.error;
return <div className="mx-auto flex h-full max-w-xl flex-col justify-center gap-6 px-4 py-12">
<div className="flex flex-col gap-3">
<MessageCircle className="size-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Who would you like to talk to?</h1>
<p className="text-sm leading-relaxed text-muted-foreground">Ask a question, think through an idea, or plan the next step with your team.</p>
</div>
{error ? <div role="alert" className="flex flex-col items-start gap-3"><p className="text-sm">Couldn’t load your agents.</p><Button variant="outline" onClick={() => { void agents.refetch(); void session.refetch(); }}>Try again</Button></div>
: agents.isPending || session.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agents…</p>
: <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{(agents.data ?? []).slice(0, 6).map(agent => <button key={agent.id} type="button" disabled={openingId !== null}
onClick={async () => {
setOpeningId(agent.id); setOpenError(null);
try { await openChat(agent); } catch (error) { setOpenError(error instanceof Error ? error.message : "Couldn’t open chat. Try again."); } finally { setOpeningId(null); }
}} className="flex items-center gap-3 rounded-lg border border-border p-4 text-left hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
<AgentAvatar agent={agent} size={32} />
<span className="flex min-w-0 flex-col gap-1"><span className="truncate text-sm font-medium">{agent.name}</span><span className="text-xs text-muted-foreground">{openingId === agent.id ? "Opening chat…" : agent.title ?? agent.role}</span></span>
</button>)}
</div>}
{openError && <p role="alert" className="text-sm text-destructive">{openError}</p>}
{agents.data?.length === 0 && <p className="text-sm text-muted-foreground">Add an agent to start a conversation.</p>}
<Button variant="ghost" className="self-start" asChild><Link to="/agents/all">Browse all agents</Link></Button>
</div>;
}
+8
View File
@@ -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() {
</SubSection>
<SubSection title="Agent chat picker">
<AgentChatPickerExample />
<SubSection title="Agent conversation sidebar">
<div className="flex flex-wrap gap-4">
<div className="h-96 w-60"><AgentConversationSidebar agents={[]} onSelect={() => {}} onBrowse={() => {}} /></div>
<div className="h-96 w-60"><AgentConversationSidebar agents={[]} loading /></div>
<div className="h-96 w-60"><AgentConversationSidebar agents={[]} error={new Error("Unavailable")} onRetry={() => {}} /></div>
</div>
</SubSection>
</SubSection>
<SubSection title="Sidebar nav items">
<p className="text-sm text-muted-foreground">
@@ -0,0 +1,7 @@
<script>
// Give the iframe a dark canvas before the JS/CSS graph loads. React applies
// the selected story theme before mounting the app (including light stories).
const storybookTheme = new URLSearchParams(location.search).get("globals")?.includes("theme:light") ? "light" : "dark";
document.documentElement.classList.toggle("dark", storybookTheme === "dark");
document.documentElement.style.colorScheme = storybookTheme;
</script>
+33 -3
View File
@@ -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 (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
@@ -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 (
<StorybookProviders key={`${context.id}:${theme}`} theme={theme}>
<StorybookProviders key={`${context.id}:${theme}`} theme={theme} initialViewportWidth={initialViewportWidth}>
<Story />
</StorybookProviders>
);
@@ -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 <Layout sidebarSections={<></>} />;
}
@@ -0,0 +1 @@
export { AgentConversationSidebar, type AgentConversationSidebarProps } from "@/components/AgentConversationSidebar";
@@ -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<typeof appearanceForPalette>[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<string, string> = {
"agent-codex": "Clarifying your request",
"chat-analyst": "Feedback analysis",
"chat-writer": "Client update draft",
"chat-strategist": "Weekly priorities",
};
@@ -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 = (
<Routes>
<Route path="/:companyPrefix" element={<Layout sidebarSections={entryScenario ? <ChatEntrySidebar /> : undefined} />}>
<Route path="chats" element={<ChatEntryLanding />} />
<Route path="/:companyPrefix" element={sidebarScenario ? <AgentChatSidebarReviewLayout scenario={sidebarScenario} /> : <Layout sidebarSections={entryScenario ? <ChatEntrySidebar /> : undefined} />}>
<Route path="chats" element={sidebarScenario ? <AgentChatSidebarLanding /> : <ChatEntryLanding />} />
<Route path="chats/:agentRef" element={<AgentChat />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="agents" element={<Agents />} />
@@ -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 => <AgentChatPrototype key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof AgentChatPrototype>;
export default meta;
type Story = StoryObj<typeof meta>;
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" }));
},
};
@@ -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<typeof AgentConversationSidebar>) {
const [activeId, setActiveId] = useState(args.activeId);
const [agents, setAgents] = useState(args.agents);
const selectAgent: NonNullable<typeof args.onSelect> = agent => {
setActiveId(agent.id);
setAgents(current => current.some(item => item.id === agent.id) ? current : [...current, agent]);
};
return <div className="h-dvh w-60 bg-background text-foreground"><AgentConversationSidebar {...args} agents={agents} activeId={activeId} onSelect={selectAgent} onAddChat={selectAgent} onBrowse={undefined} /></div>;
}
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 => <InteractiveSidebar key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof AgentConversationSidebar>;
export default meta;
type Story = StoryObj<typeof meta>;
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);
},
};