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
+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()}