Quiet multi-host sidebar: no persistent strip, label-style host headers

Design feedback: the All-hosts view stacked redundant chrome (server
icon three times, status dots everywhere, a strip repeating what the
headers below said).

- The scope strip no longer renders in All-hosts view — host section
  headers tell the story, and scope switching lives in the workspace
  options menu. When scoped to one host it becomes a slim
  '<host> only · ✕ All hosts' return banner
- Host headers are now quiet uppercase muted labels (no server icon),
  with a transport suffix only for remote hosts and a status mark only
  when a host needs attention (connecting/blocked/error/disconnected);
  healthy hosts show nothing
- Collapse, hover chevron, count badge, and the host menu are unchanged

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:32:56 -07:00
co-authored by Orca
parent 38b908ade3
commit 0ebfaf05a7
6 changed files with 81 additions and 121 deletions
@@ -1,17 +1,9 @@
import React from 'react'
import { AlertTriangle, ChevronDown, Circle, Loader2, Monitor, Server } from 'lucide-react'
import { AlertTriangle, Loader2, X } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ALL_EXECUTION_HOSTS_SCOPE } from '../../../../shared/execution-host'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
getSidebarHostHealthLabel,
getSidebarHostScopeLabel,
shouldShowHostScopeControls,
type SidebarHostScopeOption
@@ -19,104 +11,61 @@ import {
import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options'
import { translate } from '@/i18n/i18n'
function HostHealthDot({ health }: { health: SidebarHostScopeOption['health'] }) {
function HostScopeWarningIcon({ health }: { health: SidebarHostScopeOption['health'] }) {
// Why: the banner stays quiet unless the scoped host needs attention.
if (health === 'connecting') {
return <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
}
if (health === 'blocked' || health === 'error') {
return <AlertTriangle className="size-3 shrink-0 text-destructive" />
}
return (
<Circle
className={cn(
'size-2.5 shrink-0 fill-current',
health === 'available' || health === 'local'
? 'text-status-success'
: health === 'mixed'
? 'text-muted-foreground'
: 'text-muted-foreground/55'
)}
/>
)
return null
}
/** Shown only when the sidebar is scoped to a single host: names the scope and
* offers the way back. In All-hosts view the host section headers tell the
* story, so no persistent strip renders; scope switching lives in the
* workspace options menu. */
const SidebarHostScopeStrip = React.memo(function SidebarHostScopeStrip() {
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope)
const { hostOptions, hostScopeOptions } = useSidebarHostScopeOptions()
if (workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE) {
return null
}
if (!shouldShowHostScopeControls(hostOptions)) {
return null
}
const label = getSidebarHostScopeLabel(workspaceHostScope, hostScopeOptions)
const selectedHost = hostOptions.find((host) => host.id === workspaceHostScope)
const selectedScope = hostScopeOptions.find((option) => option.id === workspaceHostScope)
// Why: saved-but-idle hosts inflate a raw registry count; connection state
// is what users act on (per the design mock's "3 connected").
const connectedCount = hostOptions.filter(
(host) => host.health === 'local' || host.health === 'available'
).length
const meta =
workspaceHostScope === 'all'
? translate(
'auto.components.sidebar.SidebarHostScopeStrip.connectedCount',
'{{value0}} connected',
{
value0: connectedCount
}
)
: (selectedHost?.detail ?? 'Host')
return (
<div className="px-2 pb-1">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-full justify-between gap-2 rounded-md border border-sidebar-border/70 bg-sidebar-accent/35 px-2 text-sidebar-foreground hover:bg-sidebar-accent"
>
<span className="flex min-w-0 items-center gap-2">
{workspaceHostScope === 'all' ? (
<Server className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<Monitor className="size-3.5 shrink-0 text-muted-foreground" />
)}
<HostHealthDot health={selectedScope?.health ?? 'mixed'} />
<span className="truncate text-xs font-medium">{label}</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-[11px] font-normal text-muted-foreground">
{meta}
<ChevronDown className="size-3" />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-56">
<DropdownMenuRadioGroup
value={workspaceHostScope}
onValueChange={(value) => setWorkspaceHostScope(value as typeof workspaceHostScope)}
>
{hostScopeOptions.map((option) => (
<DropdownMenuRadioItem
key={option.id}
value={option.id}
className="flex-col items-start gap-0.5"
>
<span className="flex min-w-0 items-center gap-1.5">
<HostHealthDot health={option.health} />
<span className="truncate">{option.label}</span>
</span>
{option.detail && (
<span className="max-w-44 truncate text-[11px] font-normal text-muted-foreground">
{getSidebarHostHealthLabel(option.health)} · {option.detail}
</span>
)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex h-7 w-full items-center justify-between gap-2 rounded-md border border-sidebar-border/70 bg-sidebar-accent/35 pl-2 pr-1">
<span className="flex min-w-0 items-center gap-1.5">
<HostScopeWarningIcon health={selectedScope?.health ?? 'available'} />
<span className="truncate text-xs font-medium text-sidebar-foreground">
{translate(
'auto.components.sidebar.SidebarHostScopeStrip.scopedTo',
'{{value0}} only',
{
value0: label
}
)}
</span>
</span>
<Button
variant="ghost"
size="sm"
className="h-5 shrink-0 gap-1 rounded px-1.5 text-[11px] font-normal text-muted-foreground hover:text-foreground"
onClick={() => setWorkspaceHostScope(ALL_EXECUTION_HOSTS_SCOPE)}
>
<X className="size-3" />
{translate('auto.components.sidebar.SidebarHostScopeStrip.backToAll', 'All hosts')}
</Button>
</div>
</div>
)
})
@@ -15,7 +15,6 @@ import {
FolderInput,
FolderPlus,
Plus,
Server,
Shapes,
SlidersHorizontal,
Trash2
@@ -473,26 +472,26 @@ function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element {
)
}
function HostHeaderHealthIcon({ health }: { health: HostHeaderRow['health'] }): React.JSX.Element {
function HostHeaderHealthIcon({
health
}: {
health: HostHeaderRow['health']
}): React.JSX.Element | null {
// Why: healthy is the default state — indicating it adds noise. Only states
// needing attention (connecting, blocked, error, disconnected) get a mark.
if (health === 'connecting') {
return <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
return <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
}
if (health === 'blocked' || health === 'error') {
return <AlertTriangle className="size-3.5 shrink-0 text-destructive" />
return <AlertTriangle className="size-3 shrink-0 text-destructive" />
}
return (
<Circle
className={cn(
'size-2.5 shrink-0 fill-current',
health === 'available' || health === 'local'
? 'text-status-success'
: 'text-muted-foreground/55'
)}
/>
)
if (health === 'disconnected') {
return <Circle className="size-2 shrink-0 fill-current text-muted-foreground/55" />
}
return null
}
function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } {
function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null {
// Why: a blocked compatibility verdict gets a compact warning treatment so one
// skewed host stands out without altering how its siblings render.
if (row.health === 'blocked') {
@@ -512,7 +511,12 @@ function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boo
isWarning: true
}
}
return { text: row.detail, isWarning: false }
// Why: the transport suffix only earns space on remote hosts; "This
// computer" on Local Mac is noise.
if (row.kind !== 'local') {
return { text: row.detail, isWarning: false }
}
return null
}
function HostSectionHeader({
@@ -525,14 +529,14 @@ function HostSectionHeader({
const detail = getHostHeaderDetail(row)
return (
<div className="px-2 pt-1">
{/* Why: host headers are group labels, not cards — they must read as a
lighter tier than the host scope strip above, mirroring the other
sidebar group headers (clickable row, hover chevron, same type). */}
{/* Why: host headers are quiet group labels, one tier below workspace
cards — uppercase muted type, no icon, status mark only when the
host needs attention (matches the design doc's sidebar mock). */}
<div
role="button"
tabIndex={0}
aria-expanded={!row.collapsed}
className="group/host-header flex h-7 w-full cursor-pointer items-center gap-1.5 pr-1 text-left"
className="group/host-header flex h-6 w-full cursor-pointer items-center gap-1.5 pr-1 text-left"
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -541,18 +545,21 @@ function HostSectionHeader({
}
}}
>
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<HostHeaderHealthIcon health={row.health} />
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">{row.label}</div>
<span
className={cn(
'shrink-0 truncate text-[10px] leading-none',
detail.isWarning ? 'text-destructive' : 'text-muted-foreground/70'
)}
>
{detail.text}
</span>
<div className="flex min-w-0 flex-1 items-baseline gap-1.5">
<div className="min-w-0 truncate text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{row.label}
</div>
{detail ? (
<span
className={cn(
'shrink-0 truncate text-[10px] leading-none',
detail.isWarning ? 'text-destructive' : 'text-muted-foreground/60'
)}
>
· {detail.text}
</span>
) : null}
<SectionMetricsBadge count={row.count} />
</div>
<div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground/60 opacity-0 transition-opacity group-hover/host-header:opacity-100">
+2 -1
View File
@@ -3344,7 +3344,8 @@
"3e102f111c": "All hosts"
},
"SidebarHostScopeStrip": {
"connectedCount": "{{value0}} connected"
"scopedTo": "{{value0}} only",
"backToAll": "All hosts"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "Agent activity layout",
+2 -1
View File
@@ -3324,7 +3324,8 @@
"82594419ba": "フィルター"
},
"SidebarHostScopeStrip": {
"connectedCount": "{{value0}} connected"
"scopedTo": "{{value0}} only",
"backToAll": "All hosts"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "エージェントアクティビティのレイアウト",
+2 -1
View File
@@ -3324,7 +3324,8 @@
"82594419ba": "필터"
},
"SidebarHostScopeStrip": {
"connectedCount": "{{value0}} connected"
"scopedTo": "{{value0}} only",
"backToAll": "All hosts"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "에이전트 활동 레이아웃",
+2 -1
View File
@@ -3324,7 +3324,8 @@
"82594419ba": "过滤器"
},
"SidebarHostScopeStrip": {
"connectedCount": "{{value0}} connected"
"scopedTo": "{{value0}} only",
"backToAll": "All hosts"
},
"SidebarWorkspaceOptionsMenu": {
"95c9754653": "代理活动布局",