diff --git a/ui/src/components/CoordinatorSidebar.test.tsx b/ui/src/components/CoordinatorSidebar.test.tsx index ab03a60606d..5ceda071e89 100644 --- a/ui/src/components/CoordinatorSidebar.test.tsx +++ b/ui/src/components/CoordinatorSidebar.test.tsx @@ -95,6 +95,28 @@ describe("Coordinator task creation", () => { }); describe("Coordinator pinned pages", () => { + it("matches pinned and recent agent avatar dimensions to task status icons", async () => { + fixture.agents = [{ id: "cto", companyId: "company", name: "CTO", urlKey: "cto", role: "cto", status: "idle" } as Agent]; + const chat = { id: "chat-cto", conversationAgentId: "cto", updatedAt: "2026-11-11T12:00:00Z", isUnreadForMe: false }; + fixture.chats = [chat]; + fixture.recentTasks = [{ id: "task", companyId: "company", identifier: "TASK-1", title: "Recent task", status: "todo", recordedAt: 1 }]; + localStorage.setItem("paperclip.coordinator.pinned-nav.v1:reviewer", JSON.stringify(["agent-chat:company:cto"])); + + await renderSidebar(); + + const avatars = [...container.querySelectorAll('a[href="/chats/cto"] [data-slot="agent-avatar"] img')]; + const taskIcon = container.querySelector('a[href="/issues/TASK-1"] [data-slot="sidebar-nav-icon"] svg'); + expect(avatars).toHaveLength(2); + expect(taskIcon).not.toBeNull(); + const width = Number(taskIcon!.getAttribute("width")); + const height = Number(taskIcon!.getAttribute("height")); + expect(width).toBeGreaterThan(0); + expect(height).toBeGreaterThan(0); + for (const avatar of avatars) { + expect(avatar.width).toBe(width); + expect(avatar.height).toBe(height); + } + }); 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(); diff --git a/ui/src/components/CoordinatorSidebar.tsx b/ui/src/components/CoordinatorSidebar.tsx index d62a88759eb..f2b622121dd 100644 --- a/ui/src/components/CoordinatorSidebar.tsx +++ b/ui/src/components/CoordinatorSidebar.tsx @@ -50,7 +50,7 @@ export function buildPinnedNavigationItems(pins: readonly string[], agents: read const items = new Map(destinations.map(item => [item.to, { key: item.to, to: item.to, label: item.label, icon: }])); 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: }); + if (keys.has(key)) items.set(key, { key, to: `/chats/${encodeURIComponent(agentRouteRef(agent))}`, label: agent.name, icon: }); } return pins.flatMap(key => { const item = items.get(key); @@ -199,7 +199,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) { {recents.map(item => item.kind === "task" ? } /> : item.kind === "chat" - ? } /> + ? } /> : )} } diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx index 029c25331ba..6c77c4eedd2 100644 --- a/ui/src/components/SidebarNavItem.tsx +++ b/ui/src/components/SidebarNavItem.tsx @@ -39,7 +39,7 @@ interface SidebarNavItemProps { /** * Pre-rendered icon element for rows whose icon isn't a plain Lucide * component (e.g. the agent rows' `AgentIcon`). Takes precedence over `icon`; - * the caller owns its sizing/color classes. + * the caller supplies a 16px icon to fit the shared navigation slot. */ iconNode?: ReactNode; end?: boolean; @@ -132,7 +132,7 @@ export function SidebarNavItem({ // the sidebar edges, rounded-lg matches the card anchor, px-2 gives // the icon breathing room inside the pill. Rows with hover menus // (agents/projects) reserve extra right padding via className. - "flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium transition-colors", + "flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-left text-(length:--text-compact) font-medium transition-colors", (active ?? isActive) ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", @@ -141,7 +141,7 @@ export function SidebarNavItem({ } > {showIconSlot ? ( - + {iconNode ?? (Icon ? : null)} {alert && ( ({ + ...agent, companyId: "company-storybook", urlKey: agent.id, + appearance: appearanceForPalette(palette), +})) as Agent[]; + +const recentItems = [ + { title: "Align sidebar icons and labels", status: "blocked" }, + { agent: agents[0] }, + { title: "Rebuild Needs You overview", status: "blocked" }, + { agent: agents[1] }, + { agent: agents[2] }, + { title: "Fix agent blocked after retry", status: "todo" }, + { title: "Count shadcn components", status: "done" }, + { title: "Diagnose PAP-740 recovery", status: "done" }, + { title: "Status update on recent tasks", status: "done" }, + { agent: agents[3] }, +]; + +function SidebarIconAlignment() { + const [pins, setPins] = useState(["agent-chat:company-storybook:ceo-v2", "agent-chat:company-storybook:glm-coder", "/routines"]); + return ; +} + +const meta = { + id: "sidebar-icon-alignment", + title: "Oversight and operation/Navigation, profile, and announcements/Sidebar icon alignment", + component: SidebarIconAlignment, + parameters: { + layout: "fullscreen", initialEntries: ["/PAP/chats/ceo-v2"], + paperclip: { + capabilities: ["navigation-preferences/preferences"], status: "current", implementation: "production", boundary: "fixtures", + notes: "Production pinned-item builder/list, nav rows, avatar and task glyphs in the Coordinator sidebar surface. Mixed icon types, active state, long labels, reorder and unpin use local fixtures; this does not prove API persistence or authorization.", + }, + }, + play: async ({ canvasElement }) => { + const navigation = within(canvasElement).getByRole("navigation", { name: "Mixed navigation icons" }); + const rows = within(navigation).getAllByRole("link"); + const reference = rows.find(row => row.textContent === "Routines")!.querySelector('[data-slot="sidebar-nav-icon"]')!; + const expectedBox = reference.getBoundingClientRect(); + const expectedLabelX = reference.nextElementSibling!.getBoundingClientRect().left; + for (const row of rows) { + const icon = row.querySelector('[data-slot="sidebar-nav-icon"]')!; + const box = icon.getBoundingClientRect(); + await expect(box.width).toBe(expectedBox.width); + await expect(box.height).toBe(expectedBox.width); + await expect(icon.nextElementSibling!.getBoundingClientRect().left).toBe(expectedLabelX); + const glyph = icon.firstElementChild!.getBoundingClientRect(); + await expect(glyph.width).toBeLessThanOrEqual(box.width); + await expect(glyph.height).toBeLessThanOrEqual(box.height); + } + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Dark: Story = { globals: { theme: "dark" } }; +export const Light: Story = { globals: { theme: "light" } };