diff --git a/frontend/src/components/sidebar/SidebarActions.tsx b/frontend/src/components/sidebar/SidebarActions.tsx
new file mode 100644
index 00000000..753fe35c
--- /dev/null
+++ b/frontend/src/components/sidebar/SidebarActions.tsx
@@ -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 (
+
+
{createStackSlot}
+
+
+
+
+ {isScanning
+ ?
+ : }
+
+
+
+ Scan stacks folder
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/sidebar/SidebarActivityTicker.tsx b/frontend/src/components/sidebar/SidebarActivityTicker.tsx
new file mode 100644
index 00000000..01afd21c
--- /dev/null
+++ b/frontend/src/components/sidebar/SidebarActivityTicker.tsx
@@ -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 (
+
+
+
+ {idle ? (
+ No recent activity
+ ) : (
+
+ {latest.stack_name}
+ · {latest.message} · {formatTimeAgo(latest.timestamp * 1000)}
+
+ )}
+
+
+ {kicker}
+
+
+ );
+}
diff --git a/frontend/src/components/sidebar/SidebarBrand.tsx b/frontend/src/components/sidebar/SidebarBrand.tsx
new file mode 100644
index 00000000..37ff15f1
--- /dev/null
+++ b/frontend/src/components/sidebar/SidebarBrand.tsx
@@ -0,0 +1,21 @@
+interface SidebarBrandProps {
+ isDarkMode: boolean;
+}
+
+export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
+ return (
+
+
+
+
+ SENCHO · v{__APP_VERSION__}
+
+ Sencho
+
+
+ );
+}
diff --git a/frontend/src/components/sidebar/SidebarSearch.tsx b/frontend/src/components/sidebar/SidebarSearch.tsx
new file mode 100644
index 00000000..b376fe0f
--- /dev/null
+++ b/frontend/src/components/sidebar/SidebarSearch.tsx
@@ -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 (
+
+
+
+ {kbdHint()}
+
+
+ );
+}
diff --git a/frontend/src/components/sidebar/StackContextMenu.tsx b/frontend/src/components/sidebar/StackContextMenu.tsx
new file mode 100644
index 00000000..c5c5e047
--- /dev/null
+++ b/frontend/src/components/sidebar/StackContextMenu.tsx
@@ -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 (
+
+ {id}
+
+ );
+}
+
+function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
+ return (
+
+
+
+ {item.label}
+
+
+ {ctx.labels.length === 0 && (
+
+ No labels yet
+
+ )}
+ {ctx.labels.map(label => {
+ const assigned = ctx.assignedLabelIds.includes(label.id);
+ return (
+ ctx.toggleLabel(label.id)}>
+
+ {label.name}
+ {assigned && }
+
+ );
+ })}
+
+
+ Manage labels...
+
+
+
+ );
+}
+
+function renderItem(item: MenuItem, ctx: StackMenuCtx) {
+ if (item.id === 'labels') return
;
+ return (
+
item.onSelect()}
+ disabled={item.disabled}
+ className={item.destructive ? 'text-destructive focus:text-destructive' : undefined}
+ >
+
+ {item.label}
+ {item.shortcut && (
+ {item.shortcut}
+ )}
+
+ );
+}
+
+function renderGroup(group: MenuGroup, ctx: StackMenuCtx, showSep: boolean) {
+ return (
+
+ {showSep && }
+
+ {group.items.map(item => renderItem(item, ctx))}
+
+ );
+}
+
+export function StackContextMenu({ file, ctx, children }: StackContextMenuProps) {
+ const groups = useStackMenuItems(file, ctx);
+ return (
+
+ {children}
+
+ {groups.map((g, i) => renderGroup(g, ctx, i > 0))}
+
+
+ );
+}
diff --git a/frontend/src/components/sidebar/StackGroup.tsx b/frontend/src/components/sidebar/StackGroup.tsx
new file mode 100644
index 00000000..e338c7cb
--- /dev/null
+++ b/frontend/src/components/sidebar/StackGroup.tsx
@@ -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 (
+
+
+
+ {label}
+
+
+ {count}
+ {collapsed
+ ?
+ : }
+
+
+ {!collapsed && (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/sidebar/StackKebabMenu.tsx b/frontend/src/components/sidebar/StackKebabMenu.tsx
new file mode 100644
index 00000000..ff002a3d
--- /dev/null
+++ b/frontend/src/components/sidebar/StackKebabMenu.tsx
@@ -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 (
+
+ {id}
+
+ );
+}
+
+function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
+ return (
+
+
+
+ {item.label}
+
+
+ {ctx.labels.length === 0 && (
+
+ No labels yet
+
+ )}
+ {ctx.labels.map(label => {
+ const assigned = ctx.assignedLabelIds.includes(label.id);
+ return (
+ { e.preventDefault(); ctx.toggleLabel(label.id); }}>
+
+ {label.name}
+ {assigned && }
+
+ );
+ })}
+
+
+ Manage labels...
+
+
+
+ );
+}
+
+function renderItem(item: MenuItem, ctx: StackMenuCtx) {
+ if (item.id === 'labels') return
;
+ return (
+
item.onSelect()}
+ disabled={item.disabled}
+ className={item.destructive ? 'text-destructive focus:text-destructive' : undefined}
+ >
+
+ {item.label}
+ {item.shortcut && (
+ {item.shortcut}
+ )}
+
+ );
+}
+
+function renderGroup(group: MenuGroup, ctx: StackMenuCtx, showSep: boolean) {
+ return (
+
+ {showSep && }
+
+ {group.items.map(item => renderItem(item, ctx))}
+
+ );
+}
+
+export function StackKebabMenu({ file, ctx }: StackKebabMenuProps) {
+ const groups = useStackMenuItems(file, ctx);
+ return (
+
+
+
+
+
+
+
+ {groups.map((g, i) => renderGroup(g, ctx, i > 0))}
+
+
+ );
+}
diff --git a/frontend/src/components/sidebar/StackList.tsx b/frontend/src/components/sidebar/StackList.tsx
new file mode 100644
index 00000000..4ad5fdc0
--- /dev/null
+++ b/frontend/src/components/sidebar/StackList.tsx
@@ -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
;
+ stackStatuses: Record;
+ stackUpdates: Record;
+ gitSourcePendingMap: Record;
+ 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,
+ 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();
+ 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 (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {groups.map(g => (
+ toggleCollapse(g.id)}
+ variant={g.variant}
+ >
+ {g.files.map(file => {
+ const ctx = buildMenuCtx(file);
+ return (
+
+ onSelectFile(file)}
+ className="p-0 data-[selected=true]:bg-transparent"
+ >
+ }
+ />
+
+
+ );
+ })}
+
+ ))}
+
+ {searchQuery.trim() && (remoteLoading || remoteResults.length > 0) && (
+
+
+ Other nodes
+ {remoteLoading && }
+
+ {remoteResults.map(({ nodeId, nodeName, files: remoteFiles }) => (
+
+
+
+ {nodeName}
+
+ {remoteFiles.map(({ file, status }) => (
+
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"
+ >
+
+ {status === 'running' ? 'UP' : status === 'exited' ? 'DN' : '--'}
+
+ {file}
+
+
+ ))}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/sidebar/StackRow.tsx b/frontend/src/components/sidebar/StackRow.tsx
new file mode 100644
index 00000000..02b6f3af
--- /dev/null
+++ b/frontend/src/components/sidebar/StackRow.tsx
@@ -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 (
+
+ {trigger}
+
+
+
+ {label}
+
+
+
+ );
+}
+
+export function StackRow(props: StackRowProps) {
+ const { file, displayName, status, isBusy, isActive, isPaid, labels, hasUpdate, hasGitPending, onSelect, kebabSlot } = props;
+
+ return (
+ onSelect(file)}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(file); } }}
+ >
+
+ {isBusy ? : statusText(status)}
+
+
{displayName}
+ {isPaid && labels.length > 0 && (
+
+ {labels.map(l => )}
+
+ )}
+ {hasUpdate && (
+
}
+ label="Update available"
+ />
+ )}
+ {hasGitPending && (
+
}
+ label="Git source update pending"
+ />
+ )}
+
e.stopPropagation()}
+ >
+ {kebabSlot}
+
+
+ );
+}
diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx
new file mode 100644
index 00000000..d346f91e
--- /dev/null
+++ b/frontend/src/components/sidebar/StackSidebar.tsx
@@ -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 (
+
+
+
{nodeSwitcherSlot}
+ {canCreate && createStackSlot !== null && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx b/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx
new file mode 100644
index 00000000..141fedc4
--- /dev/null
+++ b/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx
@@ -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 {
+ 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( {}} />);
+ expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
+ });
+
+ it('renders stack name + message when a recent event exists', () => {
+ render(
+ {}} />
+ );
+ expect(screen.getByText('web')).toBeInTheDocument();
+ expect(screen.getByText(/deployed/i)).toBeInTheDocument();
+ });
+
+ it('marks connected dot when connected, amber dot when disconnected', () => {
+ const { rerender } = render( {}} />);
+ expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-success');
+ rerender( {}} />);
+ 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( {}} />);
+ expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/sidebar/__tests__/StackGroup.test.tsx b/frontend/src/components/sidebar/__tests__/StackGroup.test.tsx
new file mode 100644
index 00000000..f5fa7596
--- /dev/null
+++ b/frontend/src/components/sidebar/__tests__/StackGroup.test.tsx
@@ -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(
+ {}}>
+ child
+
+ );
+ expect(screen.getByText('PROD')).toBeInTheDocument();
+ expect(screen.getByText('4')).toBeInTheDocument();
+ });
+
+ it('hides children when collapsed', () => {
+ render(
+ {}}>
+ child
+
+ );
+ expect(screen.queryByTestId('child')).toBeNull();
+ });
+
+ it('invokes onToggle when header clicked', async () => {
+ const user = userEvent.setup();
+ const onToggle = vi.fn();
+ render(
+
+ child
+
+ );
+ await user.click(screen.getByRole('button', { name: /PROD/i }));
+ expect(onToggle).toHaveBeenCalledOnce();
+ });
+
+ it('applies pinned variant styling when variant="pinned"', () => {
+ render(
+ {}} variant="pinned">
+ child
+
+ );
+ expect(screen.getByText('PINNED')).toHaveClass('text-brand/90');
+ });
+});
diff --git a/frontend/src/components/sidebar/__tests__/StackRow.test.tsx b/frontend/src/components/sidebar/__tests__/StackRow.test.tsx
new file mode 100644
index 00000000..415e4088
--- /dev/null
+++ b/frontend/src/components/sidebar/__tests__/StackRow.test.tsx
@@ -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> = {}) {
+ 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( );
+ expect(screen.getByText('UP')).toBeInTheDocument();
+ });
+
+ it('renders DN for exited', () => {
+ render( );
+ expect(screen.getByText('DN')).toBeInTheDocument();
+ });
+
+ it('renders -- for unknown', () => {
+ render( );
+ expect(screen.getByText('--')).toBeInTheDocument();
+ });
+
+ it('renders cyan rail only when active', () => {
+ const { rerender } = render( );
+ expect(screen.getByTestId('stack-row')).not.toHaveClass('bg-accent/[0.07]');
+ rerender( );
+ expect(screen.getByTestId('stack-row')).toHaveClass('bg-accent/[0.07]');
+ });
+
+ it('fires onSelect on click', () => {
+ const onSelect = vi.fn();
+ render( );
+ screen.getByTestId('stack-row').click();
+ expect(onSelect).toHaveBeenCalledWith('web.yml');
+ });
+
+ it('fires onSelect on Enter and Space', () => {
+ const onSelect = vi.fn();
+ render( );
+ 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(k })} />);
+ screen.getByTestId('kebab').click();
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('renders loader when isBusy', () => {
+ const { container } = render( );
+ expect(container.querySelector('.animate-spin')).not.toBeNull();
+ expect(screen.queryByText('UP')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/sidebar/sidebar-styles.ts b/frontend/src/components/sidebar/sidebar-styles.ts
new file mode 100644
index 00000000..b4a5c073
--- /dev/null
+++ b/frontend/src/components/sidebar/sidebar-styles.ts
@@ -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',
+);
diff --git a/frontend/src/components/sidebar/sidebar-types.ts b/frontend/src/components/sidebar/sidebar-types.ts
new file mode 100644
index 00000000..ffe993bc
--- /dev/null
+++ b/frontend/src/components/sidebar/sidebar-types.ts
@@ -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';
diff --git a/frontend/src/hooks/__tests__/usePinnedStacks.test.ts b/frontend/src/hooks/__tests__/usePinnedStacks.test.ts
new file mode 100644
index 00000000..515b5df6
--- /dev/null
+++ b/frontend/src/hooks/__tests__/usePinnedStacks.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { usePinnedStacks } from '../usePinnedStacks';
+
+const KEY = 'sencho:sidebar:pinned';
+
+describe('usePinnedStacks', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it('returns empty pinned list when no storage', () => {
+ const { result } = renderHook(() => usePinnedStacks(1));
+ expect(result.current.pinned).toEqual([]);
+ });
+
+ it('pin adds stack and persists to localStorage', () => {
+ const { result } = renderHook(() => usePinnedStacks(1));
+ act(() => result.current.pin('web.yml'));
+ expect(result.current.pinned).toEqual(['web.yml']);
+ expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ '1': ['web.yml'] });
+ });
+
+ it('unpin removes stack', () => {
+ const { result } = renderHook(() => usePinnedStacks(1));
+ act(() => { result.current.pin('web.yml'); result.current.pin('db.yml'); });
+ act(() => result.current.unpin('web.yml'));
+ expect(result.current.pinned).toEqual(['db.yml']);
+ });
+
+ it('isPinned reports membership', () => {
+ const { result } = renderHook(() => usePinnedStacks(1));
+ act(() => result.current.pin('web.yml'));
+ expect(result.current.isPinned('web.yml')).toBe(true);
+ expect(result.current.isPinned('db.yml')).toBe(false);
+ });
+
+ it('evicts oldest when exceeding max of 10', () => {
+ const { result } = renderHook(() => usePinnedStacks(1));
+ act(() => {
+ for (let i = 0; i < 10; i++) result.current.pin(`s${i}.yml`);
+ });
+ expect(result.current.pinned).toHaveLength(10);
+ expect(result.current.evictedOldest).toBeNull();
+ act(() => result.current.pin('s10.yml'));
+ expect(result.current.pinned).toHaveLength(10);
+ expect(result.current.pinned[0]).toBe('s1.yml');
+ expect(result.current.pinned[9]).toBe('s10.yml');
+ expect(result.current.evictedOldest).toEqual({ file: 's0.yml', seq: 1 });
+ });
+
+ it('evictedOldest seq increments on each eviction', () => {
+ const { result } = renderHook(() => usePinnedStacks(1));
+ act(() => {
+ for (let i = 0; i < 10; i++) result.current.pin(`s${i}.yml`);
+ });
+ act(() => result.current.pin('s10.yml'));
+ expect(result.current.evictedOldest).toEqual({ file: 's0.yml', seq: 1 });
+ act(() => result.current.pin('s11.yml'));
+ expect(result.current.evictedOldest).toEqual({ file: 's1.yml', seq: 2 });
+ });
+
+ it('isolates state per node', () => {
+ const hookA = renderHook(() => usePinnedStacks(1));
+ act(() => hookA.result.current.pin('web.yml'));
+ const hookB = renderHook(() => usePinnedStacks(2));
+ expect(hookB.result.current.pinned).toEqual([]);
+ });
+
+ it('pin is a no-op when already pinned', () => {
+ const { result } = renderHook(() => usePinnedStacks(1));
+ act(() => { result.current.pin('web.yml'); result.current.pin('web.yml'); });
+ expect(result.current.pinned).toEqual(['web.yml']);
+ });
+});
diff --git a/frontend/src/hooks/__tests__/useSidebarGroupCollapse.test.ts b/frontend/src/hooks/__tests__/useSidebarGroupCollapse.test.ts
new file mode 100644
index 00000000..f4de92cb
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useSidebarGroupCollapse.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useSidebarGroupCollapse } from '../useSidebarGroupCollapse';
+
+describe('useSidebarGroupCollapse', () => {
+ beforeEach(() => window.localStorage.clear());
+
+ it('defaults to expanded (isCollapsed returns false)', () => {
+ const { result } = renderHook(() => useSidebarGroupCollapse(1));
+ expect(result.current.isCollapsed('prod')).toBe(false);
+ });
+
+ it('toggle flips state and persists', () => {
+ const { result } = renderHook(() => useSidebarGroupCollapse(1));
+ act(() => result.current.toggle('prod'));
+ expect(result.current.isCollapsed('prod')).toBe(true);
+ const raw = window.localStorage.getItem('sencho:sidebar:groups:1');
+ expect(raw).not.toBeNull();
+ expect(JSON.parse(raw!)).toEqual({ prod: true });
+ });
+
+ it('toggle twice returns to expanded', () => {
+ const { result } = renderHook(() => useSidebarGroupCollapse(1));
+ act(() => { result.current.toggle('prod'); result.current.toggle('prod'); });
+ expect(result.current.isCollapsed('prod')).toBe(false);
+ });
+
+ it('keys state per node', () => {
+ const a = renderHook(() => useSidebarGroupCollapse(1));
+ act(() => a.result.current.toggle('prod'));
+ const b = renderHook(() => useSidebarGroupCollapse(2));
+ expect(b.result.current.isCollapsed('prod')).toBe(false);
+ });
+
+ it('restores state from localStorage on mount', () => {
+ window.localStorage.setItem('sencho:sidebar:groups:1', JSON.stringify({ prod: true }));
+ const { result } = renderHook(() => useSidebarGroupCollapse(1));
+ expect(result.current.isCollapsed('prod')).toBe(true);
+ });
+
+ it('setCollapsed writes explicit boolean value', () => {
+ const { result } = renderHook(() => useSidebarGroupCollapse(1));
+ act(() => result.current.setCollapsed('prod', true));
+ expect(result.current.isCollapsed('prod')).toBe(true);
+ act(() => result.current.setCollapsed('prod', false));
+ expect(result.current.isCollapsed('prod')).toBe(false);
+ });
+});
diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx
new file mode 100644
index 00000000..53cf3e1e
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx
@@ -0,0 +1,98 @@
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { BellRing, Trash2 } from 'lucide-react';
+import { useStackMenuItems } from '../useStackMenuItems';
+import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
+
+function makeCtx(overrides: Partial = {}): StackMenuCtx {
+ return {
+ stackStatus: 'running',
+ hasPort: true,
+ isBusy: false,
+ isPaid: true,
+ canDelete: true,
+ isPinned: false,
+ labels: [],
+ assignedLabelIds: [],
+ menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false },
+ openAlertSheet: vi.fn(),
+ openAutoHeal: vi.fn(),
+ checkUpdates: vi.fn(),
+ openStackApp: vi.fn(),
+ deploy: vi.fn(),
+ stop: vi.fn(),
+ restart: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ pin: vi.fn(),
+ unpin: vi.fn(),
+ toggleLabel: vi.fn(),
+ openLabelManager: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe('useStackMenuItems', () => {
+ it('returns Inspect / Organize / Lifecycle / Destructive groups in order', () => {
+ const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx()));
+ expect(result.current.map(g => g.id)).toEqual(['inspect', 'organize', 'lifecycle', 'destructive']);
+ });
+
+ it('always includes Alerts in Inspect', () => {
+ const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx()));
+ const inspect = result.current.find(g => g.id === 'inspect')!;
+ expect(inspect.items.some(i => i.icon === BellRing)).toBe(true);
+ });
+
+ it('hides Auto-Heal when !isPaid', () => {
+ const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false })));
+ const inspect = result.current.find(g => g.id === 'inspect')!;
+ expect(inspect.items.find(i => i.id === 'auto-heal')).toBeUndefined();
+ });
+
+ it('hides Open App unless running + hasPort', () => {
+ const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ stackStatus: 'exited' })));
+ const inspect = result.current.find(g => g.id === 'inspect')!;
+ expect(inspect.items.find(i => i.id === 'open-app')).toBeUndefined();
+ });
+
+ it('toggles Pin / Unpin label based on isPinned', () => {
+ const pinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: true })));
+ const unpinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: false })));
+ const pinnedOrganize = pinned.result.current.find(g => g.id === 'organize')!;
+ const unpinnedOrganize = unpinned.result.current.find(g => g.id === 'organize')!;
+ expect(pinnedOrganize.items.find(i => i.id === 'pin')!.label).toBe('Unpin');
+ expect(unpinnedOrganize.items.find(i => i.id === 'pin')!.label).toBe('Pin to top');
+ });
+
+ it('omits Destructive group entirely when !canDelete', () => {
+ const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canDelete: false })));
+ expect(result.current.find(g => g.id === 'destructive')).toBeUndefined();
+ });
+
+ it('marks Delete item destructive with Trash2 icon', () => {
+ const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx()));
+ const destructive = result.current.find(g => g.id === 'destructive')!;
+ const del = destructive.items.find(i => i.id === 'delete')!;
+ expect(del.destructive).toBe(true);
+ expect(del.icon).toBe(Trash2);
+ });
+
+ it('lifecycle items follow menuVisibility flags', () => {
+ const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
+ menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true },
+ })));
+ const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
+ const ids = lifecycle.items.map(i => i.id);
+ expect(ids).toEqual(['deploy', 'update']);
+ });
+
+ it('disables every lifecycle item when isBusy', () => {
+ const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
+ isBusy: true,
+ menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true },
+ })));
+ const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
+ expect(lifecycle.items.every(i => i.disabled === true)).toBe(true);
+ });
+});
diff --git a/frontend/src/hooks/usePinnedStacks.ts b/frontend/src/hooks/usePinnedStacks.ts
new file mode 100644
index 00000000..0cce7ceb
--- /dev/null
+++ b/frontend/src/hooks/usePinnedStacks.ts
@@ -0,0 +1,69 @@
+import { useCallback, useEffect, useState } from 'react';
+
+const STORAGE_KEY = 'sencho:sidebar:pinned';
+const MAX_PINS = 10;
+
+type PinnedMap = Record;
+
+function readMap(): PinnedMap {
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return {};
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === 'object' ? parsed as PinnedMap : {};
+ } catch {
+ return {};
+ }
+}
+
+function writeMap(map: PinnedMap) {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
+ } catch {
+ // Quota exhaustion is non-fatal for this feature.
+ }
+}
+
+export interface UsePinnedStacksResult {
+ pinned: string[];
+ pin: (file: string) => void;
+ unpin: (file: string) => void;
+ isPinned: (file: string) => boolean;
+ evictedOldest: { file: string; seq: number } | null;
+}
+
+export function usePinnedStacks(nodeId: number | undefined): UsePinnedStacksResult {
+ const key = nodeId !== undefined ? String(nodeId) : '__none__';
+ const [map, setMap] = useState(() => readMap());
+ const [evictedOldest, setEvictedOldest] = useState<{ file: string; seq: number } | null>(null);
+
+ useEffect(() => { writeMap(map); }, [map]);
+
+ const pinned = map[key] ?? [];
+
+ const pin = useCallback((file: string) => {
+ setMap(prev => {
+ const current = prev[key] ?? [];
+ if (current.includes(file)) return prev;
+ const next = [...current, file];
+ if (next.length > MAX_PINS) {
+ const removed = next.shift()!;
+ setEvictedOldest(prev => ({ file: removed, seq: (prev?.seq ?? 0) + 1 }));
+ }
+ return { ...prev, [key]: next };
+ });
+ }, [key]);
+
+ const unpin = useCallback((file: string) => {
+ setMap(prev => {
+ const current = prev[key] ?? [];
+ const next = current.filter(f => f !== file);
+ if (next.length === current.length) return prev;
+ return { ...prev, [key]: next };
+ });
+ }, [key]);
+
+ const isPinned = useCallback((file: string) => pinned.includes(file), [pinned]);
+
+ return { pinned, pin, unpin, isPinned, evictedOldest };
+}
diff --git a/frontend/src/hooks/useSidebarGroupCollapse.ts b/frontend/src/hooks/useSidebarGroupCollapse.ts
new file mode 100644
index 00000000..ef134b78
--- /dev/null
+++ b/frontend/src/hooks/useSidebarGroupCollapse.ts
@@ -0,0 +1,67 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+type CollapseMap = Record;
+
+interface CollapseState {
+ key: string;
+ map: CollapseMap;
+}
+
+function storageKey(nodeId: number | undefined): string {
+ return `sencho:sidebar:groups:${nodeId ?? '__none__'}`;
+}
+
+function readMap(key: string): CollapseMap {
+ try {
+ const raw = window.localStorage.getItem(key);
+ if (!raw) return {};
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === 'object' ? parsed as CollapseMap : {};
+ } catch {
+ return {};
+ }
+}
+
+export interface UseSidebarGroupCollapseResult {
+ isCollapsed: (groupKey: string) => boolean;
+ toggle: (groupKey: string) => void;
+ setCollapsed: (groupKey: string, collapsed: boolean) => void;
+}
+
+export function useSidebarGroupCollapse(nodeId: number | undefined): UseSidebarGroupCollapseResult {
+ const key = storageKey(nodeId);
+ const [state, setState] = useState(() => ({ key, map: readMap(key) }));
+
+ if (state.key !== key) {
+ // Node changed: re-hydrate during render (derived state pattern).
+ setState({ key, map: readMap(key) });
+ }
+
+ const map = state.map;
+ const lastWrittenKey = useRef(null);
+
+ useEffect(() => {
+ if (lastWrittenKey.current !== key) {
+ // First sighting of this key (mount or node change): state was hydrated from storage; no write needed.
+ lastWrittenKey.current = key;
+ return;
+ }
+ try {
+ window.localStorage.setItem(key, JSON.stringify(map));
+ } catch {
+ // Ignore quota errors.
+ }
+ }, [key, map]);
+
+ const isCollapsed = useCallback((groupKey: string) => map[groupKey] === true, [map]);
+
+ const toggle = useCallback((groupKey: string) => {
+ setState(prev => ({ key: prev.key, map: { ...prev.map, [groupKey]: !prev.map[groupKey] } }));
+ }, []);
+
+ const setCollapsed = useCallback((groupKey: string, collapsed: boolean) => {
+ setState(prev => ({ key: prev.key, map: { ...prev.map, [groupKey]: collapsed } }));
+ }, []);
+
+ return { isCollapsed, toggle, setCollapsed };
+}
diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx
new file mode 100644
index 00000000..b95b2191
--- /dev/null
+++ b/frontend/src/hooks/useStackMenuItems.tsx
@@ -0,0 +1,86 @@
+import { useMemo } from 'react';
+import {
+ Activity,
+ ArrowUpRight,
+ BellRing,
+ Download,
+ Pin,
+ PinOff,
+ Play,
+ RefreshCw,
+ RotateCw,
+ Square,
+ Tag,
+ Trash2,
+} from 'lucide-react';
+import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sidebar-types';
+
+export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] {
+ const {
+ stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
+ openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
+ deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
+ menuVisibility,
+ } = ctx;
+ const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
+
+ return useMemo(() => {
+ const groups: MenuGroup[] = [];
+
+ const inspect: MenuItem[] = [
+ { id: 'alerts', label: 'Alerts', icon: BellRing, shortcut: 'A', onSelect: openAlertSheet },
+ ];
+ if (isPaid) {
+ inspect.push({ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal });
+ }
+ inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
+ if (stackStatus === 'running' && hasPort) {
+ inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp });
+ }
+ groups.push({ id: 'inspect', items: inspect });
+
+ const organize: MenuItem[] = [];
+ if (isPaid) {
+ organize.push({
+ id: 'labels',
+ label: 'Labels',
+ icon: Tag,
+ shortcut: 'L ›',
+ onSelect: () => {},
+ subItems: labels.map(l => ({
+ id: `label:${l.id}`,
+ label: l.name,
+ icon: Tag,
+ onSelect: () => toggleLabel(l.id),
+ })),
+ });
+ }
+ organize.push(
+ isPinned
+ ? { id: 'pin', label: 'Unpin', icon: PinOff, shortcut: 'P', onSelect: unpin }
+ : { id: 'pin', label: 'Pin to top', icon: Pin, shortcut: 'P', onSelect: pin }
+ );
+ groups.push({ id: 'organize', items: organize });
+
+ const lifecycle: MenuItem[] = [];
+ if (showDeploy) lifecycle.push({ id: 'deploy', label: 'Deploy', icon: Play, shortcut: '⌘↵', onSelect: deploy, disabled: isBusy });
+ if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy });
+ if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy });
+ if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy });
+ if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle });
+
+ if (canDelete) {
+ groups.push({
+ id: 'destructive',
+ items: [{ id: 'delete', label: 'Delete', icon: Trash2, shortcut: '⌘⌫', destructive: true, onSelect: remove }],
+ });
+ }
+
+ return groups;
+ }, [
+ stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
+ showDeploy, showStop, showRestart, showUpdate,
+ openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
+ deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
+ ]);
+}