feat(sidebar): cockpit redesign with grouped stacks and activity footer (#702)

* chore: ignore .superpowers/ brainstorm scratch dir

* feat(sidebar): add useStackMenuItems hook with grouped menu model

Pure transform hook that converts StackMenuCtx into four ordered MenuGroup
arrays (inspect, organize, lifecycle, destructive). Shared type contract in
sidebar-types.ts gives both the ContextMenu and DropdownMenu a single source
of truth so they cannot drift. Covered by 8 unit tests.

* refactor(sidebar): stabilize useStackMenuItems memoization deps

Destructure menuVisibility flags into primitive deps so inline object
literals from callers do not defeat memoization. Add a test confirming
isBusy disables all lifecycle items.

* feat(sidebar): add usePinnedStacks hook with per-node localStorage

* refactor(sidebar): stabilize usePinnedStacks eviction signal and isPinned dep

Change evictedOldest shape to { file, seq } so consumer effects re-fire on
repeated evictions. Narrow isPinned's useCallback dep to the current node's
pinned list so it only rebinds on local changes. Add test for eviction
side-effect and a second test for the seq counter.

* feat(sidebar): add useSidebarGroupCollapse hook with per-node keys

* refactor(sidebar): tighten useSidebarGroupCollapse effect ordering

Collapse the two write/read effects into a single skip-next-write ref
pattern so switching nodes no longer writes the previous node's map under
the new key before hydration. Also skip the no-op mount write. Add test
for setCollapsed.

* feat(sidebar): add row + group-header style helpers

* feat(sidebar): add SidebarBrand with mono kicker + serif hero

* feat(sidebar): add SidebarActions wrapper for create + scan

* feat(sidebar): extract SidebarSearch with kbd pill

* feat(sidebar): add StackRow with cyan-rail active state

* refactor(sidebar): dedupe tooltip markup in StackRow, widen test coverage

Extract a local RowTooltip helper so the update and git-pending branches
share the CursorProvider scaffolding. Add four behavioral tests covering
click, keyboard activation, kebab stop-propagation, and the busy loader
branch.

* feat(sidebar): unify context + kebab menus via useStackMenuItems

* feat(sidebar): add StackGroup with collapse and pinned variant

* feat(sidebar): add StackList with pinned + label groups

* feat(sidebar): add SidebarActivityTicker with idle fallback

* feat(sidebar): add StackSidebar container composing the regions

* feat(sidebar): replace sidebar block with StackSidebar composition

* docs(sidebar): add stack sidebar feature page with screenshots

* fix(sidebar): satisfy react-hooks purity and memoization rules

* fix(sidebar): restore "Sencho Logo" alt text for E2E selector
This commit is contained in:
Anso
2026-04-19 21:45:01 -04:00
committed by GitHub
parent 490c89c049
commit 370b67d7ec
29 changed files with 1793 additions and 646 deletions
@@ -0,0 +1,102 @@
import { MoreVertical, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { LabelDot } from '@/components/LabelPill';
import { cn } from '@/lib/utils';
import type { MenuGroup, MenuItem, StackMenuCtx } from './sidebar-types';
import { useStackMenuItems } from '@/hooks/useStackMenuItems';
interface StackKebabMenuProps {
file: string;
ctx: StackMenuCtx;
}
function GroupHeader({ id }: { id: string }) {
return (
<div className={cn(
'px-2 pt-2 pb-1 font-mono text-[9px] tracking-[0.22em] uppercase',
id === 'destructive' ? 'text-destructive/60' : 'text-stat-subtitle',
)}>
{id}
</div>
);
}
function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
{ctx.labels.length === 0 && (
<DropdownMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</DropdownMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<DropdownMenuItem key={label.id} onSelect={(e) => { e.preventDefault(); ctx.toggleLabel(label.id); }}>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
return (
<DropdownMenuItem
key={item.id}
onSelect={() => item.onSelect()}
disabled={item.disabled}
className={item.destructive ? 'text-destructive focus:text-destructive' : undefined}
>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span className="flex-1">{item.label}</span>
{item.shortcut && (
<span className="ml-3 font-mono text-[10px] tracking-wider text-stat-subtitle">{item.shortcut}</span>
)}
</DropdownMenuItem>
);
}
function renderGroup(group: MenuGroup, ctx: StackMenuCtx, showSep: boolean) {
return (
<div key={group.id}>
{showSep && <DropdownMenuSeparator />}
<GroupHeader id={group.id} />
{group.items.map(item => renderItem(item, ctx))}
</div>
);
}
export function StackKebabMenu({ file, ctx }: StackKebabMenuProps) {
const groups = useStackMenuItems(file, ctx);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{groups.map((g, i) => renderGroup(g, ctx, i > 0))}
</DropdownMenuContent>
</DropdownMenu>
);
}