From 330a20dcab3bf3ef6804ed14d23eae43a1ff885f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:57:24 -0700 Subject: [PATCH] feat(dashboard): identify SSH and remote hosts --- .../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 | 55 +++++++++++++ .../dashboard-popout/DashboardHostBadge.tsx | 82 +++++++++++++++++++ .../agent-map-layout-metadata.ts | 21 ++++- .../dashboard-popout/agent-map-layout.test.ts | 19 +++++ .../dashboard-popout/agent-map-layout.ts | 10 ++- .../agent-map-worktree-host.ts | 25 ++++++ ...ashboard-snapshot-folder-workspace.test.ts | 14 +++- .../build-dashboard-snapshot.test.ts | 12 +-- .../dashboard/build-dashboard-snapshot.ts | 37 ++++++--- .../dashboard/dashboard-card-host-metadata.ts | 29 +++++++ .../dashboard-snapshot-workspaces.ts | 25 +++++- src/renderer/src/i18n/locales/en.json | 8 +- src/shared/dashboard-snapshot.ts | 4 + 20 files changed, 426 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 create mode 100644 src/renderer/src/components/dashboard/dashboard-card-host-metadata.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..b02d4ad7218 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx @@ -0,0 +1,55 @@ +// @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( + + + + ) + + expect(screen.getByLabelText('Remote Orca host · Build Mac')).toHaveAttribute( + 'data-dashboard-host-badge', + 'remote' + ) + + 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..43c8d9bc80c --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/DashboardHostBadge.tsx @@ -0,0 +1,82 @@ +import { RadioTower, 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 + } + const Icon = hostKind === 'ssh' ? Server : RadioTower + 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..68c2ae9d798 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) => 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..beb3de5edeb 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,14 @@ 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 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') }) }) 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..260668c2e47 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -9,23 +9,23 @@ import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapsho import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab, Worktree } from '../../../../shared/types' import { selectRuntimeAgentOrchestrationBatch } from '../sidebar/worktree-agent-orchestration-batch' -import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces' import type * as AgentRowLineageModule from './agent-row-lineage' +import type * as DashboardCardHostMetadataModule from './dashboard-card-host-metadata' const mapMetadataCalls = vi.hoisted(() => ({ hostKind: vi.fn(), parentPaneKey: vi.fn() })) -vi.mock('./dashboard-snapshot-workspaces', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('./dashboard-card-host-metadata', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - dashboardCardMapWorkspaceMetadata: ( - ...args: Parameters + resolveDashboardCardHostMetadata: ( + ...args: Parameters ) => { mapMetadataCalls.hostKind() - return actual.dashboardCardMapWorkspaceMetadata(...args) + return actual.resolveDashboardCardHostMetadata(...args) } } }) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 808ffba0236..cc319bc451c 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -39,10 +39,8 @@ import { resolveDashboardCardContext, type DashboardCardContextState } from './dashboard-card-context' -import { - dashboardCardMapWorkspaceMetadata, - collectActiveDashboardWorkspaces -} from './dashboard-snapshot-workspaces' +import { collectActiveDashboardWorkspaces } from './dashboard-snapshot-workspaces' +import { resolveDashboardCardHostMetadata } from './dashboard-card-host-metadata' import { boundedLabel, boundedLabelOrUndefined, @@ -75,7 +73,11 @@ export type DashboardSnapshotState = Pick< | 'settings' > & DashboardCardContextState & - Partial + Partial< + DashboardCardTerminalInputState & + DashboardLaunchDetectionState & + Pick + > /** * Derive the serializable dashboard snapshot from the live renderer store. @@ -185,13 +187,20 @@ export function buildDashboardSnapshot( ? resolveDashboardCardContext(state, repo, worktree) : undefined if (workspaces && workspaces.length < DASHBOARD_MAX_MAP_WORKSPACES) { + const hostMetadata = resolveDashboardCardHostMetadata( + state, + 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 +255,15 @@ export function buildDashboardSnapshot( }) : null const finishedAt = lastEnteredDoneAt(row) + const hostMetadata = includeCardDetails + ? resolveDashboardCardHostMetadata( + state, + 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-card-host-metadata.ts b/src/renderer/src/components/dashboard/dashboard-card-host-metadata.ts new file mode 100644 index 00000000000..d01663b7f6e --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-card-host-metadata.ts @@ -0,0 +1,29 @@ +import type { AppState } from '@/store/types' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { ActiveDashboardWorkspace } from './dashboard-snapshot-workspaces' +import { + dashboardCardMapWorkspaceMetadata, + dashboardExecutionHostLabel +} from './dashboard-snapshot-workspaces' +import { boundedLabelOrUndefined } from './dashboard-card-labels' + +type DashboardHostMetadataState = Partial> + +export function resolveDashboardCardHostMetadata( + state: DashboardHostMetadataState, + workspace: ActiveDashboardWorkspace, + ptyId: string | null, + terminalInput: DashboardCard['terminalInput'], + clientPlatform: NodeJS.Platform +): ReturnType & { hostLabel?: string } { + const metadata = dashboardCardMapWorkspaceMetadata( + workspace, + ptyId, + terminalInput, + clientPlatform + ) + const hostLabel = boundedLabelOrUndefined( + dashboardExecutionHostLabel(state, metadata.executionHostId) + ) + return { ...metadata, ...(hostLabel ? { hostLabel } : {}) } +} diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts index a83619511f8..237c246f688 100644 --- a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts @@ -6,7 +6,11 @@ import type { DashboardCardWorkspaceKind } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + getWorktreeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import { isFolderRepo } from '../../../../shared/repo-kind' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' @@ -24,6 +28,25 @@ export type ActiveDashboardWorkspace = { type DashboardWorkspaceState = Pick & Partial> +type DashboardHostLabelState = Partial> + +export function dashboardExecutionHostLabel( + state: DashboardHostLabelState, + executionHostId: ExecutionHostId +): string | undefined { + const parsed = parseExecutionHostId(executionHostId) + if (parsed?.kind === 'ssh') { + return state.sshTargetLabels?.get(parsed.targetId) ?? parsed.targetId + } + if (parsed?.kind === 'runtime') { + return ( + state.runtimeEnvironments?.find((environment) => environment.id === parsed.environmentId) + ?.name ?? parsed.environmentId + ) + } + return undefined +} + function remoteHostKind( connectionId: string | null | undefined, executionHostId: string | null | undefined diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 921f3ab76c5..97865f29d4b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15418,7 +15418,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