mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user