feat(sidebar): §14 sidebar orchestration, filter chips, pinned rail, trailing column (#850)

* feat(sidebar): §14 sidebar orchestration (filter chips, pinned rail, trailing column)

- Add All / Up / Down / Updates filter chips with live counts; active chip
  filters the list; chip-filtered files computed in EditorLayout with useMemo
- Surface the PINNED group with a 3px cyan left rail and glow via the
  sidebarPinnedGroupRail token; reuses the brand token already on the active row
- Compact brand row from three stacked elements to a single 44px horizontal bar
- Restructure StackRow trailing column as fixed slots: label dots (max 3 + +N
  overflow), update-dot | git-pending icon (priority order), kebab; add reserved
  invisible checkbox slot for PR2 bulk mode
- Export statusText / statusColor from StackRow and reuse them in StackList
  remote-results section to remove the duplicate inline logic
- Lift filterChip state and chip-filtered files to EditorLayout; remove
  filterChip from StackListProps to eliminate the dual-path redundancy
- Remove unused labels param from buildGroups and the void labels workaround
- Wrap filteredFiles in useMemo so filterCounts memo is not defeated on every
  render

* fix(sidebar): move statusText and statusColor to stack-status-utils

react-refresh/only-export-components requires component files to export
only components. Move the two utility functions and StackRowStatus type
to a dedicated stack-status-utils.ts so StackRow.tsx is a pure component
module. Update StackList.tsx and EditorLayout.tsx to import from the new
source directly.
This commit is contained in:
Anso
2026-04-30 19:37:49 -04:00
committed by GitHub
parent eead195529
commit 4c0efcb9a8
10 changed files with 187 additions and 62 deletions
+26 -7
View File
@@ -73,8 +73,8 @@ import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
import { StackSidebar } from '@/components/sidebar/StackSidebar'; import { StackSidebar } from '@/components/sidebar/StackSidebar';
import { usePinnedStacks } from '@/hooks/usePinnedStacks'; import { usePinnedStacks } from '@/hooks/usePinnedStacks';
import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse'; import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse';
import type { StackRowStatus } from '@/components/sidebar/StackRow'; import type { StackRowStatus } from '@/components/sidebar/stack-status-utils';
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; import type { FilterChip, StackMenuCtx } from '@/components/sidebar/sidebar-types';
import { StackFileExplorer } from '@/components/files/StackFileExplorer'; import { StackFileExplorer } from '@/components/files/StackFileExplorer';
interface ContainerInfo { interface ContainerInfo {
@@ -1931,9 +1931,9 @@ export default function EditorLayout() {
// Stack name is now the same as selectedFile (no extension to strip) // Stack name is now the same as selectedFile (no extension to strip)
const stackName = selectedFile || ''; const stackName = selectedFile || '';
// Filter files based on search query const filteredFiles = useMemo(
const filteredFiles = files.filter(file => () => files.filter(file => file.toLowerCase().includes(searchQuery.toLowerCase())),
file.toLowerCase().includes(searchQuery.toLowerCase()) [files, searchQuery],
); );
// Get display name for stack (now just returns the name as-is since no extension) // Get display name for stack (now just returns the name as-is since no extension)
@@ -1947,6 +1947,23 @@ export default function EditorLayout() {
if (evictedOldest) toast.info('Pinned. Unpinned oldest (max 10).'); if (evictedOldest) toast.info('Pinned. Unpinned oldest (max 10).');
}, [evictedOldest]); }, [evictedOldest]);
const [filterChip, setFilterChip] = useState<FilterChip>('all');
const filterCounts = useMemo(() => ({
all: filteredFiles.length,
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
down: filteredFiles.filter(f => stackStatuses[f] === 'exited').length,
updates: filteredFiles.filter(f => !!stackUpdates[f]).length,
}), [filteredFiles, stackStatuses, stackUpdates]);
const chipFilteredFiles = useMemo(() => {
if (filterChip === 'all') return filteredFiles;
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
if (filterChip === 'down') return filteredFiles.filter(f => stackStatuses[f] === 'exited');
if (filterChip === 'updates') return filteredFiles.filter(f => !!stackUpdates[f]);
return filteredFiles;
}, [filteredFiles, filterChip, stackStatuses, stackUpdates]);
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id); const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
const remoteResults = useMemo(() => { const remoteResults = useMemo(() => {
@@ -2286,8 +2303,11 @@ export default function EditorLayout() {
canCreate={can('stack:create')} canCreate={can('stack:create')}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
filterChip={filterChip}
filterCounts={filterCounts}
onFilterChipChange={setFilterChip}
list={{ list={{
files: filteredFiles ?? [], files: chipFilteredFiles,
isLoading, isLoading,
isPaid, isPaid,
selectedFile, selectedFile,
@@ -2296,7 +2316,6 @@ export default function EditorLayout() {
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>, stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
stackUpdates, stackUpdates,
gitSourcePendingMap, gitSourcePendingMap,
labels,
pinnedFiles: pinned, pinnedFiles: pinned,
isCollapsed, isCollapsed,
toggleCollapse, toggleCollapse,
@@ -4,17 +4,17 @@ interface SidebarBrandProps {
export function SidebarBrand({ isDarkMode }: SidebarBrandProps) { export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
return ( return (
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-glass-border"> <div className="flex items-center gap-2 px-4 py-2 border-b border-glass-border">
<img <img
src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'} src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'}
alt="Sencho Logo" alt="Sencho Logo"
className="w-10 h-10 shrink-0" className="w-7 h-7 shrink-0"
/> />
<div className="flex flex-col leading-none"> <div className="flex flex-col leading-none">
<span className="font-mono text-[9px] tracking-[0.22em] uppercase text-stat-subtitle"> <span className="font-mono text-[9px] tracking-[0.22em] uppercase text-stat-subtitle">
SENCHO · v{__APP_VERSION__} SENCHO · v{__APP_VERSION__}
</span> </span>
<span className="font-serif italic text-xl text-foreground mt-1">Sencho</span> <span className="font-serif italic text-[17px] text-foreground mt-0.5">Sencho</span>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,62 @@
import { cn } from '@/lib/utils';
import type { FilterChip } from './sidebar-types';
export interface FilterCounts {
all: number;
up: number;
down: number;
updates: number;
}
interface SidebarFilterChipsProps {
active: FilterChip;
counts: FilterCounts;
onChange: (chip: FilterChip) => void;
}
const chips: { id: FilterChip; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'up', label: 'Up' },
{ id: 'down', label: 'Down' },
{ id: 'updates', label: 'Updates' },
];
export function SidebarFilterChips({ active, counts, onChange }: SidebarFilterChipsProps) {
return (
<div className="flex items-center gap-1 px-2 pb-1.5 pt-0.5 overflow-x-auto scrollbar-none">
{chips.map(({ id, label }) => {
const count = counts[id];
const isActive = active === id;
const isUpdates = id === 'updates';
const hasUpdates = isUpdates && count > 0;
return (
<button
key={id}
type="button"
onClick={() => onChange(id)}
className={cn(
'flex items-center gap-1 shrink-0 rounded px-1.5 py-0.5',
'font-mono text-[10px] tracking-[0.08em] uppercase leading-none',
'border transition-colors duration-150',
isActive
? 'bg-brand/10 border-brand/30 text-brand'
: hasUpdates
? 'bg-update/5 border-update/20 text-update hover:bg-update/10'
: 'bg-transparent border-glass-border text-stat-subtitle hover:text-stat-title hover:border-glass-border',
)}
aria-pressed={isActive}
>
{label}
<span className={cn(
'tabular-nums',
isActive ? 'text-brand/70' : hasUpdates ? 'text-update/70' : 'text-stat-icon',
)}>
{count}
</span>
</button>
);
})}
</div>
);
}
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { sidebarPinnedGroupRail } from './sidebar-styles';
interface StackGroupProps { interface StackGroupProps {
id: string; id: string;
@@ -13,18 +14,22 @@ interface StackGroupProps {
} }
export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', children }: StackGroupProps) { export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', children }: StackGroupProps) {
const labelColor = variant === 'pinned' ? 'text-brand/90' : 'text-stat-subtitle'; const isPinned = variant === 'pinned';
const labelColor = isPinned ? 'text-brand/90' : 'text-stat-subtitle';
return ( return (
<div className="mt-2"> <div className="mt-2">
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className="flex items-center justify-between w-full px-2 py-1 text-left hover:bg-glass-highlight/30 rounded-md" className={cn(
'relative flex items-center justify-between w-full py-1 text-left hover:bg-glass-highlight/30 rounded-md',
isPinned ? sidebarPinnedGroupRail : 'px-2',
)}
aria-expanded={!collapsed} aria-expanded={!collapsed}
aria-controls={`group-${id}-body`} aria-controls={`group-${id}-body`}
> >
<span className={cn('font-mono text-[9px] tracking-[0.22em] uppercase', labelColor)}> <span className={cn('font-mono text-[9px] tracking-[0.22em] uppercase', labelColor)}>
{label} {isPinned ? '★ ' : ''}{label}
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span> <span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span>
+7 -12
View File
@@ -4,8 +4,9 @@ import { useStackKeyboardShortcuts } from '@/hooks/useStackKeyboardShortcuts';
import { CommandItem, CommandList } from '@/components/ui/command'; import { CommandItem, CommandList } from '@/components/ui/command';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import type { Label } from '@/components/label-types'; import type { Label } from '@/components/label-types';
import type { StackRowStatus } from './StackRow';
import { StackRow } from './StackRow'; import { StackRow } from './StackRow';
import { statusText, statusColor } from './stack-status-utils';
import type { StackRowStatus } from './stack-status-utils';
import { StackGroup } from './StackGroup'; import { StackGroup } from './StackGroup';
import { StackContextMenu } from './StackContextMenu'; import { StackContextMenu } from './StackContextMenu';
import { StackKebabMenu } from './StackKebabMenu'; import { StackKebabMenu } from './StackKebabMenu';
@@ -27,7 +28,6 @@ export interface StackListProps {
stackStatuses: Record<string, StackRowStatus | undefined>; stackStatuses: Record<string, StackRowStatus | undefined>;
stackUpdates: Record<string, boolean>; stackUpdates: Record<string, boolean>;
gitSourcePendingMap: Record<string, boolean>; gitSourcePendingMap: Record<string, boolean>;
labels: Label[];
pinnedFiles: string[]; pinnedFiles: string[];
isCollapsed: (groupKey: string) => boolean; isCollapsed: (groupKey: string) => boolean;
toggleCollapse: (groupKey: string) => void; toggleCollapse: (groupKey: string) => void;
@@ -53,7 +53,6 @@ function buildGroups(
files: string[], files: string[],
pinnedFiles: string[], pinnedFiles: string[],
stackLabelMap: Record<string, Label[]>, stackLabelMap: Record<string, Label[]>,
labels: Label[],
): BuiltGroup[] { ): BuiltGroup[] {
const result: BuiltGroup[] = []; const result: BuiltGroup[] = [];
@@ -93,20 +92,19 @@ function buildGroups(
result.push({ kind: 'unlabeled', id: 'unlabeled', label: 'UNLABELED', count: unlabeled.length, files: unlabeled }); result.push({ kind: 'unlabeled', id: 'unlabeled', label: 'UNLABELED', count: unlabeled.length, files: unlabeled });
} }
void labels;
return result; return result;
} }
export function StackList(props: StackListProps) { export function StackList(props: StackListProps) {
const { const {
files, isLoading, isPaid, selectedFile, searchQuery, stackLabelMap, stackStatuses, files, isLoading, isPaid, selectedFile, searchQuery, stackLabelMap, stackStatuses,
stackUpdates, gitSourcePendingMap, labels, pinnedFiles, isCollapsed, toggleCollapse, stackUpdates, gitSourcePendingMap, pinnedFiles, isCollapsed, toggleCollapse,
isBusy, getDisplayName, onSelectFile, buildMenuCtx, remoteResults, remoteLoading, onSelectRemoteFile, isBusy, getDisplayName, onSelectFile, buildMenuCtx, remoteResults, remoteLoading, onSelectRemoteFile,
} = props; } = props;
const groups = useMemo( const groups = useMemo(
() => buildGroups(files, pinnedFiles, stackLabelMap, labels), () => buildGroups(files, pinnedFiles, stackLabelMap),
[files, pinnedFiles, stackLabelMap, labels], [files, pinnedFiles, stackLabelMap],
); );
useStackKeyboardShortcuts(selectedFile, buildMenuCtx); useStackKeyboardShortcuts(selectedFile, buildMenuCtx);
@@ -181,11 +179,8 @@ export function StackList(props: StackListProps) {
onClick={() => onSelectRemoteFile(nodeId, file)} 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" 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 ${ <span className={`font-mono text-[10px] shrink-0 w-5 ${statusColor(status, false)}`}>
status === 'running' ? 'text-success' : {statusText(status)}
status === 'exited' ? 'text-destructive' : 'text-stat-icon'
}`}>
{status === 'running' ? 'UP' : status === 'exited' ? 'DN' : '--'}
</span> </span>
<span className="flex-1 truncate font-mono text-xs">{file}</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} /> <ArrowUpRight className="w-3 h-3 text-stat-icon shrink-0" strokeWidth={1.5} />
+44 -36
View File
@@ -4,9 +4,9 @@ import { Cursor, CursorContainer, CursorFollow, CursorProvider } from '@/compone
import { LabelDot } from '@/components/LabelPill'; import { LabelDot } from '@/components/LabelPill';
import type { Label } from '@/components/label-types'; import type { Label } from '@/components/label-types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { sidebarRowActive, sidebarRowBase } from './sidebar-styles'; import { sidebarRowActive, sidebarRowBase, sidebarRowCheckboxSlot } from './sidebar-styles';
import { statusText, statusColor } from './stack-status-utils';
export type StackRowStatus = 'running' | 'exited' | 'unknown'; import type { StackRowStatus } from './stack-status-utils';
interface StackRowProps { interface StackRowProps {
file: string; file: string;
@@ -22,19 +22,6 @@ interface StackRowProps {
kebabSlot: ReactNode; 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 }) { function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
return ( return (
<CursorProvider> <CursorProvider>
@@ -49,9 +36,14 @@ function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
); );
} }
const MAX_VISIBLE_LABELS = 3;
export function StackRow(props: StackRowProps) { export function StackRow(props: StackRowProps) {
const { file, displayName, status, isBusy, isActive, isPaid, labels, hasUpdate, hasGitPending, onSelect, kebabSlot } = props; const { file, displayName, status, isBusy, isActive, isPaid, labels, hasUpdate, hasGitPending, onSelect, kebabSlot } = props;
const visibleLabels = isPaid ? labels.slice(0, MAX_VISIBLE_LABELS) : [];
const overflowCount = isPaid ? Math.max(0, labels.length - MAX_VISIBLE_LABELS) : 0;
return ( return (
<div <div
data-testid="stack-row" data-testid="stack-row"
@@ -61,32 +53,48 @@ export function StackRow(props: StackRowProps) {
onClick={() => onSelect(file)} onClick={() => onSelect(file)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(file); } }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(file); } }}
> >
{/* Reserved checkbox slot — revealed in bulk mode (PR2) */}
<span className={sidebarRowCheckboxSlot} aria-hidden="true" />
{/* Status pill */}
<span className={cn('font-mono text-[10px] shrink-0 w-[22px] flex items-center', statusColor(status, isBusy))}> <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)} {isBusy ? <Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} /> : statusText(status)}
</span> </span>
<span className="flex-1 truncate font-mono text-[13px]">{displayName}</span>
{isPaid && labels.length > 0 && ( {/* Stack name */}
<span className="flex-1 truncate font-mono text-[13px] min-w-0">{displayName}</span>
{/* Trailing: label dots (max 3 + overflow count) */}
{visibleLabels.length > 0 && (
<span className="flex items-center gap-0.5 shrink-0"> <span className="flex items-center gap-0.5 shrink-0">
{labels.map(l => <LabelDot key={l.id} color={l.color} />)} {visibleLabels.map(l => <LabelDot key={l.id} color={l.color} />)}
{overflowCount > 0 && (
<span className="font-mono text-[8px] text-stat-icon leading-none">+{overflowCount}</span>
)}
</span> </span>
)} )}
{hasUpdate && (
<RowTooltip {/* Fixed trailing icon slot: update dot takes priority over git pending */}
trigger={( <span className="w-3.5 h-3.5 flex items-center justify-center shrink-0">
<span className="relative inline-flex w-2 h-2 shrink-0"> {hasUpdate ? (
<span className="absolute inset-0 rounded-full bg-update opacity-75 animate-ping" /> <RowTooltip
<span className="relative w-2 h-2 rounded-full bg-update" /> trigger={(
</span> <span className="relative inline-flex w-2 h-2">
)} <span className="absolute inset-0 rounded-full bg-update opacity-75 animate-ping" />
label="Update available" <span className="relative w-2 h-2 rounded-full bg-update" />
/> </span>
)} )}
{hasGitPending && ( label="Update available"
<RowTooltip />
trigger={<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />} ) : hasGitPending ? (
label="Git source update pending" <RowTooltip
/> trigger={<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />}
)} label="Git source update pending"
/>
) : null}
</span>
{/* Kebab — always rightmost */}
<div <div
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -5,8 +5,10 @@ import type { NotificationItem } from '@/components/dashboard/types';
import { SidebarActions } from './SidebarActions'; import { SidebarActions } from './SidebarActions';
import { SidebarActivityTicker } from './SidebarActivityTicker'; import { SidebarActivityTicker } from './SidebarActivityTicker';
import { SidebarBrand } from './SidebarBrand'; import { SidebarBrand } from './SidebarBrand';
import { SidebarFilterChips, type FilterCounts } from './SidebarFilterChips';
import { SidebarSearch } from './SidebarSearch'; import { SidebarSearch } from './SidebarSearch';
import { StackList, type StackListProps } from './StackList'; import { StackList, type StackListProps } from './StackList';
import type { FilterChip } from './sidebar-types';
export interface StackSidebarProps { export interface StackSidebarProps {
isDarkMode: boolean; isDarkMode: boolean;
@@ -17,6 +19,9 @@ export interface StackSidebarProps {
canCreate: boolean; canCreate: boolean;
searchQuery: string; searchQuery: string;
onSearchChange: (v: string) => void; onSearchChange: (v: string) => void;
filterChip: FilterChip;
filterCounts: FilterCounts;
onFilterChipChange: (chip: FilterChip) => void;
list: StackListProps; list: StackListProps;
notifications: NotificationItem[]; notifications: NotificationItem[];
tickerConnected: boolean; tickerConnected: boolean;
@@ -26,7 +31,8 @@ export interface StackSidebarProps {
export function StackSidebar(props: StackSidebarProps) { export function StackSidebar(props: StackSidebarProps) {
const { const {
isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate, isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
searchQuery, onSearchChange, list, notifications, tickerConnected, onOpenActivity, searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
list, notifications, tickerConnected, onOpenActivity,
} = props; } = props;
return ( return (
@@ -38,6 +44,11 @@ export function StackSidebar(props: StackSidebarProps) {
)} )}
<Command shouldFilter={false} className="bg-transparent flex-1 flex flex-col overflow-hidden"> <Command shouldFilter={false} className="bg-transparent flex-1 flex flex-col overflow-hidden">
<SidebarSearch value={searchQuery} onValueChange={onSearchChange} /> <SidebarSearch value={searchQuery} onValueChange={onSearchChange} />
<SidebarFilterChips
active={filterChip}
counts={filterCounts}
onChange={onFilterChipChange}
/>
<ScrollArea className="flex-1 px-2 pb-2"> <ScrollArea className="flex-1 px-2 pb-2">
<div data-stacks-loaded={list.isLoading ? 'false' : 'true'}> <div data-stacks-loaded={list.isLoading ? 'false' : 'true'}>
<StackList {...list} /> <StackList {...list} />
@@ -19,3 +19,12 @@ export const sidebarGroupHeader = cn(
'text-[9px] leading-3 tracking-[0.22em] uppercase text-stat-subtitle', 'text-[9px] leading-3 tracking-[0.22em] uppercase text-stat-subtitle',
'cursor-pointer select-none', 'cursor-pointer select-none',
); );
export const sidebarRowCheckboxSlot = 'shrink-0 w-4 h-4 opacity-0 pointer-events-none flex-shrink-0';
export const sidebarPinnedGroupRail = cn(
'relative before:absolute before:left-0 before:top-0 before:bottom-0',
'before:w-[3px] before:rounded-sm before:bg-brand',
'before:shadow-[0_0_6px_var(--brand)]',
'pl-[13px]',
);
@@ -52,3 +52,5 @@ export interface StackMenuCtx {
} }
export type StackGroupKind = 'pinned' | 'labeled' | 'unlabeled'; export type StackGroupKind = 'pinned' | 'labeled' | 'unlabeled';
export type FilterChip = 'all' | 'up' | 'down' | 'updates';
@@ -0,0 +1,14 @@
export type StackRowStatus = 'running' | 'exited' | 'unknown';
export function statusText(status: StackRowStatus): string {
if (status === 'running') return 'UP';
if (status === 'exited') return 'DN';
return '--';
}
export 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';
}