From 5282cabde84624320e63bd942f6ed5124952f2a2 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:19:13 -0500 Subject: [PATCH] fix(ui): make every agent reachable from Chats (#13420) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Experimental Agent Chat gives each person a persistent conversation with each agent. > - The sidebar only showed starred or previously visited agents. > - A person could not start a conversation with an agent absent from that list. > - This pull request adds a searchable agent picker and a default sidebar entry. > - People can now reach every company agent while keeping their frequent conversations close. ## Linked Issues or Issue Description Refs #13283, which introduced the existing task-backed agent chat surface. This change adds discovery to that surface after maintainer review of the Storybook design. **What happened?** With Agent Chat enabled, a person with no starred or recent agents had no agent shortcut. The sidebar also lacked a direct way to start a chat with another agent. **Expected behavior** Show the first-created company agent by default. Let the person search all company agents and open a persistent conversation. **Steps to reproduce** 1. Enable Agent Chat in a company with agents. 2. Use an account with no starred agents or recent chats. 3. Look for a chat entry in the sidebar, or try to chat with an agent absent from its shortcuts. **Paperclip version or commit** The existing behavior is present on master at 728f7185f. ## What Changed - Add a compact picker that searches every company agent by name or role. Include loading, retry, empty, and no-match states. - Keep the first-created agent in Chats alongside stars and up to four other recent conversations. - Align the compose button with stars. Show it on hover or keyboard focus, and keep it visible on touch devices. - Use the existing company-aware chat route. Close the mobile sidebar on selection and reset the picker when the company or user changes. - Connect the approved Storybook scenarios to the production sidebar and picker. Update product and design-guide documentation. ## Verification - Ten focused tests pass for ordering, duplicate names, role search, keyboard selection, search reset, retry states, full-roster navigation, mobile dismissal, and company/user changes. - The agent-chat sidebar end-to-end test passes against an isolated PostgreSQL container. It verifies the first-use default, picker navigation, lazy creation, stars, draft preservation, and reopening existing history. The old four-row and See all link expectations now match the approved behavior. - Browser review of the production components in Storybook: open the sidebar picker, search for an agent absent from the shortcuts, select with Enter, and send a message through the existing composer. - `pnpm build`, `pnpm -r typecheck`, `pnpm check:token-gates`, and `pnpm build-storybook` pass on the rebased branch. The full local test run reached 12,020 passing server tests, then stopped because an unrelated environment-custom-images suite could not allocate PostgreSQL shared memory on this Mac. All corresponding CI test shards pass on isolated hosts. - All CI checks pass on commit `c324568324a6a8b65adfc6447ef19bcc710c61df`. Greptile reviewed that commit at 5/5 with no open findings. - Reviewer path: enable Agent Chat, open the Chats compose button, search by name or role, and select an agent. Existing conversation history must remain available. ## Risks - Sidebar ordering changes: the earliest-created agent remains visible even without a visit or star. - The hover control remains available to keyboard and touch users. - This change uses the existing feature gate, APIs, membership preferences, and persistent conversation behavior. It adds no migration or server contract. - Storybook uses fixture responses. Its first-use landing page is a review fixture and is not added as a product route. ## Model Used OpenAI GPT-6 Astra (`gpt-6-astra`) through Codex, with reasoning, code execution, and browser tools. The session does not expose its 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 #` / `Refs #` 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 --- doc/PRODUCT.md | 10 +++ tests/e2e/agent-chat.spec.ts | 32 ++++--- ui/src/components/AgentChatPicker.test.tsx | 85 ++++++++++++++++++ ui/src/components/AgentChatPicker.tsx | 87 ++++++++++++++++++ ui/src/components/AgentChatSidebar.tsx | 50 +++++------ ui/src/components/SidebarAgentChats.test.tsx | 85 ++++++++++++++++++ ui/src/components/SidebarAgentChats.tsx | 89 +++++++++++++------ ui/src/lib/recent-agent-chats.test.ts | 14 ++- ui/src/lib/recent-agent-chats.ts | 10 ++- ui/src/pages/DesignGuide.tsx | 17 +++- .../agent-chat/AgentChatPrototype.tsx | 64 ++++++++----- .../agent-chat/AgentChatSidebar.tsx | 17 ---- ui/storybook/prototypes/agent-chat/README.md | 2 +- .../prototypes/chat-entry/ChatEntryReview.tsx | 80 +++++++++++++++++ ui/storybook/prototypes/chat-entry/README.md | 19 ++++ ui/storybook/stories/agent-chat.stories.tsx | 2 +- ui/storybook/stories/chat-entry.stories.tsx | 34 +++++++ 17 files changed, 587 insertions(+), 110 deletions(-) create mode 100644 ui/src/components/AgentChatPicker.test.tsx create mode 100644 ui/src/components/AgentChatPicker.tsx create mode 100644 ui/src/components/SidebarAgentChats.test.tsx create mode 100644 ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx create mode 100644 ui/storybook/prototypes/chat-entry/README.md create mode 100644 ui/storybook/stories/chat-entry.stories.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 4a3fcfd1b33..7c739557ec9 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -199,3 +199,13 @@ An optional announcement card shares product news with board users on opening or returning to Paperclip. Dismissals persist per user across companies and browsers within an instance. Operators can disable fetching independently of telemetry. See [Announcements](ANNOUNCEMENTS.md). + +### Agent chat discovery + +With Agent Chat enabled, the Chats sidebar always includes the company's +earliest-created agent, plus personal starred agents and up to four other recent +conversations. First use has the same compact rows as returning use. The compose +icon shares a column with stars and appears on hover or keyboard focus (always on +touch). It opens a company-wide name/role search, independent of sidebar membership. +Selecting an agent opens their persistent conversation; it does not reset history +or create a task until the existing first-write flow requires one. diff --git a/tests/e2e/agent-chat.spec.ts b/tests/e2e/agent-chat.spec.ts index 59c071fc9d6..b3a1fb9d1b7 100644 --- a/tests/e2e/agent-chat.spec.ts +++ b/tests/e2e/agent-chat.spec.ts @@ -554,19 +554,30 @@ for (const bad of [ } }); -test("sidebar stars, recent agents, configuration links, and drafts survive switching", async ({ +test("sidebar discovery, stars, recent agents, configuration links, and drafts survive switching", async ({ page, request, }) => { const f = await setup(request); try { + 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(); + const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true }); + await expect(picker.getByRole("option")).toHaveCount(6); + 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(); + 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(); } - const nav = page.getByRole("navigation"); - await expect(nav.locator('a[href*="/chats/"]')).toHaveCount(4); - await expect(nav.locator('a[href*="/chats/"]').first()).toHaveText("Zeta"); + 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").click(); await expect(star).toHaveCSS("opacity", "0"); @@ -618,12 +629,13 @@ test("sidebar stars, recent agents, configuration links, and drafts survive swit ), ), ).toEqual(recent); - await page - .getByRole("link", { name: "See all agents", exact: true }) - .click(); - await expect(page).toHaveURL( - new RegExp(`/${f.company.issuePrefix}/agents/all$`), - ); + await compose.click(); + await expect(picker.getByRole("combobox")).toHaveValue(""); + 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(); } finally { await f.restore(); } diff --git a/ui/src/components/AgentChatPicker.test.tsx b/ui/src/components/AgentChatPicker.test.tsx new file mode 100644 index 00000000000..2f4a5858bfb --- /dev/null +++ b/ui/src/components/AgentChatPicker.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import { AgentChatPicker, type AgentChatPickerProps } from "./AgentChatPicker"; + +const agents = [ + { id: "first", name: "Alex", title: "Engineering Lead", role: "engineer", icon: "code", status: "idle" }, + { id: "second", name: "Alex", title: "Product Designer", role: "designer", icon: "palette", status: "paused" }, +] as Agent[]; + +let root: Root; +let container: HTMLDivElement; +let props: AgentChatPickerProps; +async function render(overrides: Partial = {}) { + props = { ...props, ...overrides }; + await act(async () => { root.render(); }); +} +async function search(value: string) { + const input = document.querySelector("[role=combobox]")!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +const options = () => [...document.querySelectorAll("[role=option]")]; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + props = { agents, open: true, onOpenChange: vi.fn(), onSelect: vi.fn() }; +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("AgentChatPicker", () => { + it("searches by role and selects the correct same-name agent with Enter", async () => { + await render(); + expect(options()).toHaveLength(2); + await search("designer"); + expect(options()).toHaveLength(1); + expect(options()[0].textContent).toContain("Paused"); + await act(async () => { + document.querySelector("[role=combobox]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + expect(props.onSelect).toHaveBeenCalledWith(agents[1]); + expect(props.onOpenChange).toHaveBeenCalledWith(false); + }); + + it("recovers from no results and resets search when reopened", async () => { + await render(); + await search("accountant"); + expect(options()).toHaveLength(0); + expect(document.body.textContent).toContain("No agents match"); + const clear = [...document.querySelectorAll("button")].find((b) => b.textContent === "Clear search")!; + await act(async () => clear.click()); + expect(options()).toHaveLength(2); + await search("designer"); + await render({ open: false }); + await render({ open: true }); + expect(document.querySelector("[role=combobox]")!.value).toBe(""); + expect(options()).toHaveLength(2); + }); + + it("shows loading, a retryable error, and an empty roster without stale choices", async () => { + await render({ loading: true }); + expect(document.querySelector("[role=status]")?.textContent).toContain("Loading agents"); + expect(options()).toHaveLength(0); + const retry = vi.fn(); + await render({ loading: false, error: new Error("Unavailable"), onRetry: retry }); + expect(document.querySelector("[role=alert]")?.textContent).toContain("Couldn’t load agents"); + expect(options()).toHaveLength(0); + await act(async () => [...document.querySelectorAll("button")].find((b) => b.textContent === "Retry")!.click()); + expect(retry).toHaveBeenCalledOnce(); + await render({ error: null, agents: [] }); + expect(document.body.textContent).toContain("Create an agent from the Agents page"); + }); +}); diff --git a/ui/src/components/AgentChatPicker.tsx b/ui/src/components/AgentChatPicker.tsx new file mode 100644 index 00000000000..4bdc7bc3ed1 --- /dev/null +++ b/ui/src/components/AgentChatPicker.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import type { Agent } from "@paperclipai/shared"; +import { AgentIcon } from "@/components/AgentIconPicker"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; + +export interface AgentChatPickerProps { + agents: Agent[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (agent: Agent) => void; + loading?: boolean; + error?: Error | null; + onRetry?: () => void; +} + +export function AgentChatPicker({ open, onOpenChange, ...props }: AgentChatPickerProps) { + return ( + + +
+ Chat with an agent +
+ {/* The dialog unmounts its content on close, so each search starts empty. */} + { + onOpenChange(false); + props.onSelect(agent); + }} /> +
+
+ ); +} + +function AgentChatPickerResults({ agents, onSelect, loading, error, onRetry }: Omit) { + const [search, setSearch] = useState(""); + return ( + + + {error ? ( +
+

Couldn’t load agents. Try again.

+ {onRetry && } +
+ ) : loading ? ( +

Loading agents…

+ ) : ( + + +
+ {agents.length ? `No agents match “${search}”` : "No agents yet."} + {agents.length ? <> + Try another name or role. + + : Create an agent from the Agents page to start chatting.} +
+
+ + {agents.map((agent) => ( + onSelect(agent)} + className="gap-3 px-3 py-3" + > + + + {agent.name} + {agent.title ?? agent.role} + + {agent.status === "paused" && Paused} + {agent.status === "terminated" && Terminated} + {agent.status === "pending_approval" && Awaiting approval} + + ))} + +
+ )} +
+ ); +} diff --git a/ui/src/components/AgentChatSidebar.tsx b/ui/src/components/AgentChatSidebar.tsx index 3fac4a7e71e..66f4f352237 100644 --- a/ui/src/components/AgentChatSidebar.tsx +++ b/ui/src/components/AgentChatSidebar.tsx @@ -1,19 +1,17 @@ -import { useState } from "react"; -import { Star, Users } from "lucide-react"; -import { SidebarSection } from "@/components/SidebarSection"; +import { Star, SquarePen } from "lucide-react"; import { SidebarNavItem } from "@/components/SidebarNavItem"; import { AgentIcon } from "@/components/AgentIconPicker"; import { Button } from "@/components/ui/button"; -import { Link } from "@/lib/router"; import { useSidebar } from "@/context/SidebarContext"; import type { Agent } from "@paperclipai/shared"; -import { agentRouteRef } from "@/lib/utils"; +import { agentRouteRef, cn } from "@/lib/utils"; import { orderChatAgents } from "@/lib/recent-agent-chats"; export function AgentChatSidebar({ activeId, starredIds, recentIds, onToggleStar, + onOpenChat, agents, href = (id: string) => `/chats/${encodeURIComponent(agentRouteRef(agents.find((agent) => agent.id === id)!))}`, @@ -24,9 +22,9 @@ export function AgentChatSidebar({ starredIds: string[]; recentIds: string[]; onToggleStar: (id: string) => void; + onOpenChat: () => void; }) { - const [open, setOpen] = useState(true); - const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar(); + const { collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; const ordered = orderChatAgents(agents, starredIds, recentIds); const row = (agent: Agent) => { @@ -54,11 +52,11 @@ export function AgentChatSidebar({ event.stopPropagation(); onToggleStar(agent.id); }} - className="absolute right-2 top-(--pct-50) -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/agent-chat:opacity-100 focus-visible:opacity-100" + className={cn("absolute right-2 top-(--pct-50) -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/agent-chat:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", pinned && "opacity-100")} >