diff --git a/doc/plans/2026-11-06-chat-contacts-pins.md b/doc/plans/2026-11-06-chat-contacts-pins.md new file mode 100644 index 00000000000..c29be51208f --- /dev/null +++ b/doc/plans/2026-11-06-chat-contacts-pins.md @@ -0,0 +1,59 @@ +# Chat contacts and pinned navigation + +Restore the full agent contact directory in Chat, with avatars, names, roles, +search, unread counts and a shortcut to the existing create-agent dialog. +The chat header spans the directory and conversation. It uses the standard +CHAT section heading and retains the shared properties control. + +Agent pins are available in the contact directory and Agents list. A pin opens +the existing direct-chat route. Personal page and agent pins share one ordered +Pinned section. Pointer and keyboard sorting preserve the stored order without +removing pins hidden by a company switch. The drag grip sits after unpin. +Hover and focus controls share the highlighted row surface. The fade masks only +the text behind the control. + +Recents uses agent avatars and names for direct chats. It retains the full task +history and fills the available navigation height above the account control. +The heading stays outside its scroll area. Only clipped edges fade. Task-summary +refreshes retain the server's small request limit and publish a complete result, +so a failed request cannot prune unrelated history. + +Inbox task rows use bold unread titles without blue dots in both retained +layouts. The All tasks default and saved view behavior already exist on the +current base branch and remain in place. + +Group chat stays in a separate follow-up. This change contains no group-chat +schema, routes, discussion worker, approval flow, picker or conversation page. + +## Verification + +Use the directory, sidebar, breadcrumb, task-row and history tests. Run the UI +typecheck, build and token gates. Verify mouse and keyboard pin sorting, +create-agent dialog access, full-width search and header, and overflow at both +Recents edges. Use both long and short viewports. Preserve existing unread-chat +counts and account/company boundaries. + +An early row-wide drag allowed native link navigation after sorting. A dedicated +grip and prevention of native link dragging fixed it. The regression failed +before the fix and passed afterward. Long-history tests also check that a failed +summary chunk retains all snapshots. + +## Review corrections + +Reserve separate space for unread badges and pin actions, including touch input. +Unread rows do not fade text that the pin no longer covers. Build pin sets and +item maps once per list, keep their inputs stable, and share pin state across +agent rows. Large-list regressions count input reads and pin parsing for five +hundred agents and pins. Reordering retains hidden-company slots. Task history +also uses indexed summary lookups now that it is no longer capped at five rows. + +## Browser journey alignment + +CI found two session tests that still used the removed header switcher, +Configure link and Add chat picker. Their journeys now use contact links and +the full-width Search chats field. They retain checks for per-agent drafts, +reload persistence, background history, terminated agents and unique +conversations. Opening or reopening a contact stays read-only; the first sent +message creates the conversation. Both corrected journeys passed against a +fresh disposable PostgreSQL database after the host's embedded-cluster startup +limit prevented the initial local launch. diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts index d4d9b9303a1..2c4ea106f65 100644 --- a/tests/e2e/agent-chat-sessions.spec.ts +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -259,14 +259,14 @@ test("Coordinator agent switching preserves drafts and opens one conversation on await page.goto(f.route); await expect(page.getByRole("navigation", { name: "Workspace navigation" })).toBeVisible(); const editor = page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]'); - const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true }); + const contacts = page.getByRole("complementary", { name: "Chat contacts", exact: true }); const switchTo = async (name: string) => { - await page.getByRole("button", { name: /^Switch agent. Currently / }).click(); - await picker.getByRole("combobox").fill(name); - await picker.getByRole("combobox").press("Enter"); - await expect(picker).not.toBeVisible(); - await expect(page.getByRole("button", { name: `Switch agent. Currently ${name}`, exact: true })).toBeVisible(); + const contact = contacts.getByRole("link", { name: new RegExp(`^${name}`) }); + await contact.click(); + await expect(contact).toHaveAttribute("aria-current", "page"); }; + await expect(page.getByRole("heading", { name: "Chat", exact: true })).toBeVisible(); + await expect(contacts.getByRole("button", { name: "Add chat", exact: true })).toHaveCount(0); await editor.fill("Unsent Coordinator draft for Alpha"); await switchTo("Beta"); await expect(editor).toHaveText(""); @@ -310,49 +310,37 @@ test("secondary chat navigation preserves layout, unique conversations, history, expect(await json(await request.get(`/api/companies/${f.company.id}/primary-agent/me`))).toMatchObject({ primaryAgentId: f.agents[0].id, initialized: true, }); - await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible(); + const sidebar = page.getByRole("complementary", { name: "Chat contacts", exact: true }); + const nav = sidebar.getByRole("navigation", { name: "Agent conversations" }); + const alphaLink = nav.getByRole("link", { name: /^Alpha/ }); + const betaLink = nav.getByRole("link", { name: /^Beta/ }); + await expect(alphaLink).toHaveAttribute("aria-current", "page"); expect(await json(await request.get(f.chatPath))).toBeNull(); expect(await json(await request.get(`/api/companies/${f.company.id}/heartbeat-runs`))).toHaveLength(0); - const sidebar = page.getByRole("complementary", { name: "Chat", exact: true }); - // The rail lists every eligible agent before any conversation exists. - const nav = sidebar.getByRole("navigation", { name: "Agent conversations" }); await expect(nav.getByRole("link")).toHaveText([/^Alpha/, /^Beta/, /^Delta/, /^Epsilon/, /^Gamma/, /^Zeta/]); 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(); + await betaLink.click(); + await expect(betaLink).toHaveAttribute("aria-current", "page"); expect(await sidebar.boundingBox()).toEqual(landingBounds); - const beta = await json(await request.get(betaPath)); + expect(await json(await request.get(betaPath))).toBeNull(); + expect(await json(await request.get(chatListPath))).toHaveLength(0); + expect(await json(await request.get(`/api/companies/${f.company.id}/heartbeat-runs`))).toHaveLength(0); + await betaLink.click(); + expect(await json(await request.get(betaPath))).toBeNull(); + await send(page, "Beta conversation acceptance"); + const beta = await idle(request, 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(); + await alphaLink.click(); + await expect(alphaLink).toHaveAttribute("aria-current", "page"); + await betaLink.click(); + await expect(betaLink).toHaveAttribute("aria-current", "page"); 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 alphaLink = nav.getByRole("link", { name: /^Alpha / }); - const betaLink = nav.getByRole("link", { name: /^Beta / }); - // Open chat first, then other conversations, then the rest alphabetically. + await alphaLink.click(); + await expect(alphaLink).toHaveAttribute("aria-current", "page"); await expect(nav.getByRole("link")).toHaveText([/^Alpha/, /^Beta/, /^Delta/, /^Epsilon/, /^Gamma/, /^Zeta/]); const editor = page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]'); await editor.fill("Unsent draft for Alpha"); @@ -361,7 +349,7 @@ test("secondary chat navigation preserves layout, unique conversations, history, await alphaLink.click(); await expect(editor).toContainText("Unsent draft for Alpha"); - const search = sidebar.getByRole("textbox", { name: "Search agents", exact: true }); + const search = sidebar.getByRole("textbox", { name: "Search chats", exact: true }); await search.fill("No matching agent"); await expect(sidebar.getByText("No agents found", { exact: true })).toBeVisible(); await search.fill("Beta"); @@ -374,11 +362,11 @@ test("secondary chat navigation preserves layout, unique conversations, history, await expect(nav.getByRole("link")).toHaveCount(6); await page.getByRole("link", { name: /^Chat(?:\s|$)/ }).click(); - await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible(); + await expect(alphaLink).toHaveAttribute("aria-current", "page"); await expect(editor).toContainText("Unsent draft for Alpha"); expect(await sidebar.boundingBox()).toEqual(landingBounds); - await page.getByRole("link", { name: "Configure Alpha", exact: true }).click(); - await expect(page).toHaveURL(/\/agents\/.*\/runtime/); + await page.goto(`/${f.company.issuePrefix}/dashboard`); + await expect(page).toHaveURL(/\/dashboard$/); await json(await request.post(`/api/issues/${beta.id}/comments`, { data: { body: "Background activity", @@ -387,15 +375,12 @@ test("secondary chat navigation preserves layout, unique conversations, history, })); await idle(request, betaPath); await page.getByRole("link", { name: /^Chat(?:\s|$)/ }).click(); - await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible(); - 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(alphaLink).toHaveAttribute("aria-current", "page"); + await betaLink.click(); + await expect(betaLink).toHaveAttribute("aria-current", "page"); 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); + expect(await json(await request.get(chatListPath))).toHaveLength(1); await json(await request.post(`/api/agents/${f.agents[1].id}/terminate`)); await page.goto(`/${f.company.issuePrefix}/chats`); await expect(betaLink).toContainText("Terminated"); diff --git a/ui/src/components/AgentChatPin.tsx b/ui/src/components/AgentChatPin.tsx new file mode 100644 index 00000000000..6bdb5602ebb --- /dev/null +++ b/ui/src/components/AgentChatPin.tsx @@ -0,0 +1,34 @@ +import { Pin, PinOff } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { authApi } from "@/api/auth"; +import { usePinnedNavigation } from "@/hooks/usePinnedNavigation"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; + +export const agentChatPinKey = (companyId: string, agentId: string) => `agent-chat:${companyId}:${agentId}`; + +export function useAgentChatPins() { + const session = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() }); + const userId = session.data?.user?.id ?? session.data?.session?.userId; + const navigation = usePinnedNavigation(userId); + const keys = useMemo(() => new Set(navigation.pins), [navigation.pins]); + return { keys, toggle: navigation.toggle, canPin: navigation.canPin, error: navigation.error }; +} + +export function AgentChatPin({ agent, pins, className }: { + agent: { id: string; companyId: string; name: string }; pins: ReturnType; className?: string; +}) { + const key = agentChatPinKey(agent.companyId, agent.id); + const pinned = pins.keys.has(key); + const Icon = pinned ? PinOff : Pin; + return <> + + {pins.error && {pins.error}} + ; +} diff --git a/ui/src/components/AgentConversationSidebar.tsx b/ui/src/components/AgentConversationSidebar.tsx index 74b68e26bef..6a9b5be6d61 100644 --- a/ui/src/components/AgentConversationSidebar.tsx +++ b/ui/src/components/AgentConversationSidebar.tsx @@ -1,5 +1,6 @@ -import { useId, useMemo, useRef, useState } from "react"; +import { useId, useMemo, useRef, useState, type ReactNode } from "react"; import { Plus, Search, Users, X } from "lucide-react"; +import { Badge } from "./ui/badge"; import type { Agent } from "@paperclipai/shared"; import { AgentChatPicker } from "@/components/AgentChatPicker"; import { AgentAvatar } from "@/components/AgentAvatar"; @@ -27,10 +28,12 @@ export interface AgentConversationSidebarProps { historyLoading?: boolean; historyError?: Error | null; onRetryHistory?: () => void; + onCreateAgent?: () => void; + renderAgentAction?: (agent: Agent) => ReactNode; } /** Searchable navigation for one conversation per agent. */ -export function AgentConversationSidebar({ agents, existingChatAgentIds, availableAgents = agents, activeId, previews = {}, unreadAgentIds = [], unreadMessageCounts, loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory }: AgentConversationSidebarProps) { +export function AgentConversationSidebar({ agents, existingChatAgentIds, availableAgents = agents, activeId, previews = {}, unreadAgentIds = [], unreadMessageCounts, loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory, onCreateAgent, renderAgentAction }: AgentConversationSidebarProps) { const navigate = useNavigate(); const [pickerOpen, setPickerOpen] = useState(false); const [search, setSearch] = useState(initialSearch); @@ -44,25 +47,17 @@ export function AgentConversationSidebar({ agents, existingChatAgentIds, availab searchRef.current?.focus(); } return