refactor: align worktree host labels across clients

This commit is contained in:
Merge Sim
2026-09-02 13:00:37 -07:00
parent 8dc3c1dd97
commit 6416b81912
12 changed files with 567 additions and 25 deletions
@@ -155,6 +155,53 @@ describe('buildRows with pinned worktrees', () => {
])
})
it('uses the registered SSH target label for openclaw rows', () => {
const sshRepo: Repo = {
...remoteRepo,
id: 'repo-openclaw',
connectionId: 'openclaw',
executionHostId: 'ssh:openclaw'
}
const sshWorktree: Worktree = {
...remoteWorktree,
id: 'wt-openclaw',
repoId: sshRepo.id
}
const rows = buildRows(
'workspace-status',
[worktree, sshWorktree],
new Map([
[repo.id, repo],
[sshRepo.id, sshRepo]
]),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([
[worktree.id, worktree],
[sshWorktree.id, sshWorktree]
]),
false,
undefined,
[],
new Set(),
new Map(),
new Map(),
[],
undefined,
[],
new Map([['ssh:openclaw', 'openclaw']])
)
expect(rows.filter((row) => row.type === 'item')).toMatchObject([
{ worktree: { id: worktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
{ worktree: { id: sshWorktree.id }, hostContextLabel: 'openclaw' }
])
})
it('shows distinct Orca server names when status grouping mixes runtime hosts', () => {
const firstRepo: Repo = {
...repo,
@@ -1,11 +1,14 @@
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import {
getExecutionHostLabel,
getRepoExecutionHostId,
getWorktreeExecutionHostId
} from '../../../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import {
getHostContextLabel,
getMixedHostContextLabels as getSharedMixedHostContextLabels
} from '../../../../../../shared/worktree/host-context-labels'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import {
getProjectGroupingForRepo,
@@ -15,7 +18,7 @@ import {
import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id'
import type { RenderableFolderWorkspace } from './folder-workspace-lanes'
function getRepoHostId(repoId: string, repoMap: Map<string, Repo>): string | null {
function getRepoHostId(repoId: string, repoMap: Map<string, Repo>): ExecutionHostId | null {
const repo = repoMap.get(repoId)
return repo ? getRepoExecutionHostId(repo) : null
}
@@ -28,14 +31,14 @@ function getRepoHostLabel(
): string | null {
const setup = projectIndex?.setupByRepoId.get(repoId)
if (setup) {
return hostLabelById?.get(setup.hostId) ?? getExecutionHostLabel(setup.hostId)
return getHostContextLabel(setup.hostId, { hostLabelById })
}
const repo = repoMap.get(repoId)
if (!repo) {
return null
}
const hostId = getRepoExecutionHostId(repo)
return hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId)
return getHostContextLabel(hostId, { hostLabelById })
}
export function getMixedHostContextLabels(
@@ -45,16 +48,22 @@ export function getMixedHostContextLabels(
hostLabelById: ReadonlyMap<string, string> | undefined
): Map<string, string> | undefined {
const labelsByRepoId = new Map<string, string>()
const uniqueLabels = new Set<string>()
// Host identity, not the rendered label, determines whether rows are ambiguous:
// two hosts can intentionally share a user-facing label.
const uniqueHostIds = new Set<ExecutionHostId>()
for (const repoId of group.repoIds) {
const label = getRepoHostLabel(repoId, repoMap, projectIndex, hostLabelById)
if (!label) {
continue
}
labelsByRepoId.set(repoId, label)
uniqueLabels.add(label)
const setup = projectIndex?.setupByRepoId.get(repoId)
const hostId = setup?.hostId ?? getRepoHostId(repoId, repoMap)
if (hostId) {
uniqueHostIds.add(hostId)
}
}
return uniqueLabels.size > 1 ? labelsByRepoId : undefined
return uniqueHostIds.size > 1 ? labelsByRepoId : undefined
}
/**
@@ -128,17 +137,12 @@ export function getMixedWorktreeHostContextLabels(
hostLabelById: ReadonlyMap<string, string> | undefined,
defaultHostId: ExecutionHostId
): Map<string, string> | undefined {
const labelsByIdentity = new Map<string, string>()
const uniqueHostIds = new Set<ExecutionHostId>()
for (const worktree of worktrees) {
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
uniqueHostIds.add(hostId)
labelsByIdentity.set(
getWorktreeHostIdentity(worktree),
hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId)
)
}
return uniqueHostIds.size > 1 ? labelsByIdentity : undefined
return getSharedMixedHostContextLabels(worktrees, {
getHostId: (worktree) =>
getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId),
getIdentity: getWorktreeHostIdentity,
sources: { hostLabelById }
})
}
export function getHostWorktreeCounts(
@@ -0,0 +1,94 @@
import {
getExecutionHostLabel,
getLocalExecutionHostLabel,
normalizeExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../execution-host'
import type { GlobalSettings } from '../global-settings-types'
import { getHostDisplayLabelOverrides } from '../host-setting-overrides'
/** Inputs used by every client when spelling a host in a workspace row. */
export type HostContextLabelSources = {
/** Explicit labels (SSH target names and per-host display overrides). */
hostLabelById?: ReadonlyMap<string, string>
/** The execution host's platform; clients must not use the device platform. */
hostPlatform?: NodeJS.Platform | null
}
/** Canonical user-facing host label used by desktop and mobile workspace rows. */
export function getHostContextLabel(
hostId: ExecutionHostId,
sources: HostContextLabelSources = {}
): string {
const override = sources.hostLabelById?.get(hostId)?.trim()
if (override) {
return override
}
if (parseExecutionHostId(hostId)?.kind === 'local') {
// An explicit null means the paired host platform is unknown (mobile); an
// omitted platform means use the current process (desktop).
if (sources.hostPlatform === null) {
return 'This computer'
}
return sources.hostPlatform !== undefined
? getLocalExecutionHostLabel(sources.hostPlatform)
: getExecutionHostLabel(hostId)
}
return getExecutionHostLabel(hostId)
}
/**
* Build labels for SSH targets and apply persisted display-name overrides.
* Target summaries have appeared both as raw target ids and canonical `ssh:` ids
* across protocol versions, so accept either representation.
*/
export function buildHostLabelById(args: {
sshTargets: readonly { id: string; label: string }[]
hostSettingOverrides: unknown
}): Map<ExecutionHostId, string> {
const labels = new Map<ExecutionHostId, string>()
for (const target of args.sshTargets) {
const label = target.label.trim()
if (!target.id.trim() || !label) {
continue
}
const hostId = normalizeExecutionHostId(target.id) ?? toSshExecutionHostId(target.id)
if (hostId) {
labels.set(hostId, label)
}
}
const overrides =
args.hostSettingOverrides && typeof args.hostSettingOverrides === 'object'
? getHostDisplayLabelOverrides({
hostSettingOverrides: args.hostSettingOverrides as GlobalSettings['hostSettingOverrides']
})
: new Map<ExecutionHostId, string>()
for (const [hostId, label] of overrides) {
const normalized = normalizeExecutionHostId(hostId) ?? toSshExecutionHostId(hostId)
if (normalized) {
labels.set(normalized, label)
}
}
return labels
}
/** Generic mixed-host projection shared by desktop grouping and mobile sections. */
export function getMixedHostContextLabels<T>(
items: readonly T[],
args: {
getHostId: (item: T) => ExecutionHostId
getIdentity: (item: T) => string
sources?: HostContextLabelSources
}
): Map<string, string> | undefined {
const labelsByIdentity = new Map<string, string>()
const hostIds = new Set<ExecutionHostId>()
for (const item of items) {
const hostId = args.getHostId(item)
hostIds.add(hostId)
labelsByIdentity.set(args.getIdentity(item), getHostContextLabel(hostId, args.sources))
}
return hostIds.size > 1 ? labelsByIdentity : undefined
}