mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
Restore chat contacts, agent pins, and scrollable recent navigation (#19685)
This commit is contained in:
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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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>}
|
||||
</>;
|
||||
}
|
||||
@@ -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"; })}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
});
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" }]);
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user