mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01: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,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({
|
||||
|
||||
Reference in new issue
Block a user