mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
co-authored by
Merge Sim
parent
074a2366bf
commit
7f8eb90ac3
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user