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

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

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import { Pin, PinOff } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { authApi } from "@/api/auth";
import { usePinnedNavigation } from "@/hooks/usePinnedNavigation";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
export const agentChatPinKey = (companyId: string, agentId: string) => `agent-chat:${companyId}:${agentId}`;
export function useAgentChatPins() {
const session = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() });
const userId = session.data?.user?.id ?? session.data?.session?.userId;
const navigation = usePinnedNavigation(userId);
const keys = useMemo(() => new Set(navigation.pins), [navigation.pins]);
return { keys, toggle: navigation.toggle, canPin: navigation.canPin, error: navigation.error };
}
export function AgentChatPin({ agent, pins, className }: {
agent: { id: string; companyId: string; name: string }; pins: ReturnType<typeof useAgentChatPins>; className?: string;
}) {
const key = agentChatPinKey(agent.companyId, agent.id);
const pinned = pins.keys.has(key);
const Icon = pinned ? PinOff : Pin;
return <>
<button type="button" aria-label={`${pinned ? "Unpin" : "Pin"} ${agent.name}`} aria-pressed={pinned}
title={`${pinned ? "Unpin" : "Pin"} ${agent.name}`} disabled={!pins.canPin}
onClick={event => { event.preventDefault(); event.stopPropagation(); pins.toggle(key); }}
className={cn("agent-chat-pin absolute right-2 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/agent-pin:opacity-100 group-focus-within/agent-pin:opacity-100 pointer-coarse:opacity-100", className)}>
<Icon aria-hidden className="size-4" />
</button>
{pins.error && <span role="alert" className="text-xs text-destructive">{pins.error}</span>}
</>;
}
+31 -26
View File
@@ -1,5 +1,6 @@
import { useId, useMemo, useRef, useState } from "react";
import { useId, useMemo, useRef, useState, type ReactNode } from "react";
import { Plus, Search, Users, X } from "lucide-react";
import { Badge } from "./ui/badge";
import type { Agent } from "@paperclipai/shared";
import { AgentChatPicker } from "@/components/AgentChatPicker";
import { AgentAvatar } from "@/components/AgentAvatar";
@@ -27,10 +28,12 @@ export interface AgentConversationSidebarProps {
historyLoading?: boolean;
historyError?: Error | null;
onRetryHistory?: () => void;
onCreateAgent?: () => void;
renderAgentAction?: (agent: Agent) => ReactNode;
}
/** Searchable navigation for one conversation per agent. */
export function AgentConversationSidebar({ agents, existingChatAgentIds, availableAgents = agents, activeId, previews = {}, unreadAgentIds = [], unreadMessageCounts, loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory }: AgentConversationSidebarProps) {
export function AgentConversationSidebar({ agents, existingChatAgentIds, availableAgents = agents, activeId, previews = {}, unreadAgentIds = [], unreadMessageCounts, loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory, onCreateAgent, renderAgentAction }: AgentConversationSidebarProps) {
const navigate = useNavigate();
const [pickerOpen, setPickerOpen] = useState(false);
const [search, setSearch] = useState(initialSearch);
@@ -44,25 +47,17 @@ export function AgentConversationSidebar({ agents, existingChatAgentIds, availab
searchRef.current?.focus();
}
return <aside aria-labelledby={headingId} className="flex h-full min-h-0 flex-col border-r border-border bg-background">
<div className="flex shrink-0 flex-col gap-4 px-3 pb-4 pt-4">
<div className="flex items-center justify-between gap-2">
<h2 id={headingId} className="text-sm font-semibold">Chat</h2>
<Button variant="outline" size="icon-sm" aria-label="Add chat" title="Add chat" onClick={() => setPickerOpen(true)}>
<Plus className="size-4" />
</Button>
</div>
<div className="relative">
<h2 id={headingId} className="sr-only">Chat contacts</h2>
<div className="flex shrink-0 items-center gap-2 px-3 pb-3 pt-5">
<div className="relative min-w-0 flex-1">
<Search aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input ref={searchRef} aria-label="Search agents" placeholder="Find an agent" value={search} onChange={event => setSearch(event.target.value)}
<Input ref={searchRef} aria-label="Search chats" placeholder="Search chats" value={search} onChange={event => setSearch(event.target.value)}
onKeyDown={event => { if (event.key === "Escape") { event.stopPropagation(); setSearch(""); } }}
className="h-8 pl-8 pr-8 text-xs md:text-xs" />
className="h-9 pl-8 pr-8 text-xs md:text-xs" />
{search && <Button variant="ghost" size="icon-xs" aria-label="Clear search" className="absolute right-1 top-1/2 -translate-y-1/2" onClick={focusSearch}><X className="size-3" /></Button>}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-3 pb-3 scrollbar-auto-hide">
<p className="px-2 text-(length:--text-micro) font-medium uppercase tracking-widest text-muted-foreground">
{query ? "Search results" : "Teammates"}
</p>
<span role="status" className="sr-only">{loading ? "Loading agents" : `${visible.length} ${visible.length === 1 ? "agent" : "agents"}`}</span>
{historyLoading && <p role="status" className="px-2 text-xs text-muted-foreground">Loading chat history…</p>}
{historyError && <div role="alert" className="flex flex-col items-start gap-2 px-2 py-3">
@@ -75,30 +70,40 @@ export function AgentConversationSidebar({ agents, existingChatAgentIds, availab
<div className="flex flex-1 flex-col gap-2"><div className="h-3 w-2/3 rounded-sm bg-muted" /><div className="h-2 w-full rounded-sm bg-muted" /></div>
</div>)}
</div> : visible.length ? <nav aria-label="Agent conversations" className="flex flex-col gap-1">
{visible.map(agent => <Link key={agent.id} to={`/chats/${encodeURIComponent(agent.status === "terminated" ? agent.id : agentRouteRef(agent))}`}
{visible.map(agent => {
const unreadCount = unreadMessageCounts?.get(agent.id) ?? 0;
const hasUnread = unreadCount > 0 || unreadIds.has(agent.id);
return <div key={agent.id} className="agent-pin-row agent-contact-pin-row group/agent-pin relative rounded-md" data-selected={activeId === agent.id || undefined} data-has-unread={hasUnread || undefined}><Link to={`/chats/${encodeURIComponent(agent.status === "terminated" ? agent.id : agentRouteRef(agent))}`}
aria-current={activeId === agent.id ? "page" : undefined}
title={`${agent.name}${agent.title ? ` · ${agent.title}` : ""}`}
onClick={event => { if (onSelect) { event.preventDefault(); onSelect(agent); } }}
className={cn("flex min-w-0 items-center gap-3 rounded-md px-2 py-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", activeId === agent.id && "bg-accent text-accent-foreground")}>
<AgentAvatar agent={agent} size={32} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
className={cn("agent-contact-row flex min-w-0 items-center gap-3 rounded-md px-2.5 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", renderAgentAction && hasUnread && "pr-12", activeId === agent.id ? "bg-accent text-accent-foreground" : "group-hover/agent-pin:bg-accent/60 group-focus-within/agent-pin:bg-accent/60")}>
<AgentAvatar agent={agent} size={40} />
<span className="agent-pin-text flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{agent.name}</span>
<span className="truncate text-xs text-muted-foreground">{agent.status === "terminated" ? "Terminated" : agent.status === "paused" ? "Paused" : previews[agent.id] ?? agent.title ?? "Start a conversation"}</span>
<span className="truncate text-xs text-muted-foreground">{agent.status === "terminated" ? "Terminated" : agent.title ?? agent.role}</span>
</span>
{unreadIds.has(agent.id) && <span role="img" aria-label={`Unread messages from ${agent.name}`} className="size-2 shrink-0 rounded-full bg-primary" />}
</Link>)}
{unreadCount > 0 ? <Badge variant="ghost" aria-label={`${unreadCount} unread ${unreadCount === 1 ? "message" : "messages"} from ${agent.name}`} className="shrink-0 bg-primary font-mono tabular-nums text-primary-foreground">{unreadCount}</Badge>
: unreadIds.has(agent.id) && <span role="img" aria-label={`Unread messages from ${agent.name}`} className="size-2 shrink-0 rounded-full bg-primary" />}
</Link>{renderAgentAction?.(agent)}</div>;
})}
</nav> : !agents.length && (historyLoading || historyError) ? null : <div className="flex flex-col items-start gap-2 px-2 py-6">
<p className="text-sm font-medium">{agents.length ? "No agents found" : "No chats yet"}</p>
<p className="text-xs leading-relaxed text-muted-foreground">{agents.length ? "Try another name or role." : "Choose an agent to start a conversation."}</p>
{!agents.length && <Button variant="outline" size="sm" onClick={() => setPickerOpen(true)}>Choose an agent</Button>}
{agents.length > 0 && <Button variant="outline" size="sm" onClick={focusSearch}>Clear search</Button>}
</div>}
{onCreateAgent && !loading && !error && <button type="button" onClick={onCreateAgent}
className="flex min-w-0 items-center gap-3 rounded-md px-2.5 py-3 text-left hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<span aria-hidden className="flex size-10 shrink-0 items-center justify-center"><span className="agent-contact-create flex size-8 items-center justify-center bg-muted"><Plus className="size-5 text-foreground" /></span></span>
<span className="text-sm font-medium">Create a new agent</span>
</button>}
</div>
<div className="shrink-0 px-3 py-4">
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 px-2 text-muted-foreground" asChild={!onBrowse} onClick={onBrowse}>
{onBrowse ? <><Users className="size-4" />Browse all agents</> : <Link to="/agents/all"><Users className="size-4" />Browse all agents</Link>}
{onBrowse && <div className="shrink-0 px-3 py-4">
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 px-2 text-muted-foreground" onClick={onBrowse}>
<Users className="size-4" />Browse all agents
</Button>
</div>
</div>}
<AgentChatPicker agents={availableAgents} open={pickerOpen} onOpenChange={setPickerOpen}
unreadMessageCounts={unreadMessageCounts}
existingChatAgentIds={existingChatAgentIds ?? agents.map(agent => agent.id)} loading={loading} error={error} onRetry={onRetry}
@@ -7,7 +7,7 @@ import type { Agent } from "@paperclipai/shared";
import { AgentConversationsSidebar } from "./AgentConversationsSidebar";
import type { AgentConversationSidebarProps } from "./AgentConversationSidebar";
import { queryKeys } from "@/lib/queryKeys";
const state = vi.hoisted(() => ({ companyId: "company-a", navigate: vi.fn(), closeSidebar: vi.fn(), ensure: vi.fn(), getAgent: vi.fn() }));
const state = vi.hoisted(() => ({ companyId: "company-a", navigate: vi.fn(), closeSidebar: vi.fn(), ensure: vi.fn(), getAgent: vi.fn(), newAgent: vi.fn() }));
const sidebarInputs = vi.hoisted(() => [] as Pick<AgentConversationSidebarProps, "existingChatAgentIds" | "unreadAgentIds" | "unreadMessageCounts">[]);
vi.mock("./AgentConversationSidebar", async importOriginal => {
const original = await importOriginal<typeof import("./AgentConversationSidebar")>();
@@ -16,6 +16,7 @@ vi.mock("./AgentConversationSidebar", async importOriginal => {
return <original.AgentConversationSidebar {...props} />;
} };
});
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewAgent: state.newAgent }) }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId }) }));
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: true, setSidebarOpen: state.closeSidebar }) }));
vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: true, loaded: true }) }));
@@ -39,16 +40,15 @@ const chat = (id: string) => ({ id: `chat-${id}`, companyId: "company-a", conver
async function render() {
await act(async () => { root.render(<QueryClientProvider client={client}><AgentConversationsSidebar /></QueryClientProvider>); });
}
async function addChat() { await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Add chat"]')!.click()); }
async function choose(name: string) {
await act(async () => [...document.querySelectorAll<HTMLElement>("[role=option]")].find(option => option.textContent?.startsWith(name))!.click());
async function openContact(name: string) {
await act(async () => container.querySelector<HTMLAnchorElement>(`a[href="/chats/${name}"]`)!.click());
}
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} });
state.companyId = "company-a";
state.navigate.mockReset(); state.closeSidebar.mockReset(); state.ensure.mockReset(); state.getAgent.mockReset(); state.newAgent.mockReset(); localStorage.clear();
sidebarInputs.length = 0;
state.navigate.mockReset(); state.closeSidebar.mockReset(); state.ensure.mockReset(); state.getAgent.mockReset();
state.ensure.mockImplementation(async (_company, id) => chat(id));
memberships.agentMemberships = {};
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
@@ -58,37 +58,36 @@ beforeEach(() => {
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
it("adds through the server and reopens one cached conversation without a duplicate row", async () => {
it("opens every contact without creating a conversation before the first message", async () => {
await render();
expect(container.querySelectorAll('nav[aria-label="Agent conversations"] a')).toHaveLength(2);
await addChat();
expect([...document.querySelectorAll("[role=option]")].find(option => option.textContent?.startsWith("bob"))?.textContent).not.toContain("Open chat");
await choose("bob");
expect(state.ensure).toHaveBeenCalledWith("company-a", "bob");
expect(container.querySelector('[aria-label="Add chat"]')).toBeNull();
expect(container.querySelector('[aria-label="Search chats"]')).not.toBeNull();
await openContact("bob");
expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob");
expect(state.closeSidebar).toHaveBeenCalledWith(false);
await render();
expect(client.getQueryData(queryKeys.agentChats.detail("company-a", "user-a", "bob"))).toEqual(chat("bob"));
await addChat();
expect([...document.querySelectorAll("[role=option]")].find(option => option.textContent?.startsWith("bob"))?.textContent).toContain("Open chat");
await choose("bob"); await render();
expect(state.ensure).not.toHaveBeenCalled();
await openContact("alice");
expect(state.navigate).toHaveBeenLastCalledWith("/chats/alice");
expect(container.querySelectorAll('a[href="/chats/bob"]')).toHaveLength(1);
expect(client.getQueryData(queryKeys.agentChats.list("company-a", "user-a"))).toHaveLength(2);
});
it("keeps failures retryable in the picker and closes it on company changes", async () => {
state.ensure.mockRejectedValueOnce(new Error("Couldn’t open chat. Try again."));
await render(); await addChat(); await choose("bob");
expect(document.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t open chat");
expect(state.navigate).not.toHaveBeenCalled();
await choose("bob");
expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob");
await addChat();
it("opens the existing create-agent dialog and persists a company-scoped chat pin", async () => {
await render();
await act(async () => [...container.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Create a new agent")!.click());
expect(state.newAgent).toHaveBeenCalledOnce();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Pin bob"]')!.click());
expect(JSON.parse(localStorage.getItem("paperclip.coordinator.pinned-nav.v1:user-a")!)).toEqual(["agent-chat:company-a:bob"]);
expect(container.querySelector('[aria-label="Unpin bob"]')?.getAttribute("aria-pressed")).toBe("true");
expect(state.ensure).not.toHaveBeenCalled();
});
it("replaces contacts when the company changes", async () => {
await render();
state.companyId = "company-b";
client.setQueryData(queryKeys.agents.list("company-b"), []);
client.setQueryData(queryKeys.agentChats.list("company-b", "user-a"), []);
await render();
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(container.textContent).not.toContain("alice");
expect(container.textContent).not.toContain("bob");
});
it("preserves recent activity order when reopening an older conversation", async () => {
@@ -96,7 +95,7 @@ it("preserves recent activity order when reopening an older conversation", async
await render();
const links = () => [...container.querySelectorAll('nav[aria-label="Agent conversations"] a')].map(link => link.getAttribute("href"));
expect(links()).toEqual(["/chats/bob", "/chats/alice"]);
await addChat(); await choose("alice"); await render();
await openContact("alice"); await render();
expect(links()).toEqual(["/chats/bob", "/chats/alice"]);
});
@@ -113,10 +112,10 @@ it("shows accessible unread indicators only for the current company and human",
expect(container.querySelector('[aria-label^="Unread messages"]')).toBeNull();
});
it("passes numeric unread counts to Add chat and updates them from the account cache", async () => {
it("passes numeric unread counts to contacts and updates them from the account cache", async () => {
const key = queryKeys.agentChats.list("company-a", "user-a");
client.setQueryData(key, [{ ...chat("alice"), isUnreadForMe: true, unreadMessageCount: 8 }, chat("bob")]);
await render(); await addChat();
await render();
expect(document.querySelector('[aria-label="8 unread messages from alice"]')?.textContent).toBe("8");
expect(document.querySelector('[aria-label*="unread message from bob"]')).toBeNull();
await act(async () => {
@@ -133,26 +132,38 @@ it("passes numeric unread counts to Add chat and updates them from the account c
it("reuses chat indexes for 500 agents across unchanged parent renders and refreshes changed counts", async () => {
const agents = Array.from({ length: 500 }, (_, index) => ({ ...roster[0], id: `agent-${index}`, name: `Agent ${index}`, urlKey: `agent-${index}` }));
const chats = agents.map(agent => ({ ...chat(agent.id), isUnreadForMe: true, unreadMessageCount: 7 }));
const key = queryKeys.agentChats.list("company-a", "user-a");
client.setQueryData(queryKeys.agents.list("company-a"), agents);
client.setQueryData(key, chats);
await render(); await addChat();
const first = sidebarInputs.at(-1)!;
expect(first.existingChatAgentIds).toHaveLength(500);
expect(first.unreadAgentIds).toHaveLength(500);
expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(500);
await render();
const unchanged = sidebarInputs.at(-1)!;
expect(unchanged.existingChatAgentIds).toBe(first.existingChatAgentIds);
expect(unchanged.unreadAgentIds).toBe(first.unreadAgentIds);
expect(unchanged.unreadMessageCounts).toBe(first.unreadMessageCounts);
await act(async () => {
client.setQueryData(key, [{ ...chats[0], isUnreadForMe: false, unreadMessageCount: 0 }, ...chats.slice(1)]);
await new Promise(resolve => setTimeout(resolve, 0));
const savedPins = JSON.stringify(agents.map(agent => `agent-chat:company-a:${agent.id}`));
localStorage.setItem("paperclip.coordinator.pinned-nav.v1:user-a", savedPins);
const originalParse = JSON.parse;
let pinParses = 0;
const parse = vi.spyOn(JSON, "parse").mockImplementation((value, reviver) => {
if (value === savedPins && ++pinParses > 8) throw new Error("Each contact parses the full pin list");
return originalParse(value, reviver);
});
expect(document.querySelector('[aria-label="7 unread messages from Agent 0"]')).toBeNull();
expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(499);
try {
const chats = agents.map(agent => ({ ...chat(agent.id), isUnreadForMe: true, unreadMessageCount: 7 }));
const key = queryKeys.agentChats.list("company-a", "user-a");
client.setQueryData(queryKeys.agents.list("company-a"), agents);
client.setQueryData(key, chats);
await render();
const first = sidebarInputs.at(-1)!;
expect(first.existingChatAgentIds).toHaveLength(500);
expect(first.unreadAgentIds).toHaveLength(500);
expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(500);
await render();
const unchanged = sidebarInputs.at(-1)!;
expect(unchanged.existingChatAgentIds).toBe(first.existingChatAgentIds);
expect(unchanged.unreadAgentIds).toBe(first.unreadAgentIds);
expect(unchanged.unreadMessageCounts).toBe(first.unreadMessageCounts);
await act(async () => {
client.setQueryData(key, [{ ...chats[0], isUnreadForMe: false, unreadMessageCount: 0 }, ...chats.slice(1)]);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(document.querySelector('[aria-label="7 unread messages from Agent 0"]')).toBeNull();
expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(499);
expect(container.querySelectorAll('[aria-label^="Unpin Agent "]')).toHaveLength(500);
expect(pinParses).toBeLessThanOrEqual(8);
} finally { parse.mockRestore(); }
});
it("retains terminated agents' history by id without offering them for new chats", async () => {
@@ -164,21 +175,7 @@ it("retains terminated agents' history by id without offering them for new chats
expect(historyLink.textContent).toContain("RetiredTerminated");
await act(async () => historyLink.click());
expect(state.navigate).toHaveBeenLastCalledWith("/chats/retired-id");
await addChat();
expect([...document.querySelectorAll("[role=option]")].some(option => option.textContent?.includes("Retired"))).toBe(false);
});
it("does not let a dismissed selection override a newer chat", async () => {
let finishAlice!: (value: ReturnType<typeof chat>) => void;
state.ensure.mockImplementation(async (_company, id) => id === "alice"
? new Promise(resolve => { finishAlice = resolve; }) : chat(id));
await render(); await addChat(); await choose("alice");
await act(async () => document.querySelector<HTMLButtonElement>('[data-slot="dialog-close"]')!.click());
await addChat(); await choose("bob");
expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob");
await act(async () => finishAlice(chat("alice")));
expect(state.navigate).toHaveBeenCalledTimes(1);
expect(client.getQueryData(queryKeys.agentChats.list("company-a", "user-a"))).toHaveLength(2);
expect(state.ensure).not.toHaveBeenCalled();
});
it("keeps healthy chats usable while a historical lookup fails and retries", async () => {
@@ -192,7 +189,7 @@ it("keeps healthy chats usable while a historical lookup fails and retries", asy
});
expect(container.querySelector('a[href="/chats/alice"]')).not.toBeNull();
await act(async () => {
const input = container.querySelector<HTMLInputElement>('[aria-label="Search agents"]')!;
const input = container.querySelector<HTMLInputElement>('[aria-label="Search chats"]')!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "No match");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
@@ -9,15 +9,19 @@ import { useQueries } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { resourceMembershipState, useResourceMemberships } from "@/hooks/useResourceMemberships";
import { useDialogActions } from "@/context/DialogContext";
import { AgentChatPin, useAgentChatPins } from "./AgentChatPin";
/** Account- and company-scoped data for the secondary chat navigation. */
export function AgentConversationsSidebar() {
const { companyId, userId, agents, chats, session } = useAgentChatNavigation();
const agentChatPins = useAgentChatPins();
const unreadMessageCounts = useMemo(() => new Map((chats.data ?? [])
.flatMap(chat => chat.conversationAgentId && chat.unreadMessageCount
? [[chat.conversationAgentId, chat.unreadMessageCount] as const] : [])), [chats.data]);
const openChat = useOpenAgentChat(companyId, userId);
const navigate = useNavigate();
const { openNewAgent } = useDialogActions();
const memberships = useResourceMemberships(companyId);
const { isMobile, setSidebarOpen } = useSidebar();
const { pathname } = useLocation();
@@ -61,6 +65,7 @@ export function AgentConversationsSidebar() {
activeId={active?.id} previews={previews}
unreadAgentIds={unreadAgentIds}
unreadMessageCounts={unreadMessageCounts}
onCreateAgent={openNewAgent} renderAgentAction={agent => <AgentChatPin agent={agent} pins={agentChatPins} />}
// Loaded chats stay listed through an outage (the banner covers it); an
// outage before the first load is a skeleton, and only a real failure is an error.
loading={[agents, chats, session].some(query => { const kind = queryViewKind(query); return kind === "loading" || kind === "reconnecting"; })}
+19
View File
@@ -131,6 +131,25 @@ describe("BreadcrumbBar", () => {
vi.unstubAllGlobals();
});
it("uses the standard section heading for Chat while retaining the properties toggle", async () => {
const toggle = vi.fn();
function ChatHeader() {
const { setBreadcrumbs, setBreadcrumbPanelControl } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([{ label: "Chat" }]);
setBreadcrumbPanelControl({ open: false, onToggle: toggle });
}, [setBreadcrumbs, setBreadcrumbPanelControl]);
return <BreadcrumbBar showPropertiesToggle />;
}
await act(async () => root.render(<BreadcrumbProvider><ChatHeader /></BreadcrumbProvider>));
expect(container.querySelector("h1")?.textContent).toBe("Chat");
expect(container.querySelector("h1")?.className).toContain("uppercase");
expect(container.querySelector('[aria-label^="Switch agent"]')).toBeNull();
expect(container.querySelector('[aria-label^="Pin "]')).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Show properties"]')!.click());
expect(toggle).toHaveBeenCalledOnce();
});
it.each([
{ mobile: false, taskDetailLayout: true },
{ mobile: true, taskDetailLayout: true },
+15 -13
View File
@@ -53,7 +53,7 @@ function GlobalToolbar({
);
}
export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
export function BreadcrumbBar({ taskDetailLayout = false, showPropertiesToggle = taskDetailLayout }: { taskDetailLayout?: boolean; showPropertiesToggle?: boolean }) {
const coordinatorEnabled = useCoordinatorPresentation();
const {
breadcrumbs,
@@ -66,6 +66,18 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
const { selectedCompanyId, selectedCompany } = useCompany();
const taskPanelOpen = breadcrumbPanelControl?.open ?? panelVisible;
const toggleTaskPanel = breadcrumbPanelControl?.onToggle ?? togglePanelVisible;
const propertiesToggle = showPropertiesToggle ? (
<Button
variant="ghost"
size="icon-sm"
className="ml-5 size-9 shrink-0 text-muted-foreground"
onClick={toggleTaskPanel}
aria-label={taskPanelOpen ? "Hide properties" : "Show properties"}
title={taskPanelOpen ? "Hide properties" : "Show properties"}
>
{taskPanelOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
) : null;
const globalToolbarSlotContext = useMemo(
() => ({
@@ -218,6 +230,7 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
<span className="ml-2 flex shrink-0 items-center">{breadcrumbs[0].trailing}</span>
) : null}
{globalToolbarSlots}
{propertiesToggle}
</div>
);
}
@@ -233,18 +246,7 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
{menuButton}
{breadcrumbTrail}
{globalToolbarSlots}
{taskDetailLayout ? (
<Button
variant="ghost"
size="icon-sm"
className="ml-5 size-9 shrink-0 text-muted-foreground"
onClick={toggleTaskPanel}
aria-label={taskPanelOpen ? "Hide properties" : "Show properties"}
title={taskPanelOpen ? "Hide properties" : "Show properties"}
>
{taskPanelOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
) : null}
{propertiesToggle}
</div>
);
}
+45 -8
View File
@@ -3,17 +3,17 @@ import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionFeed, AttentionItem } from "@paperclipai/shared";
import { CoordinatorSidebar } from "./CoordinatorSidebar";
import type { Agent, AttentionFeed, AttentionItem } from "@paperclipai/shared";
import { buildPinnedNavigationItems, CoordinatorSidebar } from "./CoordinatorSidebar";
import { TooltipProvider } from "./ui/tooltip";
import { coordinatorAttentionQueryKey, coordinatorAttentionSummaryQueryKey } from "@/lib/coordinator-attention";
import { queryKeys } from "@/lib/queryKeys";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), openNewIssue: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer", chats: [] as Array<{ isUnreadForMe: boolean }>,
recentTasks: [] as Array<Record<string, unknown>>, projects: [] as Array<Record<string, unknown>>, projectTasks: vi.fn(), enableCases: false }));
recentTasks: [] as Array<Record<string, unknown>>, projects: [] as Array<Record<string, unknown>>, projectTasks: vi.fn(), enableCases: false, agents: [] as Agent[] }));
vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: fixture.chats }, session: { isSuccess: true },
companyId: "company", userId: fixture.userId, agents: { data: fixture.agents }, chats: { data: fixture.chats }, session: { isSuccess: true },
}) }));
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: fixture.recentTasks }) }));
vi.mock("@/api/issues", () => ({ issuesApi: { listCompact: fixture.projectTasks } }));
@@ -46,13 +46,23 @@ function summary(items: AttentionItem[]) {
let root: Root, container: HTMLDivElement, client: QueryClient;
beforeEach(() => {
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer"; fixture.pathname = "/COO/issues";
fixture.chats = []; fixture.recentTasks = []; fixture.projects = []; fixture.enableCases = false;
fixture.chats = []; fixture.recentTasks = []; fixture.projects = []; fixture.enableCases = false; fixture.agents = [];
fixture.summary.mockResolvedValue(summary([pending("question"), pending("review")]));
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
describe("Coordinator task creation", () => {
it("keeps every recent entry below the section header instead of truncating at twelve", async () => {
fixture.recentTasks = Array.from({ length: 20 }, (_, index) => ({
id: `task-${index}`, companyId: "company", identifier: `TASK-${index}`, title: `Recent task ${index}`, status: "todo", recordedAt: index,
}));
await renderSidebar();
const region = container.querySelector('[role="region"][aria-label="Recents"]')!;
expect(region.querySelectorAll("a")).toHaveLength(20);
expect(region.textContent).not.toContain("Recents");
expect(region.querySelector('a[href="/issues/TASK-0"]')).not.toBeNull();
});
it.each([false, true])("opens global search from the organization header when collapsed is %s", async collapsed => {
fixture.collapsed = collapsed;
await renderSidebar();
@@ -80,6 +90,33 @@ describe("Coordinator task creation", () => {
});
describe("Coordinator pinned pages", () => {
it("builds hundreds of agent pins with input reads proportional to list size", () => {
const agents = Array.from({ length: 500 }, (_, index) => ({ id: `agent-${index}`, companyId: "company", name: `Agent ${index}`, urlKey: `agent-${index}` } as Agent));
const pins = agents.map(agent => `agent-chat:company:${agent.id}`).reverse();
let reads = 0;
function bounded<T>(items: T[]) {
return new Proxy(items, { get(target, property, receiver) {
if (typeof property === "string" && /^\d+$/.test(property) && ++reads > 4 * agents.length) throw new Error("Pin construction scans growing lists repeatedly");
return Reflect.get(target, property, receiver);
} });
}
const items = buildPinnedNavigationItems(bounded(pins), bounded(agents), []);
expect(items.map(item => item.key)).toEqual(pins);
expect(reads).toBeLessThanOrEqual(4 * agents.length);
expect(items[0].to).toBe("/chats/agent-499");
});
it("keeps mixed pins in stored order and prevents native link dragging", async () => {
fixture.agents = [{ id: "cto", companyId: "company", name: "CTO", urlKey: "cto", role: "cto", status: "idle" } as Agent];
const saved = ["agent-chat:company:cto", "agent-chat:another-company:hidden", "/artifacts", "/dashboard"];
localStorage.setItem("paperclip.coordinator.pinned-nav.v1:reviewer", JSON.stringify(saved));
await renderSidebar();
const links = [...container.querySelectorAll<HTMLAnchorElement>(".design-sidebar-pins a")];
expect(links.map(link => link.textContent)).toEqual(["CTO", "Artifacts", "Dashboard"]);
expect(JSON.parse(localStorage.getItem("paperclip.coordinator.pinned-nav.v1:reviewer")!)).toEqual(saved);
const nativeDrag = new Event("dragstart", { bubbles: true, cancelable: true });
act(() => links[1].dispatchEvent(nativeDrag));
expect(nativeDrag.defaultPrevented).toBe(true);
});
it.each([false, true])("keeps Cases reachable in More only when enabled (%s)", async enabled => {
fixture.enableCases = enabled;
await renderSidebar();
@@ -103,7 +140,7 @@ describe("Coordinator pinned pages", () => {
await flush();
}
it.each([false, true])("keeps pins above More without a separate section, restores and unpins when collapsed is %s", async collapsed => {
it.each([false, true])("restores and unpins pages in the Pinned section when collapsed is %s", async collapsed => {
fixture.collapsed = collapsed;
await renderSidebar();
expect(pinnedPage("/projects")).toBeNull();
@@ -113,9 +150,9 @@ describe("Coordinator pinned pages", () => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
expect(pinnedPage("/projects")?.textContent).toBe("Projects");
expect(navigation().querySelector('[aria-label="Pinned pages"]')).toBeNull();
expect(navigation().querySelector('[data-slot="sidebar-group-label"]')).toBeNull();
if (!collapsed) expect(navigation().querySelector('[data-slot="sidebar-group-label"]')?.textContent).toBe("Pinned");
const more = navigation().querySelector('button[aria-label="More workspace pages"]')!;
expect(pinnedPage("/projects")!.compareDocumentPosition(more) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
expect(more.compareDocumentPosition(pinnedPage("/projects")!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
await act(async () => root.unmount());
root = createRoot(container);
await renderSidebar();
+52 -15
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Inbox, Boxes, ChevronRight, CircleCheck, FolderOpen, History, LayoutDashboard, MessageCircle, MoreHorizontal, Network, Pin, PinOff, Plus, Repeat, Search, Settings, SquarePen, Unplug } from "lucide-react";
import type { Project } from "@paperclipai/shared";
import { Inbox, Boxes, ChevronRight, CircleCheck, FolderOpen, History, LayoutDashboard, MessageCircle, MoreHorizontal, Network, Pin, PinOff, Plus, Repeat, Search, Settings, SquarePen, Unplug, type LucideIcon } from "lucide-react";
import type { Agent, Project } from "@paperclipai/shared";
import { useCoordinatorAttentionCount } from "@/hooks/useCoordinatorAttentionCount";
import { issuesApi } from "@/api/issues";
import { instanceSettingsApi } from "@/api/instanceSettings";
@@ -24,6 +24,9 @@ import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { StatusIcon } from "./StatusIcon";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
import { AgentAvatar } from "./AgentAvatar";
import { agentChatPinKey } from "./AgentChatPin";
import { PinnedNavigationList, type PinnedNavigationItem } from "./PinnedNavigationList";
const moreDestinations = [
{ label: "Search", to: "/search", icon: Search },
@@ -38,6 +41,19 @@ const moreDestinations = [
{ label: "Settings", to: "/settings", icon: Settings },
] as const;
export function buildPinnedNavigationItems(pins: readonly string[], agents: readonly Agent[], destinations: readonly { to: string; label: string; icon: LucideIcon }[]): PinnedNavigationItem[] {
const keys = new Set(pins);
const items = new Map<string, PinnedNavigationItem>(destinations.map(item => [item.to, { key: item.to, to: item.to, label: item.label, icon: <item.icon className="size-4" /> }]));
for (const agent of agents) {
const key = agentChatPinKey(agent.companyId, agent.id);
if (keys.has(key)) items.set(key, { key, to: `/chats/${encodeURIComponent(agentRouteRef(agent))}`, label: agent.name, icon: <AgentAvatar agent={agent} size={20} /> });
}
return pins.flatMap(key => {
const item = items.get(key);
return item ? [item] : [];
});
}
function ProjectRecent({ project }: { project: Project }) {
const [expanded, setExpanded] = useState(false);
const { data: tasks, isPending, error } = useQuery({
@@ -61,6 +77,29 @@ function ProjectRecent({ project }: { project: Project }) {
</div>;
}
function RecentNavigationList({ children }: { children: ReactNode }) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const handleScroll = useScrollbarWhileScrolling();
useLayoutEffect(() => {
const viewport = scrollRef.current;
if (!viewport) return;
const measure = () => {
viewport.dataset.overflowTop = String(viewport.scrollTop > 1);
viewport.dataset.overflowBottom = String(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop > 1);
};
measure();
viewport.addEventListener("scroll", measure, { passive: true });
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
observer?.observe(viewport);
if (contentRef.current) observer?.observe(contentRef.current);
return () => { viewport.removeEventListener("scroll", measure); observer?.disconnect(); };
}, []);
return <div ref={scrollRef} className="design-sidebar-recent-list scrollbar-while-scrolling" onScroll={handleScroll} role="region" aria-label="Recents" tabIndex={0}>
<div ref={contentRef} className="design-sidebar-recent-content">{children}</div>
</div>;
}
/** The navigation is a view of the existing records; it never creates a chat
* or task just because someone visits or switches agents. */
export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
@@ -71,18 +110,17 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const destinations = experimental?.enableCases
const destinations = useMemo(() => experimental?.enableCases
? [...moreDestinations, { label: "Cases", to: "/cases", icon: Boxes }]
: moreDestinations;
: moreDestinations, [experimental?.enableCases]);
const [moreOpen, setMoreOpen] = useState(false);
const moreContentRef = useRef<HTMLDivElement>(null);
const pinnedDestinations = pinnedNavigation.pins.flatMap(to => destinations.filter(item => item.to === to));
const orderedPins = useMemo(() => buildPinnedNavigationItems(pinnedNavigation.pins, agents.data ?? [], destinations), [pinnedNavigation.pins, agents.data, destinations]);
const { entries: recentTasks } = useRecentTasks({ companyId, userId });
const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar();
const rail = collapsed && !peeking;
const { openNewIssue } = useDialogActions();
const location = useLocation();
const handleRecentScroll = useScrollbarWhileScrolling();
const attentionCount = useCoordinatorAttentionCount(companyId);
const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(companyId!), queryFn: () => projectsApi.list(companyId!), enabled: !!companyId });
// Only project IDs and visit times are kept in memory. Labels and task lists
@@ -104,7 +142,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
});
const projectRows = (projects ?? []).flatMap(project => projectVisits.scope === scope && projectVisits.entries[project.id]
? [{ kind: "project" as const, id: project.id, time: projectVisits.entries[project.id]!, project }] : []);
return [...taskRows, ...chatRows, ...projectRows].sort((a, b) => b.time - a.time).slice(0, 12);
return [...taskRows, ...chatRows, ...projectRows].sort((a, b) => b.time - a.time);
}, [agents.data, chats.data, recentTasks, projects, projectVisits, scope]);
const newTaskButton = <Button type="button" variant="ghost" className="design-sidebar-new-task rounded-lg" aria-label="New task" onClick={() => openNewIssue()}>
<SquarePen aria-hidden />
@@ -126,10 +164,6 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity group-hover/coordinator-new:opacity-100 group-focus-within/coordinator-new:opacity-100 pointer-coarse:opacity-100 hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-ring"><Plus className="size-4" /></button>}
</div>
<SidebarNavItem className="design-sidebar-item" to="/decisions" label="Inbox" icon={Inbox} badge={attentionCount} badgeLabel="decisions" />
{pinnedDestinations.map(({ label, to, icon }) => <div key={to} className="group/coordinator-pin relative">
<SidebarNavItem to={to} label={label} icon={icon} className={cn("design-sidebar-item", !rail && "pr-9")} />
{!rail && <button type="button" aria-label={`Unpin ${label}`} title={`Unpin ${label}`} onClick={() => pinnedNavigation.toggle(to)} className="absolute right-3 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover/coordinator-pin:opacity-100 group-focus-within/coordinator-pin:opacity-100 pointer-coarse:opacity-100"><PinOff className="size-3.5" /></button>}
</div>)}
</div>
<Flyout open={moreOpen} onOpenChange={setMoreOpen}>
<FlyoutTrigger asChild><button type="button" aria-label="More workspace pages" className="design-sidebar-item flex w-auto items-center gap-2.5 rounded-lg py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring"><MoreHorizontal className="size-4 shrink-0" /><span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>More</span></button></FlyoutTrigger>
@@ -152,15 +186,18 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
</FlyoutContent>
</Flyout>
</div>
{orderedPins.length > 0 && <div className="design-sidebar-pins"><SidebarSection label="Pinned">
<PinnedNavigationList key={`${companyId}:${userId}`} items={orderedPins} rail={rail} onReorder={pinnedNavigation.reorder} onUnpin={pinnedNavigation.toggle} />
</SidebarSection></div>}
{pinnedNavigation.error && <p role="alert" className="px-4 py-2 text-xs text-destructive">{pinnedNavigation.error}</p>}
{!rail && session.isSuccess && recents.length > 0 && <div className="design-sidebar-recents"><SidebarSection label="Recents">
<div className="design-sidebar-recent-list scrollbar-while-scrolling" onScroll={handleRecentScroll} role="region" aria-label="Recents" tabIndex={0}>
<RecentNavigationList>
{recents.map(item => item.kind === "task"
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/issues/${item.task.identifier ?? item.id}`} label={item.task.title} iconNode={<StatusIcon {...taskStatusIconProps(item.task)} />} />
: item.kind === "chat"
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/chats/${encodeURIComponent(agentRouteRef(item.agent))}`} label={item.agent.name} icon={MessageCircle} />
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/chats/${encodeURIComponent(agentRouteRef(item.agent))}`} label={item.agent.name} iconNode={<AgentAvatar agent={item.agent} size={20} />} />
: <ProjectRecent key={item.id} project={item.project} />)}
</div>
</RecentNavigationList>
</SidebarSection></div>}
{children}
</nav>
+6 -6
View File
@@ -77,6 +77,7 @@ export interface IssueRowProps {
checklistDependencyChips?: ReactNode;
checklistRowId?: string;
unreadState?: IssueRowUnreadState | null;
showUnreadIndicator?: boolean;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
@@ -160,6 +161,7 @@ export function IssueRow({
checklistDependencyChips,
checklistRowId,
unreadState = null,
showUnreadIndicator = true,
onMarkRead,
onArchive,
archiveDisabled,
@@ -175,10 +177,9 @@ export function IssueRow({
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
// Refreshed task rows communicate unread state through title typography alone.
// Existing presentations retain their unread control and its reserved space.
const showUnreadSlot = unreadState != null && !(presentation === "task" && appearance === "refreshed");
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const isUnread = unreadState === "visible" || unreadState === "fading";
const showUnreadSlot = showUnreadIndicator && unreadState != null && !(presentation === "task" && appearance === "refreshed");
const showUnreadDot = showUnreadIndicator && isUnread;
const unreadDotButton = (
<button
type="button"
@@ -238,7 +239,6 @@ export function IssueRow({
) : null;
if (presentation === "task") {
const isUnread = unreadState === "visible" || unreadState === "fading";
const refreshed = appearance === "refreshed";
const statusLabel = getStatusIconLabel(issue.status, issue.externalConversationState, issue.blockerAttention, issue.displayStatus);
const statusIcon = <StatusIcon {...taskStatusIconProps(issue)} size="md" className={selectedStatusClass} />;
@@ -495,7 +495,7 @@ export function IssueRow({
<span data-slot="task-row-title-cluster" className="flex min-w-0 items-start gap-1.5 sm:order-2 sm:flex-1 sm:items-center">
<span
data-slot="task-row-title"
className={cn("min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none", titleClassName)}
className={cn("min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none", isUnread && "font-semibold", titleClassName)}
>
{issue.title}{titleSuffix}
</span>
+8 -7
View File
@@ -136,6 +136,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
const isAgentChatRoute = agentChatEnabled && companyPathSegments[0]?.toLowerCase() === "chats";
// Chat keeps its header beside the agent sidebar, including before an agent is selected.
const useStreamlinedTaskDetailShell = streamlinedUiEnabled && (isTaskDetailRoute || isAgentChatRoute);
const useInsetTaskDetailHeader = useStreamlinedTaskDetailShell && !isAgentChatRoute;
const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools";
const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps";
const appDetailConnectionId =
@@ -219,7 +220,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
/>
)
) : null;
const secondarySidebar = isAgentChatRoute ? (coordinatorWorkspaceEnabled ? null : <AgentConversationsSidebar />) : shellRoute.builtInContextualSurface === "agent" && agentId ? (
const secondarySidebar = isAgentChatRoute ? <AgentConversationsSidebar /> : shellRoute.builtInContextualSurface === "agent" && agentId ? (
<AgentContextualSidebar agentRef={agentId} />
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
<SetupWizardSidebarOutlet><RoutineContextualSidebar routineId={routineId} /></SetupWizardSidebarOutlet>
@@ -670,7 +671,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
</SidebarShell>
)}
{!isMobile && ((showsAdjacentSecondarySidebar && !keepsPrimarySidebar) || (designSandbox && replacesPrimarySidebar)) ? (
{!isMobile && !isAgentChatRoute && ((showsAdjacentSecondarySidebar && !keepsPrimarySidebar) || (designSandbox && replacesPrimarySidebar)) ? (
<SecondarySidebar className="w-60 shrink-0">
{secondarySidebar}
</SecondarySidebar>
@@ -680,12 +681,12 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
<div
className={cn(
"app-breadcrumb",
!isMobile && useStreamlinedTaskDetailShell && "hidden",
!isMobile && useInsetTaskDetailHeader && "hidden",
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<StandaloneBrowserControls mobile={isMobile} />
<BreadcrumbBar />
<BreadcrumbBar showPropertiesToggle={isAgentChatRoute} />
{isMobile && isCompanySettingsRoute ? (
<div className="border-b border-border px-4 pb-3">
<CompanySettingsNav />
@@ -697,13 +698,13 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
isMobile ? "block" : "flex flex-1 min-h-0",
!isMobile && useStreamlinedTaskDetailShell && "streamlined-task-detail-surface",
)}>
{!isMobile && keepsPrimarySidebar ? (
<SecondarySidebar className="w-60 shrink-0 bg-background">
{!isMobile && (keepsPrimarySidebar || isAgentChatRoute) ? (
<SecondarySidebar className={cn(isAgentChatRoute ? "w-70" : "w-60", "shrink-0 bg-background")}>
{secondarySidebar}
</SecondarySidebar>
) : null}
<div className={cn(!isMobile && useStreamlinedTaskDetailShell ? "flex min-w-0 flex-1 flex-col" : "contents")}>
{!isMobile && useStreamlinedTaskDetailShell ? (
{!isMobile && useInsetTaskDetailHeader ? (
<>
<StandaloneBrowserControls mobile={false} />
<BreadcrumbBar taskDetailLayout />
@@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, PinOff } from "lucide-react";
import { SidebarNavItem } from "./SidebarNavItem";
import { cn } from "@/lib/utils";
export interface PinnedNavigationItem { key: string; to: string; label: string; icon: ReactNode }
function PinnedItem({ item, rail, onUnpin }: { item: PinnedNavigationItem; rail: boolean; onUnpin: (key: string) => void }) {
const sortable = useSortable({ id: item.key, disabled: rail, transition: null });
return <div ref={sortable.setNodeRef} style={{ transform: CSS.Transform.toString(sortable.transform) }}
className={cn("group/coordinator-pin relative select-none", !sortable.isDragging && "motion-safe:transition-transform", sortable.isDragging && "z-10 opacity-80")}
onDragStart={event => event.preventDefault()}>
<SidebarNavItem to={item.to} label={item.label} iconNode={item.icon} className={cn("design-sidebar-item group-hover/coordinator-pin:bg-sidebar-accent", !rail && "pr-16")} />
{!rail && <button type="button" aria-label={`Unpin ${item.label}`} title={`Unpin ${item.label}`}
onMouseDown={event => event.stopPropagation()} onClick={() => onUnpin(item.key)}
className="absolute right-10 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover/coordinator-pin:opacity-100 group-focus-within/coordinator-pin:opacity-100 pointer-coarse:opacity-100"><PinOff className="size-3.5" /></button>}
{!rail && <button ref={sortable.setActivatorNodeRef} type="button" {...sortable.attributes} {...sortable.listeners}
aria-label={`Reorder pinned ${item.label}`} title={`Drag to reorder ${item.label}`}
className="absolute right-3 top-1/2 flex size-7 -translate-y-1/2 touch-none cursor-grab items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity active:cursor-grabbing hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover/coordinator-pin:opacity-100 group-focus-within/coordinator-pin:opacity-100 pointer-coarse:opacity-100"><GripVertical className="size-3.5" /></button>}
</div>;
}
export function PinnedNavigationList({ items, rail, onReorder, onUnpin }: {
items: PinnedNavigationItem[]; rail: boolean; onReorder: (keys: string[]) => void; onUnpin: (key: string) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function move({ active, over }: DragEndEvent) {
if (!over || active.id === over.id) return;
const keys = items.map(item => item.key);
const from = keys.indexOf(String(active.id)), to = keys.indexOf(String(over.id));
if (from >= 0 && to >= 0) onReorder(arrayMove(keys, from, to));
}
return <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={move}>
<SortableContext items={items.map(item => item.key)} strategy={verticalListSortingStrategy}>
{items.map(item => <PinnedItem key={item.key} item={item} rail={rail} onUnpin={onUnpin} />)}
</SortableContext>
</DndContext>;
}
+2 -2
View File
@@ -12,8 +12,8 @@ import { TASK_VIEW_GROUPS, taskView, type TaskViewKey } from "@/lib/task-views";
import { cn } from "@/lib/utils";
/**
* The single control that replaced both the Inbox tab bar and the implicit
* "all tasks" default on Tasks (PAP-670). One menu, grouped by scope, so the
* The single control for Inbox views and task status presets (PAP-670).
* One menu, grouped by scope, so the
* nav does not have to grow a row every time a view is added.
*/
export function TaskViewsMenu({