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")} >