From 5608ef84372924f81c161c0acc507e2b303e0f4d Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 16:22:29 -0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(cloud):=20Orca=20Cloud=20is=20one=20ho?= =?UTF-8?q?st=20=E2=80=94=20one=20header=20with=20an=20aggregate=20state,?= =?UTF-8?q?=20one=20Hosts=20filter=20entry;=20Monitor=20for=20Local=20Mac;?= =?UTF-8?q?=20Asleep=20not=20Suspended;=20theme-sync=20offer=20waits=20for?= =?UTF-8?q?=20a=20local=20workspace=20(RA-2,=20RA-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controls/SidebarHostScopeMenuSection.tsx | 3 +- .../controls/sidebar-host-options.ts | 8 +- .../controls/sidebar-orca-cloud-host.test.ts | 137 ++++++++++++++++++ .../controls/sidebar-orca-cloud-host.ts | 74 ++++++++++ .../worktree-list/rows/HostSectionHeader.tsx | 4 + .../worktree-list/rows/host-header-detail.ts | 6 +- .../claude-theme-sync-offer.ts | 34 ++++- .../sidebar-list/host-section-aliases.ts | 86 +++++++++++ src/shared/sidebar-list/host-section-rows.ts | 22 ++- 9 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.ts create mode 100644 src/shared/sidebar-list/host-section-aliases.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/SidebarHostScopeMenuSection.tsx b/src/renderer/src/components/sidebar/worktree-list/controls/SidebarHostScopeMenuSection.tsx index 65a9bc411e1..040eba4a865 100644 --- a/src/renderer/src/components/sidebar/worktree-list/controls/SidebarHostScopeMenuSection.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/controls/SidebarHostScopeMenuSection.tsx @@ -34,7 +34,8 @@ type SidebarHostScopeMenuSectionProps = { function getHostMetadata(host: SidebarHostOption, t: Translate = translate): string { const healthLabel = getSidebarHostHealthLabel(host.health, host.blockedReason, t, host.vmState) - if (host.kind === 'local') { + // Local and Orca Cloud name themselves: "This computer", "1 running · 1 asleep". + if (host.kind === 'local' || host.orcaCloud) { return host.detail } if (host.kind === 'ssh') { diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.ts b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.ts index b0f8b641040..b93e4a62239 100644 --- a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.ts @@ -1,4 +1,5 @@ import { shallow } from 'zustand/shallow' +import { collapseOrcaCloudHosts } from './sidebar-orca-cloud-host' import type { GlobalSettings } from '../../../../../../shared/global-settings-types' import type { Repo } from '../../../../../../shared/repo-types' import type { WorkspaceHostScope } from '../../../../../../shared/ui-chrome-types' @@ -62,6 +63,8 @@ export type SidebarHostOption = { wslDistro?: string /** Why a listed WSL host cannot run: the experiment is off, or this build lacks its server files. */ wslUnavailable?: 'turned-off' | 'server-files-missing' + /** On the one row standing for every Orca Cloud sandbox: how many run and sleep. */ + orcaCloud?: { running: number; asleep: number } } export type SidebarHostScopeOption = { @@ -117,7 +120,7 @@ export function buildSidebarHostOptions( const activeRuntimeHostId = args.settings?.activeRuntimeEnvironmentId?.trim() ? toRuntimeExecutionHostId(args.settings.activeRuntimeEnvironmentId.trim()) : null - return buildExecutionHostRegistry({ + const hosts = buildExecutionHostRegistry({ repos: args.repos, settings: args.settings, sshTargetLabels: args.sshTargetLabels, @@ -126,7 +129,7 @@ export function buildSidebarHostOptions( runtimeStatusByEnvironmentId: args.runtimeStatusByEnvironmentId, hostLabelOverrides: args.hostLabelOverrides, ephemeralVmHosts: args.ephemeralVmHosts - }).map((host) => { + }).map((host): SidebarHostOption => { if (host.kind === 'local') { return { ...host, presence: 'local' } } @@ -174,6 +177,7 @@ export function buildSidebarHostOptions( presence: host.id === activeRuntimeHostId ? 'active' : 'project' } }) + return collapseOrcaCloudHosts(hosts, args.runtimeEnvironments ?? [], t) } export function shouldShowHostScopeControls(hosts: readonly SidebarHostOption[]): boolean { diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts new file mode 100644 index 00000000000..ed54d226a03 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../../../shared/repo-types' +import type { Worktree } from '../../../../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { planSidebarRows } from '../../../../../../shared/sidebar-list/sidebar-row-plan' +import { addHostSectionRows } from '../../../../../../shared/sidebar-list/host-section-rows' +import { createHostScopedRepoOf } from '../../../../../../shared/sidebar-list/sidebar-feed-index' +import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses' +import { expandEquivalentExecutionHostIds } from '../../../../../../shared/execution-host/managed-orcad-execution-host' +import { repo, worktree } from '../grouping/worktree-list-groups-test-fixtures' +import { buildSidebarHostOptions, buildSidebarHostScopeOptions } from './sidebar-host-options' + +// English defaults with {{slots}} filled, like i18next with no catalog. +const t = (_key: string, fallback: string, params?: Record): string => + fallback.replace(/\{\{(\w+)\}\}/g, (_slot, name: string) => String(params?.[name] ?? '')) + +// Two Orca Cloud sandboxes (one running, one asleep) and this computer, all for one project. +const cloudA: Repo = { ...repo, path: '/workspace/hello', executionHostId: 'runtime:env-a' } +const cloudB: Repo = { ...repo, path: '/workspace/hello', executionHostId: 'runtime:env-b' } +const environments = [ + { id: 'env-a', name: 'hello sandbox aaaaaaaa', source: 'ephemeral-vm' as const }, + { id: 'env-b', name: 'hello sandbox bbbbbbbb', source: 'ephemeral-vm' as const } +] + +function hostOptions() { + return buildSidebarHostOptions( + { + repos: [repo, cloudA, cloudB], + sshTargetLabels: new Map(), + settings: null, + runtimeEnvironments: environments, + ephemeralVmHosts: [ + { runtimeEnvironmentId: 'env-a', status: 'running', waking: false }, + { runtimeEnvironmentId: 'env-b', status: 'suspended', waking: false } + ] + }, + t + ) +} + +const worktrees: Worktree[] = [ + { ...worktree, id: 'wt-local', displayName: 'local work' }, + { ...worktree, id: 'wt-a', hostId: 'runtime:env-a', displayName: 'fix login' }, + { ...worktree, id: 'wt-b', hostId: 'runtime:env-b', displayName: 'docs refresh' } +] + +function groupedByHost() { + const hosts = hostOptions() + const repoMap = new Map([[repo.id, repo]]) + const rows = planSidebarRows({ + groupBy: 'repo', + sortBy: 'smart', + worktrees, + repoMap, + repoOfWorktree: createHostScopedRepoOf([repo, cloudA, cloudB], repoMap), + prGroupOf: () => 'in-progress', + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + projectOrderBy: 'manual', + lineageById: {}, + worktreeMap: new Map(worktrees.map((row) => [row.id, row])), + nestLineage: false, + projectGroups: [], + placeholderRepoIds: new Set(), + importedWorktreesByRepo: new Map(), + newExternalWorktreesInboxByRepo: new Map(), + pendingCreations: [], + folderWorkspaces: [], + defaultHostId: 'local', + pinnedDisplayPolicy: 'single-location', + filterRepoIds: [] + }).render(new Set()) + const everyHost: ExecutionHostId[] = expandEquivalentExecutionHostIds( + hosts, + hosts.map((host) => host.id) + ) + return addHostSectionRows({ + rows, + hostOptions: hosts, + workspaceHostScope: 'all', + // The Hosts filter with every host checked: the view that draws host headers. + visibleWorkspaceHostIds: everyHost, + defaultHostId: 'local', + localHostLabel: 'Local Mac', + preferProjectGrouping: true + }) +} + +describe('Orca Cloud is one host (RA-2)', () => { + it('collapses two sandboxes into one "Orca Cloud" section with an aggregate state', () => { + const rows = groupedByHost() + const hostHeaders = rows.filter((row) => row.type === 'host-header') + expect(hostHeaders.map((row) => row.label)).toEqual(['Local Mac', 'Orca Cloud']) + const cloud = hostHeaders[1] + expect(cloud?.count).toBe(2) + // Never one sandbox's state. + expect(cloud?.vmState).toBeUndefined() + expect(cloud?.detail).toBe('1 running · 1 asleep') + // One project header under Orca Cloud, holding both sandboxes' workspaces. + const cloudIndex = rows.indexOf(cloud!) + const underCloud = rows.slice(cloudIndex + 1) + expect(underCloud.filter((row) => row.type === 'header')).toHaveLength(1) + expect( + underCloud.flatMap((row) => (row.type === 'item' ? [row.worktree.id] : [])).sort() + ).toEqual(['wt-a', 'wt-b']) + }) + + it('lists Orca Cloud once in the Hosts filter, and checking it selects every sandbox', () => { + const hosts = hostOptions() + const scope = buildSidebarHostScopeOptions(hosts, t) + expect(scope.map((option) => option.label)).toEqual(['All hosts', 'Local Mac', 'Orca Cloud']) + const cloudRow = scope.find((option) => option.label === 'Orca Cloud') + expect(cloudRow?.detail).toBe('1 running · 1 asleep') + expect(expandEquivalentExecutionHostIds(hosts, [cloudRow!.id]).sort()).toEqual([ + 'runtime:env-a', + 'runtime:env-b' + ]) + }) + + it('says "Asleep" when every sandbox sleeps, never "Suspended"', () => { + const hosts = buildSidebarHostOptions( + { + repos: [repo, cloudA, cloudB], + sshTargetLabels: new Map(), + settings: null, + runtimeEnvironments: environments, + ephemeralVmHosts: [ + { runtimeEnvironmentId: 'env-a', status: 'suspended', waking: false }, + { runtimeEnvironmentId: 'env-b', status: 'suspended', waking: false } + ] + }, + t + ) + const cloud = hosts.find((host) => host.orcaCloud) + expect(cloud?.detail).toBe('Asleep') + expect(cloud?.vmState).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.ts b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.ts new file mode 100644 index 00000000000..d372a490394 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.ts @@ -0,0 +1,74 @@ +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { toRuntimeExecutionHostId } from '../../../../../../shared/execution-host' +import { + ORCA_CLOUD_HOST_LABEL, + isOrcaCloudRuntimeEnvironment +} from '../../../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name' +import type { PublicKnownRuntimeEnvironment } from '../../../../../../shared/remote-runtime/host-catalog/runtime-environments' +import { translate } from '@/i18n/i18n' +import type { Translate } from '@/i18n/use-translate' +import type { SidebarHostOption } from './sidebar-host-options' + +/** + * Orca Cloud is one logical host. Each sandbox is its own runtime environment underneath, so + * without this every sandbox drew its own "Orca Cloud" header and Hosts entry (RA-2). The first + * sandbox's row stands for all of them (aliasHostIds); the rest merge into it, so filters and + * host sections treat every cloud workspace as the same host. The shown row carries an aggregate + * state, never one sandbox's. + */ +export function collapseOrcaCloudHosts( + hosts: SidebarHostOption[], + runtimeEnvironments: readonly Pick[], + t: Translate = translate +): SidebarHostOption[] { + const cloudIds = new Set( + runtimeEnvironments + .filter((environment) => isOrcaCloudRuntimeEnvironment(environment)) + .map((environment) => toRuntimeExecutionHostId(environment.id.trim())) + ) + const cloudHosts = hosts.filter((host) => cloudIds.has(host.id) && !host.mergedIntoHostId) + if (cloudHosts.length === 0) { + return hosts + } + const shown = cloudHosts[0] + const asleep = cloudHosts.filter((host) => host.vmState === 'suspended').length + const failed = cloudHosts.filter( + (host) => host.vmState === 'resume_failed' || host.vmState === 'provision_failed' + ).length + const running = cloudHosts.length - asleep - failed + const aliasIds = new Set(cloudHosts.slice(1).map((host) => host.id)) + return hosts.map((host): SidebarHostOption => { + if (host.id === shown.id) { + const { vmState: _vmState, ...rest } = host + return { + ...rest, + label: ORCA_CLOUD_HOST_LABEL, + detail: orcaCloudAggregateDetail(running, asleep, t), + // Dimmed like a sleeping host only when nothing is running. + health: running > 0 ? 'available' : 'suspended', + orcaCloud: { running, asleep }, + aliasHostIds: [...(host.aliasHostIds ?? []), ...aliasIds] + } + } + return aliasIds.has(host.id) + ? { ...host, label: ORCA_CLOUD_HOST_LABEL, mergedIntoHostId: shown.id } + : host + }) +} + +/** "Asleep", "2 running", or "1 running · 1 asleep": the cloud host as a whole. */ +export function orcaCloudAggregateDetail(running: number, asleep: number, t: Translate): string { + if (running === 0 && asleep > 0) { + return t('orcaCloudWorkspace.state.asleep', 'Asleep') + } + if (asleep === 0) { + return t('auto.components.sidebar.orcaCloudHost.running', '{{count}} running', { + count: running + }) + } + return t( + 'auto.components.sidebar.orcaCloudHost.runningAsleep', + '{{running}} running · {{asleep}} asleep', + { running, asleep } + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx index 2adfd89771e..d1589e25662 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx @@ -134,6 +134,10 @@ export function HostSectionHeader({ > {row.vmState ? ( + ) : isCloudHost ? ( + + ) : row.kind === 'local' ? ( + ) : isDisconnected ? ( ) : ( diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts b/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts index 769c5b85798..ddc6d38f215 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts @@ -13,7 +13,11 @@ export function getHostHeaderDetail( /** Orca Cloud hosts use the cloud glossary: Asleep, Waking, Couldn't wake, never "Suspended". */ isOrcaCloud = false ): { text: string; isWarning: boolean } | null { - if (isOrcaCloud && row.vmState) { + if (isOrcaCloud) { + // One header for every sandbox: its detail is the aggregate ("1 running · 1 asleep"). + if (!row.vmState) { + return row.detail ? { text: row.detail, isWarning: false } : null + } const dot = vmStateToDot(row.vmState) return { text: cloudStateLabel(dot, t), isWarning: dot === 'failed' || dot === 'wakeFailed' } } diff --git a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts index cb4cc6f87ff..2129986ec92 100644 --- a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts +++ b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts @@ -6,6 +6,7 @@ import type { AppState } from '@/store/types' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { TRAILING_ACTIONS_CLASS_NAMES } from '@/components/ui/sonner' import { whenAgentTerminalAppears } from '../agent-presence/agent-terminal-presence' +import { getCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store' const OFFER_TOAST_ID = 'claude-theme-sync-offer' @@ -64,6 +65,30 @@ function showClaudeThemeSyncOffer(): void { }) } +function isOnCloudWorkspace(state: AppState): boolean { + return ( + state.activeWorktreeId !== null && getCloudWorkspaceSnapshot().ids.has(state.activeWorktreeId) + ) +} + +/** + * The sync changes Claude Code on this computer, which an Orca Cloud workspace's Claude doesn't + * use: hold the offer while one is active and make it once a local workspace is (RA-6). + */ +function offerOnLocalWorkspace(): () => void { + if (!isOnCloudWorkspace(useAppStore.getState())) { + showClaudeThemeSyncOffer() + return () => {} + } + const unsubscribe = useAppStore.subscribe((state, previous) => { + if (state.activeWorktreeId !== previous.activeWorktreeId && !isOnCloudWorkspace(state)) { + unsubscribe() + showClaudeThemeSyncOffer() + } + }) + return unsubscribe +} + /** Offers theme sync once, when Claude Code first runs in an Orca terminal. */ export function useClaudeThemeSyncOffer(): void { const due = useAppStore(isOfferDue) @@ -73,6 +98,13 @@ export function useClaudeThemeSyncOffer(): void { if (!due || isPairedWebClientWindow()) { return } - return whenAgentTerminalAppears('claude', showClaudeThemeSyncOffer) + let stopWaitingForLocal: () => void = () => {} + const stopWatching = whenAgentTerminalAppears('claude', () => { + stopWaitingForLocal = offerOnLocalWorkspace() + }) + return () => { + stopWatching() + stopWaitingForLocal() + } }, [due]) } diff --git a/src/shared/sidebar-list/host-section-aliases.ts b/src/shared/sidebar-list/host-section-aliases.ts new file mode 100644 index 00000000000..4d9dece0b9c --- /dev/null +++ b/src/shared/sidebar-list/host-section-aliases.ts @@ -0,0 +1,86 @@ +import type { ExecutionHostId } from '../execution-host' +import type { Repo } from '../repo-types' +import type { Row } from './row-types' + +type MergedHost = { id: ExecutionHostId; mergedIntoHostId?: ExecutionHostId } + +/** + * Host ids that share one section: a merged-away host (a managed server's SSH route, or one Orca + * Cloud sandbox among several) resolves to the row that stands for it. Null when nothing merges. + */ +export function createHostSectionResolver( + hosts: readonly MergedHost[] +): ((hostId: ExecutionHostId) => ExecutionHostId) | null { + const shownById = new Map() + for (const host of hosts) { + if (host.mergedIntoHostId) { + shownById.set(host.id, host.mergedIntoHostId) + } + } + return shownById.size > 0 ? (hostId) => shownById.get(hostId) ?? hostId : null +} + +function rekeyCounts( + map: ReadonlyMap | undefined, + resolve: (hostId: ExecutionHostId) => ExecutionHostId +): ReadonlyMap | undefined { + if (!map) { + return map + } + const next = new Map() + for (const [hostId, count] of map) { + const shown = resolve(hostId) + next.set(shown, (next.get(shown) ?? 0) + count) + } + return next +} + +function rekeyIds( + map: ReadonlyMap | undefined, + resolve: (hostId: ExecutionHostId) => ExecutionHostId +): ReadonlyMap | undefined { + if (!map) { + return map + } + const next = new Map() + for (const [hostId, ids] of map) { + const shown = resolve(hostId) + next.set(shown, [...(next.get(shown) ?? []), ...ids]) + } + return next +} + +function rekeyRepos( + map: ReadonlyMap, + resolve: (hostId: ExecutionHostId) => ExecutionHostId +): ReadonlyMap { + const next = new Map() + for (const [hostId, repo] of map) { + const shown = resolve(hostId) + // The shown host's own registration names the header; else the first merged one's. + if (!next.has(shown) || hostId === shown) { + next.set(shown, repo) + } + } + return next +} + +/** A header's per-host maps keyed by section: one project header per section, counts summed. */ +export function mergeHeaderHostsIntoSections( + rows: readonly Row[], + resolve: (hostId: ExecutionHostId) => ExecutionHostId +): Row[] { + return rows.map((row) => { + if (row.type !== 'header' || (!row.hostRepos && !row.hostWorktreeCounts)) { + return row + } + return { + ...row, + ...(row.hostRepos ? { hostRepos: rekeyRepos(row.hostRepos, resolve) } : {}), + ...(row.hostWorktreeCounts + ? { hostWorktreeCounts: rekeyCounts(row.hostWorktreeCounts, resolve) } + : {}), + ...(row.hostWorktreeIds ? { hostWorktreeIds: rekeyIds(row.hostWorktreeIds, resolve) } : {}) + } + }) +} diff --git a/src/shared/sidebar-list/host-section-rows.ts b/src/shared/sidebar-list/host-section-rows.ts index fb02011167d..40e54b65003 100644 --- a/src/shared/sidebar-list/host-section-rows.ts +++ b/src/shared/sidebar-list/host-section-rows.ts @@ -17,6 +17,7 @@ import type { SshConnectionStatus } from '../ssh-types' import type { Row } from './row-types' import { getFolderWorkspaceHostId } from './folder-workspace-host-id' import { countWorkspaceRows } from './host-section-workspace-count' +import { createHostSectionResolver, mergeHeaderHostsIntoSections } from './host-section-aliases' import { localizeProjectHeader, localizeRowsToHost } from './project-header-localization' export type HostHeaderRow = { @@ -50,6 +51,8 @@ export type HostSectionOption = { blockedReason?: RuntimeHostBlockedReason vmState?: ExecutionHostVmState connectionStatus?: SshConnectionStatus + /** Shares the section of the host it merged into (see host-section-aliases). */ + mergedIntoHostId?: ExecutionHostId } function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { @@ -146,6 +149,13 @@ export function addHostSectionRows(args: { return localizeRowsToHost(args.rows, args.hostOptions[0]?.id) } + // Merged-away hosts (each Orca Cloud sandbox after the first) share their shown host's section. + const resolveHost = createHostSectionResolver(args.hostOptions) + const inputRows = resolveHost ? mergeHeaderHostsIntoSections(args.rows, resolveHost) : args.rows + const sectionHostOf = (row: Row): ExecutionHostId | null => { + const hostId = getRowHostId(row, args.defaultHostId) + return hostId && resolveHost ? resolveHost(hostId) : hostId + } const hostOptionsById = new Map(args.hostOptions.map((host) => [host.id, host])) const rowsByHostId = new Map() const globalRows: Row[] = [] @@ -214,7 +224,7 @@ export function addHostSectionRows(args: { // A nested child stays in its lineage root's section, even when it runs on another machine. let lineageRootHostId: ExecutionHostId | null = null - for (const row of args.rows) { + for (const row of inputRows) { if (row.type === 'header' && row.repo) { retainPendingAncestors(row.projectGroupDepth ?? 0) } @@ -225,7 +235,7 @@ export function addHostSectionRows(args: { } continue } - const ownHostId = getRowHostId(row, args.defaultHostId) + const ownHostId = sectionHostOf(row) if ((row.type === 'item' || row.type === 'folder-workspace') && row.depth === 0) { lineageRootHostId = ownHostId } @@ -249,7 +259,7 @@ export function addHostSectionRows(args: { const hostOrder: ExecutionHostId[] = [] for (const host of args.hostOptions) { - if (rowsByHostId.has(host.id)) { + if (rowsByHostId.has(host.id) && !host.mergedIntoHostId) { hostOrder.push(host.id) } } @@ -261,7 +271,7 @@ export function addHostSectionRows(args: { // Show host headers only for mixed hosts; registered empty hosts remain in the scope picker. if (rowsByHostId.size <= 1) { - return localizeRowsToHost(args.rows, rowsByHostId.keys().next().value) + return localizeRowsToHost(inputRows, rowsByHostId.keys().next().value) } const result: HostSectionRow[] = [...globalRows] @@ -302,9 +312,7 @@ export function addHostSectionRows(args: { return { ...row, hostId: row.hostId ?? hostId, ...(repeated ? { repeated: true } : {}) } } // The enclosing host header already names the owner, unless a nested child runs elsewhere. - return 'hostContextLabel' in row && - row.hostContextLabel && - getRowHostId(row, args.defaultHostId) === hostId + return 'hostContextLabel' in row && row.hostContextLabel && sectionHostOf(row) === hostId ? { ...row, hostContextLabel: undefined } : row }) From cecdc77f31b0a056aac6377fa24e25761622a1aa Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 16:24:12 -0800 Subject: [PATCH 2/6] chore(l10n): sync catalogs --- src/renderer/src/i18n/locales/en.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index aba2658d8ea..2eca6b3ea67 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8076,6 +8076,10 @@ "delete": "Delete workspace", "uncommitted_one": "{{count}} uncommitted change", "uncommitted_other": "{{count}} uncommitted changes" + }, + "orcaCloudHost": { + "running": "{{count}} running", + "runningAsleep": "{{running}} running · {{asleep}} asleep" } }, "shared": { From 697efde11cb2b5088a4d0f682dca07aebb9d6c72 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 16:27:15 -0800 Subject: [PATCH 3/6] test: type the Orca Cloud host id --- .../controls/sidebar-orca-cloud-host.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts index ed54d226a03..4d728a3d532 100644 --- a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts @@ -110,10 +110,11 @@ describe('Orca Cloud is one host (RA-2)', () => { expect(scope.map((option) => option.label)).toEqual(['All hosts', 'Local Mac', 'Orca Cloud']) const cloudRow = scope.find((option) => option.label === 'Orca Cloud') expect(cloudRow?.detail).toBe('1 running · 1 asleep') - expect(expandEquivalentExecutionHostIds(hosts, [cloudRow!.id]).sort()).toEqual([ - 'runtime:env-a', - 'runtime:env-b' - ]) + expect( + expandEquivalentExecutionHostIds(hosts, [ + hosts.find((host) => host.id === cloudRow?.id)!.id + ]).sort() + ).toEqual(['runtime:env-a', 'runtime:env-b']) }) it('says "Asleep" when every sandbox sleeps, never "Suspended"', () => { From 2ea97f9c54f54ea9bca292e2d944291ca592670f Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 16:44:16 -0800 Subject: [PATCH 4/6] fix(cloud): host name stays whole and its detail truncates; theme-sync offer waits while the active workspace runs off this computer or is still being created (RA-2/RA-6 real-app) --- .../worktree-list/rows/HostSectionHeader.tsx | 5 +-- .../claude-theme-sync-offer.ts | 31 +++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx index d1589e25662..da7edf31660 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx @@ -148,7 +148,8 @@ export function HostSectionHeader({
@@ -157,7 +158,7 @@ export function HostSectionHeader({ {detail ? ( diff --git a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts index 2129986ec92..a9a74512bfd 100644 --- a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts +++ b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts @@ -7,6 +7,7 @@ import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { TRAILING_ACTIONS_CLASS_NAMES } from '@/components/ui/sonner' import { whenAgentTerminalAppears } from '../agent-presence/agent-terminal-presence' import { getCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host' const OFFER_TOAST_ID = 'claude-theme-sync-offer' @@ -65,10 +66,24 @@ function showClaudeThemeSyncOffer(): void { }) } -function isOnCloudWorkspace(state: AppState): boolean { - return ( - state.activeWorktreeId !== null && getCloudWorkspaceSnapshot().ids.has(state.activeWorktreeId) - ) +/** + * The sync changes Claude Code on THIS computer. Hold the offer while the active workspace runs + * elsewhere (Orca Cloud, a VM, SSH, an Orca server) or is still being created: a brand-new cloud + * workspace is not in the cloud id list yet when its Claude terminal appears (RA-6). + */ +function isAwayFromThisComputer(state: AppState): boolean { + if (state.activePendingCreationId) { + return true + } + const id = state.activeWorktreeId + if (!id) { + return false + } + if (getCloudWorkspaceSnapshot().ids.has(id) || state.ephemeralVmRuntimesByWorkspaceId?.[id]) { + return true + } + const host = state.activeWorkspaceExecutionHostId + return Boolean(host && host !== LOCAL_EXECUTION_HOST_ID) } /** @@ -76,12 +91,16 @@ function isOnCloudWorkspace(state: AppState): boolean { * use: hold the offer while one is active and make it once a local workspace is (RA-6). */ function offerOnLocalWorkspace(): () => void { - if (!isOnCloudWorkspace(useAppStore.getState())) { + if (!isAwayFromThisComputer(useAppStore.getState())) { showClaudeThemeSyncOffer() return () => {} } const unsubscribe = useAppStore.subscribe((state, previous) => { - if (state.activeWorktreeId !== previous.activeWorktreeId && !isOnCloudWorkspace(state)) { + const moved = + state.activeWorktreeId !== previous.activeWorktreeId || + state.activePendingCreationId !== previous.activePendingCreationId || + state.activeWorkspaceExecutionHostId !== previous.activeWorkspaceExecutionHostId + if (moved && !isAwayFromThisComputer(state)) { unsubscribe() showClaudeThemeSyncOffer() } From 02bc806feccf87809893938519585bbd93e69602 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 16:49:06 -0800 Subject: [PATCH 5/6] fix(cloud): theme-offer tests; state word's separator only when a branch follows (RA-6) --- .../host/WorktreeCardHostMark.tsx | 6 +- .../claude-theme-sync-offer.test.ts | 69 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts diff --git a/src/renderer/src/components/sidebar/workspace-card/host/WorktreeCardHostMark.tsx b/src/renderer/src/components/sidebar/workspace-card/host/WorktreeCardHostMark.tsx index 801b1880938..2726b59d242 100644 --- a/src/renderer/src/components/sidebar/workspace-card/host/WorktreeCardHostMark.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/host/WorktreeCardHostMark.tsx @@ -1,5 +1,6 @@ import React from 'react' import { useAppStore } from '@/store' +import { findWorktreeById } from '@/store/github/refresh/worktree-refresh' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' import { HostGlyph, vmStateToDot } from '@/components/host-row-icon' @@ -87,6 +88,8 @@ export function WorktreeCardHostStateWord({ ? vmStateToDot(selectEphemeralVmWorkspaceState(state, worktreeId)) : null ) + // The separator only when line 2 goes on (a branch); "Asleep ·" alone reads as cut off. + const hasBranch = useAppStore((state) => Boolean(findWorktreeById(state, worktreeId)?.branch)) const t = useTranslate() const state = cloud.isCloud ? cloud.state : vmDot // Unknown is not a state to name (the panel shows a placeholder until the first read). @@ -101,7 +104,8 @@ export function WorktreeCardHostStateWord({ PROBLEM.has(state) ? 'text-destructive' : 'text-muted-foreground' )} > - {cloudStateLabel(state, t)} · + {cloudStateLabel(state, t)} + {hasBranch ? ' ·' : null} ) } diff --git a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts new file mode 100644 index 00000000000..10d3f6c3b03 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const toast = vi.hoisted(() => Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() })) +vi.mock('sonner', () => ({ toast })) +vi.mock('@/lib/desktop-window-chrome', () => ({ isPairedWebClientWindow: () => false })) + +import { useAppStore } from '@/store/index' +import { seedCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store' +import { useClaudeThemeSyncOffer } from './claude-theme-sync-offer' + +const initial = useAppStore.getState() + +function seed(active: { id: string; host: string | null }): void { + useAppStore.setState({ + settings: { + ...initial.settings, + claudeCodeThemeSyncOffer: 'pending' + } as typeof initial.settings, + updateSettings: vi.fn(async () => undefined) as unknown as typeof initial.updateSettings, + activeWorktreeId: active.id, + activeWorkspaceExecutionHostId: active.host as typeof initial.activeWorkspaceExecutionHostId, + activePendingCreationId: null, + tabsByWorktree: { + [active.id]: [{ id: 'tab-1', launchAgent: 'claude' }] + } as unknown as typeof initial.tabsByWorktree + }) +} + +beforeEach(() => toast.mockClear()) +afterEach(() => { + cleanup() + seedCloudWorkspaceSnapshot({}) + useAppStore.setState(initial, true) +}) + +describe('Claude Code theme-sync offer (RA-6)', () => { + it('holds the offer while Claude runs in an Orca Cloud workspace, then makes it on a local one', () => { + seedCloudWorkspaceSnapshot({ ids: new Set(['cloud-ws']), idsLoaded: true }) + seed({ id: 'cloud-ws', host: 'runtime:env-a' }) + renderHook(() => useClaudeThemeSyncOffer()) + expect(toast).not.toHaveBeenCalled() + + act(() => { + useAppStore.setState({ + activeWorktreeId: 'local-ws', + activeWorkspaceExecutionHostId: 'local' + }) + }) + expect(toast).toHaveBeenCalledTimes(1) + expect(toast.mock.calls[0]?.[0]).toBe('Match Claude Code to your Orca theme?') + }) + + it('holds it for a brand-new cloud workspace that is not in the cloud id list yet', () => { + seedCloudWorkspaceSnapshot({ ids: new Set(), idsLoaded: true }) + seed({ id: 'new-cloud-ws', host: 'runtime:env-b' }) + renderHook(() => useClaudeThemeSyncOffer()) + expect(toast).not.toHaveBeenCalled() + }) + + it('offers right away when Claude runs on this computer', () => { + seedCloudWorkspaceSnapshot({ ids: new Set(), idsLoaded: true }) + seed({ id: 'local-ws', host: 'local' }) + renderHook(() => useClaudeThemeSyncOffer()) + expect(toast).toHaveBeenCalledTimes(1) + }) +}) From 43e79b8f4f7e6ff27ad3949c47677b7fa54bdd89 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 16:55:07 -0800 Subject: [PATCH 6/6] test: typed fixtures for the theme-offer test --- .../claude-theme-sync-offer.test.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts index 10d3f6c3b03..c204802c7da 100644 --- a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts +++ b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts @@ -10,22 +10,24 @@ vi.mock('@/lib/desktop-window-chrome', () => ({ isPairedWebClientWindow: () => f import { useAppStore } from '@/store/index' import { seedCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store' import { useClaudeThemeSyncOffer } from './claude-theme-sync-offer' +import { getDefaultSettings } from '../../../../../../shared/constants' +import { makeTerminalTab } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' const initial = useAppStore.getState() -function seed(active: { id: string; host: string | null }): void { +type State = ReturnType + +function seed(active: { id: string; host: State['activeWorkspaceExecutionHostId'] }): void { + const settings = initial.settings ?? getDefaultSettings('/home/test') useAppStore.setState({ - settings: { - ...initial.settings, - claudeCodeThemeSyncOffer: 'pending' - } as typeof initial.settings, - updateSettings: vi.fn(async () => undefined) as unknown as typeof initial.updateSettings, + settings: { ...settings, claudeCodeThemeSyncOffer: 'pending' }, + updateSettings: vi.fn(async () => undefined), activeWorktreeId: active.id, - activeWorkspaceExecutionHostId: active.host as typeof initial.activeWorkspaceExecutionHostId, + activeWorkspaceExecutionHostId: active.host, activePendingCreationId: null, tabsByWorktree: { - [active.id]: [{ id: 'tab-1', launchAgent: 'claude' }] - } as unknown as typeof initial.tabsByWorktree + [active.id]: [{ ...makeTerminalTab('tab-1', active.id, 'Claude'), launchAgent: 'claude' }] + } }) }