Files
orca/mobile/src/worktree/worktree-host-context-labels.ts
T
Jinwoo Hong 7abee44e78 feat(mobile): show SSH host health on the workspace row badge (#26415)
* feat(mobile): show SSH host health on the workspace row host badge

Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now
adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared
function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name an unhealthy SSH host even in a single-host list

The desktop card shows SSH status on any host count, so the phone now reads target summaries
whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one
target-to-host keying, and a hook-level test pins the reply-to-row wiring.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): clear stale SSH host badges and announce them without the dot

A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps,
so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox,
Disconnected") instead of reading the middle dot.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the single-host health fixtures inline so the test tsc passes

Fixtures hoisted into consts widened their health maps to string, which the now-required
hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the repo-metadata goldens for SSH host health

The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the
target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-10-11 02:53:57 -04:00

106 lines
4.1 KiB
TypeScript

import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
normalizeExecutionHostId,
type ExecutionHostId
} from '../../../src/shared/execution-host'
import {
getHostContextLabel,
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 { ExecutionHostHealth } from '../../../src/shared/execution-host-health'
import type { Worktree } from './workspace-list-types'
import { getHostHealthBadgeLabel } from './worktree-host-health'
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
/** Health per host the desktop reports one for; an absent host keeps the bare label. */
hostHealthById: ReadonlyMap<ExecutionHostId, ExecutionHostHealth>
}
export function buildRepoHostIdByRepoId(
repos: readonly { id: string; connectionId?: string | null; executionHostId?: string | null }[]
): 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)
// Why: the desktop card shows SSH status on any host count, so an unhealthy host is named even alone.
const anyUnhealthy = [...sources.hostHealthById.values()].some(getHostHealthBadgeLabel)
if (!labels && !anyUnhealthy) {
return worktrees
}
return worktrees.map((worktree) => {
const hostContextHostId = resolveWorktreeHostId(worktree, sources.repoHostIdByRepoId)
const hostContextHealthLabel = getHostHealthBadgeLabel(
sources.hostHealthById.get(hostContextHostId)
)
const hostContextLabel = labels
? labels.get(getResolvedWorktreeRowIdentity(worktree, sources.repoHostIdByRepoId))
: hostContextHealthLabel && getHostContextLabel(hostContextHostId, sources)
if (!hostContextLabel) {
return worktree
}
return {
...worktree,
hostContextLabel,
hostContextHostId,
...(hostContextHealthLabel ? { hostContextHealthLabel } : {})
}
})
}