Align worktree host labels across desktop and mobile (#18237)

* refactor: align worktree host labels across clients

* fix(mobile): expose safe host display labels

* fix(mobile): preserve legacy mixed-host labels

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-02 15:32:06 -07:00
committed by GitHub
co-authored by Merge Sim
parent 074a2366bf
commit 7f8eb90ac3
15 changed files with 618 additions and 26 deletions
+40 -1
View File
@@ -30,7 +30,9 @@ vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
GitBranch: 'GitBranch',
GitPullRequest: 'GitPullRequest'
GitPullRequest: 'GitPullRequest',
Monitor: 'Monitor',
Server: 'Server'
}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
@@ -225,4 +227,41 @@ describe('memoized worktree rows', () => {
workingMode: 'monitoring'
})
})
it('names the host with a glyph that matches the host kind', async () => {
const textNodes = (): string[] =>
renderer!.root
.findAllByType('Text' as never)
.flatMap((node) => node.props.children)
.filter((child): child is string => typeof child === 'string')
await act(async () => {
renderer = create(
createElement(ListRowHarness, {
item: { ...baseItem, hostId: 'ssh:ssh-1', hostContextLabel: 'openclaw' },
now: 2_000
})
)
})
expect(textNodes()).toContain('openclaw')
expect(renderer!.root.findAllByType('Server' as never)).toHaveLength(1)
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(0)
await act(async () =>
renderer!.update(
createElement(ListRowHarness, {
item: { ...baseItem, hostContextLabel: 'Local Mac' },
now: 2_000
})
)
)
expect(textNodes()).toContain('Local Mac')
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(1)
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
)
expect(textNodes()).not.toContain('Local Mac')
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(0)
})
})
+36 -1
View File
@@ -1,6 +1,15 @@
import { memo } from 'react'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import {
Bell,
ChevronDown,
ChevronRight,
GitBranch,
GitPullRequest,
Monitor,
Server
} from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { parseExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
@@ -22,6 +31,11 @@ function displayBranch(branch: string): string {
export type WorktreeListRowItem = {
workspaceKind?: 'git' | 'folder-workspace'
worktreeId: string
hostId?: ExecutionHostId
/** Present only when the list spans hosts; names the host this row runs on. */
hostContextLabel?: string
/** Resolved host for the display label; present when legacy rows omit hostId. */
hostContextHostId?: ExecutionHostId
repo: string
branch: string
displayName: string
@@ -150,6 +164,20 @@ function WorktreeListRowComponent<T extends WorktreeListRowItem>({
<Text style={styles.childBadgeText}>Child</Text>
</View>
)}
{item.hostContextLabel ? (
<View style={[styles.childBadge, styles.hostBadge]}>
{/* Rows from hosts that predate hostId stamping are local: a remote row always carries one. */}
{(parseExecutionHostId(item.hostContextHostId ?? item.hostId)?.kind ?? 'local') ===
'local' ? (
<Monitor size={10} color={colors.textMuted} />
) : (
<Server size={10} color={colors.textMuted} />
)}
<Text style={[styles.childBadgeText, styles.hostBadgeText]} numberOfLines={1}>
{item.hostContextLabel}
</Text>
</View>
) : null}
{/* Repo glyph+name only when not already grouped under this repo;
MobileRepoIcon falls back to a Folder (matching desktop's default)
rather than a bare colored dot. */}
@@ -306,6 +334,13 @@ const styles = StyleSheet.create({
fontSize: 10,
color: colors.textMuted
},
hostBadge: {
flexShrink: 1,
maxWidth: 140
},
hostBadgeText: {
flexShrink: 1
},
lineageToggle: {
alignSelf: 'flex-start',
flexDirection: 'row',
@@ -1,13 +1,54 @@
import { useCallback } from 'react'
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
import { setCachedRepos } from '../cache/repo-cache'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RepoSummary } from '../worktree/host-worktree-rpc-types'
import { repoColor } from '../worktree/repo-color'
import {
buildHostLabelById,
buildRepoHostIdByRepoId
} from '../worktree/worktree-host-context-labels'
import type { HostScreenState } from './use-host-screen-state'
const REPO_METADATA_REFRESH_MS = 60_000
type SshTargetSummaryRow = { id: string; label: string }
async function requestResult(client: RpcClient, method: string): Promise<unknown> {
try {
const response = await client.sendRequest(method)
return response.ok ? (response as RpcSuccess).result : null
} catch {
// Best-effort: hosts that predate a method still list repos; labels degrade to host ids.
return null
}
}
function readSshTargets(result: unknown): SshTargetSummaryRow[] {
const targets = (result as { targets?: unknown } | null)?.targets
if (!Array.isArray(targets)) {
return []
}
return targets.filter(
(target): target is SshTargetSummaryRow =>
typeof target === 'object' &&
target !== null &&
typeof (target as SshTargetSummaryRow).id === 'string' &&
typeof (target as SshTargetSummaryRow).label === 'string'
)
}
function readHostPlatform(result: unknown): NodeJS.Platform | null {
const platform = (result as { platform?: unknown } | null)?.platform
return typeof platform === 'string' && platform ? (platform as NodeJS.Platform) : null
}
function readHostSettingOverrides(result: unknown): unknown {
return (result as { settings?: { hostSettingOverrides?: unknown } } | null)?.settings
?.hostSettingOverrides
}
export function useHostRepoMetadata(args: {
client: RpcClient | null
connState: ConnectionState
@@ -20,7 +61,10 @@ export function useHostRepoMetadata(args: {
fetchRepoMetadataInFlightRef,
fetchRepoMetadataPendingRef,
repoMetadataFetchedAtRef,
setHostLabelById,
setHostPlatform,
setRepoColorsByName,
setRepoHostIdByRepoId,
setRepoIconsByName,
setRepoIdsByName
} = state
@@ -69,6 +113,28 @@ export function useHostRepoMetadata(args: {
)
)
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
setRepoHostIdByRepoId(buildRepoHostIdByRepoId(repoResult.repos))
// Why: rows only name their host when the list spans hosts, so a single-host
// catalog never pays for the label lookups. Counted over repos, not the id-keyed
// map: one repo id registered on two hosts is two hosts.
const hostIds = new Set(repoResult.repos.map((repo) => getRepoExecutionHostId(repo)))
if (hostIds.size > 1) {
const [sshTargets, hostSettings, hostPlatform] = await Promise.all([
requestResult(requestClient, 'ssh.listTargetSummaries'),
requestResult(requestClient, 'settings.get'),
requestResult(requestClient, 'host.platform')
])
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
}
setHostLabelById(
buildHostLabelById({
sshTargets: readSshTargets(sshTargets),
hostSettingOverrides: readHostSettingOverrides(hostSettings)
})
)
setHostPlatform(readHostPlatform(hostPlatform))
}
} while (fetchRepoMetadataPendingRef.current.has(requestClient))
} catch {
// Repo metadata is decorative; the next refresh can retry.
@@ -14,6 +14,7 @@ import {
useRelayRecoveryStatus
} from '../transport/client-context-connection-metrics'
import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity'
import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels'
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
import { useHostRepoMetadata } from './use-host-repo-metadata'
import { useHostScreenIdentity } from './use-host-screen-identity'
@@ -95,17 +96,23 @@ export function useHostScreenController({
// Why: live `worktrees` is authoritative only while connected; under the amber
// mount default, connecting/handshaking must keep the pre-reconnect list too.
const base = connState === 'connected' ? state.worktrees : state.lastKnownWorktrees
return applyWorktreeRowDisplayState(
base,
state.sleptIds,
state.optimisticActiveWorktreeIdentity
return applyWorktreeHostContextLabels(
applyWorktreeRowDisplayState(base, state.sleptIds, state.optimisticActiveWorktreeIdentity),
{
repoHostIdByRepoId: state.repoHostIdByRepoId,
hostLabelById: state.hostLabelById,
hostPlatform: state.hostPlatform
}
)
}, [
connState,
state.worktrees,
state.lastKnownWorktrees,
state.sleptIds,
state.optimisticActiveWorktreeIdentity
state.optimisticActiveWorktreeIdentity,
state.repoHostIdByRepoId,
state.hostLabelById,
state.hostPlatform
])
const sectionsResult = useWorkspaceSections({
displayWorktrees,
@@ -17,10 +17,13 @@ export function useHostScreenIdentity(args: {
repoMetadataFetchedAtRef,
setCatalogError,
setError,
setHostLabelById,
setHostName,
setHostPlatform,
setLastKnownWorktrees,
setPinnedIds,
setRepoColorsByName,
setRepoHostIdByRepoId,
setRepoIconsByName,
setWorktrees,
setWorktreesLoaded
@@ -54,6 +57,9 @@ export function useHostScreenIdentity(args: {
setError('')
setRepoColorsByName(new Map())
setRepoIconsByName(new Map())
setRepoHostIdByRepoId(new Map())
setHostLabelById(new Map())
setHostPlatform(null)
repoMetadataFetchedAtRef.current = 0
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
@@ -1,4 +1,5 @@
import { useRef, useState } from 'react'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types'
import { getCachedWorktrees } from '../cache/worktree-cache'
@@ -56,6 +57,12 @@ export function useHostScreenState(hostId: string | undefined, action: string |
)
// displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
const [repoIdsByName, setRepoIdsByName] = useState<Map<string, string>>(new Map())
// Host-label inputs for rows: repo → host, SSH/override labels, and the host's own platform.
const [repoHostIdByRepoId, setRepoHostIdByRepoId] = useState<Map<string, ExecutionHostId>>(
new Map()
)
const [hostLabelById, setHostLabelById] = useState<Map<ExecutionHostId, string>>(new Map())
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
const [showSortPicker, setShowSortPicker] = useState(false)
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
@@ -93,13 +100,16 @@ export function useHostScreenState(hostId: string | undefined, action: string |
fetchWorktreesInFlightRef,
filters,
groupMode,
hostLabelById,
hostName,
hostPlatform,
lastKnownWorktrees,
newWorktreeModalRef,
newWorktreeModalVisibleRef,
optimisticActiveWorktreeIdentity,
pinnedIds,
repoColorsByName,
repoHostIdByRepoId,
repoIconsByName,
repoIdsByName,
repoMetadataFetchedAtRef,
@@ -113,11 +123,14 @@ export function useHostScreenState(hostId: string | undefined, action: string |
setError,
setFilters,
setGroupMode,
setHostLabelById,
setHostName,
setHostPlatform,
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setRepoColorsByName,
setRepoHostIdByRepoId,
setRepoIconsByName,
setRepoIdsByName,
setRouteActionState,
@@ -9,6 +9,10 @@ export type Worktree = {
repoId: string
hostId?: ExecutionHostId
terminalPlatform?: NodeJS.Platform
/** Display-only; set when the list spans hosts, so rows say which host they run on. */
hostContextLabel?: string
/** Resolved host for the display label; present when legacy rows omit hostId. */
hostContextHostId?: ExecutionHostId
repo: string
branch: string
displayName: string
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest'
import type { Worktree } from './workspace-list-types'
import {
applyWorktreeHostContextLabels,
buildHostLabelById,
buildRepoHostIdByRepoId,
getWorktreeHostContextLabels,
resolveWorktreeHostId
} from './worktree-host-context-labels'
function worktree(overrides: Partial<Worktree> = {}): Worktree {
return {
workspaceKind: 'git',
worktreeId: 'repo-1::/home/me/orca',
repoId: 'repo-1',
repo: 'orca',
branch: 'main',
displayName: 'main',
path: '/home/me/orca',
liveTerminalCount: 0,
hasAttachedPty: false,
preview: '',
unread: false,
isPinned: false,
linkedPR: null,
...overrides
}
}
const sshHostId = 'ssh:ssh-1785104650217-eduhep' as const
describe('buildHostLabelById', () => {
it('labels SSH targets by their registered label and lets a display override win', () => {
const labels = buildHostLabelById({
sshTargets: [
{ id: 'ssh-1785104650217-eduhep', label: 'openclaw' },
{ id: 'ssh-blank', label: ' ' }
],
hostSettingOverrides: { [sshHostId]: { displayLabel: 'openclaw (renamed)' } }
})
expect(labels.get(sshHostId)).toBe('openclaw (renamed)')
expect(labels.has('ssh:ssh-blank')).toBe(false)
})
it('normalizes legacy raw SSH ids used by persisted display overrides', () => {
const labels = buildHostLabelById({
sshTargets: [],
hostSettingOverrides: { 'ssh-1785104650217-eduhep': { displayLabel: 'openclaw' } }
})
expect(labels.get(sshHostId)).toBe('openclaw')
})
it('accepts canonical SSH host ids from newer target-summary payloads', () => {
const labels = buildHostLabelById({
sshTargets: [{ id: sshHostId, label: 'openclaw' }],
hostSettingOverrides: undefined
})
expect(labels.get(sshHostId)).toBe('openclaw')
expect(labels.has('ssh:ssh:ssh-1785104650217-eduhep')).toBe(false)
})
it('tolerates a malformed settings payload', () => {
expect(buildHostLabelById({ sshTargets: [], hostSettingOverrides: 'nope' }).size).toBe(0)
expect(buildHostLabelById({ sshTargets: [], hostSettingOverrides: undefined }).size).toBe(0)
})
})
describe('resolveWorktreeHostId', () => {
it('prefers the row host, then the repo host, then local', () => {
const repoHosts = buildRepoHostIdByRepoId([
{ id: 'repo-1', connectionId: 'ssh-1785104650217-eduhep' },
{ id: 'repo-2', executionHostId: 'runtime:env-1' },
{ id: 'repo-3' }
])
expect(resolveWorktreeHostId(worktree({ hostId: 'local', repoId: 'repo-1' }), repoHosts)).toBe(
'local'
)
expect(resolveWorktreeHostId(worktree({ repoId: 'repo-1' }), repoHosts)).toBe(sshHostId)
expect(resolveWorktreeHostId(worktree({ repoId: 'repo-2' }), repoHosts)).toBe('runtime:env-1')
expect(resolveWorktreeHostId(worktree({ repoId: 'repo-3' }), repoHosts)).toBe('local')
expect(resolveWorktreeHostId(worktree({ repoId: 'unknown' }), repoHosts)).toBe('local')
})
})
describe('getWorktreeHostContextLabels', () => {
const sources = {
repoHostIdByRepoId: new Map(),
hostLabelById: new Map([[sshHostId, 'openclaw']]),
hostPlatform: 'darwin' as const
}
it('returns nothing for a single-host list', () => {
const rows = [worktree({ hostId: 'local' }), worktree({ hostId: 'local', worktreeId: 'b' })]
expect(getWorktreeHostContextLabels(rows, sources)).toBeUndefined()
expect(applyWorktreeHostContextLabels(rows, sources)).toBe(rows)
})
it('names every row by host once the list spans hosts', () => {
const rows = [
worktree({ hostId: 'local', worktreeId: 'a' }),
worktree({ hostId: sshHostId, worktreeId: 'b' }),
worktree({ hostId: 'ssh:unlabeled', worktreeId: 'c' }),
worktree({ hostId: 'runtime:env-1', worktreeId: 'd' })
]
const labeled = applyWorktreeHostContextLabels(rows, sources)
expect(labeled.map((row) => row.hostContextLabel)).toEqual([
'Local Mac',
'openclaw',
'unlabeled',
'env-1'
])
})
it('names the local host from the paired host platform, not the phone', () => {
const rows = [
worktree({ hostId: 'local', worktreeId: 'a' }),
worktree({ hostId: sshHostId, worktreeId: 'b' })
]
const linux = applyWorktreeHostContextLabels(rows, { ...sources, hostPlatform: 'linux' })
expect(linux[0].hostContextLabel).toBe('Local Linux')
const unknown = applyWorktreeHostContextLabels(rows, { ...sources, hostPlatform: null })
expect(unknown[0].hostContextLabel).toBe('This computer')
})
it('keys labels by host-qualified identity so a shared id on two hosts gets two labels', () => {
const rows = [
worktree({ hostId: 'local', worktreeId: 'same' }),
worktree({ hostId: sshHostId, worktreeId: 'same' })
]
const labeled = applyWorktreeHostContextLabels(rows, sources)
expect(labeled.map((row) => row.hostContextLabel)).toEqual(['Local Mac', 'openclaw'])
})
it('falls back to the repo host for rows from hosts that predate hostId stamping', () => {
const rows = [
worktree({ repoId: 'repo-local', worktreeId: 'a' }),
worktree({ repoId: 'repo-ssh', worktreeId: 'b' })
]
const labeled = applyWorktreeHostContextLabels(rows, {
...sources,
repoHostIdByRepoId: buildRepoHostIdByRepoId([
{ id: 'repo-local' },
{ id: 'repo-ssh', connectionId: 'ssh-1785104650217-eduhep' }
])
})
expect(labeled.map((row) => row.hostContextLabel)).toEqual(['Local Mac', 'openclaw'])
expect(labeled.map((row) => row.hostContextHostId)).toEqual(['local', sshHostId])
})
it('keeps labels distinct when legacy rows reuse an id across hosts', () => {
const rows = [
worktree({ repoId: 'repo-local', worktreeId: 'same' }),
worktree({ repoId: 'repo-ssh', worktreeId: 'same' })
]
const labeled = applyWorktreeHostContextLabels(rows, {
...sources,
repoHostIdByRepoId: buildRepoHostIdByRepoId([
{ id: 'repo-local' },
{ id: 'repo-ssh', connectionId: 'ssh-1785104650217-eduhep' }
])
})
expect(labeled.map((row) => row.hostContextLabel)).toEqual(['Local Mac', 'openclaw'])
})
})
@@ -0,0 +1,92 @@
import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
normalizeExecutionHostId,
type ExecutionHostId
} from '../../../src/shared/execution-host'
import { getMixedHostContextLabels as getSharedMixedHostContextLabels } from '../../../src/shared/worktree/host-context-labels'
import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity'
export {
buildHostLabelById,
getHostContextLabel
} from '../../../src/shared/worktree/host-context-labels'
import type { RepoSummary } from './host-worktree-rpc-types'
import type { Worktree } from './workspace-list-types'
export type HostLabelSources = {
/** Host id per repo id from repo.list; rows from hosts that predate `hostId` fall back to it. */
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
/** User-facing labels for non-local hosts: SSH target labels, then per-host display overrides. */
hostLabelById: ReadonlyMap<ExecutionHostId, string>
/** The paired host's own platform; the phone's platform must never name the desktop. */
hostPlatform: NodeJS.Platform | null
}
export function buildRepoHostIdByRepoId(
repos: readonly Pick<RepoSummary, 'id' | 'connectionId' | 'executionHostId'>[]
): Map<string, ExecutionHostId> {
return new Map(repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)]))
}
export function resolveWorktreeHostId(
worktree: Pick<Worktree, 'hostId' | 'repoId'>,
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
): ExecutionHostId {
return (
normalizeExecutionHostId(worktree.hostId) ??
repoHostIdByRepoId.get(worktree.repoId) ??
LOCAL_EXECUTION_HOST_ID
)
}
function getResolvedWorktreeRowIdentity(
worktree: Pick<Worktree, 'worktreeId' | 'hostId' | 'repoId'>,
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
): string {
return composeWorktreeHostIdentity(
resolveWorktreeHostId(worktree, repoHostIdByRepoId),
worktree.worktreeId
)
}
// Kept as a local adapter so existing mobile imports remain stable.
/**
* Host label per row identity, only when the list spans more than one host — a single-host
* list gains nothing from a badge on every row. Mirrors the desktop sidebar's mixed-host rule.
*/
export function getWorktreeHostContextLabels(
worktrees: readonly Worktree[],
sources: HostLabelSources
): Map<string, string> | undefined {
return getSharedMixedHostContextLabels(worktrees, {
getHostId: (worktree) => resolveWorktreeHostId(worktree, sources.repoHostIdByRepoId),
// Legacy hosts omit row.hostId; key by the resolved repo owner so duplicate
// worktree ids from different hosts do not overwrite each other's label.
getIdentity: (worktree) => getResolvedWorktreeRowIdentity(worktree, sources.repoHostIdByRepoId),
sources
})
}
export function applyWorktreeHostContextLabels(
worktrees: Worktree[],
sources: HostLabelSources
): Worktree[] {
const labels = getWorktreeHostContextLabels(worktrees, sources)
if (!labels) {
return worktrees
}
return worktrees.map((worktree) => {
const hostContextLabel = labels.get(
getResolvedWorktreeRowIdentity(worktree, sources.repoHostIdByRepoId)
)
if (!hostContextLabel) {
return worktree
}
return {
...worktree,
hostContextLabel,
hostContextHostId: resolveWorktreeHostId(worktree, sources.repoHostIdByRepoId)
}
})
}
@@ -23,6 +23,9 @@ describe('OrcaRuntimeService', () => {
...store,
getSettings: () => ({
...store.getSettings(),
hostSettingOverrides: {
'ssh:target-1': { displayLabel: 'Build host', defaultWorktreeLocation: '/srv/worktrees' }
},
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
@@ -39,6 +42,9 @@ describe('OrcaRuntimeService', () => {
minimaxUsageModels: 'general,abab6.5'
})
expect(runtime.getClientSettings()).not.toHaveProperty('terminalQuickCommands')
expect(runtime.getClientSettings().hostSettingOverrides).toEqual({
'ssh:target-1': { displayLabel: 'Build host' }
})
expect(runtime.getClientTerminalQuickCommands()).toEqual(terminalQuickCommands)
})
+15 -1
View File
@@ -10,6 +10,8 @@ import {
} from '../../shared/terminal-quick-commands'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
@@ -37,6 +39,13 @@ export type RuntimeClientSettings = Pick<
| 'artifactSharingEnabled'
| 'worktreeVisibilityDefaults'
| 'agentSkillSharingEnabled'
> & {
hostSettingOverrides: RuntimeHostDisplayLabelOverrides
}
/** Safe paired projection: host labels only; filesystem defaults stay host-private. */
export type RuntimeHostDisplayLabelOverrides = Partial<
Record<ExecutionHostId, { displayLabel: string }>
>
export type RuntimeClientSettingsUpdate = Pick<
@@ -94,7 +103,12 @@ export class RuntimeClientSettingsController {
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [],
artifactSharingEnabled: isArtifactSharingEnabled(settings),
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings)
agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings),
hostSettingOverrides: Object.fromEntries(
[
...getHostDisplayLabelOverrides({ hostSettingOverrides: settings.hostSettingOverrides })
].map(([hostId, displayLabel]) => [hostId, { displayLabel }])
) as RuntimeHostDisplayLabelOverrides
}
}
@@ -112,6 +112,7 @@ export type RuntimeStore = {
terminalHiddenDeliveryGate?: GlobalSettings['terminalHiddenDeliveryGate']
terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority']
worktreeVisibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
hostSettingOverrides?: GlobalSettings['hostSettingOverrides']
agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled']
}
// Why: narrow to `unknown` return so test mocks can return void without
@@ -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
}