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 beb3de5edeb..bfb9c35446f 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 @@ -149,4 +149,25 @@ describe('buildDashboardSnapshot folder workspaces', () => { 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 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 260668c2e47..b37d867c4f3 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-card-host-metadata', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('./dashboard-snapshot-workspaces', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - resolveDashboardCardHostMetadata: ( - ...args: Parameters + dashboardCardMapWorkspaceMetadata: ( + ...args: Parameters ) => { mapMetadataCalls.hostKind() - return actual.resolveDashboardCardHostMetadata(...args) + return actual.dashboardCardMapWorkspaceMetadata(...args) } } }) @@ -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 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 cc319bc451c..53e959f489c 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -39,8 +39,10 @@ import { resolveDashboardCardContext, type DashboardCardContextState } from './dashboard-card-context' -import { collectActiveDashboardWorkspaces } from './dashboard-snapshot-workspaces' -import { resolveDashboardCardHostMetadata } from './dashboard-card-host-metadata' +import { + collectActiveDashboardWorkspaces, + dashboardCardMapWorkspaceMetadata +} from './dashboard-snapshot-workspaces' import { boundedLabel, boundedLabelOrUndefined, @@ -187,8 +189,7 @@ export function buildDashboardSnapshot( ? resolveDashboardCardContext(state, repo, worktree) : undefined if (workspaces && workspaces.length < DASHBOARD_MAX_MAP_WORKSPACES) { - const hostMetadata = resolveDashboardCardHostMetadata( - state, + const hostMetadata = dashboardCardMapWorkspaceMetadata( workspace, null, undefined, @@ -256,8 +257,7 @@ export function buildDashboardSnapshot( : null const finishedAt = lastEnteredDoneAt(row) const hostMetadata = includeCardDetails - ? resolveDashboardCardHostMetadata( - state, + ? dashboardCardMapWorkspaceMetadata( workspace, ptyId, terminalInput ?? undefined, diff --git a/src/renderer/src/components/dashboard/dashboard-card-host-metadata.ts b/src/renderer/src/components/dashboard/dashboard-card-host-metadata.ts deleted file mode 100644 index d01663b7f6e..00000000000 --- a/src/renderer/src/components/dashboard/dashboard-card-host-metadata.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 237c246f688..fcb34581414 100644 --- a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts @@ -1,17 +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, 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' @@ -23,28 +27,31 @@ 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' + > + > -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 +function buildHostLabelLookup( + state: DashboardWorkspaceState +): ReadonlyMap { + const labels = new Map() + for (const [targetId, label] of state.sshTargetLabels ?? []) { + labels.set(toSshExecutionHostId(targetId), label) } - if (parsed?.kind === 'runtime') { - return ( - state.runtimeEnvironments?.find((environment) => environment.id === parsed.environmentId) - ?.name ?? parsed.environmentId - ) + for (const environment of state.runtimeEnvironments ?? []) { + labels.set(toRuntimeExecutionHostId(environment.id), environment.name) } - return undefined + for (const [hostId, label] of getHostDisplayLabelOverrides(state.settings)) { + labels.set(hostId, label) + } + return labels } function remoteHostKind( @@ -63,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] ?? []) { @@ -70,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, @@ -79,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 } : {}) }) } } @@ -93,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, @@ -105,7 +133,8 @@ export function collectActiveDashboardWorkspaces( folderWorkspace.connectionId ?? projectGroup?.connectionId, worktree.hostId ?? projectGroup?.executionHostId ) - : null + : null, + ...(workspaceHostLabel ? { hostLabel: workspaceHostLabel } : {}) }) } return workspaces @@ -138,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,