diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts index 01e0128cdc1..7b6d222d29e 100644 --- a/mobile/src/components/WorktreeListRow.test.ts +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -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) + }) }) diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index ba1c3865fc7..1de862e630c 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -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({ Child )} + {item.hostContextLabel ? ( + + {/* Rows from hosts that predate hostId stamping are local: a remote row always carries one. */} + {(parseExecutionHostId(item.hostContextHostId ?? item.hostId)?.kind ?? 'local') === + 'local' ? ( + + ) : ( + + )} + + {item.hostContextLabel} + + + ) : 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', diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts index a5367a971ad..198efbaf3ea 100644 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ b/mobile/src/host-screen/use-host-repo-metadata.ts @@ -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 { + 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. diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index 347fd36a72e..ad2bf9a5d77 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -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, diff --git a/mobile/src/host-screen/use-host-screen-identity.ts b/mobile/src/host-screen/use-host-screen-identity.ts index c0b09f61714..0a45502090f 100644 --- a/mobile/src/host-screen/use-host-screen-identity.ts +++ b/mobile/src/host-screen/use-host-screen-identity.ts @@ -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 diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts index b27e15ffb91..ca6bd0d85e7 100644 --- a/mobile/src/host-screen/use-host-screen-state.ts +++ b/mobile/src/host-screen/use-host-screen-state.ts @@ -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>(new Map()) + // Host-label inputs for rows: repo → host, SSH/override labels, and the host's own platform. + const [repoHostIdByRepoId, setRepoHostIdByRepoId] = useState>( + new Map() + ) + const [hostLabelById, setHostLabelById] = useState>(new Map()) + const [hostPlatform, setHostPlatform] = useState(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, diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index afa937f8baa..255e429e892 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -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 diff --git a/mobile/src/worktree/worktree-host-context-labels.test.ts b/mobile/src/worktree/worktree-host-context-labels.test.ts new file mode 100644 index 00000000000..a921ceebb90 --- /dev/null +++ b/mobile/src/worktree/worktree-host-context-labels.test.ts @@ -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 { + 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']) + }) +}) diff --git a/mobile/src/worktree/worktree-host-context-labels.ts b/mobile/src/worktree/worktree-host-context-labels.ts new file mode 100644 index 00000000000..de33c62ca5d --- /dev/null +++ b/mobile/src/worktree/worktree-host-context-labels.ts @@ -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 + /** User-facing labels for non-local hosts: SSH target labels, then per-host display overrides. */ + hostLabelById: ReadonlyMap + /** 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[] +): Map { + return new Map(repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)])) +} + +export function resolveWorktreeHostId( + worktree: Pick, + repoHostIdByRepoId: ReadonlyMap +): ExecutionHostId { + return ( + normalizeExecutionHostId(worktree.hostId) ?? + repoHostIdByRepoId.get(worktree.repoId) ?? + LOCAL_EXECUTION_HOST_ID + ) +} + +function getResolvedWorktreeRowIdentity( + worktree: Pick, + repoHostIdByRepoId: ReadonlyMap +): 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 | 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) + } + }) +} diff --git a/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts b/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts index 85a6099a8b2..ac3fdd4154d 100644 --- a/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts +++ b/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts @@ -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) }) diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 1fb815936e6..41a251ce649 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -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 > 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 } } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index aece6a8e7ad..649a8ac49b7 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts index a5f415c286a..e8cc793eeea 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts index e8265667cbb..d1c5c8be2d4 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts @@ -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 | null { +function getRepoHostId(repoId: string, repoMap: Map): 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 | undefined ): Map | undefined { const labelsByRepoId = new Map() - const uniqueLabels = new Set() + // Host identity, not the rendered label, determines whether rows are ambiguous: + // two hosts can intentionally share a user-facing label. + const uniqueHostIds = new Set() 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 | undefined, defaultHostId: ExecutionHostId ): Map | undefined { - const labelsByIdentity = new Map() - const uniqueHostIds = new Set() - 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( diff --git a/src/shared/worktree/host-context-labels.ts b/src/shared/worktree/host-context-labels.ts new file mode 100644 index 00000000000..4b9481c441b --- /dev/null +++ b/src/shared/worktree/host-context-labels.ts @@ -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 + /** 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 { + const labels = new Map() + 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() + 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( + items: readonly T[], + args: { + getHostId: (item: T) => ExecutionHostId + getIdentity: (item: T) => string + sources?: HostContextLabelSources + } +): Map | undefined { + const labelsByIdentity = new Map() + const hostIds = new Set() + 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 +}