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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { FolderSearch, Loader2 } from 'lucide-react';
interface SidebarActionsProps {
createStackSlot: ReactNode;
onScan: () => void;
isScanning: boolean;
}
export function SidebarActions({ createStackSlot, onScan, isScanning }: SidebarActionsProps) {
return (
<div className="p-4 flex gap-2">
<div className="flex-1">{createStackSlot}</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="rounded-lg shrink-0 shadow-btn-glow"
onClick={onScan}
disabled={isScanning}
>
{isScanning
? <Loader2 className="w-4 h-4 animate-spin" strokeWidth={1.5} />
: <FolderSearch className="w-4 h-4" strokeWidth={1.5} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Scan stacks folder</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@@ -0,0 +1,58 @@
import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
import { formatTimeAgo } from '@/lib/relativeTime';
import type { NotificationItem } from '@/components/dashboard/types';
const ONE_HOUR_S = 60 * 60;
const NOW_TICK_MS = 30_000;
interface SidebarActivityTickerProps {
notifications: NotificationItem[];
connected: boolean;
onNavigate: () => void;
}
export function SidebarActivityTicker({ notifications, connected, onNavigate }: SidebarActivityTickerProps) {
const [nowS, setNowS] = useState(() => Math.floor(Date.now() / 1000));
useEffect(() => {
const id = setInterval(() => setNowS(Math.floor(Date.now() / 1000)), NOW_TICK_MS);
return () => clearInterval(id);
}, []);
const latest = useMemo(() => {
return notifications
.filter(n => n.stack_name && nowS - n.timestamp <= ONE_HOUR_S)
.sort((a, b) => b.timestamp - a.timestamp)[0] ?? null;
}, [notifications, nowS]);
const idle = latest === null;
const dotClass = connected
? 'bg-success shadow-[0_0_6px_var(--success)] animate-pulse'
: 'bg-warning';
const kicker = idle ? 'IDLE · NO RECENT ACTIVITY' : 'LIVE · VIEW ACTIVITY →';
return (
<button
type="button"
onClick={onNavigate}
className={cn(
'w-full flex flex-col gap-0.5 px-4 py-2 border-t border-glass-border',
'bg-sidebar/80 hover:bg-glass-highlight text-left',
)}
>
<div className="flex items-center gap-2">
<span data-testid="ticker-dot" className={cn('w-1.5 h-1.5 rounded-full', dotClass)} />
{idle ? (
<span className="font-mono text-[11px] text-muted-foreground">No recent activity</span>
) : (
<span className="font-mono text-[11px] truncate">
<span className="text-brand">{latest.stack_name}</span>
<span className="text-muted-foreground"> · {latest.message} · {formatTimeAgo(latest.timestamp * 1000)}</span>
</span>
)}
</div>
<span className="font-mono text-[9px] tracking-[0.22em] uppercase text-stat-subtitle pl-3.5">
{kicker}
</span>
</button>
);
}
@@ -0,0 +1,21 @@
interface SidebarBrandProps {
isDarkMode: boolean;
}
export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
return (
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-glass-border">
<img
src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'}
alt="Sencho Logo"
className="w-10 h-10 shrink-0"
/>
<div className="flex flex-col leading-none">
<span className="font-mono text-[9px] tracking-[0.22em] uppercase text-stat-subtitle">
SENCHO · v{__APP_VERSION__}
</span>
<span className="font-serif italic text-xl text-foreground mt-1">Sencho</span>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import { CommandInput } from '@/components/ui/command';
interface SidebarSearchProps {
value: string;
onValueChange: (v: string) => void;
}
function kbdHint(): string {
if (typeof navigator === 'undefined') return 'Ctrl+K';
return /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
}
export function SidebarSearch({ value, onValueChange }: SidebarSearchProps) {
return (
<div className="px-4 py-2 flex-none relative">
<CommandInput
placeholder="Search stacks..."
value={value}
onValueChange={onValueChange}
className="h-9 border-none"
/>
<kbd className="pointer-events-none absolute right-6 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-glass-border bg-glass-highlight px-1.5 font-mono text-[10px] text-muted-foreground">
{kbdHint()}
</kbd>
</div>
);
}
@@ -0,0 +1,99 @@
import type { ReactNode } from 'react';
import { Check } from 'lucide-react';
import {
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
} from '@/components/ui/context-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 StackContextMenuProps {
file: string;
ctx: StackMenuCtx;
children: ReactNode;
}
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 (
<ContextMenuSub>
<ContextMenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
{ctx.labels.length === 0 && (
<ContextMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</ContextMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<ContextMenuItem key={label.id} onClick={() => 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} />}
</ContextMenuItem>
);
})}
<ContextMenuSeparator />
<ContextMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
return (
<ContextMenuItem
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>
)}
</ContextMenuItem>
);
}
function renderGroup(group: MenuGroup, ctx: StackMenuCtx, showSep: boolean) {
return (
<div key={group.id}>
{showSep && <ContextMenuSeparator />}
<GroupHeader id={group.id} />
{group.items.map(item => renderItem(item, ctx))}
</div>
);
}
export function StackContextMenu({ file, ctx, children }: StackContextMenuProps) {
const groups = useStackMenuItems(file, ctx);
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{groups.map((g, i) => renderGroup(g, ctx, i > 0))}
</ContextMenuContent>
</ContextMenu>
);
}
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface StackGroupProps {
id: string;
label: string;
count: number;
collapsed: boolean;
onToggle: () => void;
variant?: 'default' | 'pinned';
children: ReactNode;
}
export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', children }: StackGroupProps) {
const labelColor = variant === 'pinned' ? 'text-brand/90' : 'text-stat-subtitle';
return (
<div className="mt-2">
<button
type="button"
onClick={onToggle}
className="flex items-center justify-between w-full px-2 py-1 text-left hover:bg-glass-highlight/30 rounded-md"
aria-expanded={!collapsed}
aria-controls={`group-${id}-body`}
>
<span className={cn('font-mono text-[9px] tracking-[0.22em] uppercase', labelColor)}>
{label}
</span>
<span className="flex items-center gap-1.5">
<span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span>
{collapsed
? <ChevronRight className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />
: <ChevronDown className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />}
</span>
</button>
{!collapsed && (
<div id={`group-${id}-body`} className="mt-0.5">
{children}
</div>
)}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,197 @@
import { useMemo } from 'react';
import { ArrowUpRight, Loader2 } from 'lucide-react';
import { CommandItem, CommandList } from '@/components/ui/command';
import { Skeleton } from '@/components/ui/skeleton';
import type { Label } from '@/components/label-types';
import type { StackRowStatus } from './StackRow';
import { StackRow } from './StackRow';
import { StackGroup } from './StackGroup';
import { StackContextMenu } from './StackContextMenu';
import { StackKebabMenu } from './StackKebabMenu';
import type { StackMenuCtx } from './sidebar-types';
interface RemoteNodeResult {
nodeId: number;
nodeName: string;
files: { file: string; status: StackRowStatus }[];
}
export interface StackListProps {
files: string[];
isLoading: boolean;
isPaid: boolean;
selectedFile: string | null;
searchQuery: string;
stackLabelMap: Record<string, Label[]>;
stackStatuses: Record<string, StackRowStatus | undefined>;
stackUpdates: Record<string, boolean>;
gitSourcePendingMap: Record<string, boolean>;
labels: Label[];
pinnedFiles: string[];
isCollapsed: (groupKey: string) => boolean;
toggleCollapse: (groupKey: string) => void;
isBusy: (file: string) => boolean;
getDisplayName: (file: string) => string;
onSelectFile: (file: string) => void;
buildMenuCtx: (file: string) => StackMenuCtx;
remoteResults: RemoteNodeResult[];
remoteLoading: boolean;
onSelectRemoteFile: (nodeId: number, file: string) => void;
}
interface BuiltGroup {
kind: 'pinned' | 'labeled' | 'unlabeled';
id: string;
label: string;
count: number;
files: string[];
variant?: 'default' | 'pinned';
}
function buildGroups(
files: string[],
pinnedFiles: string[],
stackLabelMap: Record<string, Label[]>,
labels: Label[],
): BuiltGroup[] {
const result: BuiltGroup[] = [];
const pinnedSet = new Set(pinnedFiles.filter(f => files.includes(f)));
if (pinnedSet.size > 0) {
result.push({
kind: 'pinned', id: 'pinned', label: 'PINNED',
count: pinnedSet.size, files: Array.from(pinnedSet), variant: 'pinned',
});
}
const labelBuckets = new Map<number, { label: Label; files: string[] }>();
const unlabeled: string[] = [];
for (const file of files) {
const assigned = stackLabelMap[file] ?? [];
if (assigned.length === 0) {
unlabeled.push(file);
} else {
for (const l of assigned) {
const bucket = labelBuckets.get(l.id) ?? { label: l, files: [] };
bucket.files.push(file);
labelBuckets.set(l.id, bucket);
}
}
}
const sortedLabels = [...labelBuckets.values()]
.sort((a, b) => b.files.length - a.files.length || a.label.name.localeCompare(b.label.name));
for (const { label, files: bucketFiles } of sortedLabels) {
result.push({
kind: 'labeled', id: `label:${label.id}`,
label: label.name.toUpperCase(), count: bucketFiles.length, files: bucketFiles,
});
}
if (unlabeled.length > 0) {
result.push({ kind: 'unlabeled', id: 'unlabeled', label: 'UNLABELED', count: unlabeled.length, files: unlabeled });
}
void labels;
return result;
}
export function StackList(props: StackListProps) {
const {
files, isLoading, isPaid, selectedFile, searchQuery, stackLabelMap, stackStatuses,
stackUpdates, gitSourcePendingMap, labels, pinnedFiles, isCollapsed, toggleCollapse,
isBusy, getDisplayName, onSelectFile, buildMenuCtx, remoteResults, remoteLoading, onSelectRemoteFile,
} = props;
const groups = useMemo(
() => buildGroups(files, pinnedFiles, stackLabelMap, labels),
[files, pinnedFiles, stackLabelMap, labels],
);
if (isLoading) {
return (
<div className="space-y-2 px-2 mt-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
);
}
return (
<CommandList className="max-h-none overflow-visible">
{groups.map(g => (
<StackGroup
key={g.id}
id={g.id}
label={g.label}
count={g.count}
collapsed={isCollapsed(g.id)}
onToggle={() => toggleCollapse(g.id)}
variant={g.variant}
>
{g.files.map(file => {
const ctx = buildMenuCtx(file);
return (
<StackContextMenu key={`${g.id}:${file}`} file={file} ctx={ctx}>
<CommandItem
value={file}
onSelect={() => onSelectFile(file)}
className="p-0 data-[selected=true]:bg-transparent"
>
<StackRow
file={file}
displayName={getDisplayName(file)}
status={stackStatuses[file] ?? 'unknown'}
isBusy={isBusy(file)}
isActive={selectedFile === file}
isPaid={isPaid}
labels={stackLabelMap[file] ?? []}
hasUpdate={!!stackUpdates[file]}
hasGitPending={!!gitSourcePendingMap[file]}
onSelect={onSelectFile}
kebabSlot={<StackKebabMenu file={file} ctx={ctx} />}
/>
</CommandItem>
</StackContextMenu>
);
})}
</StackGroup>
))}
{searchQuery.trim() && (remoteLoading || remoteResults.length > 0) && (
<div className="mt-3 pt-3 border-t border-glass-border">
<h3 className="text-[10px] font-medium tracking-[0.08em] uppercase text-stat-subtitle px-4 pb-2 flex items-center gap-2">
Other nodes
{remoteLoading && <Loader2 className="w-3 h-3 animate-spin text-stat-icon" strokeWidth={1.5} />}
</h3>
{remoteResults.map(({ nodeId, nodeName, files: remoteFiles }) => (
<div key={nodeId} className="mb-2">
<div className="px-4 pb-1 flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.06em] text-stat-subtitle">
<span className="w-1.5 h-1.5 rounded-full bg-success" />
<span className="truncate">{nodeName}</span>
</div>
{remoteFiles.map(({ file, status }) => (
<button
key={`${nodeId}:${file}`}
type="button"
onClick={() => onSelectRemoteFile(nodeId, file)}
className="w-full text-left justify-start rounded-lg mb-1 cursor-pointer hover:bg-glass-highlight px-2 py-1.5 flex items-center gap-2"
>
<span className={`font-mono text-[10px] shrink-0 w-5 ${
status === 'running' ? 'text-success' :
status === 'exited' ? 'text-destructive' : 'text-stat-icon'
}`}>
{status === 'running' ? 'UP' : status === 'exited' ? 'DN' : '--'}
</span>
<span className="flex-1 truncate font-mono text-xs">{file}</span>
<ArrowUpRight className="w-3 h-3 text-stat-icon shrink-0" strokeWidth={1.5} />
</button>
))}
</div>
))}
</div>
)}
</CommandList>
);
}
@@ -0,0 +1,93 @@
import type { ReactNode } from 'react';
import { GitBranch, Loader2 } from 'lucide-react';
import { Cursor, CursorContainer, CursorFollow, CursorProvider } from '@/components/animate-ui/primitives/animate/cursor';
import { LabelDot } from '@/components/LabelPill';
import type { Label } from '@/components/label-types';
import { cn } from '@/lib/utils';
import { sidebarRowActive, sidebarRowBase } from './sidebar-styles';
export type StackRowStatus = 'running' | 'exited' | 'unknown';
interface StackRowProps {
file: string;
displayName: string;
status: StackRowStatus;
isBusy: boolean;
isActive: boolean;
isPaid: boolean;
labels: Label[];
hasUpdate: boolean;
hasGitPending: boolean;
onSelect: (file: string) => void;
kebabSlot: ReactNode;
}
function statusText(status: StackRowStatus): string {
if (status === 'running') return 'UP';
if (status === 'exited') return 'DN';
return '--';
}
function statusColor(status: StackRowStatus, isBusy: boolean): string {
if (isBusy) return 'text-muted-foreground';
if (status === 'running') return 'text-success';
if (status === 'exited') return 'text-destructive';
return 'text-stat-icon';
}
function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
return (
<CursorProvider>
<CursorContainer className="inline-flex items-center shrink-0">{trigger}</CursorContainer>
<Cursor><div className="h-2 w-2 rounded-full bg-brand" /></Cursor>
<CursorFollow side="bottom" sideOffset={4} align="center" transition={{ stiffness: 400, damping: 40, bounce: 0 }}>
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
<span className="font-mono text-xs tabular-nums text-stat-value">{label}</span>
</div>
</CursorFollow>
</CursorProvider>
);
}
export function StackRow(props: StackRowProps) {
const { file, displayName, status, isBusy, isActive, isPaid, labels, hasUpdate, hasGitPending, onSelect, kebabSlot } = props;
return (
<div
data-testid="stack-row"
role="button"
tabIndex={0}
className={cn(sidebarRowBase, isActive && sidebarRowActive)}
onClick={() => onSelect(file)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(file); } }}
>
<span className={cn('font-mono text-[10px] shrink-0 w-[22px] flex items-center', statusColor(status, isBusy))}>
{isBusy ? <Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} /> : statusText(status)}
</span>
<span className="flex-1 truncate font-mono text-[13px]">{displayName}</span>
{isPaid && labels.length > 0 && (
<span className="flex items-center gap-0.5 shrink-0">
{labels.map(l => <LabelDot key={l.id} color={l.color} />)}
</span>
)}
{hasUpdate && (
<RowTooltip
trigger={<span className="w-2 h-2 rounded-full bg-info animate-pulse" />}
label="Update available"
/>
)}
{hasGitPending && (
<RowTooltip
trigger={<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />}
label="Git source update pending"
/>
)}
<div
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{kebabSlot}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
import { Command } from '@/components/ui/command';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { NotificationItem } from '@/components/dashboard/types';
import { SidebarActions } from './SidebarActions';
import { SidebarActivityTicker } from './SidebarActivityTicker';
import { SidebarBrand } from './SidebarBrand';
import { SidebarSearch } from './SidebarSearch';
import { StackList, type StackListProps } from './StackList';
export interface StackSidebarProps {
isDarkMode: boolean;
nodeSwitcherSlot: ReactNode;
createStackSlot: ReactNode | null;
onScan: () => void;
isScanning: boolean;
canCreate: boolean;
searchQuery: string;
onSearchChange: (v: string) => void;
list: StackListProps;
notifications: NotificationItem[];
tickerConnected: boolean;
onOpenActivity: () => void;
}
export function StackSidebar(props: StackSidebarProps) {
const {
isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
searchQuery, onSearchChange, list, notifications, tickerConnected, onOpenActivity,
} = props;
return (
<div className="w-64 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col">
<SidebarBrand isDarkMode={isDarkMode} />
<div className="px-4 pt-2 pb-0">{nodeSwitcherSlot}</div>
{canCreate && createStackSlot !== null && (
<SidebarActions createStackSlot={createStackSlot} onScan={onScan} isScanning={isScanning} />
)}
<Command shouldFilter={false} className="bg-transparent flex-1 flex flex-col overflow-hidden">
<SidebarSearch value={searchQuery} onValueChange={onSearchChange} />
<ScrollArea className="flex-1 px-2 pb-2">
<div data-stacks-loaded={list.isLoading ? 'false' : 'true'}>
<StackList {...list} />
</div>
</ScrollArea>
</Command>
<SidebarActivityTicker
notifications={notifications}
connected={tickerConnected}
onNavigate={onOpenActivity}
/>
</div>
);
}
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SidebarActivityTicker } from '../SidebarActivityTicker';
import type { NotificationItem } from '@/components/dashboard/types';
function notif(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 1,
level: 'info',
message: 'web deployed',
timestamp: Math.floor(Date.now() / 1000) - 12,
is_read: 0,
stack_name: 'web',
...overrides,
};
}
describe('SidebarActivityTicker', () => {
it('shows idle fallback when no recent stack events', () => {
render(<SidebarActivityTicker notifications={[]} connected onNavigate={() => {}} />);
expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
});
it('renders stack name + message when a recent event exists', () => {
render(
<SidebarActivityTicker notifications={[notif()]} connected onNavigate={() => {}} />
);
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText(/deployed/i)).toBeInTheDocument();
});
it('marks connected dot when connected, amber dot when disconnected', () => {
const { rerender } = render(<SidebarActivityTicker notifications={[]} connected onNavigate={() => {}} />);
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-success');
rerender(<SidebarActivityTicker notifications={[]} connected={false} onNavigate={() => {}} />);
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-warning');
});
it('falls back to idle when events are older than 1 hour', () => {
const old = notif({ timestamp: Math.floor(Date.now() / 1000) - 60 * 60 - 1 });
render(<SidebarActivityTicker notifications={[old]} connected onNavigate={() => {}} />);
expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { StackGroup } from '../StackGroup';
describe('StackGroup', () => {
it('renders label and count', () => {
render(
<StackGroup id="prod" label="PROD" count={4} collapsed={false} onToggle={() => {}}>
<div>child</div>
</StackGroup>
);
expect(screen.getByText('PROD')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
});
it('hides children when collapsed', () => {
render(
<StackGroup id="prod" label="PROD" count={4} collapsed={true} onToggle={() => {}}>
<div data-testid="child">child</div>
</StackGroup>
);
expect(screen.queryByTestId('child')).toBeNull();
});
it('invokes onToggle when header clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(
<StackGroup id="prod" label="PROD" count={4} collapsed={false} onToggle={onToggle}>
<div>child</div>
</StackGroup>
);
await user.click(screen.getByRole('button', { name: /PROD/i }));
expect(onToggle).toHaveBeenCalledOnce();
});
it('applies pinned variant styling when variant="pinned"', () => {
render(
<StackGroup id="pinned" label="PINNED" count={2} collapsed={false} onToggle={() => {}} variant="pinned">
<div>child</div>
</StackGroup>
);
expect(screen.getByText('PINNED')).toHaveClass('text-brand/90');
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { StackRow } from '../StackRow';
import type { Label } from '@/components/label-types';
function base(overrides: Partial<ComponentProps<typeof StackRow>> = {}) {
return {
file: 'web.yml',
displayName: 'web',
status: 'running' as const,
isBusy: false,
isActive: false,
isPaid: true,
labels: [] as Label[],
hasUpdate: false,
hasGitPending: false,
onSelect: vi.fn(),
kebabSlot: null,
...overrides,
};
}
describe('StackRow', () => {
it('renders UP for running', () => {
render(<StackRow {...base()} />);
expect(screen.getByText('UP')).toBeInTheDocument();
});
it('renders DN for exited', () => {
render(<StackRow {...base({ status: 'exited' })} />);
expect(screen.getByText('DN')).toBeInTheDocument();
});
it('renders -- for unknown', () => {
render(<StackRow {...base({ status: 'unknown' })} />);
expect(screen.getByText('--')).toBeInTheDocument();
});
it('renders cyan rail only when active', () => {
const { rerender } = render(<StackRow {...base({ isActive: false })} />);
expect(screen.getByTestId('stack-row')).not.toHaveClass('bg-accent/[0.07]');
rerender(<StackRow {...base({ isActive: true })} />);
expect(screen.getByTestId('stack-row')).toHaveClass('bg-accent/[0.07]');
});
it('fires onSelect on click', () => {
const onSelect = vi.fn();
render(<StackRow {...base({ onSelect })} />);
screen.getByTestId('stack-row').click();
expect(onSelect).toHaveBeenCalledWith('web.yml');
});
it('fires onSelect on Enter and Space', () => {
const onSelect = vi.fn();
render(<StackRow {...base({ onSelect })} />);
const row = screen.getByTestId('stack-row');
row.focus();
fireEvent.keyDown(row, { key: 'Enter' });
fireEvent.keyDown(row, { key: ' ' });
expect(onSelect).toHaveBeenCalledTimes(2);
});
it('kebab click does not trigger onSelect', () => {
const onSelect = vi.fn();
render(<StackRow {...base({ onSelect, kebabSlot: <button data-testid="kebab">k</button> })} />);
screen.getByTestId('kebab').click();
expect(onSelect).not.toHaveBeenCalled();
});
it('renders loader when isBusy', () => {
const { container } = render(<StackRow {...base({ isBusy: true })} />);
expect(container.querySelector('.animate-spin')).not.toBeNull();
expect(screen.queryByText('UP')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils';
export const sidebarRowBase = cn(
'relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md mb-0.5',
'font-mono text-[13px] text-muted-foreground',
'hover:bg-glass-highlight hover:text-foreground',
'transition-colors group cursor-pointer',
);
export const sidebarRowActive = cn(
'bg-accent/[0.07] text-stat-value',
'after:content-[""] after:absolute after:left-[-12px] after:top-1 after:bottom-1',
'after:w-[3px] after:rounded-sm after:bg-brand',
'after:shadow-[0_0_6px_var(--brand)]',
);
export const sidebarGroupHeader = cn(
'flex items-center justify-between w-full px-2 pt-3 pb-1',
'text-[9px] leading-3 tracking-[0.22em] uppercase text-stat-subtitle',
'cursor-pointer select-none',
);
@@ -0,0 +1,49 @@
import type { LucideIcon } from 'lucide-react';
import type { Label } from '../label-types';
export type MenuGroupId = 'inspect' | 'organize' | 'lifecycle' | 'destructive';
export interface MenuItem {
id: string;
label: string;
icon: LucideIcon;
shortcut?: string;
onSelect: () => void;
disabled?: boolean;
destructive?: boolean;
subItems?: MenuItem[];
}
export interface MenuGroup {
id: MenuGroupId;
items: MenuItem[];
}
export type StackLifecycleStatus = 'running' | 'exited' | 'unknown';
export interface StackMenuCtx {
stackStatus: StackLifecycleStatus;
hasPort: boolean;
isBusy: boolean;
isPaid: boolean;
canDelete: boolean;
isPinned: boolean;
labels: Label[];
assignedLabelIds: number[];
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean };
openAlertSheet: () => void;
openAutoHeal: () => void;
checkUpdates: () => void;
openStackApp: () => void;
deploy: () => void;
stop: () => void;
restart: () => void;
update: () => void;
remove: () => void;
pin: () => void;
unpin: () => void;
toggleLabel: (labelId: number) => void;
openLabelManager: () => void;
}
export type StackGroupKind = 'pinned' | 'labeled' | 'unlabeled';