mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 17:22:00 +01:00
## 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>
210 lines
15 KiB
TypeScript
210 lines
15 KiB
TypeScript
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>;
|
||
}
|