mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #313 from nwparker/cloud/w2-cloud-one-host
feat(cloud): Orca Cloud is one host in the sidebar and Hosts filter (RA-2, RA-6)
This commit is contained in:
@@ -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}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
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, unknown>): 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, [
|
||||
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"', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<PublicKnownRuntimeEnvironment, 'id' | 'name' | 'source'>[],
|
||||
t: Translate = translate
|
||||
): SidebarHostOption[] {
|
||||
const cloudIds = new Set<ExecutionHostId>(
|
||||
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 }
|
||||
)
|
||||
}
|
||||
@@ -134,6 +134,10 @@ export function HostSectionHeader({
|
||||
>
|
||||
{row.vmState ? (
|
||||
<VmHostIcon state={row.vmState} isCloud={isCloudHost} />
|
||||
) : isCloudHost ? (
|
||||
<HostGlyph kind="orca-cloud" size={14} />
|
||||
) : row.kind === 'local' ? (
|
||||
<HostGlyph kind="local" size={14} />
|
||||
) : isDisconnected ? (
|
||||
<ServerOff className="size-3.5 shrink-0 text-muted-foreground/80" />
|
||||
) : (
|
||||
@@ -144,7 +148,8 @@ export function HostSectionHeader({
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate text-[12px] font-semibold leading-none',
|
||||
// The host name wins over its detail: "Orca Cloud" stays whole, "1 running · 1 asleep" truncates.
|
||||
'min-w-0 max-w-[70%] shrink-0 truncate text-[12px] font-semibold leading-none',
|
||||
isDisconnected || isSuspended ? 'text-muted-foreground' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -153,7 +158,7 @@ export function HostSectionHeader({
|
||||
{detail ? (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 truncate text-[10px] leading-none',
|
||||
'min-w-0 truncate text-[10px] leading-none',
|
||||
detail.isWarning ? 'text-destructive' : 'text-muted-foreground/70'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// @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'
|
||||
import { getDefaultSettings } from '../../../../../../shared/constants'
|
||||
import { makeTerminalTab } from '../../../worktree-jump/worktree-jump-palette-test-fixtures'
|
||||
|
||||
const initial = useAppStore.getState()
|
||||
|
||||
type State = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
function seed(active: { id: string; host: State['activeWorkspaceExecutionHostId'] }): void {
|
||||
const settings = initial.settings ?? getDefaultSettings('/home/test')
|
||||
useAppStore.setState({
|
||||
settings: { ...settings, claudeCodeThemeSyncOffer: 'pending' },
|
||||
updateSettings: vi.fn<State['updateSettings']>(async () => undefined),
|
||||
activeWorktreeId: active.id,
|
||||
activeWorkspaceExecutionHostId: active.host,
|
||||
activePendingCreationId: null,
|
||||
tabsByWorktree: {
|
||||
[active.id]: [{ ...makeTerminalTab('tab-1', active.id, 'Claude'), launchAgent: 'claude' }]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
+52
-1
@@ -6,6 +6,8 @@ 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'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host'
|
||||
|
||||
const OFFER_TOAST_ID = 'claude-theme-sync-offer'
|
||||
|
||||
@@ -64,6 +66,48 @@ function showClaudeThemeSyncOffer(): void {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (!isAwayFromThisComputer(useAppStore.getState())) {
|
||||
showClaudeThemeSyncOffer()
|
||||
return () => {}
|
||||
}
|
||||
const unsubscribe = useAppStore.subscribe((state, previous) => {
|
||||
const moved =
|
||||
state.activeWorktreeId !== previous.activeWorktreeId ||
|
||||
state.activePendingCreationId !== previous.activePendingCreationId ||
|
||||
state.activeWorkspaceExecutionHostId !== previous.activeWorkspaceExecutionHostId
|
||||
if (moved && !isAwayFromThisComputer(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 +117,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])
|
||||
}
|
||||
|
||||
@@ -8087,6 +8087,10 @@
|
||||
"delete": "Delete workspace",
|
||||
"uncommitted_one": "{{count}} uncommitted change",
|
||||
"uncommitted_other": "{{count}} uncommitted changes"
|
||||
},
|
||||
"orcaCloudHost": {
|
||||
"running": "{{count}} running",
|
||||
"runningAsleep": "{{running}} running · {{asleep}} asleep"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -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<ExecutionHostId, ExecutionHostId>()
|
||||
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<ExecutionHostId, number> | undefined,
|
||||
resolve: (hostId: ExecutionHostId) => ExecutionHostId
|
||||
): ReadonlyMap<ExecutionHostId, number> | undefined {
|
||||
if (!map) {
|
||||
return map
|
||||
}
|
||||
const next = new Map<ExecutionHostId, number>()
|
||||
for (const [hostId, count] of map) {
|
||||
const shown = resolve(hostId)
|
||||
next.set(shown, (next.get(shown) ?? 0) + count)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function rekeyIds(
|
||||
map: ReadonlyMap<ExecutionHostId, readonly string[]> | undefined,
|
||||
resolve: (hostId: ExecutionHostId) => ExecutionHostId
|
||||
): ReadonlyMap<ExecutionHostId, readonly string[]> | undefined {
|
||||
if (!map) {
|
||||
return map
|
||||
}
|
||||
const next = new Map<ExecutionHostId, readonly string[]>()
|
||||
for (const [hostId, ids] of map) {
|
||||
const shown = resolve(hostId)
|
||||
next.set(shown, [...(next.get(shown) ?? []), ...ids])
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function rekeyRepos(
|
||||
map: ReadonlyMap<ExecutionHostId, Repo>,
|
||||
resolve: (hostId: ExecutionHostId) => ExecutionHostId
|
||||
): ReadonlyMap<ExecutionHostId, Repo> {
|
||||
const next = new Map<ExecutionHostId, Repo>()
|
||||
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) } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<ExecutionHostId, Row[]>()
|
||||
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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user