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 { usePinnedStacks } from '@/hooks/usePinnedStacks';
import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse';
import type { StackRowStatus } from '@/components/sidebar/StackRow';
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
import type { StackRowStatus } from '@/components/sidebar/stack-status-utils';
import type { FilterChip, StackMenuCtx } from '@/components/sidebar/sidebar-types';
import { StackFileExplorer } from '@/components/files/StackFileExplorer';
interface ContainerInfo {
@@ -1931,9 +1931,9 @@ export default function EditorLayout() {
// Stack name is now the same as selectedFile (no extension to strip)
const stackName = selectedFile || '';
// Filter files based on search query
const filteredFiles = files.filter(file =>
file.toLowerCase().includes(searchQuery.toLowerCase())
const filteredFiles = useMemo(
() => files.filter(file => file.toLowerCase().includes(searchQuery.toLowerCase())),
[files, searchQuery],
);
// 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).');
}, [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 remoteResults = useMemo(() => {
@@ -2286,8 +2303,11 @@ export default function EditorLayout() {
canCreate={can('stack:create')}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filterChip={filterChip}
filterCounts={filterCounts}
onFilterChipChange={setFilterChip}
list={{
files: filteredFiles ?? [],
files: chipFilteredFiles,
isLoading,
isPaid,
selectedFile,
@@ -2296,7 +2316,6 @@ export default function EditorLayout() {
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
stackUpdates,
gitSourcePendingMap,
labels,
pinnedFiles: pinned,
isCollapsed,
toggleCollapse,
@@ -4,17 +4,17 @@ interface SidebarBrandProps {
export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
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
src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'}
alt="Sencho Logo"
className="w-10 h-10 shrink-0"
className="w-7 h-7 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>
<span className="font-serif italic text-[17px] text-foreground mt-0.5">Sencho</span>
</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 { ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { sidebarPinnedGroupRail } from './sidebar-styles';
interface StackGroupProps {
id: string;
@@ -13,18 +14,22 @@ interface 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 (
<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"
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-controls={`group-${id}-body`}
>
<span className={cn('font-mono text-[9px] tracking-[0.22em] uppercase', labelColor)}>
{label}
{isPinned ? '★ ' : ''}{label}
</span>
<span className="flex items-center gap-1.5">
<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 { Skeleton } from '@/components/ui/skeleton';
import type { Label } from '@/components/label-types';
import type { StackRowStatus } 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 { StackContextMenu } from './StackContextMenu';
import { StackKebabMenu } from './StackKebabMenu';
@@ -27,7 +28,6 @@ export interface StackListProps {
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;
@@ -53,7 +53,6 @@ function buildGroups(
files: string[],
pinnedFiles: string[],
stackLabelMap: Record<string, Label[]>,
labels: Label[],
): BuiltGroup[] {
const result: BuiltGroup[] = [];
@@ -93,20 +92,19 @@ function buildGroups(
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,
stackUpdates, gitSourcePendingMap, pinnedFiles, isCollapsed, toggleCollapse,
isBusy, getDisplayName, onSelectFile, buildMenuCtx, remoteResults, remoteLoading, onSelectRemoteFile,
} = props;
const groups = useMemo(
() => buildGroups(files, pinnedFiles, stackLabelMap, labels),
[files, pinnedFiles, stackLabelMap, labels],
() => buildGroups(files, pinnedFiles, stackLabelMap),
[files, pinnedFiles, stackLabelMap],
);
useStackKeyboardShortcuts(selectedFile, buildMenuCtx);
@@ -181,11 +179,8 @@ export function StackList(props: StackListProps) {
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 className={`font-mono text-[10px] shrink-0 w-5 ${statusColor(status, false)}`}>
{statusText(status)}
</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} />
+44 -36
View File
@@ -4,9 +4,9 @@ import { Cursor, CursorContainer, CursorFollow, CursorProvider } from '@/compone
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';
import { sidebarRowActive, sidebarRowBase, sidebarRowCheckboxSlot } from './sidebar-styles';
import { statusText, statusColor } from './stack-status-utils';
import type { StackRowStatus } from './stack-status-utils';
interface StackRowProps {
file: string;
@@ -22,19 +22,6 @@ interface StackRowProps {
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>
@@ -49,9 +36,14 @@ function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
);
}
const MAX_VISIBLE_LABELS = 3;
export function StackRow(props: StackRowProps) {
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 (
<div
data-testid="stack-row"
@@ -61,32 +53,48 @@ export function StackRow(props: StackRowProps) {
onClick={() => 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))}>
{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 && (
{/* 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">
{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>
)}
{hasUpdate && (
<RowTooltip
trigger={(
<span className="relative inline-flex w-2 h-2 shrink-0">
<span className="absolute inset-0 rounded-full bg-update opacity-75 animate-ping" />
<span className="relative w-2 h-2 rounded-full bg-update" />
</span>
)}
label="Update available"
/>
)}
{hasGitPending && (
<RowTooltip
trigger={<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />}
label="Git source update pending"
/>
)}
{/* Fixed trailing icon slot: update dot takes priority over git pending */}
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0">
{hasUpdate ? (
<RowTooltip
trigger={(
<span className="relative inline-flex w-2 h-2">
<span className="absolute inset-0 rounded-full bg-update opacity-75 animate-ping" />
<span className="relative w-2 h-2 rounded-full bg-update" />
</span>
)}
label="Update available"
/>
) : hasGitPending ? (
<RowTooltip
trigger={<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />}
label="Git source update pending"
/>
) : null}
</span>
{/* Kebab — always rightmost */}
<div
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => e.stopPropagation()}
@@ -5,8 +5,10 @@ import type { NotificationItem } from '@/components/dashboard/types';
import { SidebarActions } from './SidebarActions';
import { SidebarActivityTicker } from './SidebarActivityTicker';
import { SidebarBrand } from './SidebarBrand';
import { SidebarFilterChips, type FilterCounts } from './SidebarFilterChips';
import { SidebarSearch } from './SidebarSearch';
import { StackList, type StackListProps } from './StackList';
import type { FilterChip } from './sidebar-types';
export interface StackSidebarProps {
isDarkMode: boolean;
@@ -17,6 +19,9 @@ export interface StackSidebarProps {
canCreate: boolean;
searchQuery: string;
onSearchChange: (v: string) => void;
filterChip: FilterChip;
filterCounts: FilterCounts;
onFilterChipChange: (chip: FilterChip) => void;
list: StackListProps;
notifications: NotificationItem[];
tickerConnected: boolean;
@@ -26,7 +31,8 @@ export interface StackSidebarProps {
export function StackSidebar(props: StackSidebarProps) {
const {
isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
searchQuery, onSearchChange, list, notifications, tickerConnected, onOpenActivity,
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
list, notifications, tickerConnected, onOpenActivity,
} = props;
return (
@@ -38,6 +44,11 @@ export function StackSidebar(props: StackSidebarProps) {
)}
<Command shouldFilter={false} className="bg-transparent flex-1 flex flex-col overflow-hidden">
<SidebarSearch value={searchQuery} onValueChange={onSearchChange} />
<SidebarFilterChips
active={filterChip}
counts={filterCounts}
onChange={onFilterChipChange}
/>
<ScrollArea className="flex-1 px-2 pb-2">
<div data-stacks-loaded={list.isLoading ? 'false' : 'true'}>
<StackList {...list} />
@@ -19,3 +19,12 @@ export const sidebarGroupHeader = cn(
'text-[9px] leading-3 tracking-[0.22em] uppercase text-stat-subtitle',
'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 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';
}