Files
PaperClipAI/ui/src/components/CoordinatorSidebar.tsx
T
5e2243f081 fix(ui): align sidebar avatars and navigation icons (#20926)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The left navigation mixes agent avatars, task status icons, and page
icons.
> - Agent avatars used 20px boxes. Task and page icons used 16px boxes.
> - The different widths moved agent labels 4px to the right of other
labels.
> - This pull request gives these icons the same 16px square slot.
> - Shared label positions make pinned and recent items easier to scan.

## Linked Issues or Issue Description

Refs #19685, which added mixed agent and page pins. No duplicate
alignment PR was found.

**What happened?**

Agent avatars in pinned and recent navigation were wider than task and
page icons. Their labels started 4px farther to the right. Inline icon
layout also produced different box heights.

**Expected behavior**

Use the existing 16px task and page icon size for all leading navigation
icons. Center each icon in a square slot. Start the labels at the same
horizontal position.

**Steps to reproduce**

1. Pin an agent conversation and a page such as Routines.
2. Open agent conversations and tasks so both appear in Recents.
3. Compare the icon boxes and label positions across the rows.

**Paperclip version or commit**

Base commit: `c5e6ab5ca813`.

**Deployment mode**

Local source checkout. This is a client layout issue.

## What Changed

- Use 16px agent avatars for pinned and recent conversation rows.
- Give `SidebarNavItem` a fixed 16px square icon slot with centered
contents and left-aligned text. The existing token scale supplies the
size.
- Add light and dark Storybook examples with mixed icons and long
labels. The browser assertions compare slot sizes, label positions, and
glyph bounds.
- Add a production `CoordinatorSidebar` regression test that compares
pinned and recent avatar dimensions with a task status icon.

## Verification

- `pnpm exec vitest run ui/src/components/SidebarNavItem.test.tsx
ui/src/components/CoordinatorSidebar.test.tsx
ui/src/components/SidebarAgents.test.tsx` passes: 59 tests across three
files.
- Regression proof: the new production-avatar test fails with the
original 20px avatars (`expected 20 to be 16`) and passes with 16px
avatars. This addresses the initial review's test coverage gap.
- `pnpm check:token-gates` passes.
- `pnpm --filter @paperclipai/ui typecheck` passes.
- `pnpm --filter @paperclipai/ui build` passes on the final source tree.
- `pnpm verify --base origin/master` returns exit 2 because the new
Storybook file needs broad CI coverage. The focused local tests run
separately. CI must supply the remaining coverage.
- Browser inspection on macOS checked all 13 mixed rows in light and
dark themes. Every icon slot measured 16 by 16 CSS pixels. Every label
started at the same position. Before and after captures use production
components with fixture data; they do not prove server persistence or
permissions.
- Review `sidebar-icon-alignment--dark` and
`sidebar-icon-alignment--light` in Storybook to inspect the mixed rows
and run their layout assertions.
- [Greptile
review](https://github.com/paperclipai/paperclip/pull/20926#issuecomment-6806138954):
5/5 on `e779f4707e7826d577b23a2991f3f36755e954be`. The initial 4/5
review requested production Recents coverage. The added regression test
addresses that finding; the thread is resolved.
- CI queue recovery: the superseded first run was canceled. The next run
stalled before runner assignment; GitHub accepted force-cancel but left
it queued and refused the rerun request. The final empty commit requests
fresh CI without changing the source tree. Required CI is tracked in
[run
43095809539](https://github.com/paperclipai/paperclip/actions/runs/43095809539).
Its jobs are still queued; CI completion is not yet claimed.
- External blocker: after more than 15 minutes, the fresh run still has
zero jobs. Other repository PR runs are also queued. A repository
Actions or runner administrator must restore dispatch before the
required typecheck, test, build, and E2E gates can report results. The
root cause of the dispatch delay is not established.

## Risks

- Low risk. The shared icon slot also affects other navigation rows.
Existing badge, rail, and expanded-state tests cover the shared
component.
- Agent avatars become smaller in pinned and recent navigation, as
requested. No data or API contracts change.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected. This change is a
scoped UI bug fix.

## Model Used

- OpenAI GPT-6 via Codex. The host does not expose the exact served
model snapshot or context window size. Used reasoning, code editing,
shell execution, and browser inspection.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Scott Tong <scott@scottsmbpm5max.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-11 14:01:22 -08:00

210 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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";
import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation";
import { useRecentTasks } from "@/hooks/useRecentTasks";
import { useRecentTaskOpenWarmup } from "@/hooks/useRecentTaskOpenWarmup";
import { usePinnedNavigation } from "@/hooks/usePinnedNavigation";
import { useDialogActions } from "@/context/DialogContext";
import { useSidebar } from "@/context/SidebarContext";
import { queryKeys } from "@/lib/queryKeys";
import { Link, useLocation } from "@/lib/router";
import { agentRouteRef, cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
import { SidebarNavItem } from "./SidebarNavItem";
import { RecentTaskNavItem } from "./RecentTaskNavItem";
import { SidebarSection } from "./SidebarSection";
import { TruncatedTextMarqueeProvider } from "./TruncatedText";
import { useScrollbarWhileScrolling } from "./side-panel/use-scrollbar-while-scrolling";
import { Flyout, FlyoutContent, FlyoutTrigger, FlyoutAppearanceProvider } from "./ui/flyout";
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";
import { companyProjectsQuery } from "@/lib/company-projects-query";
const NO_RECENT_TASKS: never[] = [];
const moreDestinations = [
{ label: "Search", to: "/search", icon: Search },
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
{ label: "Projects", to: "/projects", icon: FolderOpen },
{ label: "Routines", to: "/routines", icon: Repeat },
{ label: "Artifacts", to: "/artifacts", icon: Boxes },
{ label: "Agents", to: "/agents/all", icon: Network },
{ label: "Skills", to: "/skills", icon: Boxes },
{ label: "Connectors", to: "/apps", icon: Unplug },
{ label: "Audit", to: "/activity", icon: History },
{ 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={16} /> });
}
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({
queryKey: [...queryKeys.issues.listByProject(project.companyId, project.id), "coordinator-recents"],
queryFn: () => issuesApi.listCompact(project.companyId, { projection: "rows", projectId: project.id, limit: 12, sortField: "updated", sortDir: "desc" }),
enabled: expanded,
});
return <div>
<div className="group/coordinator-recent relative">
<SidebarNavItem to={`/projects/${projectRouteRef(project)}`} label={project.name} icon={FolderOpen} className="design-sidebar-item pr-9" />
<button type="button" aria-label={`${expanded ? "Collapse" : "Expand"} ${project.name}`} aria-expanded={expanded}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-ring" onClick={() => setExpanded(!expanded)}>
<ChevronRight className={cn("size-3.5 transition-transform", expanded && "rotate-90")} />
</button>
</div>
{expanded && <div className="ml-5 border-l border-border pl-1">
{isPending ? <p className="px-3 py-2 text-xs text-muted-foreground" role="status">Loading tasks…</p> : error ? <p className="px-3 py-2 text-xs text-muted-foreground">Couldn’t load tasks.</p> : tasks?.length ? tasks.map(task =>
<RecentTaskNavItem className="design-sidebar-item" key={task.id} task={task} label={task.title} iconNode={<StatusIcon {...taskStatusIconProps(task)} />} />)
: <p className="px-3 py-2 text-xs text-muted-foreground">No tasks yet.</p>}
</div>}
</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 }) {
const { companyId, userId, agents, chats, session } = useAgentChatNavigation();
const unreadChatCount = chats.data?.filter(chat => chat.isUnreadForMe).length ?? 0;
const pinnedNavigation = usePinnedNavigation(userId);
const { data: experimental } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const destinations = useMemo(() => experimental?.enableCases
? [...moreDestinations, { label: "Cases", to: "/cases", icon: Boxes }]
: moreDestinations, [experimental?.enableCases]);
const [moreOpen, setMoreOpen] = useState(false);
const moreContentRef = useRef<HTMLDivElement>(null);
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;
// Warm opens only while the Recents list is on screen.
useRecentTaskOpenWarmup({ companyId, userId, tasks: !rail && session.isSuccess ? recentTasks : NO_RECENT_TASKS });
const { openNewIssue } = useDialogActions();
const location = useLocation();
const attentionCount = useCoordinatorAttentionCount(companyId);
const { data: projects } = useQuery({ ...companyProjectsQuery(companyId!), enabled: !!companyId });
// Only project IDs and visit times are kept in memory. Labels and task lists
// always come from the authorized API, scoped to the current person/company.
const scope = `${companyId}:${userId}`;
const [projectVisits, setProjectVisits] = useState<{ scope: string; entries: Record<string, number> }>({ scope, entries: {} });
useEffect(() => {
const ref = location.pathname.match(/\/projects\/([^/]+)/)?.[1];
const project = projects?.find(item => item.id === ref || projectRouteRef(item) === ref);
if (!project) return;
setProjectVisits(previous => ({ scope, entries: { ...(previous.scope === scope ? previous.entries : {}), [project.id]: Date.now() } }));
}, [location.pathname, projects, scope]);
const recents = useMemo(() => {
const roster = new Map((agents.data ?? []).map(agent => [agent.id, agent]));
const taskRows = recentTasks.map(task => ({ kind: "task" as const, id: task.id, time: task.recordedAt, task }));
const chatRows = (chats.data ?? []).flatMap(chat => {
const agent = chat.conversationAgentId ? roster.get(chat.conversationAgentId) : null;
return agent ? [{ kind: "chat" as const, id: chat.id, time: new Date(chat.updatedAt).getTime(), agent }] : [];
});
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);
}, [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 />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "truncate"}>New task</span>
</Button>;
return <FlyoutAppearanceProvider><TruncatedTextMarqueeProvider><aside className="coordinator-sidebar design-sidebar w-full">
<div className="design-sidebar-brand" data-collapsed={rail || undefined}>
<SidebarCompanyMenu showChevron={false} />
<Tooltip><TooltipTrigger asChild><Button variant="ghost" size="icon-sm" className="design-sidebar-search" aria-label="Global search" onClick={() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true, cancelable: true }))}><Search aria-hidden /></Button></TooltipTrigger><TooltipContent side="right">Search tasks, agents, projects</TooltipContent></Tooltip>
</div>
<nav className="design-sidebar-navigation" aria-label="Workspace navigation">
<div className="design-sidebar-primary">
{rail ? <Tooltip><TooltipTrigger asChild>{newTaskButton}</TooltipTrigger><TooltipContent side="right">New task</TooltipContent></Tooltip> : newTaskButton}
<div className="design-sidebar-primary my-2">
<SidebarNavItem className="design-sidebar-item" to="/chats" label="Chat" icon={MessageCircle} badge={unreadChatCount} badgeLabel="unread chats" />
<div className="group/coordinator-new relative">
<SidebarNavItem to="/issues" label="Tasks" icon={CircleCheck} className={cn("design-sidebar-item", !rail && "pr-9")} />
{!rail && <button type="button" aria-label="New task" title="New task" onClick={() => openNewIssue()}
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" />
{orderedPins.length > 0 && <PinnedNavigationList key={`${companyId}:${userId}`} items={orderedPins} rail={rail} onReorder={pinnedNavigation.reorder} onUnpin={pinnedNavigation.toggle} />}
</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>
<FlyoutContent ref={moreContentRef} tabIndex={-1} onOpenAutoFocus={(event) => { event.preventDefault(); moreContentRef.current?.focus(); }} side={isMobile ? "bottom" : "right"} align="start" className="w-56 p-1" aria-label="More workspace pages">
{destinations.map(({ label, to, icon: Icon }, index) => {
const pinned = pinnedNavigation.pins.includes(to);
const PinIcon = pinned ? PinOff : Pin;
return <div key={to}>
{index === 6 || index === 10 ? <div role="separator" className="my-1 border-t border-border" /> : null}
<div className="group/coordinator-more relative flex items-center rounded-sm transition-colors hover:bg-accent">
<Link to={to} onClick={() => { setMoreOpen(false); if (isMobile) setSidebarOpen(false); }} className="flex min-w-0 flex-1 items-center gap-2 rounded-sm px-2 py-1.5 pr-10 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Icon className="size-4 shrink-0 text-muted-foreground" />{label}
</Link>
<button type="button" aria-label={`${pinned ? "Unpin" : "Pin"} ${label}`} aria-pressed={pinned} title={`${pinned ? "Unpin" : "Pin"} ${label}`} disabled={!pinnedNavigation.canPin} onClick={() => pinnedNavigation.toggle(to)} className={cn("absolute right-0.5 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-sm opacity-0 transition-opacity outline-none group-hover/coordinator-more:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", pinned ? "text-foreground" : "text-muted-foreground")}>
<PinIcon className="size-3.5" />
</button>
</div>
</div>;
})}
</FlyoutContent>
</Flyout>
</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">
<RecentNavigationList>
{recents.map(item => item.kind === "task"
? <RecentTaskNavItem className="design-sidebar-item" key={item.id} task={item.task} 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} iconNode={<AgentAvatar agent={item.agent} size={16} />} />
: <ProjectRecent key={item.id} project={item.project} />)}
</RecentNavigationList>
</SidebarSection></div>}
{children}
</nav>
</aside></TruncatedTextMarqueeProvider></FlyoutAppearanceProvider>;
}