mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -3344,7 +3344,8 @@
|
||||
"3e102f111c": "All hosts"
|
||||
},
|
||||
"SidebarHostScopeStrip": {
|
||||
"connectedCount": "{{value0}} connected"
|
||||
"scopedTo": "{{value0}} only",
|
||||
"backToAll": "All hosts"
|
||||
},
|
||||
"SidebarWorkspaceOptionsMenu": {
|
||||
"95c9754653": "Agent activity layout",
|
||||
|
||||
@@ -3324,7 +3324,8 @@
|
||||
"82594419ba": "フィルター"
|
||||
},
|
||||
"SidebarHostScopeStrip": {
|
||||
"connectedCount": "{{value0}} connected"
|
||||
"scopedTo": "{{value0}} only",
|
||||
"backToAll": "All hosts"
|
||||
},
|
||||
"SidebarWorkspaceOptionsMenu": {
|
||||
"95c9754653": "エージェントアクティビティのレイアウト",
|
||||
|
||||
@@ -3324,7 +3324,8 @@
|
||||
"82594419ba": "필터"
|
||||
},
|
||||
"SidebarHostScopeStrip": {
|
||||
"connectedCount": "{{value0}} connected"
|
||||
"scopedTo": "{{value0}} only",
|
||||
"backToAll": "All hosts"
|
||||
},
|
||||
"SidebarWorkspaceOptionsMenu": {
|
||||
"95c9754653": "에이전트 활동 레이아웃",
|
||||
|
||||
@@ -3324,7 +3324,8 @@
|
||||
"82594419ba": "过滤器"
|
||||
},
|
||||
"SidebarHostScopeStrip": {
|
||||
"connectedCount": "{{value0}} connected"
|
||||
"scopedTo": "{{value0}} only",
|
||||
"backToAll": "All hosts"
|
||||
},
|
||||
"SidebarWorkspaceOptionsMenu": {
|
||||
"95c9754653": "代理活动布局",
|
||||
|
||||
Reference in New Issue
Block a user