feat(ui): redesign node switcher as sidebar identity anchor (#694)

Replace the inline Select in the sidebar with a dedicated NodeSwitcher
component that always renders as an identity card, regardless of node
count. With two or more nodes it opens a Popover listing every node
with status dot, type, version, last-seen metadata, and an active-row
accent rail, matching the design language of the user menu and
notification panel.

Extract the relative-time formatters out of NodeManager into a shared
@/lib/relativeTime module with formatTimeUntil and formatTimeAgo, and
add a 'just now' / '<1m' branch so fresh heartbeats and imminent runs
read naturally.
This commit is contained in:
Anso
2026-04-19 12:15:31 -04:00
committed by GitHub
parent aa181aa932
commit e721742560
5 changed files with 236 additions and 58 deletions
+12 -43
View File
@@ -20,7 +20,7 @@ import { springs } from '@/lib/motion';
import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight';
import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { LabelPill, LabelDot } from './LabelPill';
import { type Label as StackLabel } from './label-types';
@@ -28,7 +28,6 @@ import { LabelAssignPopover } from './LabelAssignPopover';
import { UserProfileDropdown } from './UserProfileDropdown';
import { NotificationPanel } from './NotificationPanel';
import { apiFetch, fetchForNode } from '@/lib/api';
import { isValidVersion } from '@/lib/version';
import { toast } from '@/components/ui/toast-store';
import { Label } from './ui/label';
import { Command, CommandInput, CommandList, CommandItem } from './ui/command';
@@ -59,6 +58,7 @@ import AutoUpdateReadinessView from './AutoUpdateReadinessView';
import { SecurityHistoryView } from './SecurityHistoryView';
import { SENCHO_NAVIGATE_EVENT } from './NodeManager';
import type { SenchoNavigateDetail } from './NodeManager';
import { NodeSwitcher } from './NodeSwitcher';
import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
import type { SenchoOpenLogsDetail } from '@/lib/events';
import { useNodes } from '@/context/NodeContext';
@@ -185,7 +185,7 @@ export default function EditorLayout() {
}
};
}, []);
const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes();
const { nodes, activeNode, setActiveNode } = useNodes();
// Stable ref so notification callbacks always read the latest nodes list
// without needing nodes in their dependency arrays (which would cause loops).
const nodesRef = useRef<Node[]>([]);
@@ -344,7 +344,7 @@ export default function EditorLayout() {
// Notifications & Settings state
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels'>('account');
const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels' | 'nodes'>('account');
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
const [alertSheetStack, setAlertSheetStack] = useState('');
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(null);
@@ -1860,45 +1860,14 @@ export default function EditorLayout() {
</div>
</div>
{/* Node Switcher */}
{nodes.length > 1 && (
<div className="px-4 pt-2 pb-0">
<Select
value={activeNode?.id?.toString() || ''}
onValueChange={(val) => {
const node = nodes.find(n => n.id === parseInt(val));
if (node) setActiveNode(node);
}}
>
<SelectTrigger className="w-full h-9 text-sm">
<div className="flex items-center gap-2">
<Server className="w-3.5 h-3.5 text-muted-foreground" />
<SelectValue placeholder="Select node" />
</div>
</SelectTrigger>
<SelectContent>
{nodes.map(node => {
const meta = nodeMeta.get(node.id);
return (
<SelectItem key={node.id} value={node.id.toString()}>
<div className="flex items-center gap-2 w-full">
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-success' :
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
}`} />
<span>{node.name}</span>
{isValidVersion(meta?.version) && (
<span className="font-mono text-[10px] tabular-nums text-muted-foreground/60 ml-auto">
v{meta.version}
</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
<div className="px-4 pt-2 pb-0">
<NodeSwitcher
onManageNodes={() => {
setSettingsInitialSection('nodes');
setSettingsModalOpen(true);
}}
/>
</div>
{/* Create Stack & Scan Buttons */}
{can('stack:create') && <div className="p-4 flex gap-2">
+4 -13
View File
@@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Combobox } from './ui/combobox';
import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw, Terminal } from 'lucide-react';
import { formatTimeUntil, formatTimeAgo } from '@/lib/relativeTime';
interface NodeSchedulingSummary {
active_tasks: number;
@@ -29,16 +30,6 @@ export interface SenchoNavigateDetail {
nodeId?: number;
}
function formatRelativeTime(timestamp: number): string {
const diff = timestamp - Date.now();
if (diff < 0) return 'overdue';
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
return `${Math.floor(hrs / 24)}d`;
}
interface NodeFormData {
name: string;
type: 'local' | 'remote';
@@ -562,7 +553,7 @@ export function NodeManager() {
? 'docker.sock'
: node.mode === 'pilot_agent'
? (node.pilot_last_seen
? `tunnel (seen ${formatRelativeTime(node.pilot_last_seen + 60_000)} ago)`
? `tunnel (seen ${formatTimeAgo(node.pilot_last_seen)})`
: 'tunnel (waiting)')
: (node.api_url || '-')}
</TableCell>
@@ -583,7 +574,7 @@ export function NodeManager() {
<Tooltip>
<TooltipTrigger>
<span className="text-xs text-muted-foreground">
next {formatRelativeTime(summary.next_run_at)}
next {formatTimeUntil(summary.next_run_at)}
</span>
</TooltipTrigger>
<TooltipContent>
@@ -789,7 +780,7 @@ export function NodeManager() {
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Expires <span className="font-mono tabular-nums">{formatRelativeTime(activeEnrollment.enrollment.expiresAt)}</span> from now.
Expires <span className="font-mono tabular-nums">{formatTimeUntil(activeEnrollment.enrollment.expiresAt)}</span> from now.
</p>
<Button size="sm" variant="outline" className="gap-1" onClick={copyEnrollment}>
{enrollmentCopied ? <Check className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
+194
View File
@@ -0,0 +1,194 @@
import { useState } from 'react';
import {
ChevronsUpDown,
Star,
Settings2,
CircleDashed,
} from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useNodes, type Node, type NodeMode } from '@/context/NodeContext';
import { formatTimeAgo } from '@/lib/relativeTime';
import { isValidVersion } from '@/lib/version';
interface NodeSwitcherProps {
onManageNodes: () => void;
}
function dotClass(status: Node['status']): string {
if (status === 'online') {
return 'bg-success shadow-[0_0_0_3px_color-mix(in_oklch,var(--success)_20%,transparent)]';
}
if (status === 'offline') return 'bg-destructive';
return 'bg-muted-foreground/40';
}
function typeLabel(type: Node['type'], mode: NodeMode | undefined): string {
if (type === 'local') return 'Local';
if (mode === 'pilot_agent') return 'Agent';
return 'Remote';
}
export function NodeSwitcher({ onManageNodes }: NodeSwitcherProps) {
const { nodes, activeNode, setActiveNode, nodeMeta, isLoading } = useNodes();
const [open, setOpen] = useState(false);
const hasNodes = nodes.length > 0;
const hasMultiple = nodes.length > 1;
const kickerType = activeNode
? typeLabel(activeNode.type, activeNode.mode).toUpperCase()
: hasNodes
? 'UNKNOWN'
: 'LOADING';
const triggerContent = (
<div
className={cn(
'flex w-full items-center gap-3 rounded-md border border-card-border/60 bg-card/40 px-3.5 py-2.5 text-left transition-colors',
hasMultiple && 'cursor-pointer hover:bg-accent focus-visible:bg-accent focus-visible:outline-none',
!hasMultiple && 'cursor-default',
)}
>
{activeNode ? (
<span
aria-hidden
className={cn('h-2 w-2 flex-shrink-0 rounded-full', dotClass(activeNode.status))}
/>
) : (
<CircleDashed
className="h-3 w-3 flex-shrink-0 text-stat-icon"
strokeWidth={1.5}
aria-hidden
/>
)}
<div className="min-w-0 flex-1">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Node · {kickerType}
</div>
<div className="mt-0.5 truncate font-display text-base italic leading-none text-stat-value">
{activeNode?.name ?? (isLoading ? '—' : 'No node')}
</div>
</div>
{hasMultiple ? (
<ChevronsUpDown
className="h-3.5 w-3.5 flex-shrink-0 text-stat-icon"
strokeWidth={1.5}
aria-hidden
/>
) : null}
</div>
);
if (!hasMultiple) {
return triggerContent;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className="w-full" aria-label="Switch node">
{triggerContent}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="overflow-hidden rounded-md p-0"
style={{ width: 'var(--radix-popover-trigger-width)', minWidth: '260px' }}
>
<div className="relative overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
<div className="relative flex items-center justify-between px-5 py-3.5">
<div className="flex items-baseline gap-2.5">
<span className="font-display text-xl italic leading-none text-stat-value">
Connected
</span>
<span className="font-mono text-[11px] uppercase tracking-[0.14em] tabular-nums text-brand">
{nodes.length} nodes
</span>
</div>
</div>
</div>
<div className="max-h-[320px] overflow-y-auto border-t border-card-border/60">
{nodes.map((node) => {
const meta = nodeMeta.get(node.id);
const isActive = activeNode?.id === node.id;
const version = isValidVersion(meta?.version) ? meta.version : null;
const typePart = typeLabel(node.type, node.mode).toUpperCase();
const metaParts: string[] = [typePart];
if (node.type === 'remote' && node.mode === 'pilot_agent') {
metaParts.push(
node.pilot_last_seen
? `SEEN ${formatTimeAgo(node.pilot_last_seen).toUpperCase()}`
: 'WAITING',
);
}
if (version) metaParts.push(`v${version}`);
return (
<button
key={node.id}
type="button"
onClick={() => {
setActiveNode(node);
setOpen(false);
}}
aria-current={isActive ? 'true' : undefined}
className={cn(
'group relative flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors',
'hover:bg-accent focus-visible:bg-accent focus-visible:outline-none',
)}
>
{isActive ? (
<div
className="pointer-events-none absolute inset-y-0 left-0 w-[3px] bg-brand"
aria-hidden
/>
) : null}
<span
aria-hidden
className={cn('h-2 w-2 flex-shrink-0 rounded-full', dotClass(node.status))}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
'truncate text-sm leading-snug',
isActive ? 'font-medium text-stat-value' : 'text-stat-value',
)}
>
{node.name}
</div>
<div className="mt-0.5 truncate font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
{metaParts.join(' · ')}
</div>
</div>
{node.is_default ? (
<Star
className="h-3 w-3 flex-shrink-0 fill-brand text-brand"
strokeWidth={1.5}
aria-label="Default node"
/>
) : null}
</button>
);
})}
</div>
<div className="border-t border-card-border/60">
<button
type="button"
onClick={() => {
setOpen(false);
onManageNodes();
}}
className="flex w-full items-center gap-2.5 px-5 py-2.5 text-left text-sm text-stat-value transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
>
<Settings2 className="h-4 w-4 text-stat-icon" strokeWidth={1.5} />
<span className="flex-1 truncate">Manage nodes</span>
</button>
</div>
</PopoverContent>
</Popover>
);
}