From af7dcdc196ddcfc94a70dcbec4c74dc2f27804fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:09:36 -0700 Subject: [PATCH] feat(dashboard): identify SSH and remote hosts (#14177) * feat(dashboard): identify SSH and remote hosts * fix(dashboard): resolve host labels consistently * fix(dashboard): reuse host server icon * test(dashboard): guard host label refresh cost * test(dashboard): satisfy runtime environment shape --- .../ipc/dashboard-payload-validation.test.ts | 14 ++++ src/main/ipc/dashboard-payload-validation.ts | 1 + .../dashboard-workspace-payload-validation.ts | 1 + .../dashboard-popout/AgentKanbanCard.test.tsx | 17 ++++ .../dashboard-popout/AgentKanbanCard.tsx | 10 +++ .../AgentMapProjectLabel.test.tsx | 53 ++++++++++++ .../dashboard-popout/AgentMapScene.tsx | 18 +++++ .../DashboardHostBadge.test.tsx | 54 +++++++++++++ .../dashboard-popout/DashboardHostBadge.tsx | 81 +++++++++++++++++++ .../agent-map-layout-metadata.ts | 21 ++++- .../dashboard-popout/agent-map-layout.test.ts | 51 ++++++++++++ .../dashboard-popout/agent-map-layout.ts | 10 ++- .../agent-map-worktree-host.ts | 25 ++++++ ...ashboard-snapshot-folder-workspace.test.ts | 35 +++++++- .../build-dashboard-snapshot.test.ts | 44 ++++++++++ .../dashboard/build-dashboard-snapshot.ts | 33 +++++--- .../dashboard-snapshot-workspaces.ts | 72 ++++++++++++++--- .../useDashboardPopoutBridge.test.tsx | 7 ++ .../dashboard/useDashboardPopoutBridge.ts | 1 + .../useLiveDashboardSnapshot.test.ts | 13 +++ .../dashboard/useLiveDashboardSnapshot.ts | 3 + src/renderer/src/i18n/locales/en.json | 8 +- src/shared/dashboard-snapshot.ts | 4 + 23 files changed, 547 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/DashboardHostBadge.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-host.ts diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 958fff9e8a3..83970cfa4ca 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -45,6 +45,7 @@ const SNAPSHOT = { worktreeName: 'Dashboard', hostKind: 'ssh', executionHostId: 'ssh:build-box', + hostLabel: 'Build box', workspaceKind: 'worktree', workspaceStatusId: 'in-review', workspaceStatusLabel: 'In review', @@ -68,6 +69,7 @@ const SNAPSHOT = { parentWorktreeId: 'parent-worktree-1', hostKind: 'ssh', executionHostId: 'ssh:build-box', + hostLabel: 'Build box', workspaceKind: 'worktree', workspaceStatusId: 'in-review', workspaceStatusLabel: 'In review', @@ -143,6 +145,12 @@ describe('dashboard payload validation', () => { cards: [{ ...SNAPSHOT.cards[0], executionHostId: `ssh:${'x'.repeat(4_097)}` }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], hostLabel: 'x'.repeat(1_025) }] + }) + ).toBe(false) expect( isDashboardSnapshot({ ...SNAPSHOT, @@ -236,6 +244,12 @@ describe('dashboard payload validation', () => { workspaces: [{ ...SNAPSHOT.workspaces[0], worktreeName: 'x'.repeat(1_025) }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [{ ...SNAPSHOT.workspaces[0], hostLabel: 'x'.repeat(1_025) }] + }) + ).toBe(false) }) it('validates bounded launch choices and spawn requests', () => { diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index f819392dae1..6186e638fd1 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -272,6 +272,7 @@ function isDashboardCard(value: unknown): boolean { (card.executionHostId === undefined || (isBoundedString(card.executionHostId, MAX_ID_LENGTH) && normalizeExecutionHostId(card.executionHostId) !== null)) && + isOptionalBoundedString(card.hostLabel, MAX_LABEL_LENGTH) && (card.workspaceKind === undefined || (typeof card.workspaceKind === 'string' && DASHBOARD_WORKSPACE_KINDS.has(card.workspaceKind))) && diff --git a/src/main/ipc/dashboard-workspace-payload-validation.ts b/src/main/ipc/dashboard-workspace-payload-validation.ts index 88e16d9af45..f64cf63d42a 100644 --- a/src/main/ipc/dashboard-workspace-payload-validation.ts +++ b/src/main/ipc/dashboard-workspace-payload-validation.ts @@ -50,6 +50,7 @@ export function isDashboardWorkspace(value: unknown): value is DashboardWorkspac HOST_KINDS.has(workspace.hostKind) && isString(workspace.executionHostId, MAX_ID_LENGTH) && normalizeExecutionHostId(workspace.executionHostId) !== null && + isOptionalString(workspace.hostLabel, DASHBOARD_MAX_LABEL_LENGTH) && typeof workspace.workspaceKind === 'string' && WORKSPACE_KINDS.has(workspace.workspaceKind) && isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) && diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index 5036478d94c..523da058675 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -102,6 +102,23 @@ describe('AgentKanbanCard', () => { expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull() }) + it('shows the saved SSH host beside the repository metadata', () => { + const { container } = renderCard({ + card: card({ + hostKind: 'ssh', + executionHostId: 'ssh:opaque-target', + hostLabel: 'openclaw' + }), + now: 2_000 + }) + + expect(screen.getByLabelText('SSH host · openclaw')).toHaveAttribute( + 'data-dashboard-host-badge', + 'ssh' + ) + expect(container.querySelector('.lucide-server')).toBeInTheDocument() + }) + it('shows review metadata and expands grouped subagents without opening the terminal', () => { const onOpenTerminal = vi.fn() renderCard({ diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index 4e6cffa14af..580c6c3a165 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -20,6 +20,7 @@ import { } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { translate } from '@/i18n/i18n' +import { DashboardHostBadge } from './DashboardHostBadge' /** Compact "started N ago" (the card is glanceable — coarse units are fine). */ function formatStartedAgo(startedAt: number, now: number): string { @@ -95,6 +96,9 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.leafId === b.leafId && a.repoName === b.repoName && a.worktreeName === b.worktreeName && + a.hostKind === b.hostKind && + a.executionHostId === b.executionHostId && + a.hostLabel === b.hostLabel && a.hasReview === b.hasReview && a.review?.number === b.review?.number && a.review?.state === b.review?.state && @@ -327,6 +331,12 @@ export const AgentKanbanCard = memo( {card.repoName} + {worktreeInFooter ? {card.worktreeName} : null} {displayTimestamp(card) > 0 ? ( diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx index c900aa01740..afc3b44c057 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx @@ -5,6 +5,7 @@ import { render } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import type { AgentMapLayout } from './agent-map-layout' import { AgentMapScene } from './AgentMapScene' +import { TooltipProvider } from '@/components/ui/tooltip' const LAYOUT: AgentMapLayout = { projects: [ @@ -60,4 +61,56 @@ describe('AgentMapScene project labels', () => { expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('x', '-48') expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('width', '96') }) + + it('labels an SSH-backed project ring with its saved host', () => { + const sshLayout: AgentMapLayout = { + ...LAYOUT, + projects: [ + { + ...LAYOUT.projects[0], + worktrees: [ + { + id: 'worktree-1:openclaw', + worktreeId: 'worktree-1', + executionHostId: 'ssh:opaque-target', + hostKind: 'ssh', + hostLabel: 'openclaw', + name: 'humpback', + workspaceKind: 'worktree', + x: 120, + y: 120, + radius: 48, + agents: [], + statusCounts: { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 }, + quiet: true + } + ] + } + ] + } + const { container } = render( + + + + + + ) + + const badge = container.querySelector('[data-dashboard-host-badge="ssh"]') + expect(badge).toHaveAccessibleName('SSH host · openclaw') + expect(badge).toHaveClass('agent-map-project-host-badge', 'pointer-events-auto') + }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index 2181e4c0f2c..ea7ed7d192e 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -15,6 +15,7 @@ import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path' import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel' import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode' +import { DashboardHostBadge } from './DashboardHostBadge' type AgentMapSceneProps = { layout: AgentMapLayout @@ -129,6 +130,13 @@ export const AgentMapScene = memo(function AgentMapScene({ {layout.projects.map((project) => { const worktreesById = new Map(project.worktrees.map((worktree) => [worktree.id, worktree])) const projectLabelHalfWidth = project.radius * mapScale + const projectHostsById = new Map() + for (const worktree of project.worktrees) { + if (worktree.hostKind === 'ssh' || worktree.hostKind === 'remote') { + projectHostsById.set(`${worktree.hostKind}:${worktree.executionHostId ?? ''}`, worktree) + } + } + const projectHosts = [...projectHostsById.values()] const projectCountText = translate( 'dashboardPopout.map.projectCount', '{{agents}} agents · {{workspaces}} workspaces', @@ -253,6 +261,16 @@ export const AgentMapScene = memo(function AgentMapScene({ {project.name.toUpperCase()} + {projectHosts.map((host) => ( + + ))} {visibleLabels.projectCountIds.has(project.id) ? ( diff --git a/src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx b/src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx new file mode 100644 index 00000000000..08211439eb7 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { DashboardHostBadge } from './DashboardHostBadge' + +afterEach(cleanup) + +describe('DashboardHostBadge', () => { + it('names a saved SSH host in its focusable tooltip', async () => { + render( + + + + ) + + const badge = screen.getByLabelText('SSH host · openclaw') + expect(badge).toHaveAttribute('data-dashboard-host-badge', 'ssh') + expect(badge.querySelector('.lucide-server')).toBeInTheDocument() + + fireEvent.focus(badge) + expect(await screen.findByRole('tooltip')).toHaveTextContent('SSH host · openclaw') + }) + + it('distinguishes paired Orca hosts and omits local hosts', () => { + const { rerender } = render( + + + + ) + + const badge = screen.getByLabelText('Remote Orca host · Build Mac') + expect(badge).toHaveAttribute('data-dashboard-host-badge', 'remote') + expect(badge.querySelector('.lucide-server')).toBeInTheDocument() + + rerender( + + + + ) + expect(screen.queryByLabelText(/host/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/DashboardHostBadge.tsx b/src/renderer/src/components/dashboard-popout/DashboardHostBadge.tsx new file mode 100644 index 00000000000..6b8f0d88f03 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/DashboardHostBadge.tsx @@ -0,0 +1,81 @@ +import { Server } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' + +type DashboardHostBadgeProps = { + hostKind?: DashboardCardHostKind + executionHostId?: ExecutionHostId + hostLabel?: string + keyboardFocusable?: boolean + className?: string + iconClassName?: string +} + +function fallbackHostLabel(executionHostId: ExecutionHostId | undefined): string | null { + const parsed = parseExecutionHostId(executionHostId) + if (parsed?.kind === 'ssh') { + return parsed.targetId + } + if (parsed?.kind === 'runtime') { + return parsed.environmentId + } + return null +} + +export function dashboardHostTooltipLabel({ + hostKind, + executionHostId, + hostLabel +}: Pick): string | null { + if (hostKind !== 'ssh' && hostKind !== 'remote') { + return null + } + const label = hostLabel?.trim() || fallbackHostLabel(executionHostId) + if (hostKind === 'ssh') { + return label + ? translate('dashboardPopout.host.sshNamed', 'SSH host · {{host}}', { host: label }) + : translate('dashboardPopout.host.ssh', 'SSH host') + } + return label + ? translate('dashboardPopout.host.remoteNamed', 'Remote Orca host · {{host}}', { host: label }) + : translate('dashboardPopout.host.remote', 'Remote Orca host') +} + +export function DashboardHostBadge({ + hostKind, + executionHostId, + hostLabel, + keyboardFocusable = false, + className, + iconClassName +}: DashboardHostBadgeProps): React.JSX.Element | null { + const tooltipLabel = dashboardHostTooltipLabel({ hostKind, executionHostId, hostLabel }) + if (!tooltipLabel) { + return null + } + return ( + + + + + + + + {tooltipLabel} + + + ) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts index 69238b73cf3..e24274b7718 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts @@ -1,5 +1,6 @@ -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { AgentMapLayout, AgentMapStatusCounts } from './agent-map-layout' +import { agentMapWorkspaceIdentity } from './agent-map-workspace-identity' import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' function emptyStatusCounts(): AgentMapStatusCounts { @@ -9,15 +10,25 @@ function emptyStatusCounts(): AgentMapStatusCounts { export function refreshAgentMapMetadata( geometry: AgentMapLayout, cards: DashboardCard[], + workspaces: DashboardWorkspace[], now: number ): AgentMapLayout { const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card])) + const workspacesById = new Map( + workspaces.map((workspace) => [agentMapWorkspaceIdentity(workspace), workspace]) + ) const projects = geometry.projects.map((project) => { let projectName = project.name let agentCount = 0 const worktrees = project.worktrees.map((worktree) => { - let worktreeName = worktree.name - let workspaceKind = worktree.workspaceKind + const workspace = workspacesById.get(worktree.id) + if (workspace) { + projectName = workspace.repoName + } + let worktreeName = workspace?.worktreeName ?? worktree.name + let workspaceKind = workspace?.workspaceKind ?? worktree.workspaceKind + let hostKind = workspace?.hostKind ?? worktree.hostKind + let hostLabel = workspace?.hostLabel ?? worktree.hostLabel const statusCounts = emptyStatusCounts() const agents = worktree.agents.flatMap((agent) => { const card = cardsByPaneKey.get(agent.card.paneKey) @@ -27,6 +38,8 @@ export function refreshAgentMapMetadata( projectName = card.repoName worktreeName = card.worktreeName workspaceKind = card.workspaceKind ?? 'worktree' + hostKind = card.hostKind ?? hostKind + hostLabel = card.hostLabel ?? hostLabel agentCount += 1 statusCounts[agentMapNodeStatus(card)] += 1 return [ @@ -42,6 +55,8 @@ export function refreshAgentMapMetadata( ...worktree, name: worktreeName, workspaceKind, + hostKind, + hostLabel, agents, statusCounts, quiet: statusCounts.idle === agents.length diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts index 52c47be08f5..c36cbda6ff2 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts @@ -142,6 +142,25 @@ describe('agent map layout', () => { ) }) + it('preserves remote host presentation on its workspace ring', () => { + const layout = deriveAgentMapLayout( + [ + card({ + executionHostId: 'ssh:opaque-target', + hostKind: 'ssh', + hostLabel: 'openclaw' + }) + ], + NOW + ) + + expect(layout.projects[0].worktrees[0]).toMatchObject({ + executionHostId: 'ssh:opaque-target', + hostKind: 'ssh', + hostLabel: 'openclaw' + }) + }) + it('reserves project and workspace header bands above dense ring contents', () => { const layout = deriveAgentMapLayout( Array.from({ length: 24 }, (_unused, index) => @@ -448,6 +467,38 @@ describe('agent map layout', () => { expect(topologyChanged.cache.packingGeneration).toBe(2) }) + it('refreshes saved host labels without repacking geometry', () => { + const cards = [ + card({ + executionHostId: 'ssh:builder', + hostKind: 'ssh', + hostLabel: 'Builder' + }) + ] + const workspaces = [ + workspace({ + worktreeId: 'worktree-1', + executionHostId: 'ssh:builder', + hostKind: 'ssh', + hostLabel: 'Builder' + }) + ] + const initial = updateAgentMapLayout(null, cards, NOW, workspaces) + packWorktrees.mockClear() + + const updated = updateAgentMapLayout( + initial.cache, + cards.map((candidate) => ({ ...candidate, hostLabel: 'CI Builder' })), + NOW, + workspaces.map((candidate) => ({ ...candidate, hostLabel: 'CI Builder' })) + ) + + expect(updated.cache).toBe(initial.cache) + expect(updated.cache.packingGeneration).toBe(1) + expect(packWorktrees).not.toHaveBeenCalled() + expect(updated.layout.projects[0].worktrees[0].hostLabel).toBe('CI Builder') + }) + it('packs worktree rings tightly without a square grid', () => { const layout = deriveAgentMapLayout( Array.from({ length: 36 }, (_, index) => diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts index 79f5e7c77f0..f8a5d975233 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts @@ -13,6 +13,7 @@ import { agentMapWorktreeIdentityFromParts } from './agent-map-workspace-identity' import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' +import { agentMapWorktreeHost } from './agent-map-worktree-host' type DashboardCard = DashboardSnapshotTypes.DashboardCard type DashboardCardDotState = DashboardSnapshotTypes.DashboardCardDotState @@ -58,6 +59,8 @@ export type AgentMapWorktreeRing = { clusterParentId?: string worktreeId: string executionHostId: DashboardCard['executionHostId'] + hostKind?: DashboardCard['hostKind'] + hostLabel?: string name: string workspaceKind: NonNullable x: number @@ -154,7 +157,8 @@ function buildLocalWorktree( for (const card of cards) { statusCounts[agentMapNodeStatus(card)] += 1 } - const executionHostId = workspace?.executionHostId ?? cards[0]?.executionHostId + const host = agentMapWorktreeHost(cards, workspace) + const executionHostId = host.executionHostId const parentWorktreeId = workspace?.parentWorktreeId ?? cards[0]?.parentWorktreeId return { id, @@ -162,7 +166,7 @@ function buildLocalWorktree( ? agentMapWorktreeIdentityFromParts(parentWorktreeId, executionHostId) : undefined, worktreeId: workspace?.worktreeId ?? cards[0]?.worktreeId ?? id, - executionHostId, + ...host, name: workspace?.worktreeName ?? cards[0]?.worktreeName ?? id, workspaceKind: workspace?.workspaceKind ?? cards[0]?.workspaceKind ?? 'worktree', x: 0, @@ -316,6 +320,6 @@ export function updateAgentMapLayout( layout: geometry } } - const layout = refreshAgentMapMetadata(cache.geometry, cards, now) + const layout = refreshAgentMapMetadata(cache.geometry, cards, workspaces, now) return { cache, layout } } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-host.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-host.ts new file mode 100644 index 00000000000..247b3f5b08c --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-host.ts @@ -0,0 +1,25 @@ +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { parseExecutionHostId } from '../../../../shared/execution-host' + +export function agentMapWorktreeHost( + cards: DashboardCard[], + workspace?: DashboardWorkspace +): { + executionHostId: DashboardCard['executionHostId'] + hostKind: DashboardCard['hostKind'] + hostLabel: DashboardCard['hostLabel'] +} { + const executionHostId = workspace?.executionHostId ?? cards[0]?.executionHostId + const parsedHost = parseExecutionHostId(executionHostId) + const hostKind = + parsedHost?.kind === 'ssh' + ? 'ssh' + : parsedHost?.kind === 'runtime' + ? 'remote' + : (workspace?.hostKind ?? cards[0]?.hostKind) + return { + executionHostId, + hostKind, + hostLabel: workspace?.hostLabel ?? cards[0]?.hostLabel + } +} diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts index f5db7045970..822a3b80e96 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts @@ -100,7 +100,9 @@ function state(): DashboardSnapshotState { describe('buildDashboardSnapshot folder workspaces', () => { it('places folder-workspace agents in their real project group without git assumptions', () => { - const snapshot = buildDashboardSnapshot(state(), NOW) + const sshState = state() + sshState.sshTargetLabels = new Map([['ssh-1', 'openclaw']]) + const snapshot = buildDashboardSnapshot(sshState, NOW) expect(snapshot.cards).toHaveLength(1) expect(snapshot.cards[0]).toMatchObject({ @@ -111,7 +113,8 @@ describe('buildDashboardSnapshot folder workspaces', () => { worktreeName: 'Docs workspace', workspaceKind: 'folder', hostKind: 'ssh', - executionHostId: 'ssh:ssh-1' + executionHostId: 'ssh:ssh-1', + hostLabel: 'openclaw' }) expect(snapshot.filterOptions?.projects).toEqual([ { id: 'folder-workspace:group-1', label: 'Documentation' } @@ -124,7 +127,8 @@ describe('buildDashboardSnapshot folder workspaces', () => { worktreeName: 'Docs workspace', workspaceKind: 'folder', hostKind: 'ssh', - executionHostId: 'ssh:ssh-1' + executionHostId: 'ssh:ssh-1', + hostLabel: 'openclaw' }) ]) }) @@ -135,10 +139,35 @@ describe('buildDashboardSnapshot folder workspaces', () => { { ...folderWorkspace(), connectionId: null, executionHostId: 'runtime:environment-1' } ] runtimeState.projectGroups = [{ ...projectGroup(), connectionId: null }] + runtimeState.runtimeEnvironments = [ + { id: 'environment-1', name: 'Build Mac' } + ] as unknown as DashboardSnapshotState['runtimeEnvironments'] const snapshot = buildDashboardSnapshot(runtimeState, NOW) expect(snapshot.cards[0].hostKind).toBe('remote') expect(snapshot.cards[0].executionHostId).toBe('runtime:environment-1') + expect(snapshot.cards[0].hostLabel).toBe('Build Mac') + }) + + it('uses the user-facing host label override', () => { + const runtimeState = state() + runtimeState.folderWorkspaces = [ + { ...folderWorkspace(), connectionId: null, executionHostId: 'runtime:environment-1' } + ] + runtimeState.projectGroups = [{ ...projectGroup(), connectionId: null }] + runtimeState.runtimeEnvironments = [ + { id: 'environment-1', name: 'Build Mac' } + ] as unknown as DashboardSnapshotState['runtimeEnvironments'] + runtimeState.settings = { + hostSettingOverrides: { + 'runtime:environment-1': { displayLabel: 'CI Builder' } + } + } as unknown as DashboardSnapshotState['settings'] + + const snapshot = buildDashboardSnapshot(runtimeState, NOW) + + expect(snapshot.cards[0].hostLabel).toBe('CI Builder') + expect(snapshot.workspaces?.[0].hostLabel).toBe('CI Builder') }) }) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index 90630a3d924..d4e9bdc565a 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -603,6 +603,50 @@ describe('buildDashboardSnapshot', () => { expect(mapMetadataCalls.parentPaneKey).not.toHaveBeenCalled() }) + it('indexes runtime host labels once per detailed snapshot', () => { + let environmentIdReads = 0 + const environmentCount = 24 + const runtimeEnvironments = Array.from({ length: environmentCount }, (_, index) => { + const environment = { id: `environment-${index}`, name: `Builder ${index}` } + Object.defineProperty(environment, 'id', { + enumerable: true, + get: () => { + environmentIdReads += 1 + return `environment-${index}` + } + }) + return environment + }) as unknown as DashboardSnapshotState['runtimeEnvironments'] + const executionHostId = `runtime:environment-${environmentCount - 1}` as const + + const snapshot = buildDashboardSnapshot( + baseState({ + repos: [ + { + id: 'r1', + path: '/r1', + displayName: 'Repo One', + badgeColor: '#000', + addedAt: 0, + executionHostId + } + ], + worktreesByRepo: { + r1: [ + { ...worktree(), hostId: executionHostId }, + { ...worktree('w2', 'wt-two'), hostId: executionHostId } + ] + }, + runtimeEnvironments, + agentStatusByPaneKey: { [PANE_KEY]: entry({}) } + }), + NOW + ) + + expect(snapshot.cards[0].hostLabel).toBe(`Builder ${environmentCount - 1}`) + expect(environmentIdReads).toBe(environmentCount) + }) + it("resolves a live pty's host-input profile for card snapshots", () => { const snapshot = buildDashboardSnapshot( baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }), diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 808ffba0236..53e959f489c 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -40,8 +40,8 @@ import { type DashboardCardContextState } from './dashboard-card-context' import { - dashboardCardMapWorkspaceMetadata, - collectActiveDashboardWorkspaces + collectActiveDashboardWorkspaces, + dashboardCardMapWorkspaceMetadata } from './dashboard-snapshot-workspaces' import { boundedLabel, @@ -75,7 +75,11 @@ export type DashboardSnapshotState = Pick< | 'settings' > & DashboardCardContextState & - Partial + Partial< + DashboardCardTerminalInputState & + DashboardLaunchDetectionState & + Pick + > /** * Derive the serializable dashboard snapshot from the live renderer store. @@ -185,13 +189,19 @@ export function buildDashboardSnapshot( ? resolveDashboardCardContext(state, repo, worktree) : undefined if (workspaces && workspaces.length < DASHBOARD_MAX_MAP_WORKSPACES) { + const hostMetadata = dashboardCardMapWorkspaceMetadata( + workspace, + null, + undefined, + clientHost.platform + ) workspaces.push({ repoId: workspace.projectId, worktreeId, repoName: boundedLabel(workspace.projectName), worktreeName: boundedLabel(worktree.displayName), ...(parentWorktreeId ? { parentWorktreeId } : {}), - ...dashboardCardMapWorkspaceMetadata(workspace, null, undefined, clientHost.platform), + ...hostMetadata, workspaceStatusId: context?.workspaceStatus.id, workspaceStatusLabel: context?.workspaceStatus.label, workspaceStatusColor: context?.workspaceStatus.color, @@ -246,6 +256,14 @@ export function buildDashboardSnapshot( }) : null const finishedAt = lastEnteredDoneAt(row) + const hostMetadata = includeCardDetails + ? dashboardCardMapWorkspaceMetadata( + workspace, + ptyId, + terminalInput ?? undefined, + clientHost.platform + ) + : undefined // Only repos that actually contribute a card ship their icon. repoIconsByRepoId[workspace.projectId] = workspace.repoIcon @@ -266,12 +284,7 @@ export function buildDashboardSnapshot( ? { parentPaneKey: dashboardCardParentPaneKey(row), ...(parentWorktreeId ? { parentWorktreeId } : {}), - ...dashboardCardMapWorkspaceMetadata( - workspace, - ptyId, - terminalInput ?? undefined, - clientHost.platform - ) + ...hostMetadata } : {}), workspaceStatusId: context?.workspaceStatus.id, diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts index a83619511f8..fcb34581414 100644 --- a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts @@ -1,13 +1,21 @@ import type { AppState } from '@/store/types' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' -import type { - DashboardCard, - DashboardCardHostKind, - DashboardCardWorkspaceKind +import { + DASHBOARD_MAX_LABEL_LENGTH, + type DashboardCard, + type DashboardCardHostKind, + type DashboardCardWorkspaceKind } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + getWorktreeExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isFolderRepo } from '../../../../shared/repo-kind' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' @@ -19,10 +27,32 @@ export type ActiveDashboardWorkspace = { worktree: AppState['worktreesByRepo'][string][number] & { parentWorktreeId?: string | null } workspaceKind: DashboardCardWorkspaceKind remoteHostKind: Extract | null + hostLabel?: string } type DashboardWorkspaceState = Pick & - Partial> + Partial< + Pick< + AppState, + 'folderWorkspaces' | 'projectGroups' | 'runtimeEnvironments' | 'settings' | 'sshTargetLabels' + > + > + +function buildHostLabelLookup( + state: DashboardWorkspaceState +): ReadonlyMap { + const labels = new Map() + for (const [targetId, label] of state.sshTargetLabels ?? []) { + labels.set(toSshExecutionHostId(targetId), label) + } + for (const environment of state.runtimeEnvironments ?? []) { + labels.set(toRuntimeExecutionHostId(environment.id), environment.name) + } + for (const [hostId, label] of getHostDisplayLabelOverrides(state.settings)) { + labels.set(hostId, label) + } + return labels +} function remoteHostKind( connectionId: string | null | undefined, @@ -40,6 +70,20 @@ export function collectActiveDashboardWorkspaces( ): ActiveDashboardWorkspace[] { const workspaces: ActiveDashboardWorkspace[] = [] const seenWorkspaceIds = new Set() + let hostLabels: ReadonlyMap | null = null + const resolveHostLabel = (executionHostId: ExecutionHostId): string | undefined => { + const parsed = includeMapMetadata ? parseExecutionHostId(executionHostId) : null + if (parsed?.kind !== 'ssh' && parsed?.kind !== 'runtime') { + return undefined + } + hostLabels ??= buildHostLabelLookup(state) + const label = + hostLabels.get(executionHostId) ?? + (parsed.kind === 'ssh' ? parsed.targetId : parsed.environmentId) + return label.length > DASHBOARD_MAX_LABEL_LENGTH + ? label.slice(0, DASHBOARD_MAX_LABEL_LENGTH) + : label + } for (const repo of state.repos ?? []) { for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) { @@ -47,6 +91,9 @@ export function collectActiveDashboardWorkspaces( continue } seenWorkspaceIds.add(worktree.id) + const workspaceHostLabel = includeMapMetadata + ? resolveHostLabel(getWorktreeExecutionHostId(worktree, repo)) + : undefined workspaces.push({ projectId: repo.id, projectName: repo.displayName, @@ -56,7 +103,8 @@ export function collectActiveDashboardWorkspaces( workspaceKind: includeMapMetadata && isFolderRepo(repo) ? 'folder' : 'worktree', remoteHostKind: includeMapMetadata ? remoteHostKind(repo.connectionId, worktree.hostId ?? repo.executionHostId) - : null + : null, + ...(workspaceHostLabel ? { hostLabel: workspaceHostLabel } : {}) }) } } @@ -70,6 +118,9 @@ export function collectActiveDashboardWorkspaces( continue } const projectGroup = projectGroupsById.get(folderWorkspace.projectGroupId) + const workspaceHostLabel = includeMapMetadata + ? resolveHostLabel(getWorktreeExecutionHostId(worktree, undefined)) + : undefined workspaces.push({ projectId: `folder-workspace:${folderWorkspace.projectGroupId}`, projectName: projectGroup?.name ?? folderWorkspace.name, @@ -82,7 +133,8 @@ export function collectActiveDashboardWorkspaces( folderWorkspace.connectionId ?? projectGroup?.connectionId, worktree.hostId ?? projectGroup?.executionHostId ) - : null + : null, + ...(workspaceHostLabel ? { hostLabel: workspaceHostLabel } : {}) }) } return workspaces @@ -115,10 +167,12 @@ export function dashboardCardMapWorkspaceMetadata( hostKind: DashboardCardHostKind executionHostId: ExecutionHostId workspaceKind: DashboardCardWorkspaceKind + hostLabel?: string } { return { hostKind: dashboardCardHostKind(workspace, ptyId, terminalInput, clientPlatform), executionHostId: getWorktreeExecutionHostId(workspace.worktree, workspace.repo ?? undefined), - workspaceKind: workspace.workspaceKind + workspaceKind: workspace.workspaceKind, + ...(workspace.hostLabel ? { hostLabel: workspace.hostLabel } : {}) } } diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index c632f19bc4e..7cd7bf94c93 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -77,6 +77,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState { detectedWorktreesByRepo: {}, folderWorkspaces: [], projectGroups: [], + sshTargetLabels: new Map(), restoredRuntimeHostIdByWorkspaceSessionKey: {}, runtimeEnvironments: [], runtimeEnvironmentCatalogHydrated: false, @@ -207,6 +208,12 @@ describe('useDashboardPopoutBridge', () => { expect( dashboardSnapshotInputsChanged({ ...previousState, agentStatusEpoch: 1 }, previousState) ).toBe(false) + expect( + dashboardSnapshotInputsChanged( + { ...previousState, sshTargetLabels: new Map([['target-1', 'Builder']]) }, + previousState + ) + ).toBe(true) // Why: each card's preview terminal keys against a host-input profile // derived from these. Not republishing leaves the pop-out encoding bytes diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 2c42757036d..3d58b9100c8 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -78,6 +78,7 @@ export function dashboardSnapshotInputsChanged( // these two instead of worktreesByRepo. state.folderWorkspaces !== previousState.folderWorkspaces || state.projectGroups !== previousState.projectGroups || + state.sshTargetLabels !== previousState.sshTargetLabels || state.restoredRuntimeHostIdByWorkspaceSessionKey !== previousState.restoredRuntimeHostIdByWorkspaceSessionKey || state.runtimeEnvironments !== previousState.runtimeEnvironments || diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts index 2b6c35db8f0..d2481a9201e 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts @@ -161,4 +161,17 @@ describe('useLiveDashboardSnapshot', () => { rerender() expect(result.current.cards[0].terminalInput?.hostPlatform).toBe('win32') }) + + it('re-derives saved SSH labels when the target catalog changes', () => { + seed({ tabAutoGenerateTitle: false }) + useAppStore.setState({ + repos: [{ ...repo(), connectionId: 'target-1', executionHostId: 'ssh:target-1' }] + }) + const { result, rerender } = renderHook(() => useLiveDashboardSnapshot()) + expect(result.current.cards[0].hostLabel).toBe('target-1') + + useAppStore.setState({ sshTargetLabels: new Map([['target-1', 'Builder']]) }) + rerender() + expect(result.current.cards[0].hostLabel).toBe('Builder') + }) }) diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 9782f91c697..d3848178509 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -41,6 +41,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { // these two instead of worktreesByRepo. const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const projectGroups = useAppStore((s) => s.projectGroups) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) @@ -82,6 +83,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { detectedWorktreesByRepo, folderWorkspaces, projectGroups, + sshTargetLabels, sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId, @@ -120,6 +122,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { detectedWorktreesByRepo, folderWorkspaces, projectGroups, + sshTargetLabels, sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1db13836f7d..0be9970b6e1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15453,7 +15453,13 @@ "clear": "Clear search", "results": "{{shown}} of {{total}} shown" }, - "settingsLabel": "Agent Dashboard settings" + "settingsLabel": "Agent Dashboard settings", + "host": { + "sshNamed": "SSH host · {{host}}", + "ssh": "SSH host", + "remoteNamed": "Remote Orca host · {{host}}", + "remote": "Remote Orca host" + } }, "dashboard": { "sidebar": { diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 9367393118e..cd59983ae25 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -65,6 +65,8 @@ export type DashboardWorkspace = { parentWorktreeId?: string hostKind: DashboardCardHostKind executionHostId: ExecutionHostId + /** Friendly saved-host name for compact host tooltips. */ + hostLabel?: string workspaceKind: DashboardCardWorkspaceKind workspaceStatusId?: string workspaceStatusLabel?: string @@ -103,6 +105,8 @@ export type DashboardCard = { hostKind?: DashboardCardHostKind /** Exact owner used by in-window workspace actions when IDs collide across hosts. */ executionHostId?: ExecutionHostId + /** Friendly saved-host name for compact host tooltips. */ + hostLabel?: string /** Folder workspaces share the ring hierarchy without pretending to be git worktrees. */ workspaceKind?: DashboardCardWorkspaceKind workspaceStatusId?: string