Restore chat contacts, agent pins, and scrollable recent navigation (#19685)

This commit is contained in:
scotttong authored and GitHub committed 2026-11-06 19:17:25 -08:00
1 parent 08f7ac03dc
commit f5fb8ada3b
27 files changed
+571 -273

No files matched your search

@@ -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.
+34 -49
View File
@@ -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");
+34
View File
@@ -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<typeof useAgentChatPins>; className?: string;
}) {
const key = agentChatPinKey(agent.companyId, agent.id);
const pinned = pins.keys.has(key);
const Icon = pinned ? PinOff : Pin;
return <>
<button type="button" aria-label={`${pinned ? "Unpin" : "Pin"} ${agent.name}`} aria-pressed={pinned}
title={`${pinned ? "Unpin" : "Pin"} ${agent.name}`} disabled={!pins.canPin}
onClick={event => { event.preventDefault(); event.stopPropagation(); pins.toggle(key); }}
className={cn("agent-chat-pin absolute right-2 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/agent-pin:opacity-100 group-focus-within/agent-pin:opacity-100 pointer-coarse:opacity-100", className)}>
<Icon aria-hidden className="size-4" />
</button>
{pins.error && <span role="alert" className="text-xs text-destructive">{pins.error}</span>}
</>;
}
+31 -26
View File
@@ -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 <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">
<h2 id={headingId} className="sr-only">Chat contacts</h2>
<div className="flex shrink-0 items-center gap-2 px-3 pb-3 pt-5">
<div className="relative min-w-0 flex-1">
<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)}
<Input ref={searchRef} aria-label="Search chats" placeholder="Search chats" 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" />
className="h-9 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">
@@ -75,30 +70,40 @@ export function AgentConversationSidebar({ agents, existingChatAgentIds, availab
<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))}`}
{visible.map(agent => {
const unreadCount = unreadMessageCounts?.get(agent.id) ?? 0;
const hasUnread = unreadCount > 0 || unreadIds.has(agent.id);
return <div key={agent.id} className="agent-pin-row agent-contact-pin-row group/agent-pin relative rounded-md" data-selected={activeId === agent.id || undefined} data-has-unread={hasUnread || undefined}><Link 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">
className={cn("agent-contact-row flex min-w-0 items-center gap-3 rounded-md px-2.5 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", renderAgentAction && hasUnread && "pr-12", activeId === agent.id ? "bg-accent text-accent-foreground" : "group-hover/agent-pin:bg-accent/60 group-focus-within/agent-pin:bg-accent/60")}>
<AgentAvatar agent={agent} size={40} />
<span className="agent-pin-text 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 className="truncate text-xs text-muted-foreground">{agent.status === "terminated" ? "Terminated" : agent.title ?? agent.role}</span>
</span>
{unreadIds.has(agent.id) && <span role="img" aria-label={`Unread messages from ${agent.name}`} className="size-2 shrink-0 rounded-full bg-primary" />}
</Link>)}
{unreadCount > 0 ? <Badge variant="ghost" aria-label={`${unreadCount} unread ${unreadCount === 1 ? "message" : "messages"} from ${agent.name}`} className="shrink-0 bg-primary font-mono tabular-nums text-primary-foreground">{unreadCount}</Badge>
: unreadIds.has(agent.id) && <span role="img" aria-label={`Unread messages from ${agent.name}`} className="size-2 shrink-0 rounded-full bg-primary" />}
</Link>{renderAgentAction?.(agent)}</div>;
})}
</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>}
{onCreateAgent && !loading && !error && <button type="button" onClick={onCreateAgent}
className="flex min-w-0 items-center gap-3 rounded-md px-2.5 py-3 text-left hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<span aria-hidden className="flex size-10 shrink-0 items-center justify-center"><span className="agent-contact-create flex size-8 items-center justify-center bg-muted"><Plus className="size-5 text-foreground" /></span></span>
<span className="text-sm font-medium">Create a new agent</span>
</button>}
</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>}
{onBrowse && <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" onClick={onBrowse}>
<Users className="size-4" />Browse all agents
</Button>
</div>
</div>}
<AgentChatPicker agents={availableAgents} open={pickerOpen} onOpenChange={setPickerOpen}
unreadMessageCounts={unreadMessageCounts}
existingChatAgentIds={existingChatAgentIds ?? agents.map(agent => agent.id)} loading={loading} error={error} onRetry={onRetry}
@@ -7,7 +7,7 @@ import type { Agent } from "@paperclipai/shared";
import { AgentConversationsSidebar } from "./AgentConversationsSidebar";
import type { AgentConversationSidebarProps } from "./AgentConversationSidebar";
import { queryKeys } from "@/lib/queryKeys";
const state = vi.hoisted(() => ({ companyId: "company-a", navigate: vi.fn(), closeSidebar: vi.fn(), ensure: vi.fn(), getAgent: vi.fn() }));
const state = vi.hoisted(() => ({ companyId: "company-a", navigate: vi.fn(), closeSidebar: vi.fn(), ensure: vi.fn(), getAgent: vi.fn(), newAgent: vi.fn() }));
const sidebarInputs = vi.hoisted(() => [] as Pick<AgentConversationSidebarProps, "existingChatAgentIds" | "unreadAgentIds" | "unreadMessageCounts">[]);
vi.mock("./AgentConversationSidebar", async importOriginal => {
const original = await importOriginal<typeof import("./AgentConversationSidebar")>();
@@ -16,6 +16,7 @@ vi.mock("./AgentConversationSidebar", async importOriginal => {
return <original.AgentConversationSidebar {...props} />;
} };
});
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewAgent: state.newAgent }) }));
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 }) }));
@@ -39,16 +40,15 @@ const chat = (id: string) => ({ id: `chat-${id}`, companyId: "company-a", conver
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());
async function openContact(name: string) {
await act(async () => container.querySelector<HTMLAnchorElement>(`a[href="/chats/${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.newAgent.mockReset(); localStorage.clear();
sidebarInputs.length = 0;
state.navigate.mockReset(); state.closeSidebar.mockReset(); state.ensure.mockReset(); state.getAgent.mockReset();
state.ensure.mockImplementation(async (_company, id) => chat(id));
memberships.agentMemberships = {};
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
@@ -58,37 +58,36 @@ beforeEach(() => {
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 () => {
it("opens every contact without creating a conversation before the first message", async () => {
await render();
expect(container.querySelectorAll('nav[aria-label="Agent conversations"] a')).toHaveLength(2);
await addChat();
expect([...document.querySelectorAll("[role=option]")].find(option => option.textContent?.startsWith("bob"))?.textContent).not.toContain("Open chat");
await choose("bob");
expect(state.ensure).toHaveBeenCalledWith("company-a", "bob");
expect(container.querySelector('[aria-label="Add chat"]')).toBeNull();
expect(container.querySelector('[aria-label="Search chats"]')).not.toBeNull();
await openContact("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(state.ensure).not.toHaveBeenCalled();
await openContact("alice");
expect(state.navigate).toHaveBeenLastCalledWith("/chats/alice");
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();
it("opens the existing create-agent dialog and persists a company-scoped chat pin", async () => {
await render();
await act(async () => [...container.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Create a new agent")!.click());
expect(state.newAgent).toHaveBeenCalledOnce();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Pin bob"]')!.click());
expect(JSON.parse(localStorage.getItem("paperclip.coordinator.pinned-nav.v1:user-a")!)).toEqual(["agent-chat:company-a:bob"]);
expect(container.querySelector('[aria-label="Unpin bob"]')?.getAttribute("aria-pressed")).toBe("true");
expect(state.ensure).not.toHaveBeenCalled();
});
it("replaces contacts when the company changes", async () => {
await render();
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");
expect(container.textContent).not.toContain("bob");
});
it("preserves recent activity order when reopening an older conversation", async () => {
@@ -96,7 +95,7 @@ it("preserves recent activity order when reopening an older conversation", async
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();
await openContact("alice"); await render();
expect(links()).toEqual(["/chats/bob", "/chats/alice"]);
});
@@ -113,10 +112,10 @@ it("shows accessible unread indicators only for the current company and human",
expect(container.querySelector('[aria-label^="Unread messages"]')).toBeNull();
});
it("passes numeric unread counts to Add chat and updates them from the account cache", async () => {
it("passes numeric unread counts to contacts and updates them from the account cache", async () => {
const key = queryKeys.agentChats.list("company-a", "user-a");
client.setQueryData(key, [{ ...chat("alice"), isUnreadForMe: true, unreadMessageCount: 8 }, chat("bob")]);
await render(); await addChat();
await render();
expect(document.querySelector('[aria-label="8 unread messages from alice"]')?.textContent).toBe("8");
expect(document.querySelector('[aria-label*="unread message from bob"]')).toBeNull();
await act(async () => {
@@ -133,26 +132,38 @@ it("passes numeric unread counts to Add chat and updates them from the account c
it("reuses chat indexes for 500 agents across unchanged parent renders and refreshes changed counts", async () => {
const agents = Array.from({ length: 500 }, (_, index) => ({ ...roster[0], id: `agent-${index}`, name: `Agent ${index}`, urlKey: `agent-${index}` }));
const chats = agents.map(agent => ({ ...chat(agent.id), isUnreadForMe: true, unreadMessageCount: 7 }));
const key = queryKeys.agentChats.list("company-a", "user-a");
client.setQueryData(queryKeys.agents.list("company-a"), agents);
client.setQueryData(key, chats);
await render(); await addChat();
const first = sidebarInputs.at(-1)!;
expect(first.existingChatAgentIds).toHaveLength(500);
expect(first.unreadAgentIds).toHaveLength(500);
expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(500);
await render();
const unchanged = sidebarInputs.at(-1)!;
expect(unchanged.existingChatAgentIds).toBe(first.existingChatAgentIds);
expect(unchanged.unreadAgentIds).toBe(first.unreadAgentIds);
expect(unchanged.unreadMessageCounts).toBe(first.unreadMessageCounts);
await act(async () => {
client.setQueryData(key, [{ ...chats[0], isUnreadForMe: false, unreadMessageCount: 0 }, ...chats.slice(1)]);
await new Promise(resolve => setTimeout(resolve, 0));
const savedPins = JSON.stringify(agents.map(agent => `agent-chat:company-a:${agent.id}`));
localStorage.setItem("paperclip.coordinator.pinned-nav.v1:user-a", savedPins);
const originalParse = JSON.parse;
let pinParses = 0;
const parse = vi.spyOn(JSON, "parse").mockImplementation((value, reviver) => {
if (value === savedPins && ++pinParses > 8) throw new Error("Each contact parses the full pin list");
return originalParse(value, reviver);
});
expect(document.querySelector('[aria-label="7 unread messages from Agent 0"]')).toBeNull();
expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(499);
try {
const chats = agents.map(agent => ({ ...chat(agent.id), isUnreadForMe: true, unreadMessageCount: 7 }));
const key = queryKeys.agentChats.list("company-a", "user-a");
client.setQueryData(queryKeys.agents.list("company-a"), agents);
client.setQueryData(key, chats);
await render();
const first = sidebarInputs.at(-1)!;
expect(first.existingChatAgentIds).toHaveLength(500);
expect(first.unreadAgentIds).toHaveLength(500);
expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(500);
await render();
const unchanged = sidebarInputs.at(-1)!;
expect(unchanged.existingChatAgentIds).toBe(first.existingChatAgentIds);
expect(unchanged.unreadAgentIds).toBe(first.unreadAgentIds);
expect(unchanged.unreadMessageCounts).toBe(first.unreadMessageCounts);
await act(async () => {
client.setQueryData(key, [{ ...chats[0], isUnreadForMe: false, unreadMessageCount: 0 }, ...chats.slice(1)]);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(document.querySelector('[aria-label="7 unread messages from Agent 0"]')).toBeNull();
expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(499);
expect(container.querySelectorAll('[aria-label^="Unpin Agent "]')).toHaveLength(500);
expect(pinParses).toBeLessThanOrEqual(8);
} finally { parse.mockRestore(); }
});
it("retains terminated agents' history by id without offering them for new chats", async () => {
@@ -164,21 +175,7 @@ it("retains terminated agents' history by id without offering them for new chats
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);
expect(state.ensure).not.toHaveBeenCalled();
});
it("keeps healthy chats usable while a historical lookup fails and retries", async () => {
@@ -192,7 +189,7 @@ it("keeps healthy chats usable while a historical lookup fails and retries", asy
});
expect(container.querySelector('a[href="/chats/alice"]')).not.toBeNull();
await act(async () => {
const input = container.querySelector<HTMLInputElement>('[aria-label="Search agents"]')!;
const input = container.querySelector<HTMLInputElement>('[aria-label="Search chats"]')!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "No match");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
@@ -9,15 +9,19 @@ import { useQueries } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { resourceMembershipState, useResourceMemberships } from "@/hooks/useResourceMemberships";
import { useDialogActions } from "@/context/DialogContext";
import { AgentChatPin, useAgentChatPins } from "./AgentChatPin";
/** Account- and company-scoped data for the secondary chat navigation. */
export function AgentConversationsSidebar() {
const { companyId, userId, agents, chats, session } = useAgentChatNavigation();
const agentChatPins = useAgentChatPins();
const unreadMessageCounts = useMemo(() => new Map((chats.data ?? [])
.flatMap(chat => chat.conversationAgentId && chat.unreadMessageCount
? [[chat.conversationAgentId, chat.unreadMessageCount] as const] : [])), [chats.data]);
const openChat = useOpenAgentChat(companyId, userId);
const navigate = useNavigate();
const { openNewAgent } = useDialogActions();
const memberships = useResourceMemberships(companyId);
const { isMobile, setSidebarOpen } = useSidebar();
const { pathname } = useLocation();
@@ -61,6 +65,7 @@ export function AgentConversationsSidebar() {
activeId={active?.id} previews={previews}
unreadAgentIds={unreadAgentIds}
unreadMessageCounts={unreadMessageCounts}
onCreateAgent={openNewAgent} renderAgentAction={agent => <AgentChatPin agent={agent} pins={agentChatPins} />}
// Loaded chats stay listed through an outage (the banner covers it); an
// outage before the first load is a skeleton, and only a real failure is an error.
loading={[agents, chats, session].some(query => { const kind = queryViewKind(query); return kind === "loading" || kind === "reconnecting"; })}
+19
View File
@@ -131,6 +131,25 @@ describe("BreadcrumbBar", () => {
vi.unstubAllGlobals();
});
it("uses the standard section heading for Chat while retaining the properties toggle", async () => {
const toggle = vi.fn();
function ChatHeader() {
const { setBreadcrumbs, setBreadcrumbPanelControl } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([{ label: "Chat" }]);
setBreadcrumbPanelControl({ open: false, onToggle: toggle });
}, [setBreadcrumbs, setBreadcrumbPanelControl]);
return <BreadcrumbBar showPropertiesToggle />;
}
await act(async () => root.render(<BreadcrumbProvider><ChatHeader /></BreadcrumbProvider>));
expect(container.querySelector("h1")?.textContent).toBe("Chat");
expect(container.querySelector("h1")?.className).toContain("uppercase");
expect(container.querySelector('[aria-label^="Switch agent"]')).toBeNull();
expect(container.querySelector('[aria-label^="Pin "]')).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Show properties"]')!.click());
expect(toggle).toHaveBeenCalledOnce();
});
it.each([
{ mobile: false, taskDetailLayout: true },
{ mobile: true, taskDetailLayout: true },
+15 -13
View File
@@ -53,7 +53,7 @@ function GlobalToolbar({
);
}
export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
export function BreadcrumbBar({ taskDetailLayout = false, showPropertiesToggle = taskDetailLayout }: { taskDetailLayout?: boolean; showPropertiesToggle?: boolean }) {
const coordinatorEnabled = useCoordinatorPresentation();
const {
breadcrumbs,
@@ -66,6 +66,18 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
const { selectedCompanyId, selectedCompany } = useCompany();
const taskPanelOpen = breadcrumbPanelControl?.open ?? panelVisible;
const toggleTaskPanel = breadcrumbPanelControl?.onToggle ?? togglePanelVisible;
const propertiesToggle = showPropertiesToggle ? (
<Button
variant="ghost"
size="icon-sm"
className="ml-5 size-9 shrink-0 text-muted-foreground"
onClick={toggleTaskPanel}
aria-label={taskPanelOpen ? "Hide properties" : "Show properties"}
title={taskPanelOpen ? "Hide properties" : "Show properties"}
>
{taskPanelOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
) : null;
const globalToolbarSlotContext = useMemo(
() => ({
@@ -218,6 +230,7 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
<span className="ml-2 flex shrink-0 items-center">{breadcrumbs[0].trailing}</span>
) : null}
{globalToolbarSlots}
{propertiesToggle}
</div>
);
}
@@ -233,18 +246,7 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
{menuButton}
{breadcrumbTrail}
{globalToolbarSlots}
{taskDetailLayout ? (
<Button
variant="ghost"
size="icon-sm"
className="ml-5 size-9 shrink-0 text-muted-foreground"
onClick={toggleTaskPanel}
aria-label={taskPanelOpen ? "Hide properties" : "Show properties"}
title={taskPanelOpen ? "Hide properties" : "Show properties"}
>
{taskPanelOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
) : null}
{propertiesToggle}
</div>
);
}
+45 -8
View File
@@ -3,17 +3,17 @@ import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionFeed, AttentionItem } from "@paperclipai/shared";
import { CoordinatorSidebar } from "./CoordinatorSidebar";
import type { Agent, AttentionFeed, AttentionItem } from "@paperclipai/shared";
import { buildPinnedNavigationItems, CoordinatorSidebar } from "./CoordinatorSidebar";
import { TooltipProvider } from "./ui/tooltip";
import { coordinatorAttentionQueryKey, coordinatorAttentionSummaryQueryKey } from "@/lib/coordinator-attention";
import { queryKeys } from "@/lib/queryKeys";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), openNewIssue: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer", chats: [] as Array<{ isUnreadForMe: boolean }>,
recentTasks: [] as Array<Record<string, unknown>>, projects: [] as Array<Record<string, unknown>>, projectTasks: vi.fn(), enableCases: false }));
recentTasks: [] as Array<Record<string, unknown>>, projects: [] as Array<Record<string, unknown>>, projectTasks: vi.fn(), enableCases: false, agents: [] as Agent[] }));
vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: fixture.chats }, session: { isSuccess: true },
companyId: "company", userId: fixture.userId, agents: { data: fixture.agents }, chats: { data: fixture.chats }, session: { isSuccess: true },
}) }));
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: fixture.recentTasks }) }));
vi.mock("@/api/issues", () => ({ issuesApi: { listCompact: fixture.projectTasks } }));
@@ -46,13 +46,23 @@ function summary(items: AttentionItem[]) {
let root: Root, container: HTMLDivElement, client: QueryClient;
beforeEach(() => {
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer"; fixture.pathname = "/COO/issues";
fixture.chats = []; fixture.recentTasks = []; fixture.projects = []; fixture.enableCases = false;
fixture.chats = []; fixture.recentTasks = []; fixture.projects = []; fixture.enableCases = false; fixture.agents = [];
fixture.summary.mockResolvedValue(summary([pending("question"), pending("review")]));
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
describe("Coordinator task creation", () => {
it("keeps every recent entry below the section header instead of truncating at twelve", async () => {
fixture.recentTasks = Array.from({ length: 20 }, (_, index) => ({
id: `task-${index}`, companyId: "company", identifier: `TASK-${index}`, title: `Recent task ${index}`, status: "todo", recordedAt: index,
}));
await renderSidebar();
const region = container.querySelector('[role="region"][aria-label="Recents"]')!;
expect(region.querySelectorAll("a")).toHaveLength(20);
expect(region.textContent).not.toContain("Recents");
expect(region.querySelector('a[href="/issues/TASK-0"]')).not.toBeNull();
});
it.each([false, true])("opens global search from the organization header when collapsed is %s", async collapsed => {
fixture.collapsed = collapsed;
await renderSidebar();
@@ -80,6 +90,33 @@ describe("Coordinator task creation", () => {
});
describe("Coordinator pinned pages", () => {
it("builds hundreds of agent pins with input reads proportional to list size", () => {
const agents = Array.from({ length: 500 }, (_, index) => ({ id: `agent-${index}`, companyId: "company", name: `Agent ${index}`, urlKey: `agent-${index}` } as Agent));
const pins = agents.map(agent => `agent-chat:company:${agent.id}`).reverse();
let reads = 0;
function bounded<T>(items: T[]) {
return new Proxy(items, { get(target, property, receiver) {
if (typeof property === "string" && /^\d+$/.test(property) && ++reads > 4 * agents.length) throw new Error("Pin construction scans growing lists repeatedly");
return Reflect.get(target, property, receiver);
} });
}
const items = buildPinnedNavigationItems(bounded(pins), bounded(agents), []);
expect(items.map(item => item.key)).toEqual(pins);
expect(reads).toBeLessThanOrEqual(4 * agents.length);
expect(items[0].to).toBe("/chats/agent-499");
});
it("keeps mixed pins in stored order and prevents native link dragging", async () => {
fixture.agents = [{ id: "cto", companyId: "company", name: "CTO", urlKey: "cto", role: "cto", status: "idle" } as Agent];
const saved = ["agent-chat:company:cto", "agent-chat:another-company:hidden", "/artifacts", "/dashboard"];
localStorage.setItem("paperclip.coordinator.pinned-nav.v1:reviewer", JSON.stringify(saved));
await renderSidebar();
const links = [...container.querySelectorAll<HTMLAnchorElement>(".design-sidebar-pins a")];
expect(links.map(link => link.textContent)).toEqual(["CTO", "Artifacts", "Dashboard"]);
expect(JSON.parse(localStorage.getItem("paperclip.coordinator.pinned-nav.v1:reviewer")!)).toEqual(saved);
const nativeDrag = new Event("dragstart", { bubbles: true, cancelable: true });
act(() => links[1].dispatchEvent(nativeDrag));
expect(nativeDrag.defaultPrevented).toBe(true);
});
it.each([false, true])("keeps Cases reachable in More only when enabled (%s)", async enabled => {
fixture.enableCases = enabled;
await renderSidebar();
@@ -103,7 +140,7 @@ describe("Coordinator pinned pages", () => {
await flush();
}
it.each([false, true])("keeps pins above More without a separate section, restores and unpins when collapsed is %s", async collapsed => {
it.each([false, true])("restores and unpins pages in the Pinned section when collapsed is %s", async collapsed => {
fixture.collapsed = collapsed;
await renderSidebar();
expect(pinnedPage("/projects")).toBeNull();
@@ -113,9 +150,9 @@ describe("Coordinator pinned pages", () => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
expect(pinnedPage("/projects")?.textContent).toBe("Projects");
expect(navigation().querySelector('[aria-label="Pinned pages"]')).toBeNull();
expect(navigation().querySelector('[data-slot="sidebar-group-label"]')).toBeNull();
if (!collapsed) expect(navigation().querySelector('[data-slot="sidebar-group-label"]')?.textContent).toBe("Pinned");
const more = navigation().querySelector('button[aria-label="More workspace pages"]')!;
expect(pinnedPage("/projects")!.compareDocumentPosition(more) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
expect(more.compareDocumentPosition(pinnedPage("/projects")!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
await act(async () => root.unmount());
root = createRoot(container);
await renderSidebar();
+52 -15
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Inbox, Boxes, ChevronRight, CircleCheck, FolderOpen, History, LayoutDashboard, MessageCircle, MoreHorizontal, Network, Pin, PinOff, Plus, Repeat, Search, Settings, SquarePen, Unplug } from "lucide-react";
import type { Project } from "@paperclipai/shared";
import { Inbox, Boxes, ChevronRight, CircleCheck, FolderOpen, History, LayoutDashboard, MessageCircle, MoreHorizontal, Network, Pin, PinOff, Plus, Repeat, Search, Settings, SquarePen, Unplug, type LucideIcon } from "lucide-react";
import type { Agent, Project } from "@paperclipai/shared";
import { useCoordinatorAttentionCount } from "@/hooks/useCoordinatorAttentionCount";
import { issuesApi } from "@/api/issues";
import { instanceSettingsApi } from "@/api/instanceSettings";
@@ -24,6 +24,9 @@ import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { StatusIcon } from "./StatusIcon";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
import { AgentAvatar } from "./AgentAvatar";
import { agentChatPinKey } from "./AgentChatPin";
import { PinnedNavigationList, type PinnedNavigationItem } from "./PinnedNavigationList";
const moreDestinations = [
{ label: "Search", to: "/search", icon: Search },
@@ -38,6 +41,19 @@ const moreDestinations = [
{ label: "Settings", to: "/settings", icon: Settings },
] as const;
export function buildPinnedNavigationItems(pins: readonly string[], agents: readonly Agent[], destinations: readonly { to: string; label: string; icon: LucideIcon }[]): PinnedNavigationItem[] {
const keys = new Set(pins);
const items = new Map<string, PinnedNavigationItem>(destinations.map(item => [item.to, { key: item.to, to: item.to, label: item.label, icon: <item.icon className="size-4" /> }]));
for (const agent of agents) {
const key = agentChatPinKey(agent.companyId, agent.id);
if (keys.has(key)) items.set(key, { key, to: `/chats/${encodeURIComponent(agentRouteRef(agent))}`, label: agent.name, icon: <AgentAvatar agent={agent} size={20} /> });
}
return pins.flatMap(key => {
const item = items.get(key);
return item ? [item] : [];
});
}
function ProjectRecent({ project }: { project: Project }) {
const [expanded, setExpanded] = useState(false);
const { data: tasks, isPending, error } = useQuery({
@@ -61,6 +77,29 @@ function ProjectRecent({ project }: { project: Project }) {
</div>;
}
function RecentNavigationList({ children }: { children: ReactNode }) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const handleScroll = useScrollbarWhileScrolling();
useLayoutEffect(() => {
const viewport = scrollRef.current;
if (!viewport) return;
const measure = () => {
viewport.dataset.overflowTop = String(viewport.scrollTop > 1);
viewport.dataset.overflowBottom = String(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop > 1);
};
measure();
viewport.addEventListener("scroll", measure, { passive: true });
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
observer?.observe(viewport);
if (contentRef.current) observer?.observe(contentRef.current);
return () => { viewport.removeEventListener("scroll", measure); observer?.disconnect(); };
}, []);
return <div ref={scrollRef} className="design-sidebar-recent-list scrollbar-while-scrolling" onScroll={handleScroll} role="region" aria-label="Recents" tabIndex={0}>
<div ref={contentRef} className="design-sidebar-recent-content">{children}</div>
</div>;
}
/** The navigation is a view of the existing records; it never creates a chat
* or task just because someone visits or switches agents. */
export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
@@ -71,18 +110,17 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const destinations = experimental?.enableCases
const destinations = useMemo(() => experimental?.enableCases
? [...moreDestinations, { label: "Cases", to: "/cases", icon: Boxes }]
: moreDestinations;
: moreDestinations, [experimental?.enableCases]);
const [moreOpen, setMoreOpen] = useState(false);
const moreContentRef = useRef<HTMLDivElement>(null);
const pinnedDestinations = pinnedNavigation.pins.flatMap(to => destinations.filter(item => item.to === to));
const orderedPins = useMemo(() => buildPinnedNavigationItems(pinnedNavigation.pins, agents.data ?? [], destinations), [pinnedNavigation.pins, agents.data, destinations]);
const { entries: recentTasks } = useRecentTasks({ companyId, userId });
const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar();
const rail = collapsed && !peeking;
const { openNewIssue } = useDialogActions();
const location = useLocation();
const handleRecentScroll = useScrollbarWhileScrolling();
const attentionCount = useCoordinatorAttentionCount(companyId);
const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(companyId!), queryFn: () => projectsApi.list(companyId!), enabled: !!companyId });
// Only project IDs and visit times are kept in memory. Labels and task lists
@@ -104,7 +142,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
});
const projectRows = (projects ?? []).flatMap(project => projectVisits.scope === scope && projectVisits.entries[project.id]
? [{ kind: "project" as const, id: project.id, time: projectVisits.entries[project.id]!, project }] : []);
return [...taskRows, ...chatRows, ...projectRows].sort((a, b) => b.time - a.time).slice(0, 12);
return [...taskRows, ...chatRows, ...projectRows].sort((a, b) => b.time - a.time);
}, [agents.data, chats.data, recentTasks, projects, projectVisits, scope]);
const newTaskButton = <Button type="button" variant="ghost" className="design-sidebar-new-task rounded-lg" aria-label="New task" onClick={() => openNewIssue()}>
<SquarePen aria-hidden />
@@ -126,10 +164,6 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity group-hover/coordinator-new:opacity-100 group-focus-within/coordinator-new:opacity-100 pointer-coarse:opacity-100 hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-ring"><Plus className="size-4" /></button>}
</div>
<SidebarNavItem className="design-sidebar-item" to="/decisions" label="Inbox" icon={Inbox} badge={attentionCount} badgeLabel="decisions" />
{pinnedDestinations.map(({ label, to, icon }) => <div key={to} className="group/coordinator-pin relative">
<SidebarNavItem to={to} label={label} icon={icon} className={cn("design-sidebar-item", !rail && "pr-9")} />
{!rail && <button type="button" aria-label={`Unpin ${label}`} title={`Unpin ${label}`} onClick={() => pinnedNavigation.toggle(to)} className="absolute right-3 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover/coordinator-pin:opacity-100 group-focus-within/coordinator-pin:opacity-100 pointer-coarse:opacity-100"><PinOff className="size-3.5" /></button>}
</div>)}
</div>
<Flyout open={moreOpen} onOpenChange={setMoreOpen}>
<FlyoutTrigger asChild><button type="button" aria-label="More workspace pages" className="design-sidebar-item flex w-auto items-center gap-2.5 rounded-lg py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring"><MoreHorizontal className="size-4 shrink-0" /><span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>More</span></button></FlyoutTrigger>
@@ -152,15 +186,18 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
</FlyoutContent>
</Flyout>
</div>
{orderedPins.length > 0 && <div className="design-sidebar-pins"><SidebarSection label="Pinned">
<PinnedNavigationList key={`${companyId}:${userId}`} items={orderedPins} rail={rail} onReorder={pinnedNavigation.reorder} onUnpin={pinnedNavigation.toggle} />
</SidebarSection></div>}
{pinnedNavigation.error && <p role="alert" className="px-4 py-2 text-xs text-destructive">{pinnedNavigation.error}</p>}
{!rail && session.isSuccess && recents.length > 0 && <div className="design-sidebar-recents"><SidebarSection label="Recents">
<div className="design-sidebar-recent-list scrollbar-while-scrolling" onScroll={handleRecentScroll} role="region" aria-label="Recents" tabIndex={0}>
<RecentNavigationList>
{recents.map(item => item.kind === "task"
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/issues/${item.task.identifier ?? item.id}`} label={item.task.title} iconNode={<StatusIcon {...taskStatusIconProps(item.task)} />} />
: item.kind === "chat"
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/chats/${encodeURIComponent(agentRouteRef(item.agent))}`} label={item.agent.name} icon={MessageCircle} />
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/chats/${encodeURIComponent(agentRouteRef(item.agent))}`} label={item.agent.name} iconNode={<AgentAvatar agent={item.agent} size={20} />} />
: <ProjectRecent key={item.id} project={item.project} />)}
</div>
</RecentNavigationList>
</SidebarSection></div>}
{children}
</nav>
+6 -6
View File
@@ -77,6 +77,7 @@ export interface IssueRowProps {
checklistDependencyChips?: ReactNode;
checklistRowId?: string;
unreadState?: IssueRowUnreadState | null;
showUnreadIndicator?: boolean;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
@@ -160,6 +161,7 @@ export function IssueRow({
checklistDependencyChips,
checklistRowId,
unreadState = null,
showUnreadIndicator = true,
onMarkRead,
onArchive,
archiveDisabled,
@@ -175,10 +177,9 @@ export function IssueRow({
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
// Refreshed task rows communicate unread state through title typography alone.
// Existing presentations retain their unread control and its reserved space.
const showUnreadSlot = unreadState != null && !(presentation === "task" && appearance === "refreshed");
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const isUnread = unreadState === "visible" || unreadState === "fading";
const showUnreadSlot = showUnreadIndicator && unreadState != null && !(presentation === "task" && appearance === "refreshed");
const showUnreadDot = showUnreadIndicator && isUnread;
const unreadDotButton = (
<button
type="button"
@@ -238,7 +239,6 @@ export function IssueRow({
) : null;
if (presentation === "task") {
const isUnread = unreadState === "visible" || unreadState === "fading";
const refreshed = appearance === "refreshed";
const statusLabel = getStatusIconLabel(issue.status, issue.externalConversationState, issue.blockerAttention, issue.displayStatus);
const statusIcon = <StatusIcon {...taskStatusIconProps(issue)} size="md" className={selectedStatusClass} />;
@@ -495,7 +495,7 @@ export function IssueRow({
<span data-slot="task-row-title-cluster" className="flex min-w-0 items-start gap-1.5 sm:order-2 sm:flex-1 sm:items-center">
<span
data-slot="task-row-title"
className={cn("min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none", titleClassName)}
className={cn("min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none", isUnread && "font-semibold", titleClassName)}
>
{issue.title}{titleSuffix}
</span>
+8 -7
View File
@@ -136,6 +136,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
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 useInsetTaskDetailHeader = useStreamlinedTaskDetailShell && !isAgentChatRoute;
const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools";
const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps";
const appDetailConnectionId =
@@ -219,7 +220,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
/>
)
) : null;
const secondarySidebar = isAgentChatRoute ? (coordinatorWorkspaceEnabled ? null : <AgentConversationsSidebar />) : 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>
@@ -670,7 +671,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
</SidebarShell>
)}
{!isMobile && ((showsAdjacentSecondarySidebar && !keepsPrimarySidebar) || (designSandbox && replacesPrimarySidebar)) ? (
{!isMobile && !isAgentChatRoute && ((showsAdjacentSecondarySidebar && !keepsPrimarySidebar) || (designSandbox && replacesPrimarySidebar)) ? (
<SecondarySidebar className="w-60 shrink-0">
{secondarySidebar}
</SecondarySidebar>
@@ -680,12 +681,12 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
<div
className={cn(
"app-breadcrumb",
!isMobile && useStreamlinedTaskDetailShell && "hidden",
!isMobile && useInsetTaskDetailHeader && "hidden",
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<StandaloneBrowserControls mobile={isMobile} />
<BreadcrumbBar />
<BreadcrumbBar showPropertiesToggle={isAgentChatRoute} />
{isMobile && isCompanySettingsRoute ? (
<div className="border-b border-border px-4 pb-3">
<CompanySettingsNav />
@@ -697,13 +698,13 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
isMobile ? "block" : "flex flex-1 min-h-0",
!isMobile && useStreamlinedTaskDetailShell && "streamlined-task-detail-surface",
)}>
{!isMobile && keepsPrimarySidebar ? (
<SecondarySidebar className="w-60 shrink-0 bg-background">
{!isMobile && (keepsPrimarySidebar || isAgentChatRoute) ? (
<SecondarySidebar className={cn(isAgentChatRoute ? "w-70" : "w-60", "shrink-0 bg-background")}>
{secondarySidebar}
</SecondarySidebar>
) : null}
<div className={cn(!isMobile && useStreamlinedTaskDetailShell ? "flex min-w-0 flex-1 flex-col" : "contents")}>
{!isMobile && useStreamlinedTaskDetailShell ? (
{!isMobile && useInsetTaskDetailHeader ? (
<>
<StandaloneBrowserControls mobile={false} />
<BreadcrumbBar taskDetailLayout />
@@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, PinOff } from "lucide-react";
import { SidebarNavItem } from "./SidebarNavItem";
import { cn } from "@/lib/utils";
export interface PinnedNavigationItem { key: string; to: string; label: string; icon: ReactNode }
function PinnedItem({ item, rail, onUnpin }: { item: PinnedNavigationItem; rail: boolean; onUnpin: (key: string) => void }) {
const sortable = useSortable({ id: item.key, disabled: rail, transition: null });
return <div ref={sortable.setNodeRef} style={{ transform: CSS.Transform.toString(sortable.transform) }}
className={cn("group/coordinator-pin relative select-none", !sortable.isDragging && "motion-safe:transition-transform", sortable.isDragging && "z-10 opacity-80")}
onDragStart={event => event.preventDefault()}>
<SidebarNavItem to={item.to} label={item.label} iconNode={item.icon} className={cn("design-sidebar-item group-hover/coordinator-pin:bg-sidebar-accent", !rail && "pr-16")} />
{!rail && <button type="button" aria-label={`Unpin ${item.label}`} title={`Unpin ${item.label}`}
onMouseDown={event => event.stopPropagation()} onClick={() => onUnpin(item.key)}
className="absolute right-10 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover/coordinator-pin:opacity-100 group-focus-within/coordinator-pin:opacity-100 pointer-coarse:opacity-100"><PinOff className="size-3.5" /></button>}
{!rail && <button ref={sortable.setActivatorNodeRef} type="button" {...sortable.attributes} {...sortable.listeners}
aria-label={`Reorder pinned ${item.label}`} title={`Drag to reorder ${item.label}`}
className="absolute right-3 top-1/2 flex size-7 -translate-y-1/2 touch-none cursor-grab items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity active:cursor-grabbing hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover/coordinator-pin:opacity-100 group-focus-within/coordinator-pin:opacity-100 pointer-coarse:opacity-100"><GripVertical className="size-3.5" /></button>}
</div>;
}
export function PinnedNavigationList({ items, rail, onReorder, onUnpin }: {
items: PinnedNavigationItem[]; rail: boolean; onReorder: (keys: string[]) => void; onUnpin: (key: string) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function move({ active, over }: DragEndEvent) {
if (!over || active.id === over.id) return;
const keys = items.map(item => item.key);
const from = keys.indexOf(String(active.id)), to = keys.indexOf(String(over.id));
if (from >= 0 && to >= 0) onReorder(arrayMove(keys, from, to));
}
return <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={move}>
<SortableContext items={items.map(item => item.key)} strategy={verticalListSortingStrategy}>
{items.map(item => <PinnedItem key={item.key} item={item} rail={rail} onUnpin={onUnpin} />)}
</SortableContext>
</DndContext>;
}
+2 -2
View File
@@ -12,8 +12,8 @@ import { TASK_VIEW_GROUPS, taskView, type TaskViewKey } from "@/lib/task-views";
import { cn } from "@/lib/utils";
/**
* The single control that replaced both the Inbox tab bar and the implicit
* "all tasks" default on Tasks (PAP-670). One menu, grouped by scope, so the
* The single control for Inbox views and task status presets (PAP-670).
* One menu, grouped by scope, so the
* nav does not have to grow a row every time a view is added.
*/
export function TaskViewsMenu({
+40
View File
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { usePinnedNavigation } from "./usePinnedNavigation";
it("reorders hundreds of pins without nested scans and preserves hidden-company slots", async () => {
const key = "paperclip.coordinator.pinned-nav.v1:pin-review";
const visible = Array.from({ length: 500 }, (_, index) => `agent-chat:company:agent-${index}`);
const hidden = "agent-chat:other-company:hidden";
localStorage.setItem(key, JSON.stringify([...visible.slice(0, 250), hidden, ...visible.slice(250)]));
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
let navigation!: ReturnType<typeof usePinnedNavigation>;
function Harness() { navigation = usePinnedNavigation("pin-review"); return null; }
const original = Array.prototype.includes;
let scanned = 0;
const includes = vi.spyOn(Array.prototype, "includes").mockImplementation(function (this: unknown[], value: unknown, from?: number) {
if (this.length >= 500 && typeof value === "string" && value.startsWith("agent-chat:")) {
scanned += this.length;
if (scanned > visible.length * 3) throw new Error("Pin reordering scans growing lists repeatedly");
}
return original.call(this, value, from);
});
try {
await act(async () => root.render(<Harness />));
const before = navigation.pins;
await act(async () => root.render(<Harness />));
expect(navigation.pins).toBe(before);
await act(async () => navigation.reorder([...visible].reverse()));
const saved = JSON.parse(localStorage.getItem(key)!);
expect(saved[250]).toBe(hidden);
expect(saved.filter((entry: string) => entry !== hidden)).toEqual([...visible].reverse());
expect(scanned).toBeLessThanOrEqual(visible.length * 3);
} finally {
includes.mockRestore();
await act(async () => root.unmount());
host.remove(); localStorage.removeItem(key);
}
});
+21 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useState, useSyncExternalStore } from "react";
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
const UPDATED = "paperclip:pinned-navigation-updated";
const EMPTY = "[]";
@@ -27,19 +27,34 @@ export function usePinnedNavigation(userId: string | null | undefined) {
return () => { window.removeEventListener("storage", storage); window.removeEventListener(UPDATED, updated); };
}, [key]);
const snapshot = useSyncExternalStore(subscribe, () => read(key), () => EMPTY);
const pins = useMemo(() => parse(snapshot), [snapshot]);
function toggle(to: string) {
function save(next: string[]) {
if (!key) return;
const current = parse(read(key));
const next = current.includes(to) ? current.filter(entry => entry !== to) : [...current, to];
try {
localStorage.setItem(key, JSON.stringify(next));
setFailure(null);
window.dispatchEvent(new CustomEvent(UPDATED, { detail: key }));
} catch {
setFailure({ key, message: "Couldn’t save pinned pages. Allow browser storage and try again." });
setFailure({ key, message: "Couldn’t save pinned items. Allow browser storage and try again." });
}
}
return { pins: parse(snapshot), toggle, canPin: !!key, error: failure?.key === key ? failure.message : null };
function toggle(to: string) {
if (!key) return;
const current = parse(read(key));
save(current.includes(to) ? current.filter(entry => entry !== to) : [...current, to]);
}
function reorder(visibleOrder: string[]) {
if (!key) return;
const current = parse(read(key));
const currentKeys = new Set(current);
const ordered = [...new Set(visibleOrder)].filter(entry => currentKeys.has(entry));
const visible = new Set(ordered);
let index = 0;
save(current.map(entry => visible.has(entry) ? ordered[index++] : entry));
}
return { pins, toggle, reorder, canPin: !!key, error: failure?.key === key ? failure.message : null };
}
+17
View File
@@ -182,6 +182,23 @@ describe("recent task synchronization", () => {
expect(summaries).not.toHaveBeenCalled();
});
it.each([false, true])("refreshes long histories in bounded requests without pruning on a partial failure (%s)", async fails => {
const tasks = Array.from({ length: 13 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${i}` }));
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
summaries.mockImplementation(async (_company, ids: string[]) => {
expect(ids.length).toBeLessThanOrEqual(5);
if (fails && summaries.mock.calls.length === 2) throw new ApiError("Unavailable", 500, null);
return tasks.filter(issue => ids.includes(issue.id)).map(issue => ({ ...issue, title: `Refreshed ${issue.title}`, updatedAt: new Date(100) }));
});
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="long-history" /></QueryClientProvider>));
await flush();
expect(summaries).toHaveBeenCalledTimes(fails ? 2 : 3);
const entries = readRecentTasks(storageKey, "company-1");
expect(entries).toHaveLength(13);
expect(entries.every(entry => entry.title.startsWith("Refreshed"))).toBe(!fails);
expect(getIssue).not.toHaveBeenCalled();
});
it("prunes successful omissions without resurrecting cached full details", async () => {
const missing = task(10, "Removed");
recordRecentTask(missing, "user-1");
+11 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { ISSUE_SUMMARIES_MAX_IDS, type Issue, type IssueSummary } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import {
@@ -81,7 +81,13 @@ export function useRecentTasks({
}), [queryClient, scope]);
const summaryQuery = useQuery({
queryKey: summaryKey,
queryFn: ({ signal }) => issuesApi.summaries(companyId!, queryIds, { signal }),
queryFn: async ({ signal }) => {
const summaries: IssueSummary[] = [];
for (let offset = 0; offset < queryIds.length; offset += ISSUE_SUMMARIES_MAX_IDS) {
summaries.push(...await issuesApi.summaries(companyId!, queryIds.slice(offset, offset + ISSUE_SUMMARIES_MAX_IDS), { signal }));
}
return summaries;
},
enabled: Boolean(companyId && queryIds.length),
staleTime: 30_000,
});
@@ -117,7 +123,8 @@ export function useRecentTasks({
useEffect(() => {
if (!storageKey || !companyId || !summaries) return;
const authorizedIds = new Set(summaries.filter((issue) => issue.companyId === companyId).map((issue) => issue.id));
const summaryById = new Map(summaries.filter((issue) => issue.companyId === companyId).map((issue) => [issue.id, issue]));
const authorizedIds = new Set(summaryById.keys());
const mergeCached = (value: unknown) => {
if (!value || typeof value !== "object") return;
const issue = value as Issue;
@@ -126,7 +133,7 @@ export function useRecentTasks({
|| !issue.updatedAt || !Object.hasOwn(issue, "projectId")) return;
// At equal task versions, the summary's independently computed readiness
// is newer than an already-cached full detail response.
const summary = summaries.find((item) => item.id === issue.id);
const summary = summaryById.get(issue.id);
if (!summary || new Date(issue.updatedAt).getTime() <= new Date(summary.updatedAt).getTime()) return;
updateRecentTaskSnapshots(storageKey, companyId, [issue]);
};
+36 -1
View File
@@ -4725,7 +4725,22 @@ html:has(.design-proposal-theme) .design-navigation-group-label {
.design-sidebar-recents > div { display: flex; flex: 1; flex-direction: column; min-width: 0; min-height: 0; }
.design-sidebar-recents > div > :first-child { flex-shrink: 0; }
.design-sidebar-recents > div > :last-child { flex: 1; min-height: 0; }
.design-sidebar-recent-list { display: flex; flex-direction: column; gap: var(--design-sidebar-item-gap); min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; }
.design-sidebar-recent-list {
--recent-fade-top: 0px;
--recent-fade-bottom: 0px;
display: flex;
flex-direction: column;
gap: var(--design-sidebar-item-gap);
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
mask-image: linear-gradient(to bottom, transparent, var(--cell-marquee-mask-solid) var(--recent-fade-top), var(--cell-marquee-mask-solid) calc(100% - var(--recent-fade-bottom)), transparent);
}
.design-sidebar-recent-list[data-overflow-top="true"] { --recent-fade-top: var(--task-list-edge-fade-width); }
.design-sidebar-recent-list[data-overflow-bottom="true"] { --recent-fade-bottom: var(--task-list-edge-fade-width); }
.design-sidebar-recent-content { display: flex; flex-shrink: 0; flex-direction: column; gap: var(--design-sidebar-item-gap); }
.design-sidebar-recent-list:focus-visible { outline: var(--design-page-focus-width) solid var(--ring); outline-offset: calc(-1 * var(--design-page-focus-width)); }
.design-sidebar-recent-list .design-sidebar-item { flex-shrink: 0; }
.design-sidebar-account { flex-shrink: 0; }
@@ -5423,3 +5438,23 @@ html:has(.design-proposal-theme) .agent-markdown-name-field [data-slot="input"],
:root { --agent-header-character-size: 102px; }
.agent-header-character { width: var(--agent-header-character-size); height: var(--agent-header-character-size); }
.agent-pin-row { --agent-pin-surface: var(--background); }
.agent-contact-pin-row:is(:hover, :focus-within) { --agent-pin-surface: color-mix(in srgb, var(--accent) 60%, var(--background)); }
.agent-contact-pin-row[data-selected="true"] { --agent-pin-surface: var(--accent); }
.agent-roster-pin-row:is(:hover, :focus-within) { --agent-pin-surface: color-mix(in srgb, var(--accent) 50%, var(--background)); }
.agent-chat-pin {
background: var(--agent-pin-surface, transparent);
}
.agent-pin-row:is(:hover, :focus-within) .agent-pin-text {
mask-image: linear-gradient(to right, var(--foreground) calc(100% - var(--spacing) * 10), transparent calc(100% - var(--spacing) * 2));
}
.agent-contact-create {
border-radius: var(--agent-contact-top-radius) var(--agent-contact-top-radius) var(--radius-sm) var(--radius-sm);
}
:root { --agent-contact-top-radius: 50%; }
@media (pointer: coarse) {
.agent-pin-row .agent-pin-text {
mask-image: linear-gradient(to right, var(--foreground) calc(100% - var(--spacing) * 10), transparent calc(100% - var(--spacing) * 2));
}
}
.agent-contact-pin-row[data-has-unread="true"] .agent-pin-text { mask-image: none; }
+4 -5
View File
@@ -4,7 +4,6 @@ import type { Issue } from "@paperclipai/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
RECENT_TASKS_LIMIT,
RECENT_TASKS_UPDATED_EVENT,
getRecentTasksStorageKey,
migrateRecentTasks,
@@ -38,19 +37,19 @@ describe("recent task persistence", () => {
);
});
it("deduplicates, only promotes newer activity, and stays bounded", () => {
for (let index = 0; index < RECENT_TASKS_LIMIT + 2; index += 1) {
it("retains the full history, deduplicates, and only promotes newer activity", () => {
for (let index = 0; index < 20; index += 1) {
recordRecentTask(issue(String(index)), "user-1", index);
}
recordRecentTask(issue("4"), "user-1", 4);
let entries = readRecentTasks(getRecentTasksStorageKey("company-1", "user-1"), "company-1");
expect(entries[0]?.id).toBe("6");
expect(entries[0]?.id).toBe("19");
recordRecentTask(issue("4"), "user-1", 99);
entries = readRecentTasks(getRecentTasksStorageKey("company-1", "user-1"), "company-1");
expect(entries).toHaveLength(RECENT_TASKS_LIMIT);
expect(entries).toHaveLength(20);
expect(entries[0]?.id).toBe("4");
expect(entries.filter((entry) => entry.id === "4")).toHaveLength(1);
});
+4 -6
View File
@@ -1,6 +1,5 @@
import type { Issue, IssueStatus, IssueSummary } from "@paperclipai/shared";
export const RECENT_TASKS_LIMIT = 5;
export const RECENT_TASKS_UPDATED_EVENT = "paperclip:recent-tasks-updated";
const STORAGE_PREFIX = "paperclip.recentTasks.v2:";
const LEGACY_STORAGE_PREFIX = "paperclip.recentTasks:";
@@ -83,8 +82,7 @@ function normalizeRecentTasks(entries: RecentTaskEntry[]) {
if (seen.has(entry.id)) return false;
seen.add(entry.id);
return true;
})
.slice(0, RECENT_TASKS_LIMIT);
});
}
function publishRecentTasks(storageKey: string, entries: RecentTaskEntry[]) {
@@ -96,15 +94,15 @@ function publishRecentTasks(storageKey: string, entries: RecentTaskEntry[]) {
export function writeRecentTasks(storageKey: string, entries: RecentTaskEntry[]) {
if (typeof window === "undefined") return;
const bounded = normalizeRecentTasks(entries);
const normalized = normalizeRecentTasks(entries);
try {
const serialized = JSON.stringify(bounded);
const serialized = JSON.stringify(normalized);
if (window.localStorage.getItem(storageKey) === serialized) return;
window.localStorage.setItem(storageKey, serialized);
} catch {
// The in-tab event still keeps mounted navigation current for this session.
}
publishRecentTasks(storageKey, bounded);
publishRecentTasks(storageKey, normalized);
}
type TaskSnapshot = Omit<IssueSummary, "externalConversationState"> & Pick<Issue, "externalConversationState">;
+7 -3
View File
@@ -1,6 +1,7 @@
import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation";
import { AgentBulkRoster } from "@/components/agents/AgentBulkRoster";
import { AgentAvatar } from "@/components/AgentAvatar";
import { AgentChatPin, useAgentChatPins } from "@/components/AgentChatPin";
import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled";
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
import { Link, useNavigate, useLocation } from "@/lib/router";
@@ -189,6 +190,7 @@ export function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds:
export type AgentsView = "list" | "org";
export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {}) {
const agentChatPins = useAgentChatPins();
const [query, setQuery] = useState("");
const agentChat = useAgentChatEnabled();
const { selectedCompanyId } = useCompany();
@@ -382,16 +384,16 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
</>
) : null;
return (
<div key={agent.id} className="agent-pin-row agent-roster-pin-row group/agent-pin relative">
<EntityRow
key={agent.id}
title={agent.name}
titleAccessory={<PrimaryAgentIndicator agentId={agent.id} companyId={agent.companyId} />}
titleClassName="flex-1"
titleClassName="agent-pin-text flex-1"
titleTextClassName="truncate"
subtitleClassName="truncate"
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
className="agent-roster-identity group py-3"
className="agent-roster-identity group py-3 group-hover/agent-pin:bg-accent/50 group-focus-within/agent-pin:bg-accent/50"
leading={hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
) : (
@@ -408,6 +410,8 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
</div>
)}
/>
<AgentChatPin agent={agent} pins={agentChatPins} />
</div>
);
};
+11 -26
View File
@@ -1663,7 +1663,8 @@ describe("Inbox toolbar", () => {
});
});
it("does not indent unread rows: the mark-read dot overlays the shared task-row gutter", async () => {
it.each([true, false])("uses bold titles without unread dots (Streamlined UI=%s)", async streamlined => {
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false, enableStreamlinedUi: streamlined });
routerMock.location.pathname = "/inbox/mine";
// Two sibling leaf rows, one unread and one read, so their leading columns
// are directly comparable.
@@ -1700,33 +1701,17 @@ describe("Inbox toolbar", () => {
const rows = Array.from(container.querySelectorAll("[data-inbox-item]"));
const rowFor = (text: string) => rows.find((row) => row.textContent?.includes(text));
const markReadButton = (row: Element) => row.querySelector('button[aria-label="Mark as read"]');
// The canonical spacer reserves the disclosure column on every leaf row.
const hasLeadingSpacer = (row: Element) =>
!!row.querySelector('[data-slot="task-row-disclosure-spacer"]');
// The overlay anchor is present on read AND unread Inbox rows without
// consuming a layout column.
const dotSlot = (row: Element) =>
row.querySelector('[data-testid="issue-row-unread-slot"]');
const unreadRow = rowFor("Unread inbox row")!;
const readRow = rowFor("Read inbox row")!;
// Both rows share the canonical task-row geometry. The dot is absolutely
// positioned in the row gutter, so Inbox does not gain a column that Tasks
// lacks and unread state cannot shift status/title alignment.
const unreadSlot = dotSlot(unreadRow);
const readSlot = dotSlot(readRow);
expect(unreadSlot).not.toBeNull();
expect(readSlot).not.toBeNull();
expect(unreadSlot?.className).toContain("absolute");
// Only the unread row carries the dot button; the read slot is empty.
expect(markReadButton(unreadSlot!)).not.toBeNull();
expect(readSlot?.querySelector('button[aria-label="Mark as read"]')).toBeNull();
expect(hasLeadingSpacer(unreadRow)).toBe(true);
// Read rows keep the same spacer, so both rows line up.
expect(hasLeadingSpacer(readRow)).toBe(true);
expect(unreadRow.querySelector('[data-slot="task-row-title"]')?.className).toContain("font-semibold");
expect(readRow.querySelector('[data-slot="task-row-title"]')?.className).not.toContain("font-semibold");
expect(container.querySelector('[data-testid="issue-row-unread-slot"]')).toBeNull();
expect(container.querySelector('button[aria-label="Mark as read"]')).toBeNull();
if (streamlined) {
for (const row of [unreadRow, readRow]) {
expect(row.querySelector('[data-slot="task-row-disclosure-spacer"]')).not.toBeNull();
}
}
act(() => {
root.unmount();
+1
View File
@@ -3100,6 +3100,7 @@ function StreamlinedInbox({
)
) : undefined}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
showUnreadIndicator={false}
onMarkRead={() => markReadMutation.mutate(issue.id)}
onArchive={allowArchive ? () => archiveIssueMutation.mutate(issue.id) : undefined}
archiveDisabled={isArchiving}
+3 -21
View File
@@ -1,5 +1,3 @@
import { AgentAvatar } from "../components/AgentAvatar";
import { CoordinatorAgentSwitcher } from "../components/CoordinatorAgentSwitcher";
import { TaskChatPausedTakeover, type TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
// @vitest-environment jsdom
@@ -2078,29 +2076,13 @@ describe("IssueDetail", () => {
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockIssuesApi.markRead).not.toHaveBeenCalled();
const [breadcrumbs] = mockSetBreadcrumbs.mock.calls.at(-1)!;
if (coordinator) {
expect(breadcrumbs[0].control.type).toBe(CoordinatorAgentSwitcher);
expect(breadcrumbs[0].control.props.agent).toEqual(agent);
expect(breadcrumbs[0].leading).toBeUndefined();
expect(breadcrumbs[0].trailing).toBeUndefined();
} else {
expect(breadcrumbs[0].leading.type).toBe(AgentAvatar);
expect(breadcrumbs[0].leading.props).toMatchObject({ agent, size: 24 });
}
expect(breadcrumbs[0].leadingKey).toBe(`agent:${agent.id}:${JSON.stringify(agent.appearance)}`);
const updatedAgent = { ...agent, appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "deep-tide" } } as Agent;
expect(breadcrumbs).toEqual([{ label: "Chat" }]);
const updatedAgent = { ...agent, name: "Updated agent" };
await act(async () => {
renderIssueView(<TaskDetailSurface conversation={{ agent: updatedAgent, issue: canonical, ensureIssue: async () => canonical }} />);
});
await flushReact();
const [updatedBreadcrumbs] = mockSetBreadcrumbs.mock.calls.at(-1)!;
if (coordinator) {
expect(updatedBreadcrumbs[0].control.props.agent).toEqual(updatedAgent);
expect(updatedBreadcrumbs[0].controlKey).not.toBe(breadcrumbs[0].controlKey);
} else {
expect(updatedBreadcrumbs[0].leading.props.agent).toEqual(updatedAgent);
}
expect(updatedBreadcrumbs[0].leadingKey).not.toBe(breadcrumbs[0].leadingKey);
expect(mockSetBreadcrumbs.mock.calls.at(-1)![0]).toEqual([{ label: "Chat" }]);
});
+1 -12
View File
@@ -4,7 +4,6 @@ import { useConversationHistoryPages, type ConversationHistoryPage } from "@/hoo
import { taskChatHashAnchor } from "@/components/task-chat/scroll-navigation";
import { AgentChatReadProvider } from "@/hooks/useAgentChatRead";
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
import { CoordinatorAgentSwitcher } from "@/components/CoordinatorAgentSwitcher";
import { TaskDecisionRecord } from "@/components/TaskDecisionRecord";
import { useAccountBoundMutation } from "@/hooks/useAccountBoundMutation";
import { bindAccount } from "@/lib/account-scope";
@@ -29,8 +28,6 @@ import { AgentIdentity } from "@/components/AgentIdentity";
import { clearLegacyChatMessageRequests } from "@/lib/chat-message-request";
import { agentChatDraft } from "@/lib/agent-chat-draft";
import { trackRecentProject } from "@/lib/recent-projects";
import { Settings as ChatSettings } from "lucide-react";
import { agentDetailHref } from "./agent-detail-navigation";
import { ExecutionBlockerNotice } from "../components/ExecutionBlockerNotice";
import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
@@ -5973,15 +5970,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const conversationAgent = conversation?.agent ?? agents?.find(agent => agent.id === issue?.conversationAgentId);
useEffect(() => {
if (conversationAgent) {
setBreadcrumbs([{
label: conversationAgent.name,
control: coordinatorWorkspaceEnabled ? <CoordinatorAgentSwitcher agent={conversationAgent} /> : undefined,
controlKey: coordinatorWorkspaceEnabled ? `switch:${conversationAgent.id}:${JSON.stringify(conversationAgent.appearance)}` : undefined,
leading: coordinatorWorkspaceEnabled ? undefined : <AgentAvatar agent={conversationAgent} size={24} />,
leadingKey: `agent:${conversationAgent.id}:${JSON.stringify(conversationAgent.appearance)}`,
trailing: coordinatorWorkspaceEnabled ? undefined : <Button variant="ghost" size="icon-xs" asChild aria-label={`Configure ${conversationAgent.name}`}><Link to={agentDetailHref(conversationAgent.id, "runtime")}><ChatSettings /></Link></Button>,
trailingKey: `${coordinatorWorkspaceEnabled ? "switch" : "configure"}:${conversationAgent.id}`,
}]);
setBreadcrumbs([{ label: "Chat" }]);
return;
}
setBreadcrumbs([
+1
View File
@@ -2773,6 +2773,7 @@ export function Inbox() {
)
}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
showUnreadIndicator={false}
onMarkRead={() => markReadMutation.mutate(issue.id)}
onArchive={allowArchive ? () => archiveIssueMutation.mutate(issue.id) : undefined}
archiveDisabled={isArchiving}