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