From 06272109daa309c3e015e11862f186eb0188ac35 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:07:46 -0700
Subject: [PATCH 01/35] wip: remote-sidebar-offline (#16942)
---
.../ipc-events/runtime-client-ipc-bridge.ts | 129 +++++-
...time-environment-subscription-selection.ts | 5 +
.../runtime-reconnect-host-status.test.ts | 430 ++++++++++++++++++
.../ipc-events/runtime-status-reprobe.ts | 38 ++
...ents-runtime-environment-selectors.test.ts | 4 +
5 files changed, 605 insertions(+), 1 deletion(-)
create mode 100644 src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts
create mode 100644 src/renderer/src/hooks/ipc-events/runtime-status-reprobe.ts
diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts
index 2b8d720ca63..94dc4acb076 100644
--- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts
+++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts
@@ -27,8 +27,23 @@ import {
getRuntimeClientEventEnvironmentIds,
invalidateRuntimeClientEventReplay
} from './runtime-environment-subscription-selection'
+import { dialRuntimeStatusReprobe } from './runtime-status-reprobe'
import type { WorktreeEventRuntime } from './worktree-event-runtime'
+/** Backoff for re-asking status.get after a probe that failed on its own socket. */
+const RUNTIME_STATUS_PROBE_RETRY_DELAYS_MS = [2_000, 10_000]
+
+/**
+ * Why a request carries its reason: `reconnected` must re-ask even when the cache reads
+ * reachable (a restart changes the runtimeId under an unchanged-looking status), while
+ * `recordedUnreachable` is satisfied by any answer that clears the offline verdict.
+ */
+type RuntimeStatusProbeTrigger = 'reconnected' | 'recordedUnreachable'
+
+function isRuntimeStatusRecordedUnreachable(environmentId: string): boolean {
+ return useAppStore.getState().runtimeStatusByEnvironmentId?.get(environmentId)?.status === null
+}
+
export function registerRuntimeClientIpcBridge(
unsubs: (() => void)[],
worktreeRuntime: WorktreeEventRuntime
@@ -134,6 +149,103 @@ export function registerRuntimeClientIpcBridge(
})
}
+ const inFlightRuntimeStatusProbes = new Set()
+ const trailingRuntimeStatusProbes = new Map()
+ const runtimeStatusProbeRetryTimers = new Map>()
+ let runtimeStatusProbesStopped = false
+ // Why not the desired-subscription set alone: a host that is not the active environment
+ // drops out of it the moment anything records its status unreachable, so gating the
+ // retry on it cancels the retry exactly where recovery matters. Only removal
+ // (or a still-unhydrated catalog behind an active id) decides whether to keep asking,
+ // and the tombstone covers the window where settings still names a deleted host active.
+ const shouldProbeRuntimeStatus = (environmentId: string): boolean => {
+ const state = useAppStore.getState()
+ if (state.removedRuntimeEnvironmentIds?.has(environmentId)) {
+ return false
+ }
+ return (
+ (state.runtimeEnvironments ?? []).some((environment) => environment.id === environmentId) ||
+ getRuntimeClientEventEnvironmentIds(state).includes(environmentId)
+ )
+ }
+ // Why: concurrent probes resolve in arbitrary order, so a slow one can publish its
+ // stale answer over a newer one and leave the sidebar naming a superseded runtime.
+ const probeRuntimeStatus = (
+ environmentId: string,
+ attempt = 0,
+ trigger: RuntimeStatusProbeTrigger = 'reconnected'
+ ): void => {
+ if (runtimeStatusProbesStopped || !shouldProbeRuntimeStatus(environmentId)) {
+ return
+ }
+ if (inFlightRuntimeStatusProbes.has(environmentId)) {
+ // Serialize, don't drop: the in-flight answer predates this request, so a reconnect
+ // that lands mid-probe would otherwise go unasked — and a probe that succeeds
+ // schedules no retry to pick it up later. A reconnect outranks a queued
+ // recorded-unreachable request, which the in-flight answer may already settle.
+ if (trigger === 'reconnected' || !trailingRuntimeStatusProbes.has(environmentId)) {
+ trailingRuntimeStatusProbes.set(environmentId, trigger)
+ }
+ return
+ }
+ const pendingRetry = runtimeStatusProbeRetryTimers.get(environmentId)
+ if (pendingRetry !== undefined) {
+ clearTimeout(pendingRetry)
+ runtimeStatusProbeRetryTimers.delete(environmentId)
+ }
+ inFlightRuntimeStatusProbes.add(environmentId)
+ void dialRuntimeStatusReprobe(environmentId)
+ .catch(() => false)
+ .then((reachable) => {
+ inFlightRuntimeStatusProbes.delete(environmentId)
+ const trailingTrigger = trailingRuntimeStatusProbes.get(environmentId)
+ if (trailingTrigger !== undefined) {
+ trailingRuntimeStatusProbes.delete(environmentId)
+ // A newer reconnect asked while this one was dialing: restart the attempt chain.
+ // A resubscribe only asked because the cache read unreachable, so skip the extra
+ // socket + E2EE handshake when this answer already cleared that.
+ if (
+ trailingTrigger === 'reconnected' ||
+ isRuntimeStatusRecordedUnreachable(environmentId)
+ ) {
+ probeRuntimeStatus(environmentId, 0, trailingTrigger)
+ return
+ }
+ }
+ // Why: status.get dials its own short-lived socket, so it can fail while the
+ // control transport that just proved the host is up stays healthy. That failure
+ // is unverifiable and publishes nothing, so no store transition, resubscribe or
+ // further trigger follows — without this bounded retry one unlucky probe leaves
+ // a host already recorded offline stranded until the next transport gap.
+ const retryDelayMs = RUNTIME_STATUS_PROBE_RETRY_DELAYS_MS[attempt]
+ if (
+ reachable ||
+ retryDelayMs === undefined ||
+ runtimeStatusProbesStopped ||
+ !shouldProbeRuntimeStatus(environmentId)
+ ) {
+ return
+ }
+ runtimeStatusProbeRetryTimers.set(
+ environmentId,
+ setTimeout(() => {
+ runtimeStatusProbeRetryTimers.delete(environmentId)
+ probeRuntimeStatus(environmentId, attempt + 1)
+ }, retryDelayMs)
+ )
+ })
+ }
+ unsubs.push(() => {
+ // The flag, not just the timers: a probe still in flight at teardown would
+ // otherwise schedule a fresh retry chain after the bridge is gone.
+ runtimeStatusProbesStopped = true
+ trailingRuntimeStatusProbes.clear()
+ for (const retryTimer of runtimeStatusProbeRetryTimers.values()) {
+ clearTimeout(retryTimer)
+ }
+ runtimeStatusProbeRetryTimers.clear()
+ })
+
const runtimeClientEventsSync = createRuntimeClientEventsSync({
getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()),
getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]),
@@ -141,7 +253,7 @@ export function registerRuntimeClientIpcBridge(
const sshGeneration = getEnvironmentSshStateGeneration(environmentId)
const runtimeGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId)
const runtimeRevision = getRuntimeEnvironmentRevision(environmentId)
- return subscribeRuntimeClientEvents(
+ const subscription = subscribeRuntimeClientEvents(
environmentId,
(event) => {
if (
@@ -156,6 +268,7 @@ export function registerRuntimeClientIpcBridge(
() => {
invalidateRuntimeClientEventReplay({
getSshStateReference: () => useAppStore.getState().sshStateByEnvironment,
+ refreshRuntimeStatus: () => probeRuntimeStatus(environmentId),
requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId),
markEnvironmentSshStateStale: () =>
useAppStore.getState().markEnvironmentSshStateStale(environmentId),
@@ -165,6 +278,20 @@ export function registerRuntimeClientIpcBridge(
})
}
)
+ // Why: only a reconnect of an already-ready transport replays with the tag above.
+ // A connection whose first ready lands after the host recovered (app started, or
+ // the env was added, while it was down) never replays, so the recorded-unreachable
+ // verdict this subscribe just disproved has to be re-asked here. Kept off the
+ // returned promise so subscription registration/teardown ordering is unchanged.
+ void subscription.then(
+ () => {
+ if (isRuntimeStatusRecordedUnreachable(environmentId)) {
+ probeRuntimeStatus(environmentId, 0, 'recordedUnreachable')
+ }
+ },
+ () => {}
+ )
+ return subscription
},
onEvent: handleRuntimeClientEvent
})
diff --git a/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts
index b2fee2d646e..d6ec4dd5ec6 100644
--- a/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts
+++ b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts
@@ -178,6 +178,7 @@ export function createRuntimeEnvironmentStoreSyncSubscriber(
type RuntimeClientEventReplayInvalidationDeps = {
getSshStateReference: () => RuntimeEnvironmentStoreSyncState['sshStateByEnvironment']
+ refreshRuntimeStatus: () => void
requestProjectRefresh: () => void
markEnvironmentSshStateStale: () => void
hydrateEnvironmentSshState: () => Promise
@@ -193,6 +194,10 @@ type RuntimeClientEventReplayInvalidationDeps = {
export function invalidateRuntimeClientEventReplay(
deps: RuntimeClientEventReplayInvalidationDeps
): void {
+ // Why: nothing else re-probes status.get after a reconnect, so a host recorded
+ // unreachable during the gap stayed 'disconnected' in the sidebar forever while
+ // its terminals and RPCs worked again.
+ deps.refreshRuntimeStatus()
deps.requestProjectRefresh()
const previousSshStateReference = deps.getSshStateReference()
deps.markEnvironmentSshStateStale()
diff --git a/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts b/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts
new file mode 100644
index 00000000000..0d605a3fab1
--- /dev/null
+++ b/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts
@@ -0,0 +1,430 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { toast } from 'sonner'
+import { useAppStore } from '@/store'
+import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry'
+import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
+import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture'
+import { tagRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay'
+import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
+import type { RuntimeStatus } from '../../../../shared/runtime-types'
+import type { WorktreeEventRuntime } from './worktree-event-runtime'
+import { registerRuntimeClientIpcBridge } from './runtime-client-ipc-bridge'
+
+vi.mock('sonner', () => ({
+ toast: Object.assign(vi.fn(), {
+ warning: vi.fn(),
+ error: vi.fn(),
+ dismiss: vi.fn()
+ })
+}))
+
+const ENVIRONMENT_ID = 'env-devbox'
+
+function environment(): PublicKnownRuntimeEnvironment {
+ return {
+ id: ENVIRONMENT_ID,
+ name: 'Remote devbox',
+ createdAt: 1,
+ updatedAt: 1,
+ lastUsedAt: null,
+ runtimeId: null,
+ endpoints: [{ id: 'ws-a', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }],
+ preferredEndpointId: 'ws-a'
+ } as PublicKnownRuntimeEnvironment
+}
+
+/** What the sidebar host header renders from: 'available' is online, 'disconnected' is the offline GUI. */
+function sidebarHostHealth(): string | undefined {
+ const state = useAppStore.getState()
+ return buildExecutionHostRegistry({
+ repos: [],
+ settings: state.settings,
+ runtimeEnvironments: state.runtimeEnvironments,
+ runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId
+ }).find((host) => host.id === toRuntimeExecutionHostId(ENVIRONMENT_ID))?.health
+}
+
+function liveRuntimeStatus(): RuntimeStatus {
+ const response = createCompatibleRuntimeStatusResponse()
+ if (!response.ok) {
+ throw new Error('fixture must be a successful status response')
+ }
+ return response.result
+}
+
+async function settle(): Promise {
+ for (let index = 0; index < 60; index += 1) {
+ await Promise.resolve()
+ }
+}
+
+describe('remote Orca server reconnect', () => {
+ let unsubs: (() => void)[] = []
+ let stopBridge: (() => void) | null = null
+ let subscriptionResponders: {
+ selector: string
+ onResponse: (response: unknown) => void
+ }[] = []
+ let liveRuntimeId = 'remote-runtime'
+ let failingStatusProbes = 0
+ let blockNextStatusProbe = false
+ let releaseBlockedStatusProbe: (() => void) | null = null
+
+ beforeEach(() => {
+ subscriptionResponders = []
+ unsubs = []
+ liveRuntimeId = 'remote-runtime'
+ failingStatusProbes = 0
+ blockNextStatusProbe = false
+ releaseBlockedStatusProbe = null
+ // Module-level sonner double: without this a toast from an earlier test leaks into
+ // the assertions below.
+ vi.mocked(toast.warning).mockClear()
+ vi.stubGlobal('window', {
+ api: {
+ runtimeEnvironments: {
+ list: vi.fn(async () => [environment()]),
+ getStatus: vi.fn(async () => {
+ // Captured before the block so a probe that is still dialing answers with the
+ // runtime it was dispatched against, not with whatever restarted meanwhile.
+ const dispatchedRuntimeId = liveRuntimeId
+ if (blockNextStatusProbe) {
+ blockNextStatusProbe = false
+ await new Promise((resolve) => {
+ releaseBlockedStatusProbe = resolve
+ })
+ }
+ if (failingStatusProbes > 0) {
+ failingStatusProbes -= 1
+ // status.get dials its own socket; it can fail while the control transport is up.
+ return {
+ id: 'status.get',
+ ok: false,
+ error: {
+ code: 'runtime_unavailable',
+ message: 'probe socket refused'
+ },
+ _meta: { runtimeId: null }
+ }
+ }
+ return createCompatibleRuntimeStatusResponse(dispatchedRuntimeId)
+ }),
+ call: vi.fn(async () => ({ id: 'x', ok: true, result: [] })),
+ subscribe: vi.fn(
+ async (
+ args: { selector: string },
+ callbacks: { onResponse: (response: unknown) => void }
+ ): Promise<{ unsubscribe: () => void }> => {
+ subscriptionResponders.push({
+ selector: args.selector,
+ onResponse: callbacks.onResponse
+ })
+ return { unsubscribe: vi.fn() }
+ }
+ )
+ }
+ }
+ })
+ useAppStore.setState({
+ settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID } as never,
+ runtimeEnvironments: [environment()],
+ // Cleared explicitly: the removal case leaves a tombstone in this module-level
+ // store, which would silently suppress every later test's probes.
+ removedRuntimeEnvironmentIds: new Set(),
+ runtimeStatusByEnvironmentId: new Map([
+ [ENVIRONMENT_ID, { status: liveRuntimeStatus(), checkedAt: Date.now() }]
+ ]) as never
+ })
+ })
+
+ afterEach(() => {
+ stopBridge?.()
+ stopBridge = null
+ for (const unsub of unsubs.splice(0)) {
+ unsub()
+ }
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ })
+
+ /** Delivers the replay-tagged first response a shared-control reconnect produces. */
+ function replaySubscription(selector = ENVIRONMENT_ID): void {
+ const responder = subscriptionResponders.findLast((entry) => entry.selector === selector)
+ if (!responder) {
+ throw new Error(`no client-event subscription for ${selector}`)
+ }
+ responder.onResponse(
+ tagRuntimeSubscriptionReplayResponse({
+ id: 'sub',
+ ok: true,
+ result: { type: 'ready', snapshot: { sshStates: [] } }
+ })
+ )
+ }
+
+ function startBridge(): void {
+ stopBridge = registerRuntimeClientIpcBridge(unsubs, {
+ worktreeChangeRefreshQueue: { enqueue: vi.fn() },
+ activateNotifiedWorktree: vi.fn()
+ } as unknown as WorktreeEventRuntime)
+ }
+
+ it('re-probes a replayed subscription while the cached status still looks reachable', async () => {
+ startBridge()
+ await settle()
+ expect(sidebarHostHealth()).toBe('available')
+
+ // The gap was short enough that nothing probed during it, so the cached status still
+ // names the pre-restart runtime and the replay tag is the only evidence it is stale.
+ liveRuntimeId = 'remote-runtime-restarted'
+ replaySubscription()
+ await settle()
+
+ expect(
+ useAppStore.getState().runtimeStatusByEnvironmentId.get(ENVIRONMENT_ID)?.status?.runtimeId
+ ).toBe('remote-runtime-restarted')
+ expect(sidebarHostHealth()).toBe('available')
+ })
+
+ it('returns the sidebar host to online when the first subscription lands untagged', async () => {
+ // A connection that was never ready does not replay: nothing tags its first
+ // response, so a client that booted while the host was down has only the
+ // successful subscribe as evidence that the recorded verdict is stale.
+ useAppStore.setState({
+ runtimeStatusByEnvironmentId: new Map([
+ [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }]
+ ]) as never
+ })
+ expect(sidebarHostHealth()).toBe('disconnected')
+
+ startBridge()
+ await settle()
+
+ expect(sidebarHostHealth()).toBe('available')
+ expect(subscriptionResponders.length).toBeGreaterThan(0)
+ })
+
+ it('re-asks after a probe that failed while the transport stayed up', async () => {
+ vi.useFakeTimers()
+ // Already recorded unreachable, so the failing probe's `null` is an unchanged
+ // re-publication: it writes nothing and leaves no store transition for the
+ // resubscribe path to key off. Only a retry can still recover this host.
+ useAppStore.setState({
+ runtimeStatusByEnvironmentId: new Map([
+ [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }]
+ ]) as never
+ })
+ failingStatusProbes = 1
+
+ startBridge()
+ await settle()
+ expect(sidebarHostHealth()).toBe('disconnected')
+
+ await vi.advanceTimersByTimeAsync(2_000)
+ await settle()
+
+ expect(sidebarHostHealth()).toBe('available')
+ })
+
+ it('stops re-asking a host that keeps refusing, instead of polling it forever', async () => {
+ vi.useFakeTimers()
+ useAppStore.setState({
+ runtimeStatusByEnvironmentId: new Map([
+ [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }]
+ ]) as never
+ })
+ failingStatusProbes = Number.POSITIVE_INFINITY
+
+ startBridge()
+ await settle()
+ await vi.advanceTimersByTimeAsync(120_000)
+ await settle()
+
+ // One probe on the successful subscribe plus the two bounded retries.
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(3)
+ })
+
+ it('stops probing once the bridge is torn down mid-probe', async () => {
+ vi.useFakeTimers()
+ useAppStore.setState({
+ runtimeStatusByEnvironmentId: new Map([
+ [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }]
+ ]) as never
+ })
+ failingStatusProbes = Number.POSITIVE_INFINITY
+ blockNextStatusProbe = true
+
+ startBridge()
+ await settle()
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1)
+
+ // Teardown clears scheduled retries, but this probe has not answered yet.
+ stopBridge?.()
+ stopBridge = null
+ for (const unsub of unsubs.splice(0)) {
+ unsub()
+ }
+ releaseBlockedStatusProbe?.()
+ await settle()
+ await vi.advanceTimersByTimeAsync(120_000)
+ await settle()
+
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1)
+ })
+
+ it('re-asks for a saved host that is not the active environment', async () => {
+ vi.useFakeTimers()
+ // A non-active host is only in the desired-subscription set while its status is non-null,
+ // so gating the retry on that set would strand it offline with its subscription already
+ // torn down the moment anything else (an explicit disconnect, the toast's own retry)
+ // records the same outage.
+ useAppStore.setState({
+ settings: { activeRuntimeEnvironmentId: 'env-laptop' } as never
+ })
+ startBridge()
+ await settle()
+ expect(sidebarHostHealth()).toBe('available')
+
+ failingStatusProbes = 1
+ replaySubscription()
+ await settle()
+ useAppStore.getState().setRuntimeEnvironmentStatus(ENVIRONMENT_ID, {
+ status: null,
+ checkedAt: Date.now()
+ })
+ expect(sidebarHostHealth()).toBe('disconnected')
+
+ await vi.advanceTimersByTimeAsync(2_000)
+ await settle()
+
+ expect(sidebarHostHealth()).toBe('available')
+ })
+
+ it('re-asks after a reconnect that lands while an earlier probe is still dialing', async () => {
+ blockNextStatusProbe = true
+ startBridge()
+ await settle()
+ replaySubscription()
+ await settle()
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1)
+
+ // The host restarts while that probe is still on its own socket: the transport drops,
+ // reconnects and replays again. Serializing is right, dropping the request is not —
+ // the in-flight answer predates the restart this replay is reporting.
+ liveRuntimeId = 'remote-runtime-restarted'
+ replaySubscription()
+ await settle()
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1)
+
+ releaseBlockedStatusProbe?.()
+ await settle()
+
+ expect(
+ useAppStore.getState().runtimeStatusByEnvironmentId.get(ENVIRONMENT_ID)?.status?.runtimeId
+ ).toBe('remote-runtime-restarted')
+ })
+
+ it('does not resurrect a host removed while a retry was pending', async () => {
+ vi.useFakeTimers()
+ useAppStore.setState({
+ runtimeStatusByEnvironmentId: new Map([
+ [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }]
+ ]) as never
+ })
+ failingStatusProbes = Number.POSITIVE_INFINITY
+
+ startBridge()
+ await settle()
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1)
+
+ // The user deletes the remote server before the first retry fires. Settings can still
+ // name it as active until the next settings read, so removal is what has to stop this.
+ useAppStore.getState().setRuntimeEnvironments([])
+ await settle()
+ expect(useAppStore.getState().runtimeStatusByEnvironmentId.has(ENVIRONMENT_ID)).toBe(false)
+
+ await vi.advanceTimersByTimeAsync(120_000)
+ await settle()
+
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1)
+ // buildExecutionHostRegistry enumerates the status map, so a re-published entry for a
+ // deleted id puts that host back in the sidebar under its raw id.
+ expect(useAppStore.getState().runtimeStatusByEnvironmentId.has(ENVIRONMENT_ID)).toBe(false)
+ })
+
+ it('keeps a live cached status when the replay-triggered probe fails on its own socket', async () => {
+ startBridge()
+ await settle()
+ expect(sidebarHostHealth()).toBe('available')
+ // Asserted over every write, not just the end state: a demotion that a later probe
+ // undoes still flashed the sidebar offline and still fired the toast.
+ let recordedUnreachable = false
+ unsubs.push(
+ useAppStore.subscribe((state) => {
+ recordedUnreachable ||=
+ state.runtimeStatusByEnvironmentId.get(ENVIRONMENT_ID)?.status === null
+ })
+ )
+
+ // status.get dials its own short-lived socket, so its failure is unverifiable — and the
+ // transport that just replayed is proof the host is up. Recording it as offline would
+ // manufacture the stuck-offline sidebar this re-probe exists to cure.
+ failingStatusProbes = 1
+ replaySubscription()
+ await settle()
+
+ expect(recordedUnreachable).toBe(false)
+ expect(sidebarHostHealth()).toBe('available')
+ expect(toast.warning).not.toHaveBeenCalled()
+ })
+
+ it('returns a stuck-offline host to online when the replayed probe answers', async () => {
+ // The reported bug: the sidebar stayed offline after the connection recovered. The first
+ // probe failing keeps the recorded verdict unreachable, so only the replay recovers it
+ // (its retry chain is still parked behind a 2s timer this test never advances).
+ useAppStore.setState({
+ runtimeStatusByEnvironmentId: new Map([
+ [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }]
+ ]) as never
+ })
+ failingStatusProbes = 1
+ startBridge()
+ await settle()
+ expect(sidebarHostHealth()).toBe('disconnected')
+
+ replaySubscription()
+ await settle()
+
+ expect(sidebarHostHealth()).toBe('available')
+ })
+
+ it('does not dial a second status socket when the in-flight probe already answered', async () => {
+ startBridge()
+ await settle()
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(0)
+
+ // A reconnect re-probes unconditionally, and that probe is still on its own socket.
+ blockNextStatusProbe = true
+ replaySubscription()
+ await settle()
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1)
+
+ // Meanwhile the outage's own failed probe is recorded, which resubscribes; that
+ // resubscribe resolves against a cache that still reads unreachable.
+ useAppStore.getState().setRuntimeEnvironmentStatus(ENVIRONMENT_ID, {
+ status: null,
+ checkedAt: Date.now()
+ })
+ await settle()
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1)
+
+ releaseBlockedStatusProbe?.()
+ await settle()
+
+ // The resubscribe only wanted an answer for a host the cache called unreachable, and
+ // the probe it waited on gave one: a second status.get is a whole extra socket dial.
+ expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1)
+ expect(sidebarHostHealth()).toBe('available')
+ })
+})
diff --git a/src/renderer/src/hooks/ipc-events/runtime-status-reprobe.ts b/src/renderer/src/hooks/ipc-events/runtime-status-reprobe.ts
new file mode 100644
index 00000000000..aa7a8990c62
--- /dev/null
+++ b/src/renderer/src/hooks/ipc-events/runtime-status-reprobe.ts
@@ -0,0 +1,38 @@
+import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay'
+import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach'
+import { refreshRuntimeEnvironmentStatus } from '@/store/slices/runtime-status-refresh'
+import { useAppStore } from '../../store'
+
+/** Same wait as the store's status action, so a re-probe is no more impatient than any status.get. */
+const RUNTIME_STATUS_REPROBE_TIMEOUT_MS = 10_000
+
+/**
+ * Re-asks `status.get` after a reconnect and publishes only what it actually verified.
+ *
+ * The store's `refreshRuntimeEnvironmentStatus` records a failed probe as `null`, which is right
+ * for a user-initiated check but wrong here: this probe dials its own short-lived socket with a
+ * fresh handshake (`src/shared/remote-runtime-request-socket.ts`), so it can fail while the
+ * control transport that just replayed is demonstrably alive. That failure is `unverifiable`, not
+ * evidence the host is down — publishing it over a live cached verdict demotes the sidebar to
+ * offline behind a "Can't reach" toast, which is the exact symptom this re-probe exists to cure.
+ * A failed probe therefore leaves the cached verdict untouched and the caller's retry chain settles
+ * it. Resolves to whether the host answered, so callers can drive that chain.
+ */
+export function dialRuntimeStatusReprobe(environmentId: string): Promise {
+ return refreshRuntimeEnvironmentStatus(
+ environmentId,
+ RUNTIME_STATUS_REPROBE_TIMEOUT_MS,
+ (status) => {
+ if (status === null) {
+ return
+ }
+ useAppStore
+ .getState()
+ .setRuntimeEnvironmentStatus(environmentId, { status, checkedAt: Date.now() })
+ // Mirrors the store action's recovery follow-ups: the host that just answered may be holding
+ // pages this desktop hosts, and closes it never heard must not be lost to the reattach.
+ void ensureBrowserClientHostsForRestoredPages(useAppStore.getState())
+ void replayClientHostedBrowserCloseIntents(environmentId, useAppStore.getState())
+ }
+ )
+}
diff --git a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts
index c47487dd57e..ec150ac1099 100644
--- a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts
@@ -303,6 +303,7 @@ describe('createRuntimeEnvironmentStoreSyncSubscriber', () => {
describe('invalidateRuntimeClientEventReplay', () => {
it('explicitly syncs an advanced SSH generation when stale publication is a no-op and hydration fails', async () => {
const sshStateReference = new Map()
+ const refreshRuntimeStatus = vi.fn()
const requestProjectRefresh = vi.fn()
const markEnvironmentSshStateStale = vi.fn()
const sync = vi.fn()
@@ -312,6 +313,7 @@ describe('invalidateRuntimeClientEventReplay', () => {
invalidateRuntimeClientEventReplay({
getSshStateReference: () => sshStateReference,
+ refreshRuntimeStatus,
requestProjectRefresh,
markEnvironmentSshStateStale,
hydrateEnvironmentSshState,
@@ -319,6 +321,7 @@ describe('invalidateRuntimeClientEventReplay', () => {
})
await Promise.resolve()
+ expect(refreshRuntimeStatus).toHaveBeenCalledOnce()
expect(requestProjectRefresh).toHaveBeenCalledOnce()
expect(markEnvironmentSshStateStale).toHaveBeenCalledOnce()
expect(sync).toHaveBeenCalledOnce()
@@ -331,6 +334,7 @@ describe('invalidateRuntimeClientEventReplay', () => {
invalidateRuntimeClientEventReplay({
getSshStateReference: () => sshStateReference,
+ refreshRuntimeStatus: vi.fn(),
requestProjectRefresh: vi.fn(),
markEnvironmentSshStateStale: () => {
sshStateReference = new Map()
From 52ade074a9a92e1d2ce78bba02d03ba73c559c29 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:10:17 -0700
Subject: [PATCH 02/35] Display host on automation details and dialog (#16823)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Show automation host in details and support moving between hosts
- Rename AutomationCreateDestinationField to AutomationDestinationField to
reflect dual use in create and edit modes
- Add host display to automation detail view, showing storage authority
- Allow editing automations to move them to different hosts within same
authority; project list filters to available projects on chosen host
- Update copy from create-only terminology to mode-agnostic wording
* Display automation host and support cross-authority moves
Users can now move automations to different storage authorities. The
destination picker shows all available hosts, and selecting a new one
displays a warning about the move. The save creates the automation on
the destination and deletes it from the source; if deletion fails,
both copies remain and the user is notified.
* Remove cross-authority move support for automations
An automation's authority (the Orca instance that stores and schedules it)
cannot change; edits now only offer hosts within the same authority and
move logic is removed entirely. This simplifies the destination picker and
removes move-specific UI messaging.
* Fix undefined selectedRowKey in automation host recovery
Replace references to the undefined selectedRowKey variable with
selectedRow?.key to properly access the row's key when recovering
automation runs across hosts.
* Support moving automations across execution authorities
Allows users to move automations between different authorities (desktop ↔ runtime environments) during editing. A save to a different authority creates the automation on the destination and deletes the original with its run history. Includes clear messaging about the move operation, proper handling of workspace id resets, and graceful error handling when deletion fails. Supports destination-aware project and worktree fetching.
* Reuse creationKey across move retries when schedule changes
When retrying a failed automation move, dtstart is minted fresh each
attempt, changing the payload. Previously, operationKey included the
full payload, so retries would mint new creationKeys and risk duplicate
automations on the destination if the initial create failed in transport.
Now key only by the move (source + destination) to ensure stable
creationKey across retries.
Also fix workspace auto-selection to use authority-scoped worktrees
instead of the merged cache, preventing unwanted restoration of
source-host workspaces after switching authorities.
* Rename `note` to `moveWarning` for automation host moves
Clarifies that the field specifically warns when an automation would move to another host, replacing the plain storage line.
---
src/main/persistence-automations.test.ts | 22 +
.../automation-definition-operations.ts | 9 +
src/main/runtime/orca-runtime.ts | 1 +
.../runtime/rpc/methods/automation-schemas.ts | 1 +
...sx => AutomationDestinationField.test.tsx} | 41 +-
...eld.tsx => AutomationDestinationField.tsx} | 33 +-
.../AutomationDetail.host.test.tsx | 100 ++++
.../automations/AutomationDetail.tsx | 15 +
.../automations/AutomationEditorDialog.tsx | 9 +-
.../AutomationEditorSettingsSidebar.tsx | 15 +-
.../automations/AutomationsDetailPane.tsx | 5 +
...utomationsPage.create-destination.test.tsx | 268 +++++++++
...tionsPage.cross-authority-actions.test.tsx | 18 +
.../automations/AutomationsPage.tsx | 513 +++++++++++++++---
.../automation-capability-probe.ts | 14 +-
.../automations/automation-host-client.ts | 9 +
.../automation-host-detail-display.test.ts | 69 +++
.../automation-host-detail-display.ts | 36 ++
.../automation-scoped-list-client.test.ts | 6 +-
.../automation-scoped-list-client.ts | 4 +
.../automations-page-test-harness.tsx | 13 +-
.../use-automation-create-destination.ts | 4 +-
src/renderer/src/i18n/locales/en.json | 11 +-
src/renderer/src/i18n/locales/es.json | 17 +
src/renderer/src/i18n/locales/ja.json | 17 +
src/renderer/src/i18n/locales/ko.json | 17 +
src/renderer/src/i18n/locales/zh.json | 17 +
src/shared/automations-types.ts | 4 +
src/shared/protocol-version.ts | 8 +-
29 files changed, 1162 insertions(+), 134 deletions(-)
rename src/renderer/src/components/automations/{AutomationCreateDestinationField.test.tsx => AutomationDestinationField.test.tsx} (68%)
rename src/renderer/src/components/automations/{AutomationCreateDestinationField.tsx => AutomationDestinationField.tsx} (81%)
create mode 100644 src/renderer/src/components/automations/AutomationDetail.host.test.tsx
create mode 100644 src/renderer/src/components/automations/automation-host-detail-display.test.ts
create mode 100644 src/renderer/src/components/automations/automation-host-detail-display.ts
diff --git a/src/main/persistence-automations.test.ts b/src/main/persistence-automations.test.ts
index 605b7c35296..9db16b7e681 100644
--- a/src/main/persistence-automations.test.ts
+++ b/src/main/persistence-automations.test.ts
@@ -86,6 +86,28 @@ describe('Store', () => {
expect(persisted.automations[0].baseBranch).toBeNull()
})
+ it('returns the existing automation for a repeated creation key', async () => {
+ const store = await createStore()
+ store.addRepo(makeRepo())
+ const input = {
+ creationKey: 'move-retry-1',
+ name: 'Retry-safe move',
+ prompt: 'Run checks',
+ agentId: 'claude' as const,
+ projectId: 'r1',
+ workspaceMode: 'new_per_run' as const,
+ timezone: 'UTC',
+ rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
+ dtstart: new Date('2026-05-13T00:00:00Z').getTime()
+ }
+
+ const first = store.createAutomation(input)
+ const retry = store.createAutomation({ ...input, name: 'Changed by a retry' })
+
+ expect(retry.id).toBe(first.id)
+ expect(store.listAutomations()).toHaveLength(1)
+ })
+
it('persists session reuse only for existing-workspace automations', async () => {
const store = await createStore()
store.addRepo(makeRepo())
diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts
index 3107ae37a5a..93bb7de2cea 100644
--- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts
+++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts
@@ -50,6 +50,14 @@ export function createAutomation(
input: AutomationCreateInput,
options?: { destination?: AutomationDestination }
): Automation {
+ if (input.creationKey) {
+ const existing = (operations.state.automations ?? []).find(
+ (automation) => automation.creationKey === input.creationKey
+ )
+ if (existing) {
+ return existing
+ }
+ }
const repo = operations.state.repos.find((entry) => entry.id === input.projectId)
const now = Date.now()
const workspaceId = input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null
@@ -72,6 +80,7 @@ export function createAutomation(
const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? [])
const automation: Automation = {
id: randomUUID(),
+ ...(input.creationKey ? { creationKey: input.creationKey } : {}),
name: input.name.trim() || 'Untitled automation',
prompt: input.prompt,
precheck: normalizeAutomationPrecheck(input.precheck),
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 4e94b6d6872..8f30d27c3ba 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -4374,6 +4374,7 @@ export class OrcaRuntimeService {
throw new Error('Session reuse requires an existing workspace target.')
}
const createInput: AutomationCreateInput = {
+ creationKey: input.creationKey,
name: input.name,
prompt: input.prompt,
precheck: input.precheck,
diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts
index 4d4f43a2350..5e5dbe4e4ad 100644
--- a/src/main/runtime/rpc/methods/automation-schemas.ts
+++ b/src/main/runtime/rpc/methods/automation-schemas.ts
@@ -141,6 +141,7 @@ export const AutomationRuns = z.object({
})
export const AutomationCreate = z.object({
+ creationKey: OptionalString,
name: requiredString('Missing automation name'),
prompt: requiredString('Missing automation prompt'),
precheck: AutomationPrecheck,
diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx b/src/renderer/src/components/automations/AutomationDestinationField.test.tsx
similarity index 68%
rename from src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx
rename to src/renderer/src/components/automations/AutomationDestinationField.test.tsx
index e32e6500d5f..29d71acf0b8 100644
--- a/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx
+++ b/src/renderer/src/components/automations/AutomationDestinationField.test.tsx
@@ -4,7 +4,7 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
-import { AutomationCreateDestinationField } from './AutomationCreateDestinationField'
+import { AutomationDestinationField } from './AutomationDestinationField'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
@@ -61,10 +61,12 @@ const LEGACY_ENTRY: AutomationHostCatalogEntry = {
function control(
projects: Repo[],
- entries: AutomationHostCatalogEntry[] = [ENTRY]
+ entries: AutomationHostCatalogEntry[] = [ENTRY],
+ moveWarning?: string
): AutomationCreateDestinationControl {
return {
entries,
+ moveWarning,
resolution: {
status: 'ready',
authority: OWNER.authority,
@@ -76,11 +78,15 @@ function control(
}
}
-function render(projects: Repo[], entries?: AutomationHostCatalogEntry[]): void {
+function render(
+ projects: Repo[],
+ entries?: AutomationHostCatalogEntry[],
+ moveWarning?: string
+): void {
act(() => {
root.render(
-
+
)
})
@@ -90,7 +96,7 @@ function emptyNote(): HTMLElement | null {
return container.querySelector('[data-testid="automation-create-no-projects"]')
}
-describe('AutomationCreateDestinationField', () => {
+describe('AutomationDestinationField', () => {
it('names the host that has no projects rather than leaving Create dead', () => {
render([])
@@ -107,8 +113,10 @@ describe('AutomationCreateDestinationField', () => {
it('names the hosts a server update would let create, instead of hiding them', () => {
render([{ id: 'repo-1' } as Repo], [ENTRY, LEGACY_ENTRY])
- const note = container.querySelector('[data-testid="automation-create-update-required"]')
- expect(note?.textContent).toContain('legacy-box')
+ const updateRequired = container.querySelector(
+ '[data-testid="automation-create-update-required"]'
+ )
+ expect(updateRequired?.textContent).toContain('legacy-box')
})
it('says nothing about updates when every offered host is eligible', () => {
@@ -116,4 +124,23 @@ describe('AutomationCreateDestinationField', () => {
expect(container.querySelector('[data-testid="automation-create-update-required"]')).toBeNull()
})
+
+ it('states a move in place of the host it would otherwise just name', () => {
+ render([{ id: 'repo-1' } as Repo], undefined, 'Saving creates this automation on GPU box.')
+
+ // Both lines name the same host; the one that mentions the delete wins.
+ expect(container.querySelector('[data-testid="automation-host-move"]')?.textContent).toContain(
+ 'GPU box'
+ )
+ expect(container.textContent).not.toContain('Stored and scheduled by')
+ })
+
+ it('names the host without a tense, so editing reads as a move', () => {
+ render([{ id: 'repo-1' } as Repo])
+
+ // The same field serves both modes; create-only wording would misread on an
+ // existing record, whose save moves it rather than creating it.
+ expect(container.querySelector('[aria-label="Host"]')).not.toBeNull()
+ expect(container.textContent).not.toContain('created')
+ })
})
diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx b/src/renderer/src/components/automations/AutomationDestinationField.tsx
similarity index 81%
rename from src/renderer/src/components/automations/AutomationCreateDestinationField.tsx
rename to src/renderer/src/components/automations/AutomationDestinationField.tsx
index 43a4f2cc08c..e3ef789b670 100644
--- a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx
+++ b/src/renderer/src/components/automations/AutomationDestinationField.tsx
@@ -19,16 +19,11 @@ import {
import { groupAutomationHostEntriesByAuthority } from './automation-host-picker-groups'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
-/**
- * Where the new automation will be stored, named on the form before submit.
- *
- * This is the storage authority, not the workspace's execution host — the two
- * routinely differ, and only this one decides which machine keeps and schedules
- * the record. A concrete host filter only preselects: the list filter narrows a
- * view, so it must not decide where a new record is allowed to live.
- */
+// The host that stores and schedules the automation, not the workspace's
+// execution host — the two routinely differ. Serves create and edit alike; on an
+// existing record a save that lands elsewhere is a move.
-export function AutomationCreateDestinationField({
+export function AutomationDestinationField({
control,
labelClassName
}: {
@@ -42,7 +37,7 @@ export function AutomationCreateDestinationField({
control.entries.filter(automationCreateHostOffered)
)
const updateRequiredAuthorities = automationCreateUpdateRequiredAuthorityLabels(control.entries)
- const label = translate('auto.components.automations.createDestination.label', 'Create on')
+ const label = translate('auto.components.automations.createDestination.label', 'Host')
return (
@@ -84,6 +79,12 @@ export function AutomationCreateDestinationField({
'No projects are set up on {host}. Add one there, or choose another host.'
).replace('{host}', selected.label)}
+ ) : control.moveWarning ? (
+ // Replaces the storedOn line: both name the same host, and the move is
+ // the consequential half.
+
+ {control.moveWarning}
+
) : (
{selected
@@ -93,7 +94,7 @@ export function AutomationCreateDestinationField({
).replace('{authority}', selected.authorityLabel)
: translate(
'auto.components.automations.createDestination.unselected',
- 'Choose the host this automation will be created on.'
+ 'Choose the host that stores and schedules this automation.'
)}
)}
@@ -103,11 +104,13 @@ export function AutomationCreateDestinationField({
className="text-xs text-muted-foreground"
data-testid="automation-create-update-required"
>
- {translate(
- 'auto.components.automations.createDestination.updateRequired',
- 'Update the Orca server on {hosts} to create automations there.'
+ {
// Replacer fn: a literal replacement would expand `$` patterns in host labels.
- ).replace('{hosts}', () => updateRequiredAuthorities.join(', '))}
+ translate(
+ 'auto.components.automations.createDestination.updateRequired',
+ 'Update the Orca server on {hosts} to store automations there.'
+ ).replace('{hosts}', () => updateRequiredAuthorities.join(', '))
+ }
) : null}
diff --git a/src/renderer/src/components/automations/AutomationDetail.host.test.tsx b/src/renderer/src/components/automations/AutomationDetail.host.test.tsx
new file mode 100644
index 00000000000..14fe6ef799d
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationDetail.host.test.tsx
@@ -0,0 +1,100 @@
+// @vitest-environment happy-dom
+
+/**
+ * The detail view never said which machine an automation lived on, so two rows
+ * from different hosts read identically. These pin the host to the row's own
+ * catalog entry rather than to the record's host-local execution target.
+ */
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import type { Automation } from '../../../../shared/automations-types'
+import { AutomationDetail } from './AutomationDetail'
+import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
+import { makeAutomation } from './automations-page-fixtures'
+
+const roots: Root[] = []
+
+const RUNTIME_ENTRY: AutomationHostCatalogEntry = {
+ stableRef: { authority: { kind: 'runtime', environmentId: 'r1' }, selector: { kind: 'self' } },
+ owner: {
+ authority: { kind: 'runtime', environmentId: 'r1', pairingRevision: 1 },
+ selector: { kind: 'self' }
+ },
+ stableKey: 'host:runtime:r1:self',
+ label: 'GPU box',
+ authorityLabel: 'GPU box',
+ kind: 'self',
+ catalogState: 'authoritative',
+ authorityHealth: 'fresh',
+ executionHealth: 'connected',
+ querySupport: 'scoped'
+}
+
+async function render(
+ overrides: Partial,
+ hostEntry: AutomationHostCatalogEntry | null
+): Promise {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ roots.push(root)
+ await act(async () => {
+ root.render(
+
+
+
+ )
+ })
+ return container
+}
+
+function hostMetric(container: HTMLDivElement): string | null {
+ const label = [...container.querySelectorAll('div')].find(
+ (node) => node.textContent === 'Host' && node.className.includes('uppercase')
+ )
+ return label?.nextElementSibling?.textContent ?? null
+}
+
+beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+})
+
+afterEach(async () => {
+ await act(async () => {
+ roots.splice(0).forEach((root) => root.unmount())
+ })
+ document.body.innerHTML = ''
+})
+
+describe('AutomationDetail host', () => {
+ it('names the host the row was listed from', async () => {
+ // executionTargetType 'local' means local to the runtime, so the record
+ // alone would have this read as this Mac.
+ const container = await render({ executionTargetType: 'local' }, RUNTIME_ENTRY)
+
+ expect(hostMetric(container)).toBe('GPU box')
+ })
+
+ it('falls back to the record’s own target when no host answered for the row', async () => {
+ const container = await render({ executionTargetType: 'local' }, null)
+
+ expect(hostMetric(container)).toBe('Local Mac')
+ })
+})
diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx
index a15c56e5abf..11a7545a1d3 100644
--- a/src/renderer/src/components/automations/AutomationDetail.tsx
+++ b/src/renderer/src/components/automations/AutomationDetail.tsx
@@ -14,6 +14,8 @@ import {
summarizeAutomationRunUsage
} from './automation-usage-model'
import type { AutomationTargetAvailability } from './automation-target-availability'
+import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
+import { getAutomationHostDetailDisplay } from './automation-host-detail-display'
import { getAutomationSourceDisplay } from './automation-source-display'
import { translate } from '@/i18n/i18n'
import { AutomationPromptDisclosure } from './AutomationPromptDisclosure'
@@ -24,6 +26,8 @@ type AutomationDetailProps = {
projectName: string
workspaceName: string
projectDefaultBaseRef: string | null
+ /** The catalog entry the selected row was listed from; absent for legacy unscoped rows. */
+ hostEntry?: AutomationHostCatalogEntry | null
hostLabelById?: ReadonlyMap
runNowAvailability: AutomationTargetAvailability | null
now: number
@@ -101,6 +105,7 @@ export function AutomationDetail({
projectName,
workspaceName,
projectDefaultBaseRef,
+ hostEntry,
hostLabelById,
runNowAvailability,
now,
@@ -133,6 +138,11 @@ export function AutomationDetail({
? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default')
: workspaceName
const sourceDisplay = getAutomationSourceDisplay(automation.sourceContext, hostLabelById)
+ const hostDisplay = getAutomationHostDetailDisplay({
+ automation,
+ entry: hostEntry,
+ hostLabelById
+ })
const runNowDisabled = runNowAvailability?.canRunNow === false
return (
@@ -238,6 +248,11 @@ export function AutomationDetail({
: 'Paused'
}
/>
+ void
@@ -104,6 +106,7 @@ export function AutomationEditorDialog({
settings,
draft,
createDestination,
+ editDestination,
notice,
onNoticeRecover,
onNoticeDismiss,
@@ -122,6 +125,7 @@ export function AutomationEditorDialog({
const isHermesTarget = createTarget === 'hermes'
const isCreateMode = !isEditing && !isEditingExternal
const isHermesCreate = isCreateMode && isHermesTarget
+ const destination = isCreateMode ? createDestination : editDestination
const visibleAgents = React.useMemo(() => {
const enabledIds = new Set(
filterEnabledTuiAgents(
@@ -186,8 +190,7 @@ export function AutomationEditorDialog({
onDismiss={() => onOpenChange(false)}
/>
- {createDestination && isCreateMode && !isHermesTarget ? (
+ {destination && !isHermesTarget ? (
-
diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx
index f596c21c36b..7eb2ff13c1d 100644
--- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx
+++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx
@@ -36,6 +36,7 @@ import {
import { getAutomationRunContent } from './automation-run-content'
import type { AutomationActionNotice } from './automation-row-action-dispatch'
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
+import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationTargetAvailability } from './automation-target-availability'
import type { AutomationRunViewState } from './automation-run-view-state'
import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
@@ -56,6 +57,8 @@ type AutomationsDetailPaneProps = {
selectedRepoDisplayName: string
selectedRepoDefaultBaseRef: string | null
selectedWorkspaceName: string
+ /** Catalog entry the selected row was listed from; absent for legacy unscoped rows. */
+ selectedHostEntry: AutomationHostCatalogEntry | null
hostLabelById: ReadonlyMap
selectedRunNowAvailability: AutomationTargetAvailability | null
selectedAutomationRunPageWorkspaceDisplay: AutomationRunWorkspaceDisplay | null
@@ -107,6 +110,7 @@ export function AutomationsDetailPane({
selectedRepoDisplayName,
selectedRepoDefaultBaseRef,
selectedWorkspaceName,
+ selectedHostEntry,
hostLabelById,
selectedRunNowAvailability,
selectedAutomationRunPageWorkspaceDisplay,
@@ -221,6 +225,7 @@ export function AutomationsDetailPane({
projectName={selectedRepoDisplayName}
projectDefaultBaseRef={selectedRepoDefaultBaseRef}
workspaceName={selectedWorkspaceName}
+ hostEntry={selectedHostEntry}
hostLabelById={hostLabelById}
runNowAvailability={selectedRunNowAvailability}
now={relativeNow}
diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx
index da04a8cd58f..a0778029ef9 100644
--- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx
@@ -32,6 +32,7 @@ import {
} from './automations-page-test-harness'
import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures'
import type { Repo } from '../../../../shared/repo-types'
+import type { ProjectHostSetup } from '../../../../shared/project-types'
installAutomationsPageHarness()
@@ -71,6 +72,52 @@ function addSshProject(): void {
} as Repo
mocks.state.repos = [...(mocks.state.repos as Repo[]), repo]
mocks.repoMap.set(SSH_REPO_ID, repo)
+ // Without a ready setup on that host the project has no run context, and a
+ // save against it is refused before any destination is stated.
+ mocks.state.projectHostSetups = [
+ ...(mocks.state.projectHostSetups as ProjectHostSetup[]),
+ {
+ id: 'setup-ssh',
+ projectId: 'project-ssh',
+ hostId: `ssh:${SSH_TARGET_ID}`,
+ repoId: SSH_REPO_ID,
+ path: '/repos/orca',
+ displayName: 'orca',
+ setupState: 'ready',
+ setupMethod: 'legacy-repo',
+ createdAt: 1,
+ updatedAt: 1
+ }
+ ]
+}
+
+/** A runtime project with the same host-local id as the desktop project. */
+function addCollidingRuntimeProject(): void {
+ const repo = {
+ id: REPO_ID,
+ displayName: 'orca on m4-air',
+ path: '/repos/orca',
+ badgeColor: '#111111',
+ addedAt: 1,
+ worktreeBaseRef: 'main',
+ executionHostId: `runtime:${RUNTIME_ID}`
+ } as Repo
+ mocks.state.repos = [...(mocks.state.repos as Repo[]), repo]
+ mocks.state.projectHostSetups = [
+ ...(mocks.state.projectHostSetups as ProjectHostSetup[]),
+ {
+ id: 'setup-runtime-collision',
+ projectId: 'project-runtime-collision',
+ hostId: `runtime:${RUNTIME_ID}`,
+ repoId: REPO_ID,
+ path: '/repos/orca',
+ displayName: 'orca on m4-air',
+ setupState: 'ready',
+ setupMethod: 'legacy-repo',
+ createdAt: 1,
+ updatedAt: 1
+ }
+ ]
}
/** The runtime answers a create; without this the RPC double returns an empty result. */
@@ -395,6 +442,227 @@ describe('AutomationsPage edit dialog projects', () => {
})
})
+describe('AutomationsPage edit destination', () => {
+ it('names the edited row’s own host, and offers every host it could move to', async () => {
+ const automation = makeAutomation({ id: 'a-1' })
+ api.automations.list.mockResolvedValue([])
+ scopedList([automation])
+ addSshHost()
+ addSshProject()
+ runtimeHost([], [])
+
+ await renderPage()
+ await settleHostQueries()
+ await act(async () => {
+ void mocks.listPanel?.openEditDialog(listedRow(automation.id))
+ })
+
+ const resolution = mocks.editorDialog?.editDestination?.resolution
+ expect(resolution?.status === 'ready' && resolution.entry.stableKey).toBe(DESKTOP_SELF_KEY)
+ // Another authority is a legal destination — reached by a create there and a
+ // delete here — so restricting the list to this one would hide a real move.
+ expect(mocks.editorDialog?.editDestination?.entries.map((entry) => entry.stableKey)).toContain(
+ RUNTIME_SELF_KEY
+ )
+ expect(mocks.editorDialog?.editDestination?.moveWarning).toBeFalsy()
+ })
+
+ it('moves the record across authorities by creating there and deleting here', async () => {
+ const automation = makeAutomation({ id: 'a-1' })
+ api.automations.list.mockResolvedValue([])
+ scopedList([automation])
+ runtimeHost([], [])
+ addRuntimeProject()
+ runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID }))
+
+ await renderPage()
+ await settleHostQueries()
+ await act(async () => {
+ void mocks.listPanel?.openEditDialog(listedRow(automation.id))
+ })
+ await act(async () => {
+ mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY)
+ })
+
+ expect(mocks.editorDialog?.editDestination?.moveWarning).toBeTruthy()
+
+ await act(async () => {
+ mocks.editorDialog?.onDraftChange((current) => ({
+ ...(current as Record),
+ projectId: RUNTIME_REPO_ID,
+ workspaceMode: 'existing',
+ workspaceId: RUNTIME_WORKSPACE_ID
+ }))
+ })
+ await save()
+
+ expect(api.automations.update).not.toHaveBeenCalled()
+ expect(runtimeCreateCalls()).toHaveLength(1)
+ expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({
+ repo: `id:${RUNTIME_REPO_ID}`,
+ dtstart: automation.dtstart
+ })
+ expect(api.automations.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'a-1' }))
+ })
+
+ it('moves when the destination project has the same host-local id', async () => {
+ const automation = makeAutomation({ id: 'a-1' })
+ api.automations.list.mockResolvedValue([])
+ scopedList([automation])
+ runtimeHost([], [])
+ addCollidingRuntimeProject()
+ runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: REPO_ID }))
+
+ await renderPage()
+ await settleHostQueries()
+ await act(async () => {
+ void mocks.listPanel?.openEditDialog(listedRow(automation.id))
+ })
+ await act(async () => {
+ mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY)
+ })
+ await save()
+
+ expect(runtimeCreateCalls()).toHaveLength(1)
+ expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ repo: `id:${REPO_ID}` })
+ expect(api.automations.delete).toHaveBeenCalled()
+ })
+
+ it('reuses the move creation key on retry even when the schedule changed', async () => {
+ const automation = makeAutomation({ id: 'a-1' })
+ api.automations.list.mockResolvedValue([])
+ scopedList([automation])
+ runtimeHost([], [])
+ addRuntimeProject()
+ // The first create dies in transport after the server may have committed;
+ // only an identical creationKey on the retry keeps that copy singular.
+ let failCreate = true
+ const previous = mocks.callRuntimeRpc.getMockImplementation()
+ mocks.callRuntimeRpc.mockImplementation(
+ async (target: unknown, method: string, params: unknown, options: unknown) => {
+ if (method === 'automation.create') {
+ if (failCreate) {
+ failCreate = false
+ throw new Error('socket hang up')
+ }
+ return { automation: makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID }) }
+ }
+ return await previous?.(target, method, params, options)
+ }
+ )
+
+ await renderPage()
+ await settleHostQueries()
+ await act(async () => {
+ void mocks.listPanel?.openEditDialog(listedRow(automation.id))
+ })
+ await act(async () => {
+ mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY)
+ })
+ await act(async () => {
+ mocks.editorDialog?.onDraftChange((current) => ({
+ ...(current as Record),
+ projectId: RUNTIME_REPO_ID,
+ workspaceMode: 'existing',
+ workspaceId: RUNTIME_WORKSPACE_ID,
+ // A changed schedule mints dtstart per attempt — the volatile input that
+ // must not re-key the move.
+ preset: 'custom',
+ customSchedule: 'FREQ=DAILY;BYHOUR=10;BYMINUTE=0'
+ }))
+ })
+
+ const realNow = Date.now.bind(Date)
+ let clockSkew = 0
+ const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => realNow() + clockSkew)
+ try {
+ await save()
+ clockSkew = 5000
+ await save()
+ } finally {
+ nowSpy.mockRestore()
+ }
+
+ const creationKeys = runtimeCreateCalls().map(
+ (call) => (call[2] as { creationKey?: string }).creationKey
+ )
+ expect(creationKeys).toHaveLength(2)
+ expect(creationKeys[0]).toBeTruthy()
+ expect(creationKeys[1]).toBe(creationKeys[0])
+ expect(api.automations.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'a-1' }))
+ })
+
+ it('reports a kept original as two live copies, never as a completed move', async () => {
+ const automation = makeAutomation({ id: 'a-1' })
+ api.automations.list.mockResolvedValue([])
+ scopedList([automation])
+ runtimeHost([], [])
+ addRuntimeProject()
+ runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID }))
+ api.automations.delete.mockRejectedValue(new Error('gone away'))
+
+ await renderPage()
+ await settleHostQueries()
+ await act(async () => {
+ void mocks.listPanel?.openEditDialog(listedRow(automation.id))
+ })
+ await act(async () => {
+ mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY)
+ })
+ await act(async () => {
+ mocks.editorDialog?.onDraftChange((current) => ({
+ ...(current as Record),
+ projectId: RUNTIME_REPO_ID,
+ workspaceMode: 'existing',
+ workspaceId: RUNTIME_WORKSPACE_ID
+ }))
+ })
+ await save()
+
+ expect(runtimeCreateCalls()).toHaveLength(1)
+ expect(api.automations.delete).toHaveBeenCalled()
+ expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('could not be deleted'))
+ expect(mocks.toastSuccess).not.toHaveBeenCalled()
+ })
+
+ it('moves the record to the host the user picks, clearing the stranded project', async () => {
+ const automation = makeAutomation({ id: 'a-1' })
+ api.automations.list.mockResolvedValue([])
+ scopedList([automation])
+ addSshHost()
+ addSshProject()
+
+ await renderPage()
+ await settleHostQueries()
+ await act(async () => {
+ void mocks.listPanel?.openEditDialog(listedRow(automation.id))
+ })
+ await act(async () => {
+ mocks.editorDialog?.editDestination?.onSelect(SSH_HOST_KEY)
+ })
+
+ // Keeping the local project selected only defers the same refusal to submit.
+ expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([SSH_REPO_ID])
+ expect(mocks.editorDialog?.draft?.projectId).toBe('')
+
+ await act(async () => {
+ mocks.editorDialog?.onDraftChange((current) => ({
+ ...(current as Record),
+ projectId: SSH_REPO_ID,
+ workspaceMode: 'new_per_run',
+ workspaceId: ''
+ }))
+ })
+ await save()
+
+ expect(api.automations.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ destination: { selector: { kind: 'ssh', targetId: SSH_TARGET_ID, targetGeneration: 1 } }
+ })
+ )
+ })
+})
+
describe('AutomationsPage create admission', () => {
it('keeps the create button available while every offered host is ineligible', async () => {
// No host has answered, so nothing resolves ready — but the dialog is where
diff --git a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx
index 5c7225a9bd9..618c092b45a 100644
--- a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx
@@ -53,6 +53,24 @@ function runtimeAutomationCalls(): string[] {
}
describe('AutomationsPage row actions under a colliding automation id', () => {
+ it('names the selected row’s own host, not the desktop that listed it', async () => {
+ await collidingHosts()
+ await renderPage()
+ await settleHostQueries()
+
+ const remote = mocks.listPanel?.filteredRows.find(
+ (candidate) => candidate.automation.name === 'Remote nightly'
+ )
+ await act(async () => {
+ mocks.listPanel?.selectAutomationRow(remote?.key ?? '')
+ })
+
+ // The runtime copy's own execution target reads `local` — local to that
+ // server — so only the row's catalog entry can name the host on the detail view.
+ expect(mocks.detailPane?.selected?.name).toBe('Remote nightly')
+ expect(mocks.detailPane?.selectedHostEntry?.authorityLabel).toBe('GPU box')
+ })
+
it('runs the selected desktop row on the desktop, not on the runtime holding the same id', async () => {
await collidingHosts()
const { container } = await renderPage()
diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx
index 7eef4ac6f57..1ff12cf347a 100644
--- a/src/renderer/src/components/automations/AutomationsPage.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.tsx
@@ -26,7 +26,9 @@ import {
getLocalExecutionHostLabel,
getRepoExecutionHostId,
getWorktreeExecutionHostId,
- parseExecutionHostId
+ parseExecutionHostId,
+ toRuntimeExecutionHostId,
+ type ExecutionHostId
} from '../../../../shared/execution-host'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
@@ -75,6 +77,7 @@ import {
import {
deleteAutomationForTarget,
type AutomationHostTarget,
+ getAutomationAuthorityTarget,
getAutomationHostTargetFromKey,
getAutomationHostTargetKey,
getAutomationListTarget,
@@ -139,13 +142,18 @@ import {
groupReposByAutomationAuthority
} from './automation-authority-identity'
import {
+ automationCreateEligibleProjects,
automationCreateHostOffered,
automationCreateHostStableKey,
+ automationCreateProjectMismatch,
resolveAutomationCreateDestination,
revalidateAutomationCreateDestination,
type AutomationCreateDestination
} from './automation-create-destination'
-import { useAutomationCreateDestination } from './use-automation-create-destination'
+import {
+ useAutomationCreateDestination,
+ type AutomationCreateDestinationControl
+} from './use-automation-create-destination'
import { persistSkipDeleteAutomationConfirm } from './automation-delete-confirm-preference'
import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft'
import { createAutomationAtDestination } from './automation-owner-action-runner'
@@ -193,6 +201,7 @@ export default function AutomationsPage(): React.JSX.Element {
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
+ const fetchRuntimeEnvironmentRepos = useAppStore((s) => s.fetchRuntimeEnvironmentRepos)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
const updateSettings = useAppStore((s) => s.updateSettings)
@@ -257,6 +266,7 @@ export default function AutomationsPage(): React.JSX.Element {
// Separate from the page notice: while the editor is open it covers the page,
// so a refusal posted there is a save that visibly did nothing at all.
const [editorNotice, setEditorNotice] = useState(null)
+ const [editorNoticeHost, setEditorNoticeHost] = useState(null)
const [externalActionKey, setExternalActionKey] = useState(null)
const [rerunRunIdsInFlight, setRerunRunIdsInFlight] = useState>(
() => new Set()
@@ -277,6 +287,12 @@ export default function AutomationsPage(): React.JSX.Element {
projectId: string
destination: AutomationCreateDestination
} | null>(null)
+ // The host the edit form names. It filters the project list; the destination a
+ // save commits still comes from the chosen project, which is what moves the record.
+ const [editingHostStableKey, setEditingHostStableKey] = useState(null)
+ // Reuse a move's create key after an ambiguous transport failure so retrying
+ // cannot schedule a second copy on the destination authority.
+ const moveCreationKeysRef = useRef(new Map())
const [relativeNow, setRelativeNow] = useState(Date.now())
const [activePaneTab, setActivePaneTab] = useState('overview')
const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState(
@@ -503,32 +519,70 @@ export default function AutomationsPage(): React.JSX.Element {
(
candidate: Pick
): AutomationDraft['setupDecision'] => {
- const settingsForRepo = getSettingsForRepoRuntimeOwner(
- { repos, settings },
- candidate.projectId
- )
- const hookKey = `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}`
+ // During an edit the selected destination, not the ambient repo-id lookup,
+ // owns setup policy. Repo ids can collide across runtime authorities.
+ const selectedEditEntry =
+ editingAutomationId !== null && editingHostStableKey
+ ? hostCatalog.entries.find((entry) => entry.stableKey === editingHostStableKey)
+ : null
+ const selectedEditAuthority = selectedEditEntry?.stableRef.authority
+ const setupHostId =
+ selectedEditAuthority?.kind === 'runtime'
+ ? toRuntimeExecutionHostId(selectedEditAuthority.environmentId)
+ : undefined
+ const setupRepos =
+ selectedEditAuthority?.kind === 'runtime'
+ ? getAutomationCreateRepos(repos, {
+ kind: 'environment',
+ environmentId: selectedEditAuthority.environmentId
+ })
+ : repos
+ const setupProjectHostSetups = setupHostId
+ ? projectHostSetups.filter(
+ (setup) => setup.repoId !== candidate.projectId || setup.hostId === setupHostId
+ )
+ : projectHostSetups
+ const settingsForRepo = setupHostId
+ ? {
+ ...settings,
+ activeRuntimeEnvironmentId:
+ selectedEditAuthority?.kind === 'runtime' ? selectedEditAuthority.environmentId : null
+ }
+ : getSettingsForRepoRuntimeOwner({ repos, settings }, candidate.projectId)
+ const hookKey = `${setupHostId ?? settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}`
return getVisibleAutomationSetupDecision({
createTarget,
workspaceMode: candidate.workspaceMode,
repoId: candidate.projectId,
- repos,
- projectHostSetups,
+ repos: setupRepos,
+ projectHostSetups: setupProjectHostSetups,
yamlHooks: automationYamlHooksByRepoKey[hookKey]
})
},
- [automationYamlHooksByRepoKey, createTarget, projectHostSetups, repos, settings]
+ [
+ automationYamlHooksByRepoKey,
+ createTarget,
+ editingAutomationId,
+ editingHostStableKey,
+ hostCatalog.entries,
+ projectHostSetups,
+ repos,
+ settings
+ ]
)
const getAutomationHooksCacheKey = useCallback(
- (repoId: string): string => {
+ (repoId: string, hostId?: ExecutionHostId): string => {
+ if (hostId) {
+ return `${hostId}:${repoId}`
+ }
const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId)
return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}`
},
[repos, settings]
)
const loadAutomationYamlHooksForRepo = useCallback(
- async (repoId: string): Promise => {
- const key = getAutomationHooksCacheKey(repoId)
+ async (repoId: string, hostId?: ExecutionHostId): Promise => {
+ const key = getAutomationHooksCacheKey(repoId, hostId)
if (Object.hasOwn(automationYamlHooksByRepoKey, key)) {
return automationYamlHooksByRepoKey[key] ?? null
}
@@ -537,7 +591,7 @@ export default function AutomationsPage(): React.JSX.Element {
return (await existingPromise).hooks
}
const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId)
- const promise = checkRuntimeHooks(settingsForRepo, repoId)
+ const promise = checkRuntimeHooks(settingsForRepo, repoId, hostId)
.then((result) => ({
hooks: result.status === 'error' ? null : ((result.hooks as OrcaHooks | null) ?? null),
ok: result.status !== 'error'
@@ -604,10 +658,6 @@ export default function AutomationsPage(): React.JSX.Element {
})
)
}, [repoForRow, selectedRow, selectedRuns, worktreeForRow, worktreeMap])
- const worktrees = useMemo(
- () => worktreesByRepo[draft.projectId] ?? [],
- [draft.projectId, worktreesByRepo]
- )
const automationHostTarget = useMemo(
() => getAutomationHostTargetFromKey(automationHostTargetKey),
[automationHostTargetKey]
@@ -658,6 +708,9 @@ export default function AutomationsPage(): React.JSX.Element {
createDestination.control.resolution.authority.kind === 'runtime'
? createDestination.control.resolution.authority.environmentId
: null
+ const createDestinationHostId = createDestinationRuntimeEnvironmentId
+ ? toRuntimeExecutionHostId(createDestinationRuntimeEnvironmentId)
+ : undefined
useEffect(() => {
if (createDestinationRuntimeEnvironmentId) {
void useAppStore
@@ -923,7 +976,9 @@ export default function AutomationsPage(): React.JSX.Element {
const canSaveDraft =
editingAutomationId === null ||
!draftAtOpen ||
- JSON.stringify(draft) !== JSON.stringify(draftAtOpen)
+ JSON.stringify(draft) !== JSON.stringify(draftAtOpen) ||
+ (editingAutomationId !== null &&
+ editingHostStableKey !== rowRecoveryHost(editingRowKey)?.stableKey)
const getAutomationRepoHostLabel = useCallback(
(repo: Repo): string => {
const hostId = getRepoExecutionHostId(repo)
@@ -1015,14 +1070,127 @@ export default function AutomationsPage(): React.JSX.Element {
)
})()
const isOrcaForm = createTarget === 'orca' && editingExternalTarget === null
+ const dialogAuthorityRepos = getAutomationCreateRepos(repos, automationDialogTarget)
+ // The authority storing the edited record; a save landing on another one is a move.
+ const dialogAuthorityKey = automationAuthorityCatalogKey(
+ automationDialogTarget.kind === 'environment'
+ ? { kind: 'runtime', environmentId: automationDialogTarget.environmentId }
+ : { kind: 'desktop' }
+ )
+ const editHostEntries = hostCatalog.entries
+ const editHostResolution = resolveAutomationCreateDestination(
+ editingHostStableKey
+ ? editHostEntries.find((entry) => entry.stableKey === editingHostStableKey)
+ : null
+ )
+ const editHostProjects =
+ editHostResolution.status === 'ready'
+ ? automationCreateEligibleProjects(
+ repoTables,
+ editHostResolution,
+ getAutomationCreateRepos(
+ repos,
+ getAutomationAuthorityTarget(editHostResolution.authority)
+ )
+ )
+ : dialogAuthorityRepos
+ const editMoveTargetEntry =
+ editHostResolution.status === 'ready' &&
+ automationAuthorityCatalogKey(editHostResolution.authority) !== dialogAuthorityKey
+ ? editHostResolution.entry
+ : null
+ const editDestinationRuntimeEnvironmentId =
+ editHostResolution.status === 'ready' && editHostResolution.authority.kind === 'runtime'
+ ? editHostResolution.authority.environmentId
+ : null
+ useEffect(() => {
+ if (editDestinationRuntimeEnvironmentId) {
+ void fetchRuntimeEnvironmentRepos(editDestinationRuntimeEnvironmentId)
+ }
+ }, [editDestinationRuntimeEnvironmentId, fetchRuntimeEnvironmentRepos])
+ // Picking a host strands a project that lives on another one; clearing it here
+ // states the move instead of deferring the same refusal to submit.
+ const handleEditHostChange = (stableKey: string): void => {
+ setEditingHostStableKey(stableKey)
+ const resolved = resolveAutomationCreateDestination(
+ editHostEntries.find((entry) => entry.stableKey === stableKey)
+ )
+ const projectId = draftRef.current.projectId
+ if (
+ projectId &&
+ resolved.status === 'ready' &&
+ !automationCreateProjectMismatch(repoTables, resolved, projectId)
+ ) {
+ setEditingDestination({ projectId, destination: resolved })
+ // Workspace ids are host-local. Never carry the source host's workspace
+ // into a move, even when the destination happens to reuse the project id.
+ if (editingHostStableKey !== stableKey) {
+ setDraft((current) => ({ ...current, workspaceId: '', baseBranch: '' }))
+ if (resolved.authority.kind === 'runtime') {
+ void fetchWorktrees(projectId, {
+ executionHostId: toRuntimeExecutionHostId(resolved.authority.environmentId)
+ })
+ }
+ }
+ return
+ }
+ setEditingDestination(null)
+ setDraft((current) => ({ ...current, projectId: '', workspaceId: '', baseBranch: '' }))
+ }
+ const editDestinationControl: AutomationCreateDestinationControl = {
+ entries: editHostEntries,
+ resolution: editHostResolution,
+ onSelect: handleEditHostChange,
+ projects: editHostProjects,
+ // Replacer fn: a literal replacement would expand `$` patterns in host labels.
+ moveWarning: editMoveTargetEntry
+ ? translate(
+ 'auto.components.automations.createDestination.move',
+ 'Saving creates this automation on {host} and deletes the original and its run history.'
+ ).replace('{host}', () => editMoveTargetEntry.authorityLabel)
+ : null
+ }
const dialogRepos = isOrcaForm
? editingAutomationId !== null
- ? getAutomationCreateRepos(repos, automationDialogTarget)
+ ? editHostProjects
: editorProjects
: getAutomationCreateRepos(repos, { kind: 'local' })
+ // Worktree caches are merged by project id across hosts. The dialog's repo
+ // list is already authority-scoped, so use that repo instance to keep
+ // host-local workspace ids from crossing an authority boundary.
+ const dialogWorktrees = useMemo(() => {
+ const candidates = worktreesByRepo[draft.projectId] ?? []
+ const project = dialogRepos.find((repo) => repo.id === draft.projectId)
+ if (!project) {
+ return candidates
+ }
+ const hostId = getRepoExecutionHostId(project)
+ const parsedHost = parseExecutionHostId(hostId)
+ return candidates.filter((worktree) => {
+ if (getWorktreeExecutionHostId(worktree, project) === hostId) {
+ return true
+ }
+ // A paired runtime can own an SSH execution host; nested worktrees keep
+ // hostId=ssh:* while runtimeOwnerEnvironmentId names the routing owner.
+ return (
+ parsedHost?.kind === 'runtime' &&
+ worktree.runtimeOwnerEnvironmentId === parsedHost.environmentId
+ )
+ })
+ }, [dialogRepos, draft.projectId, worktreesByRepo])
const destinationForProject = useCallback(
- (projectId: string): AutomationCreateDestination | null => {
+ (projectId: string, hostStableKey?: string | null): AutomationCreateDestination | null => {
+ const selectedEntry = hostStableKey
+ ? hostCatalog.entries.find((candidate) => candidate.stableKey === hostStableKey)
+ : null
+ const selected = resolveAutomationCreateDestination(selectedEntry)
+ if (hostStableKey) {
+ return selected.status === 'ready' &&
+ !automationCreateProjectMismatch(repoTables, selected, projectId)
+ ? selected
+ : null
+ }
const runContext = buildAutomationRunContextForRepo({
repoId: projectId,
repos,
@@ -1038,7 +1206,7 @@ export default function AutomationsPage(): React.JSX.Element {
const resolved = resolveAutomationCreateDestination(entry)
return resolved.status === 'ready' ? resolved : null
},
- [hostCatalog.entries, projectHostSetups, repos]
+ [hostCatalog.entries, projectHostSetups, repoTables, repos]
)
const reloadExternalManagers = scopedExternal.reload
@@ -1158,7 +1326,9 @@ export default function AutomationsPage(): React.JSX.Element {
}, [refresh])
useEffect(() => {
- if (!draft.projectId) {
+ // Creates only: an edit whose project was cleared is waiting for one on the
+ // chosen host, and this default would reinstate a project that host lacks.
+ if (!draft.projectId && editingAutomationId === null) {
const target = getDefaultTarget()
if (!target.projectId) {
return
@@ -1169,18 +1339,19 @@ export default function AutomationsPage(): React.JSX.Element {
workspaceId: target.workspaceId
}))
}
- }, [draft.projectId, getDefaultTarget])
+ }, [draft.projectId, editingAutomationId, getDefaultTarget])
useEffect(() => {
if (!draft.projectId) {
return
}
- const available = worktreesByRepo[draft.projectId] ?? []
- const defaultWorktree = getDefaultWorktree(available)
+ // Authority-scoped: the merged cache would restore a source-host workspace
+ // after a host change clears it, which save validation then rejects.
+ const defaultWorktree = getDefaultWorktree(dialogWorktrees)
if (!draft.workspaceId && defaultWorktree) {
setDraft((current) => ({ ...current, workspaceId: defaultWorktree.id }))
}
- }, [draft.projectId, draft.workspaceId, worktreesByRepo])
+ }, [dialogWorktrees, draft.projectId, draft.workspaceId])
useEffect(() => {
if (
@@ -1191,10 +1362,11 @@ export default function AutomationsPage(): React.JSX.Element {
) {
return
}
- void loadAutomationYamlHooksForRepo(draft.projectId)
+ void loadAutomationYamlHooksForRepo(draft.projectId, createDestinationHostId)
}, [
createOpen,
createTarget,
+ createDestinationHostId,
draft.projectId,
draft.workspaceMode,
loadAutomationYamlHooksForRepo
@@ -1280,6 +1452,7 @@ export default function AutomationsPage(): React.JSX.Element {
setEditingAutomationId(null)
setEditingExternalTarget(null)
setEditingDestination(null)
+ setEditingHostStableKey(null)
setCreateTarget('orca')
const baseDraft: AutomationDraft = {
name: '',
@@ -1344,10 +1517,12 @@ export default function AutomationsPage(): React.JSX.Element {
}
setEditingAutomationId(latest.id)
setEditingRowKey(row.key)
- const initialDestination = destinationForProject(latest.projectId)
+ const initialHostStableKey = rowRecoveryHost(row.key)?.stableKey ?? null
+ const initialDestination = destinationForProject(latest.projectId, initialHostStableKey)
setEditingDestination(
initialDestination ? { projectId: latest.projectId, destination: initialDestination } : null
)
+ setEditingHostStableKey(initialDestination?.entry.stableKey ?? null)
const nextDraft = buildAutomationEditDraft(latest)
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
@@ -1383,6 +1558,7 @@ export default function AutomationsPage(): React.JSX.Element {
setEditingAutomationId(null)
setEditingRowKey(null)
setEditingDestination(null)
+ setEditingHostStableKey(null)
setEditingExternalTarget({ manager, job, scope })
setCreateTarget('hermes')
setDraft(nextDraft)
@@ -1394,9 +1570,27 @@ export default function AutomationsPage(): React.JSX.Element {
(projectId: string): void => {
const currentWorktrees = worktreesByRepo[projectId] ?? []
const currentDefaultWorktree = getDefaultWorktree(currentWorktrees)
+ const selectedEditDestination =
+ editingAutomationId !== null && editingHostStableKey
+ ? resolveAutomationCreateDestination(
+ hostCatalog.entries.find((entry) => entry.stableKey === editingHostStableKey)
+ )
+ : null
+ const worktreeFetchOptions =
+ selectedEditDestination?.status === 'ready' &&
+ selectedEditDestination.authority.kind === 'runtime'
+ ? {
+ executionHostId: toRuntimeExecutionHostId(
+ selectedEditDestination.authority.environmentId
+ )
+ }
+ : undefined
if (editingAutomationId !== null) {
- const destination = destinationForProject(projectId)
+ const destination = destinationForProject(projectId, editingHostStableKey)
setEditingDestination(destination ? { projectId, destination } : null)
+ if (destination) {
+ setEditingHostStableKey(destination.entry.stableKey)
+ }
}
setDraft((current) => ({
...current,
@@ -1405,7 +1599,7 @@ export default function AutomationsPage(): React.JSX.Element {
baseBranch: ''
}))
- void fetchWorktrees(projectId).then(() => {
+ void fetchWorktrees(projectId, worktreeFetchOptions).then(() => {
const latestWorktrees = useAppStore.getState().worktreesByRepo[projectId] ?? []
const latestWorktree = getDefaultWorktree(latestWorktrees)
if (!latestWorktree) {
@@ -1420,7 +1614,14 @@ export default function AutomationsPage(): React.JSX.Element {
)
})
},
- [destinationForProject, editingAutomationId, fetchWorktrees, worktreesByRepo]
+ [
+ destinationForProject,
+ editingAutomationId,
+ editingHostStableKey,
+ fetchWorktrees,
+ hostCatalog.entries,
+ worktreesByRepo
+ ]
)
const handleDraftChange = useCallback(
@@ -1433,11 +1634,14 @@ export default function AutomationsPage(): React.JSX.Element {
editingAutomationId !== null &&
(next.projectId !== current.projectId || next.workspaceId !== current.workspaceId)
) {
- const destination = destinationForProject(next.projectId)
+ const destination = destinationForProject(next.projectId, editingHostStableKey)
setEditingDestination(destination ? { projectId: next.projectId, destination } : null)
+ if (destination) {
+ setEditingHostStableKey(destination.entry.stableKey)
+ }
}
},
- [destinationForProject, editingAutomationId]
+ [destinationForProject, editingAutomationId, editingHostStableKey]
)
const saveAutomation = async (): Promise => {
@@ -1496,7 +1700,7 @@ export default function AutomationsPage(): React.JSX.Element {
try {
const selectedWorkspaceExists =
draft.workspaceMode !== 'existing' ||
- worktrees.some((worktree) => worktree.id === draft.workspaceId)
+ dialogWorktrees.some((worktree) => worktree.id === draft.workspaceId)
if (!selectedWorkspaceExists) {
toast.error(
translate(
@@ -1614,18 +1818,29 @@ export default function AutomationsPage(): React.JSX.Element {
const precheck = buildDraftPrecheck(draft)
const runContext = buildAutomationRunContextForRepo({
repoId: draft.projectId,
- repos,
+ repos: editingAutomationId !== null ? dialogRepos : repos,
projectHostSetups
})
+ const setupDestination =
+ editingAutomationId !== null ? editHostResolution : createDestination.control.resolution
+ const setupHostId: ExecutionHostId | undefined =
+ setupDestination.status === 'ready' && setupDestination.authority.kind === 'runtime'
+ ? toRuntimeExecutionHostId(setupDestination.authority.environmentId)
+ : undefined
+ const setupProjectHostSetups = setupHostId
+ ? projectHostSetups.filter(
+ (setup) => setup.repoId !== draft.projectId || setup.hostId === setupHostId
+ )
+ : projectHostSetups
let setupDecision = resolveAutomationSetupDecisionForSave({
createTarget,
workspaceMode: draft.workspaceMode,
repoId: draft.projectId,
- repos,
- projectHostSetups,
+ repos: editingAutomationId !== null ? dialogRepos : repos,
+ projectHostSetups: setupProjectHostSetups,
yamlHooks:
createTarget === 'orca' && draft.workspaceMode === 'new_per_run'
- ? await loadAutomationYamlHooksForRepo(draft.projectId)
+ ? await loadAutomationYamlHooksForRepo(draft.projectId, setupHostId)
: null,
draftSetupDecision: draft.setupDecision
})
@@ -1633,7 +1848,8 @@ export default function AutomationsPage(): React.JSX.Element {
const trustDecision = await ensureHooksConfirmed(
useAppStore.getState(),
draft.projectId,
- 'setup'
+ 'setup',
+ setupHostId
)
if (trustDecision === 'skip') {
setupDecision = 'skip'
@@ -1669,9 +1885,14 @@ export default function AutomationsPage(): React.JSX.Element {
currentAutomation = (reread.ok ? reread.value : null) ?? currentAutomation
}
let editDestination: AutomationDestination | undefined
+ let moveTarget: AutomationCreateDestination | null = null
+ const destinationHostChanged =
+ editingDestination !== null &&
+ editingDestination.destination.entry.stableKey !== rowRecoveryHost(editingRowKey)?.stableKey
const destinationChanged =
currentAutomation &&
- (currentAutomation.projectId !== draft.projectId ||
+ (destinationHostChanged ||
+ currentAutomation.projectId !== draft.projectId ||
currentAutomation.workspaceId !== (draft.workspaceId || null) ||
currentAutomation.workspaceMode !== draft.workspaceMode)
if (editingAutomationId && currentAutomation && destinationChanged) {
@@ -1713,6 +1934,11 @@ export default function AutomationsPage(): React.JSX.Element {
return
}
editDestination = revalidated.destination
+ // `update` reaches only the authority already holding the record, so a
+ // save landing on another one is a create there plus a delete here.
+ if (automationAuthorityCatalogKey(revalidated.authority) !== dialogAuthorityKey) {
+ moveTarget = revalidated
+ }
}
const updates: AutomationUpdateInput = {
name: draft.name,
@@ -1734,44 +1960,57 @@ export default function AutomationsPage(): React.JSX.Element {
updates.rrule = rrule
updates.dtstart = now
}
+ const createInput: AutomationCreateInput = {
+ name: draft.name,
+ prompt: draft.prompt,
+ precheck,
+ agentId: draft.agentId,
+ runContext,
+ projectId: draft.projectId,
+ workspaceMode: draft.workspaceMode,
+ workspaceId: draft.workspaceId,
+ baseBranch: draft.baseBranch.trim() || null,
+ setupDecision,
+ reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession,
+ timezone,
+ rrule,
+ dtstart: now,
+ missedRunGraceMinutes
+ }
const editSource = currentAutomation
- const saved = editingAutomationId
- ? await dispatchAutomationUpdate(
- automationDispatchContext,
- { rowKey: editingRowKey ?? '', automationId: editingAutomationId },
- updates,
- () => {
- // Nothing names a host: no captured owner and no record to read one
- // from, so the save is refused rather than sent unfenced.
- if (!editSource) {
- throw new Error(
- automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
- )
- }
- return updateAutomationForTarget(editSource, updates, automationHostTarget)
- },
- 'save',
- editDestination
- )
- : await createDraftAutomation({
- name: draft.name,
- prompt: draft.prompt,
- precheck,
- agentId: draft.agentId,
- runContext,
- projectId: draft.projectId,
- workspaceMode: draft.workspaceMode,
- workspaceId: draft.workspaceId,
- baseBranch: draft.baseBranch.trim() || null,
- setupDecision,
- reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession,
- timezone,
- rrule,
- dtstart: now,
- missedRunGraceMinutes
+ const move = moveTarget
+ ? await moveAutomationToDestination(editSource, moveTarget, {
+ ...createInput,
+ // A move keeps the existing schedule anchor and paused state.
+ dtstart: updates.dtstart ?? editSource?.dtstart ?? createInput.dtstart,
+ enabled: editSource?.enabled ?? true,
+ sourceContext: editSource?.sourceContext ?? null
})
+ : null
+ const saved = move
+ ? move.saved
+ : editingAutomationId
+ ? await dispatchAutomationUpdate(
+ automationDispatchContext,
+ { rowKey: editingRowKey ?? '', automationId: editingAutomationId },
+ updates,
+ () => {
+ // Nothing names a host: no captured owner and no record to read one
+ // from, so the save is refused rather than sent unfenced.
+ if (!editSource) {
+ throw new Error(
+ automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
+ )
+ }
+ return updateAutomationForTarget(editSource, updates, automationHostTarget)
+ },
+ 'save',
+ editDestination
+ )
+ : await createDraftAutomation(createInput)
if (!saved.ok) {
setEditorNotice(saved.notice)
+ setEditorNoticeHost(moveTarget?.entry ?? null)
return
}
const automation = saved.value
@@ -1788,7 +2027,9 @@ export default function AutomationsPage(): React.JSX.Element {
})
setDraft((current) => ({ ...current, name: '', prompt: '' }))
await refresh()
- if (editingAutomationId && editingRowKey) {
+ // A move retires the edited row key; the else branch selects the new copy
+ // by id alone and lets the refreshed list resolve its row.
+ if (editingAutomationId && editingRowKey && !moveTarget) {
setSelectedAutomationRunPageId(null)
setSelectedRowKey(editingRowKey)
setSelectedId(automation.id)
@@ -1799,13 +2040,25 @@ export default function AutomationsPage(): React.JSX.Element {
if (!editingAutomationId) {
useAppStore.getState().recordFeatureInteraction('automation-created')
}
+ // A kept original already raised its own toast; "moved" would contradict it.
+ if (move && !move.originalRemoved) {
+ return
+ }
toast.success(
- editingAutomationId
+ moveTarget
? translate(
- 'auto.components.automations.AutomationsPage.244727e655',
- 'Automation updated.'
- )
- : translate('auto.components.automations.AutomationsPage.2a20596d6b', 'Automation saved.')
+ 'auto.components.automations.AutomationsPage.moved',
+ 'Automation moved to {host}.'
+ ).replace('{host}', () => moveTarget.entry.authorityLabel)
+ : editingAutomationId
+ ? translate(
+ 'auto.components.automations.AutomationsPage.244727e655',
+ 'Automation updated.'
+ )
+ : translate(
+ 'auto.components.automations.AutomationsPage.2a20596d6b',
+ 'Automation saved.'
+ )
)
} catch (error) {
if (isHermesSave) {
@@ -1824,6 +2077,79 @@ export default function AutomationsPage(): React.JSX.Element {
}
}
+ /** Creates on the new authority before removing the old copy. */
+ const moveAutomationToDestination = async (
+ source: Automation | null,
+ target: AutomationCreateDestination,
+ input: AutomationCreateInput
+ ): Promise<{ saved: AutomationDispatchResult; originalRemoved: boolean }> => {
+ if (!source) {
+ return {
+ saved: {
+ ok: false,
+ notice: {
+ message: automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged),
+ recovery: 'retry',
+ severity: 'owner'
+ }
+ },
+ originalRemoved: false
+ }
+ }
+ // Keyed by the move, not its payload: dtstart is minted per attempt, so a
+ // payload-keyed retry would mint a fresh creationKey and let an ambiguous
+ // create failure schedule a second copy on the destination.
+ const operationKey = `${source.id}:${target.entry.stableKey}`
+ const creationKey = moveCreationKeysRef.current.get(operationKey) ?? crypto.randomUUID()
+ moveCreationKeysRef.current.set(operationKey, creationKey)
+ const created = toDispatchResult(
+ await createAutomationAtDestination(
+ target.authority,
+ { ...input, creationKey },
+ target.destination
+ )
+ )
+ if (!created.ok) {
+ return { saved: created, originalRemoved: false }
+ }
+ invalidateWrittenHost(target.entry.stableRef, 'definition')
+ const removed = await dispatchAutomationDelete(
+ automationDispatchContext,
+ { rowKey: editingRowKey ?? '', automationId: source.id },
+ () => deleteAutomationForTarget(source, automationDialogTarget)
+ )
+ if (!removed.ok) {
+ // A transport error is not proof that deletion failed. Re-read the source
+ // under its captured owner before telling the user a duplicate remains.
+ const reread = await dispatchAutomationReread(
+ automationDispatchContext,
+ { rowKey: editingRowKey ?? '', automationId: source.id },
+ async () =>
+ (await listAutomationsForTarget(automationDialogTarget)).find(
+ (automation) => automation.id === source.id
+ ) ?? null
+ )
+ if (reread.ok && reread.value === null) {
+ moveCreationKeysRef.current.delete(operationKey)
+ return { saved: created, originalRemoved: true }
+ }
+ const messageKey =
+ reread.ok && reread.value
+ ? 'auto.components.automations.AutomationsPage.moveOriginalKept'
+ : 'auto.components.automations.AutomationsPage.moveOriginalUnverified'
+ const message =
+ reread.ok && reread.value
+ ? 'Created on {host}, but the original could not be deleted. Remove it on the old host.'
+ : 'Created on {host}, but the original deletion could not be verified. Check the old host before retrying.'
+ toast.error(
+ translate(messageKey, message).replace('{host}', () => target.entry.authorityLabel)
+ )
+ } else {
+ moveCreationKeysRef.current.delete(operationKey)
+ }
+ return { saved: created, originalRemoved: removed.ok }
+ }
+
/** Creation states its destination and re-checks it at submit; it never infers one. */
const createDraftAutomation = async (
input: AutomationCreateInput
@@ -2279,27 +2605,41 @@ export default function AutomationsPage(): React.JSX.Element {
automationYamlHooksByRepoKey={automationYamlHooksByRepoKey}
getAutomationHooksCacheKey={getAutomationHooksCacheKey}
repoMap={repoMap}
- worktrees={worktrees}
+ worktrees={dialogWorktrees}
settings={settings}
draft={draft}
createDestination={createDestination.control}
+ editDestination={isOrcaForm ? editDestinationControl : undefined}
notice={editorNotice}
onNoticeRecover={(action) => {
+ const recoveryHost = editorNoticeHost ?? editorRecoveryHost
setEditorNotice(null)
- hostCatalog.recover(action, editorRecoveryHost)
+ setEditorNoticeHost(null)
+ hostCatalog.recover(action, recoveryHost)
if (action === 'retry') {
void refresh()
}
}}
- onNoticeDismiss={() => setEditorNotice(null)}
+ onNoticeDismiss={() => {
+ setEditorNotice(null)
+ setEditorNoticeHost(null)
+ }}
onProjectChange={handleProjectChange}
getRepoHostLabel={getAutomationRepoHostLabel}
- allowAddProject={!isOrcaForm || automationDialogTarget.kind === 'local'}
+ allowAddProject={
+ !isOrcaForm ||
+ (editingAutomationId !== null
+ ? editHostResolution.status === 'ready'
+ ? getAutomationAuthorityTarget(editHostResolution.authority).kind === 'local'
+ : automationDialogTarget.kind === 'local'
+ : automationDialogTarget.kind === 'local')
+ }
onCreateTargetChange={handleCreateTargetChange}
onOpenChange={(open) => {
setCreateOpen(open)
if (!open) {
setEditorNotice(null)
+ setEditorNoticeHost(null)
}
}}
onDraftChange={handleDraftChange}
@@ -2350,10 +2690,11 @@ export default function AutomationsPage(): React.JSX.Element {
selectedAutomationRunPage={selectedAutomationRunPage}
selectedRuns={selectedRuns}
selectedRunsNotice={selectedRunsNotice}
+ selectedHostEntry={rowRecoveryHost(selectedRow?.key ?? null)}
recoverSelectedRuns={(action) => {
// Reconnect/Update server act on the selected row's own host; the
// re-ask is what brings this automation's history back either way.
- hostCatalog.recover(action, rowRecoveryHost(selectedRowKey))
+ hostCatalog.recover(action, rowRecoveryHost(selectedRow?.key ?? null))
setSelectedAutomationRuns((current) => ({ ...current, notice: null }))
setRunHistoryReloadToken((token) => token + 1)
}}
diff --git a/src/renderer/src/components/automations/automation-capability-probe.ts b/src/renderer/src/components/automations/automation-capability-probe.ts
index 38cf3c69326..258fb4eb292 100644
--- a/src/renderer/src/components/automations/automation-capability-probe.ts
+++ b/src/renderer/src/components/automations/automation-capability-probe.ts
@@ -5,6 +5,7 @@ import {
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
@@ -129,7 +130,18 @@ export async function assertOwnerFencingSupported(
)
}
+export async function assertAutomationCreateIdempotencySupported(
+ authority: AutomationAuthorityRef
+): Promise {
+ await assertAuthorityCapability(
+ authority,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
+ 'Moving automations to this host requires a newer Orca server. Update the HUB and try again.'
+ )
+}
+
export {
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE
+ AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
}
diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts
index 2b7c3b7c102..e7ef5a44770 100644
--- a/src/renderer/src/components/automations/automation-host-client.ts
+++ b/src/renderer/src/components/automations/automation-host-client.ts
@@ -5,6 +5,7 @@ import type {
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
+import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
@@ -49,6 +50,14 @@ export function getAutomationTargetFromHostId(
: { kind: 'local' }
}
+export function getAutomationAuthorityTarget(
+ authority: AutomationAuthorityRef
+): AutomationHostTarget {
+ return authority.kind === 'runtime'
+ ? { kind: 'environment', environmentId: authority.environmentId }
+ : { kind: 'local' }
+}
+
export function getAutomationListTarget(
settings: Pick | null | undefined
): AutomationHostTarget {
diff --git a/src/renderer/src/components/automations/automation-host-detail-display.test.ts b/src/renderer/src/components/automations/automation-host-detail-display.test.ts
new file mode 100644
index 00000000000..a2b2817194b
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-host-detail-display.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest'
+import type { Automation } from '../../../../shared/automations-types'
+import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
+import { getAutomationHostDetailDisplay } from './automation-host-detail-display'
+
+const RUNTIME_SELF: AutomationHostCatalogEntry = {
+ stableRef: { authority: { kind: 'runtime', environmentId: 'r1' }, selector: { kind: 'self' } },
+ owner: {
+ authority: { kind: 'runtime', environmentId: 'r1', pairingRevision: 1 },
+ selector: { kind: 'self' }
+ },
+ stableKey: 'host:runtime:r1:self',
+ label: 'GPU box',
+ authorityLabel: 'GPU box',
+ kind: 'self',
+ catalogState: 'authoritative',
+ authorityHealth: 'fresh',
+ executionHealth: 'connected',
+ querySupport: 'scoped'
+}
+
+const DESKTOP_SSH: AutomationHostCatalogEntry = {
+ ...RUNTIME_SELF,
+ stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } },
+ owner: {
+ authority: { kind: 'desktop' },
+ selector: { kind: 'ssh', targetId: 't1', targetGeneration: 1 }
+ },
+ stableKey: 'host:desktop:ssh:t1',
+ label: 'openclaw',
+ authorityLabel: 'Local Mac',
+ kind: 'ssh'
+}
+
+function automation(overrides: Partial): Automation {
+ return { executionTargetType: 'local', executionTargetId: 'local', ...overrides } as Automation
+}
+
+describe('getAutomationHostDetailDisplay', () => {
+ it('names the runtime that stores the record, not the “local” its target reads', () => {
+ // The record's own target is local *to that server*; rendering it verbatim
+ // would claim a remote automation runs on this Mac.
+ const display = getAutomationHostDetailDisplay({
+ automation: automation({ executionTargetType: 'local' }),
+ entry: RUNTIME_SELF
+ })
+
+ expect(display).toEqual({ label: 'GPU box', title: 'GPU box' })
+ })
+
+ it('qualifies a host with the authority that stores it when the two differ', () => {
+ const display = getAutomationHostDetailDisplay({
+ automation: automation({ executionTargetType: 'ssh', executionTargetId: 't1' }),
+ entry: DESKTOP_SSH
+ })
+
+ expect(display).toEqual({ label: 'openclaw', title: 'Local Mac · openclaw' })
+ })
+
+ it('falls back to the record’s own target for a row no host answered for', () => {
+ const display = getAutomationHostDetailDisplay({
+ automation: automation({ executionTargetType: 'ssh', executionTargetId: 't1' }),
+ entry: null,
+ hostLabelById: new Map([['ssh:t1', 'openclaw']])
+ })
+
+ expect(display).toEqual({ label: 'openclaw', title: 'openclaw' })
+ })
+})
diff --git a/src/renderer/src/components/automations/automation-host-detail-display.ts b/src/renderer/src/components/automations/automation-host-detail-display.ts
new file mode 100644
index 00000000000..2d8bdc3ee65
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-host-detail-display.ts
@@ -0,0 +1,36 @@
+// The row's catalog entry names the storing authority; the record's own target
+// cannot — a runtime-stored automation reads `local`, meaning local to that
+// server. That target is only the fallback for a legacy row no host answered for.
+
+import type { Automation } from '../../../../shared/automations-types'
+import { getExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host'
+import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
+
+export type AutomationHostDetailDisplay = {
+ label: string
+ /** Authority-qualified when the two differ; a self host is its own authority. */
+ title: string
+}
+
+export function getAutomationHostDetailDisplay(input: {
+ automation: Pick
+ entry?: AutomationHostCatalogEntry | null
+ hostLabelById?: ReadonlyMap
+}): AutomationHostDetailDisplay {
+ const { automation, entry, hostLabelById } = input
+ if (entry) {
+ return {
+ label: entry.label,
+ title:
+ entry.authorityLabel === entry.label
+ ? entry.label
+ : `${entry.authorityLabel} · ${entry.label}`
+ }
+ }
+ const hostId =
+ automation.executionTargetType === 'ssh'
+ ? toSshExecutionHostId(automation.executionTargetId)
+ : 'local'
+ const label = hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId)
+ return { label, title: label }
+}
diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts
index e1bf8873e97..e07399319fb 100644
--- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts
+++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../../shared/automation-owner-conflict'
import {
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
const callRuntimeRpc = vi.fn()
@@ -27,7 +28,8 @@ const SSH_OWNER = {
const ALL_CAPABILITIES = {
capabilities: [
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
]
}
diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.ts b/src/renderer/src/components/automations/automation-scoped-list-client.ts
index ec44ac74ece..825329a1812 100644
--- a/src/renderer/src/components/automations/automation-scoped-list-client.ts
+++ b/src/renderer/src/components/automations/automation-scoped-list-client.ts
@@ -38,6 +38,7 @@ import {
import {
assertAuthorityCapability,
assertOwnerFencingSupported,
+ assertAutomationCreateIdempotencySupported,
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
AutomationHostScopeUnsupportedError,
@@ -286,6 +287,9 @@ export async function createAutomationForDestination(
input: AutomationCreateInput,
destination: AutomationDestination
): Promise {
+ if (input.creationKey) {
+ await assertAutomationCreateIdempotencySupported(authority)
+ }
await assertOwnerFencingSupported(authority)
const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', {
...toRuntimeAutomationCreateInput(input),
diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx
index 815338927c6..ae01653093c 100644
--- a/src/renderer/src/components/automations/automations-page-test-harness.tsx
+++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx
@@ -17,10 +17,12 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, type Mock, vi } from 'vitest'
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
import {
- AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY as LIST_HOST_SCOPE,
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY as OWNER_FENCING,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY as CREATE_IDEMPOTENCY
} from '../../../../shared/protocol-version'
import type { AppState } from '@/store'
+import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationHostCatalogView } from './use-automation-host-catalog'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
@@ -76,6 +78,7 @@ export type ListPanelProps = {
export type DetailPaneProps = {
selected: Automation | null
+ selectedHostEntry: AutomationHostCatalogEntry | null
selectedRuns: AutomationRun[]
selectedRunsNotice: { message: string } | null
runNow: (automation: Automation) => void
@@ -95,6 +98,7 @@ export type EditorDialogProps = {
open: boolean
isEditing: boolean
createDestination?: AutomationCreateDestinationControl
+ editDestination?: AutomationCreateDestinationControl
notice?: { message: string; recovery: string | null } | null
onNoticeRecover?: (action: string) => void
repos?: { id: string }[]
@@ -300,10 +304,7 @@ export const DESKTOP_SELF_OWNER = { authority: { kind: 'desktop' }, selector: {
export const RUNTIME_ID = 'gpu'
/** A runtime that advertises both automation capabilities, so its rows carry owners. */
-const RUNTIME_CAPABILITIES = [
- AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
-]
+const RUNTIME_CAPABILITIES = [LIST_HOST_SCOPE, OWNER_FENCING, CREATE_IDEMPOTENCY]
export const RUNTIME_SELF_FILTER = {
kind: 'host' as const,
host: {
diff --git a/src/renderer/src/components/automations/use-automation-create-destination.ts b/src/renderer/src/components/automations/use-automation-create-destination.ts
index e6d83e9491a..a47faa37c71 100644
--- a/src/renderer/src/components/automations/use-automation-create-destination.ts
+++ b/src/renderer/src/components/automations/use-automation-create-destination.ts
@@ -35,6 +35,8 @@ export type AutomationCreateDestinationControl = {
onSelect: (stableKey: string) => void
/** Projects the resolved destination can hold; the whole list until one resolves. */
projects: readonly Repo[]
+ /** Set when saving would move the record to another host; replaces the plain line. */
+ moveWarning?: string | null
}
export type AutomationCreateDestinationCheck =
@@ -73,7 +75,7 @@ function choiceMessage(reason: AutomationCreateDestinationChoiceReason): string
}
return translate(
'auto.components.automations.createDestination.unselected',
- 'Choose the host this automation will be created on.'
+ 'Choose the host that stores and schedules this automation.'
)
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 06989bb3f9e..115cfb5ae1f 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -15220,6 +15220,7 @@
"showMore": "Show more"
},
"AutomationDetail": {
+ "host": "Host",
"007c8ad874": "Prompt",
"a1d52c2189": "Usage coverage",
"449fc83bf7": "Tokens",
@@ -15388,6 +15389,9 @@
"b11170a008": "Failed to save automation.",
"2a20596d6b": "Automation saved.",
"244727e655": "Automation updated.",
+ "moved": "Automation moved to {host}.",
+ "moveOriginalKept": "Created on {host}, but the original could not be deleted. Remove it on the old host.",
+ "moveOriginalUnverified": "Created on {host}, but the original deletion could not be verified. Check the old host before retrying.",
"77b81bc4ac": "Hermes automation created.",
"08efc3ae12": "Hermes automation updated.",
"e431bb85d4": "Choose a workspace on the same host as this Hermes automation.",
@@ -15693,16 +15697,17 @@
"occurrences": "{{times}} times"
},
"createDestination": {
- "label": "Create on",
+ "label": "Host",
"placeholder": "Select a host",
"storedOn": "Stored and scheduled by {authority}.",
- "unselected": "Choose the host this automation will be created on.",
+ "unselected": "Choose the host that stores and schedules this automation.",
"orphan": "Automations with no host cannot hold new ones. Choose a host to create this automation on.",
"unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.",
"stale": "{host} changed while this form was open. Choose it again before saving.",
"projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.",
"noProjects": "No projects are set up on {host}. Add one there, or choose another host.",
- "updateRequired": "Update the Orca server on {hosts} to create automations there."
+ "updateRequired": "Update the Orca server on {hosts} to store automations there.",
+ "move": "Saving creates this automation on {host} and deletes the original and its run history."
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 69e7c383533..8b2717c3385 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -13727,6 +13727,7 @@
"showMore": "Mostrar más"
},
"AutomationDetail": {
+ "host": "Host",
"007c8ad874": "Inmediato",
"a1d52c2189": "Cobertura de uso",
"449fc83bf7": "Fichas",
@@ -13890,6 +13891,9 @@
"b11170a008": "No se pudo guardar la automatización.",
"2a20596d6b": "Automatización guardada.",
"244727e655": "Automatización actualizada.",
+ "moved": "Automatización movida a {host}.",
+ "moveOriginalKept": "Se creó en {host}, pero no se pudo eliminar la original. Elimínela en el host anterior.",
+ "moveOriginalUnverified": "Se creó en {host}, pero no se pudo verificar la eliminación de la original. Compruebe el host anterior antes de volver a intentarlo.",
"77b81bc4ac": "Se crea la automatización de Hermes.",
"08efc3ae12": "Automatización Hermes actualizada.",
"e431bb85d4": "Elija un espacio de trabajo en el mismo host que esta automatización de Hermes.",
@@ -14086,6 +14090,19 @@
"filters": "Filtros",
"clear": "Limpiar filtros",
"removeFilter": "Eliminar filtro de {{value0}}"
+ },
+ "createDestination": {
+ "label": "Host",
+ "placeholder": "Seleccionar un host",
+ "storedOn": "Almacenada y programada por {authority}.",
+ "unselected": "Elija el host que almacena y programa esta automatización.",
+ "orphan": "Las automatizaciones sin host no pueden alojar otras nuevas. Elija un host en el que crear esta automatización.",
+ "unavailable": "Ese host todavía no puede alojar una nueva automatización. Elija otro host para crear esta.",
+ "stale": "{host} cambió mientras este formulario estaba abierto. Elíjalo de nuevo antes de guardar.",
+ "projectMismatch": "Ese proyecto no está en {host}. Elija un proyecto en ese host, u otro host.",
+ "noProjects": "No hay proyectos configurados en {host}. Agregue uno allí o elija otro host.",
+ "updateRequired": "Actualice el servidor de Orca en {hosts} para almacenar automatizaciones allí.",
+ "move": "Al guardar se crea esta automatización en {host} y se eliminan la original y su historial de ejecuciones."
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 43129d595af..a105e956622 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -13718,6 +13718,7 @@
"showMore": "さらに表示"
},
"AutomationDetail": {
+ "host": "ホスト",
"007c8ad874": "プロンプト",
"a1d52c2189": "使用範囲",
"449fc83bf7": "トークン",
@@ -13881,6 +13882,9 @@
"b11170a008": "オートメーションを保存できませんでした。",
"2a20596d6b": "オートメーションが保存されました。",
"244727e655": "自動化が更新されました。",
+ "moved": "自動化を {host} に移動しました。",
+ "moveOriginalKept": "{host} に作成しましたが、元の自動化を削除できませんでした。元のホストで削除してください。",
+ "moveOriginalUnverified": "{host} に作成しましたが、元の自動化の削除を確認できませんでした。再試行する前に元のホストを確認してください。",
"77b81bc4ac": "Hermes オートメーションが作成されました。",
"08efc3ae12": "Hermes オートメーションが更新されました。",
"e431bb85d4": "この Hermes オートメーションと同じホスト上のワークスペースを選択します。",
@@ -14077,6 +14081,19 @@
"filters": "フィルター",
"clear": "フィルターをクリア",
"removeFilter": "{{value0}} フィルターを解除"
+ },
+ "createDestination": {
+ "label": "ホスト",
+ "placeholder": "ホストを選択",
+ "storedOn": "{authority} が保存してスケジュールします。",
+ "unselected": "この自動化を保存してスケジュールするホストを選択します。",
+ "orphan": "ホストのない自動化は新しい自動化を保持できません。この自動化を作成するホストを選択してください。",
+ "unavailable": "そのホストはまだ新しい自動化を保持できません。これを作成する別のホストを選択してください。",
+ "stale": "このフォームを開いている間に {host} が変更されました。保存する前にもう一度選択してください。",
+ "projectMismatch": "そのプロジェクトは {host} にありません。そのホスト上のプロジェクト、または別のホストを選択してください。",
+ "noProjects": "{host} にはプロジェクトが設定されていません。そこに追加するか、別のホストを選択してください。",
+ "updateRequired": "{hosts} の Orca サーバーを更新すると、そこに自動化を保存できます。",
+ "move": "保存すると、この自動化は {host} に作成され、元の自動化と実行履歴は削除されます。"
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index e88670171ac..e09d641641a 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -13750,6 +13750,7 @@
"showMore": "더 보기"
},
"AutomationDetail": {
+ "host": "호스트",
"007c8ad874": "프롬프트",
"a1d52c2189": "사용 범위",
"449fc83bf7": "토큰",
@@ -13913,6 +13914,9 @@
"b11170a008": "자동화를 저장하지 못했습니다.",
"2a20596d6b": "자동화가 저장되었습니다.",
"244727e655": "자동화가 업데이트되었습니다.",
+ "moved": "자동화를 {host}(으)로 이동했습니다.",
+ "moveOriginalKept": "{host}에 생성했지만 원본을 삭제하지 못했습니다. 이전 호스트에서 삭제하세요.",
+ "moveOriginalUnverified": "{host}에 생성했지만 원본 삭제를 확인할 수 없습니다. 다시 시도하기 전에 이전 호스트를 확인하세요.",
"77b81bc4ac": "Hermes 자동화가 생성되었습니다.",
"08efc3ae12": "Hermes 자동화가 업데이트되었습니다.",
"e431bb85d4": "이 Hermes 자동화와 동일한 호스트에서 워크스페이스를 선택하세요.",
@@ -14109,6 +14113,19 @@
"filters": "필터",
"clear": "필터 지우기",
"removeFilter": "{{value0}} 필터 제거"
+ },
+ "createDestination": {
+ "label": "호스트",
+ "placeholder": "호스트 선택",
+ "storedOn": "{authority}에서 저장하고 예약합니다.",
+ "unselected": "이 자동화를 저장하고 예약할 호스트를 선택하세요.",
+ "orphan": "호스트가 없는 자동화는 새 자동화를 보유할 수 없습니다. 이 자동화를 만들 호스트를 선택하세요.",
+ "unavailable": "해당 호스트는 아직 새 자동화를 보유할 수 없습니다. 이 자동화를 만들 다른 호스트를 선택하세요.",
+ "stale": "이 양식이 열려 있는 동안 {host}이(가) 변경되었습니다. 저장하기 전에 다시 선택하세요.",
+ "projectMismatch": "해당 프로젝트는 {host}에 없습니다. 그 호스트의 프로젝트를 선택하거나 다른 호스트를 선택하세요.",
+ "noProjects": "{host}에 설정된 프로젝트가 없습니다. 거기에 추가하거나 다른 호스트를 선택하세요.",
+ "updateRequired": "{hosts}의 Orca 서버를 업데이트하면 거기에 자동화를 저장할 수 있습니다.",
+ "move": "저장하면 이 자동화가 {host}에 생성되고 원본과 실행 기록은 삭제됩니다."
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 00f5e077a07..4d4a7010809 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -13774,6 +13774,7 @@
"showMore": "显示更多"
},
"AutomationDetail": {
+ "host": "主机",
"007c8ad874": "提示词",
"a1d52c2189": "使用范围",
"449fc83bf7": "token",
@@ -13937,6 +13938,9 @@
"b11170a008": "无法保存自动化。",
"2a20596d6b": "自动化已保存。",
"244727e655": "自动化已更新。",
+ "moved": "自动化已移动到 {host}。",
+ "moveOriginalKept": "已在 {host} 上创建,但无法删除原有自动化。请在原主机上删除它。",
+ "moveOriginalUnverified": "已在 {host} 上创建,但无法确认是否删除了原有自动化。重试前请检查原主机。",
"77b81bc4ac": "Hermes 自动化创建。",
"08efc3ae12": "Hermes 自动化已更新。",
"e431bb85d4": "选择与此 Hermes 自动化位于同一主机上的工作区。",
@@ -14133,6 +14137,19 @@
"filters": "筛选器",
"clear": "清除筛选器",
"removeFilter": "移除 {{value0}} 筛选器"
+ },
+ "createDestination": {
+ "label": "主机",
+ "placeholder": "选择主机",
+ "storedOn": "由 {authority} 存储和调度。",
+ "unselected": "选择存储和调度此自动化的主机。",
+ "orphan": "没有主机的自动化无法容纳新的自动化。请选择要在其上创建此自动化的主机。",
+ "unavailable": "该主机尚无法容纳新的自动化。请选择另一台主机来创建它。",
+ "stale": "此表单打开期间 {host} 已更改。保存前请重新选择。",
+ "projectMismatch": "该项目不在 {host} 上。请选择该主机上的项目,或选择另一台主机。",
+ "noProjects": "{host} 上未设置任何项目。请在该主机上添加一个,或选择另一台主机。",
+ "updateRequired": "更新 {hosts} 上的 Orca 服务器即可在其中存储自动化。",
+ "move": "保存后会在 {host} 上创建此自动化,并删除原有自动化及其运行历史。"
}
},
"agent": {
diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts
index e3c661f976e..24d9345242d 100644
--- a/src/shared/automations-types.ts
+++ b/src/shared/automations-types.ts
@@ -90,6 +90,8 @@ export type AutomationPrecheckResult = {
export type Automation = {
id: string
+ /** Optional client request key used to make cross-authority creates retry-safe. */
+ creationKey?: string
name: string
prompt: string
precheck: AutomationPrecheck | null
@@ -169,6 +171,8 @@ export type AutomationRun = {
}
export type AutomationCreateInput = {
+ /** Optional idempotency key; repeated creates return the original record. */
+ creationKey?: string
name: string
prompt: string
precheck?: AutomationPrecheck | null
diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts
index 8f10558d7e5..9a262cafd7e 100644
--- a/src/shared/protocol-version.ts
+++ b/src/shared/protocol-version.ts
@@ -137,6 +137,8 @@ export const AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE =
export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = 'automation.owner-fencing.v1' as const
export const AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE =
'Editing automations on this host requires a newer Orca server. Update the HUB and try again.'
+export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
+ 'automation.create-idempotency.v1' as const
// Generic native clients include the CLI and must not claim Electron-only page
// placement support.
@@ -145,7 +147,8 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
] as const
// Electron clients can decode client-hosted page placement; becoming a page
@@ -213,7 +216,8 @@ export const RUNTIME_CAPABILITIES = [
SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_DELETE_CAPABILITY,
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
From 3d796b82cba90ffec6018410931bfd40e2287318 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:13:14 -0700
Subject: [PATCH 03/35] fix(claude-usage): bill 1-hour cache writes at 2x base
input (#16878)
---
.../claude-usage/claude-model-pricing.test.ts | 77 +++++++++++++++
src/main/claude-usage/claude-model-pricing.ts | 72 ++++++++++----
.../claude-usage-automation-attribution.ts | 7 +-
.../claude-usage-report-aggregation.test.ts | 71 ++++++++++++++
.../claude-usage-report-aggregation.ts | 12 ++-
src/main/claude-usage/scanner.test.ts | 69 ++++++++++++--
src/main/claude-usage/store.test.ts | 95 +++++++++++++++----
src/main/claude-usage/store.ts | 5 +-
.../claude-usage/transcript-record-parser.ts | 17 +++-
src/main/claude-usage/types.ts | 5 +
src/main/claude-usage/usage-aggregation.ts | 13 ++-
11 files changed, 386 insertions(+), 57 deletions(-)
create mode 100644 src/main/claude-usage/claude-model-pricing.test.ts
create mode 100644 src/main/claude-usage/claude-usage-report-aggregation.test.ts
diff --git a/src/main/claude-usage/claude-model-pricing.test.ts b/src/main/claude-usage/claude-model-pricing.test.ts
new file mode 100644
index 00000000000..8c5f317d875
--- /dev/null
+++ b/src/main/claude-usage/claude-model-pricing.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest'
+import { estimateCostUsd } from './claude-model-pricing'
+
+describe('estimateCostUsd cache-write TTL rates', () => {
+ it('bills 5-minute cache writes at 1.25x base input', () => {
+ expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(6.25)
+ })
+
+ it('keeps the legacy five-argument call billing every write at the 5-minute rate', () => {
+ expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000)).toBeCloseTo(6.25)
+ })
+
+ it('bills 1-hour cache writes at 2x base input', () => {
+ expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(10)
+ })
+
+ it('splits a mixed write bucket without double-billing the 1-hour share', () => {
+ expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(7.75)
+ })
+
+ it('clamps a 1-hour count that exceeds the reported write total', () => {
+ expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000, 5_000)).toBeCloseTo(0.01)
+ })
+
+ it('applies the long-context tier to 1-hour writes', () => {
+ expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(3.6)
+ })
+
+ it('shares one long-context allowance across both TTL buckets', () => {
+ // 400k writes split evenly: 200k @ (3.75/7.5) and 200k @ (6/12), each tier
+ // getting half of the 200k allowance.
+ expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 200_000)).toBeCloseTo(2.925)
+ })
+
+ it('never lowers a long-context estimate as writes shift from 5-minute to 1-hour', () => {
+ const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map(
+ (write1h) => estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, write1h)!
+ )
+ for (let index = 1; index < costs.length; index++) {
+ expect(costs[index]).toBeGreaterThan(costs[index - 1])
+ }
+ })
+
+ it('still returns null for unknown models', () => {
+ expect(estimateCostUsd('gpt-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeNull()
+ })
+})
+
+// Published Claude Sonnet 5 rates (platform.claude.com/docs/en/about-claude/pricing):
+// $2 base input, $10 output, $0.20 cache hit, $2.50 5m write, $4 1h write per MTok.
+// The $2/$10 launch price, announced as introductory through 2026-08-31, is now the
+// standard price — the scheduled 2026-09-01 rise to $3/$15 was cancelled — so the rate
+// is date-independent.
+describe('estimateCostUsd Claude Sonnet 5 rates', () => {
+ it('bills base input at $2/MTok', () => {
+ expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(2)
+ })
+
+ it('bills output at $10/MTok', () => {
+ expect(estimateCostUsd('claude-sonnet-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(10)
+ })
+
+ it('bills cache hits at $0.20/MTok', () => {
+ expect(estimateCostUsd('claude-sonnet-5', 0, 0, 1_000_000, 0, 0)).toBeCloseTo(0.2)
+ })
+
+ it('bills 5-minute cache writes at $2.50/MTok and 1-hour writes at $4/MTok', () => {
+ expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(2.5)
+ expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4)
+ })
+
+ it('keeps the full 1M window at flat rates with no long-context tier', () => {
+ expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(
+ estimateCostUsd('claude-sonnet-5', 500_000, 0, 0, 0, 0)! * 2
+ )
+ })
+})
diff --git a/src/main/claude-usage/claude-model-pricing.ts b/src/main/claude-usage/claude-model-pricing.ts
index 6d28b3deace..eace643eb3b 100644
--- a/src/main/claude-usage/claude-model-pricing.ts
+++ b/src/main/claude-usage/claude-model-pricing.ts
@@ -2,12 +2,16 @@ type ClaudeModelPricing = {
input: number
output: number
cacheRead: number
+ /** 5-minute TTL cache write (1.25x base input). */
cacheWrite: number
+ /** 1-hour TTL cache write (2x base input). */
+ cacheWrite1h: number
thresholdTokens?: number
inputAboveThreshold?: number
outputAboveThreshold?: number
cacheReadAboveThreshold?: number
cacheWriteAboveThreshold?: number
+ cacheWrite1hAboveThreshold?: number
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000
@@ -16,26 +20,29 @@ const SONNET_LONG_CONTEXT_PRICING = {
inputAboveThreshold: 6,
outputAboveThreshold: 22.5,
cacheReadAboveThreshold: 0.6,
- cacheWriteAboveThreshold: 7.5
+ cacheWriteAboveThreshold: 7.5,
+ cacheWrite1hAboveThreshold: 12
} satisfies Partial
const MODEL_PRICING: Record = {
- 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
- 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
+ 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5, cacheWrite1h: 20 },
+ 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
// Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here.
- // Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension.
- 'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
- 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
- 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
- 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
- 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
- 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
- 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
+ // Why: $2/$10 needs no date dimension — it launched as introductory pricing through
+ // 2026-08-31 but is now the standard price; the 2026-09-01 rise to $3/$15 was cancelled.
+ 'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite1h: 4 },
+ 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
+ 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
+ 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
+ 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
+ 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 },
+ 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 },
'claude-sonnet-4-6': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
+ cacheWrite1h: 6,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4-5': {
@@ -43,6 +50,7 @@ const MODEL_PRICING: Record = {
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
+ cacheWrite1h: 6,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4': {
@@ -50,13 +58,20 @@ const MODEL_PRICING: Record = {
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
+ cacheWrite1h: 6,
...SONNET_LONG_CONTEXT_PRICING
},
- 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
- 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
- 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
- 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
- 'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 }
+ 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 },
+ 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 },
+ 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 },
+ 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, cacheWrite1h: 1.6 },
+ 'claude-haiku-3': {
+ input: 0.25,
+ output: 1.25,
+ cacheRead: 0.03,
+ cacheWrite: 0.3,
+ cacheWrite1h: 0.5
+ }
}
const MODEL_ALIASES: Record = {
@@ -179,18 +194,31 @@ function calculateTieredCost(
return belowTokens * basePrice + aboveTokens * abovePrice
}
+/**
+ * @param cacheWriteTokens every cache write, both TTLs
+ * @param cacheWrite1hTokens the 1-hour-TTL subset of `cacheWriteTokens`
+ */
export function estimateCostUsd(
model: string | null,
inputTokens: number,
outputTokens: number,
cacheReadTokens: number,
- cacheWriteTokens: number
+ cacheWriteTokens: number,
+ cacheWrite1hTokens = 0
): number | null {
const normalized = normalizeModelForPricing(model)
if (!normalized) {
return null
}
const pricing = MODEL_PRICING[normalized]
+ const write1hTokens = Math.min(Math.max(cacheWrite1hTokens, 0), cacheWriteTokens)
+ // Why: both TTL buckets share one long-context allowance. Giving each its own
+ // would make a split bucket cheaper than the same tokens billed entirely at 5m.
+ const write1hShare = cacheWriteTokens > 0 ? write1hTokens / cacheWriteTokens : 0
+ const write5mThreshold =
+ pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * (1 - write1hShare)
+ const write1hThreshold =
+ pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * write1hShare
return (
(calculateTieredCost(
inputTokens,
@@ -211,10 +239,16 @@ export function estimateCostUsd(
pricing.thresholdTokens
) +
calculateTieredCost(
- cacheWriteTokens,
+ cacheWriteTokens - write1hTokens,
pricing.cacheWrite,
pricing.cacheWriteAboveThreshold,
- pricing.thresholdTokens
+ write5mThreshold
+ ) +
+ calculateTieredCost(
+ write1hTokens,
+ pricing.cacheWrite1h,
+ pricing.cacheWrite1hAboveThreshold,
+ write1hThreshold
)) /
1_000_000
)
diff --git a/src/main/claude-usage/claude-usage-automation-attribution.ts b/src/main/claude-usage/claude-usage-automation-attribution.ts
index adda1ad394e..0b4a8a9844f 100644
--- a/src/main/claude-usage/claude-usage-automation-attribution.ts
+++ b/src/main/claude-usage/claude-usage-automation-attribution.ts
@@ -106,6 +106,7 @@ export async function resolveAutomationRunUsage(
acc.outputTokens += entry.outputTokens
acc.cacheReadTokens += entry.cacheReadTokens
acc.cacheWriteTokens += entry.cacheWriteTokens
+ acc.cacheWrite1hTokens += entry.cacheWrite1hTokens
return acc
},
{
@@ -113,7 +114,8 @@ export async function resolveAutomationRunUsage(
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
- cacheWriteTokens: 0
+ cacheWriteTokens: 0,
+ cacheWrite1hTokens: 0
}
)
const estimatedCostUsd = estimateCostUsd(
@@ -121,7 +123,8 @@ export async function resolveAutomationRunUsage(
totals.inputTokens,
totals.outputTokens,
totals.cacheReadTokens,
- totals.cacheWriteTokens
+ totals.cacheWriteTokens,
+ totals.cacheWrite1hTokens
)
return {
diff --git a/src/main/claude-usage/claude-usage-report-aggregation.test.ts b/src/main/claude-usage/claude-usage-report-aggregation.test.ts
new file mode 100644
index 00000000000..7d09c3074f2
--- /dev/null
+++ b/src/main/claude-usage/claude-usage-report-aggregation.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+import type { ClaudeUsageDailyAggregate, ClaudeUsagePersistedState } from './types'
+import { buildSummary } from './claude-usage-report-aggregation'
+
+function dailyRow(day: string, model: string): ClaudeUsageDailyAggregate {
+ return {
+ day,
+ model,
+ projectKey: 'project',
+ projectLabel: 'project',
+ repoId: null,
+ worktreeId: 'worktree-1',
+ turnCount: 1,
+ zeroCacheReadTurnCount: 0,
+ inputTokens: 1_000_000,
+ outputTokens: 1_000_000,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ cacheWrite1hTokens: 0
+ }
+}
+
+function stateWithDaily(dailyAggregates: ClaudeUsageDailyAggregate[]): ClaudeUsagePersistedState {
+ return {
+ schemaVersion: 6,
+ worktreeFingerprint: null,
+ processedFiles: [],
+ sessions: [],
+ dailyAggregates,
+ scanState: {
+ enabled: true,
+ lastScanStartedAt: null,
+ lastScanCompletedAt: null,
+ lastScanError: null
+ }
+ }
+}
+
+describe('buildSummary Claude Sonnet 5 pricing over time', () => {
+ // Why: the "All" range re-prices historical days on every render, so a day inside the
+ // announced 2026-08-31 introductory window has to price the same as a later day —
+ // the scheduled $3/$15 increase was cancelled and never took effect.
+ it('prices an introductory-window day and a post-window day at the same $2/$10 rate', () => {
+ const inWindow = buildSummary(
+ stateWithDaily([dailyRow('2026-08-15', 'claude-sonnet-5')]),
+ 'all',
+ 'all'
+ )
+ const afterWindow = buildSummary(
+ stateWithDaily([dailyRow('2026-09-15', 'claude-sonnet-5')]),
+ 'all',
+ 'all'
+ )
+
+ expect(inWindow.estimatedCostUsd).toBeCloseTo(12)
+ expect(afterWindow.estimatedCostUsd).toBeCloseTo(12)
+ })
+
+ it('sums both days in the All range without re-pricing history upward', () => {
+ const summary = buildSummary(
+ stateWithDaily([
+ dailyRow('2026-08-15', 'claude-sonnet-5'),
+ dailyRow('2026-09-15', 'claude-sonnet-5')
+ ]),
+ 'all',
+ 'all'
+ )
+
+ expect(summary.estimatedCostUsd).toBeCloseTo(24)
+ })
+})
diff --git a/src/main/claude-usage/claude-usage-report-aggregation.ts b/src/main/claude-usage/claude-usage-report-aggregation.ts
index f9bdd863c36..9231af85cfa 100644
--- a/src/main/claude-usage/claude-usage-report-aggregation.ts
+++ b/src/main/claude-usage/claude-usage-report-aggregation.ts
@@ -47,7 +47,8 @@ export function buildSummary(
row.inputTokens,
row.outputTokens,
row.cacheReadTokens,
- row.cacheWriteTokens
+ row.cacheWriteTokens,
+ row.cacheWrite1hTokens
)
if (cost !== null) {
hasAnyBillableCost = true
@@ -111,6 +112,8 @@ export function buildBreakdown(
kind: ClaudeUsageBreakdownKind
): ClaudeUsageBreakdownRow[] {
const rows = new Map()
+ // Why: the 1h cache-write split only feeds pricing, so it stays off the renderer row type.
+ const cacheWrite1hTokensByKey = new Map()
const filteredDaily = getFilteredDaily(state, scope, range)
const filteredSessions = getFilteredSessions(state, scope, range)
@@ -133,6 +136,10 @@ export function buildBreakdown(
existing.outputTokens += daily.outputTokens
existing.cacheReadTokens += daily.cacheReadTokens
existing.cacheWriteTokens += daily.cacheWriteTokens
+ cacheWrite1hTokensByKey.set(
+ key,
+ (cacheWrite1hTokensByKey.get(key) ?? 0) + daily.cacheWrite1hTokens
+ )
rows.set(key, existing)
}
@@ -168,7 +175,8 @@ export function buildBreakdown(
row.inputTokens,
row.outputTokens,
row.cacheReadTokens,
- row.cacheWriteTokens
+ row.cacheWriteTokens,
+ cacheWrite1hTokensByKey.get(row.key) ?? 0
)
}
}
diff --git a/src/main/claude-usage/scanner.test.ts b/src/main/claude-usage/scanner.test.ts
index dc4d6085a5a..cf5b4cc99a3 100644
--- a/src/main/claude-usage/scanner.test.ts
+++ b/src/main/claude-usage/scanner.test.ts
@@ -36,10 +36,59 @@ describe('parseClaudeUsageRecord', () => {
inputTokens: 100,
outputTokens: 25,
cacheReadTokens: 10,
- cacheWriteTokens: 5
+ cacheWriteTokens: 5,
+ cacheWrite1hTokens: 0
})
})
+ it('splits the cache-write total by TTL when the breakdown is present', () => {
+ const parsed = parseClaudeUsageRecord(
+ JSON.stringify({
+ type: 'assistant',
+ sessionId: 'session-1',
+ timestamp: '2026-04-09T10:00:00.000Z',
+ message: {
+ model: 'claude-sonnet-4-6',
+ usage: {
+ input_tokens: 100,
+ output_tokens: 25,
+ cache_read_input_tokens: 10,
+ cache_creation_input_tokens: 1000,
+ cache_creation: {
+ ephemeral_5m_input_tokens: 600,
+ ephemeral_1h_input_tokens: 400
+ }
+ }
+ }
+ })
+ )
+
+ expect(parsed?.cacheWriteTokens).toBe(1000)
+ expect(parsed?.cacheWrite1hTokens).toBe(400)
+ })
+
+ it('clamps a 1-hour breakdown that exceeds the reported cache-write total', () => {
+ const parsed = parseClaudeUsageRecord(
+ JSON.stringify({
+ type: 'assistant',
+ sessionId: 'session-1',
+ timestamp: '2026-04-09T10:00:00.000Z',
+ message: {
+ model: 'claude-sonnet-4-6',
+ usage: {
+ input_tokens: 100,
+ output_tokens: 25,
+ cache_creation_input_tokens: 100,
+ cache_creation: { ephemeral_1h_input_tokens: 400 }
+ }
+ }
+ })
+ )
+
+ expect(parsed?.cacheWriteTokens).toBe(100)
+ expect(parsed?.cacheWrite1hTokens).toBe(100)
+ })
+
it('merges duplicate streamed assistant usage by message and request id', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-claude-dedupe-'))
const filePath = join(root, 'session.jsonl')
@@ -92,7 +141,8 @@ describe('parseClaudeUsageRecord', () => {
inputTokens: 120,
outputTokens: 30,
cacheReadTokens: 12,
- cacheWriteTokens: 7
+ cacheWriteTokens: 7,
+ cacheWrite1hTokens: 0
}
])
} finally {
@@ -114,7 +164,8 @@ describe('Claude usage aggregation', () => {
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
- cacheWriteTokens: 5
+ cacheWriteTokens: 5,
+ cacheWrite1hTokens: 0
},
{
sessionId: 'session-1',
@@ -125,7 +176,8 @@ describe('Claude usage aggregation', () => {
inputTokens: 50,
outputTokens: 10,
cacheReadTokens: 0,
- cacheWriteTokens: 0
+ cacheWriteTokens: 0,
+ cacheWrite1hTokens: 0
}
],
new Map([
@@ -154,7 +206,8 @@ describe('Claude usage aggregation', () => {
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
- cacheWriteTokens: 5
+ cacheWriteTokens: 5,
+ cacheWrite1hTokens: 0
},
{
locationKey: 'cwd:/outside/repo-b',
@@ -165,7 +218,8 @@ describe('Claude usage aggregation', () => {
inputTokens: 50,
outputTokens: 10,
cacheReadTokens: 0,
- cacheWriteTokens: 0
+ cacheWriteTokens: 0,
+ cacheWrite1hTokens: 0
}
])
})
@@ -182,7 +236,8 @@ describe('Claude usage aggregation', () => {
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
- cacheWriteTokens: 5
+ cacheWriteTokens: 5,
+ cacheWrite1hTokens: 0
}
],
new Map([
diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts
index 3609399ac33..650976a2455 100644
--- a/src/main/claude-usage/store.test.ts
+++ b/src/main/claude-usage/store.test.ts
@@ -101,6 +101,7 @@ describe('ClaudeUsageStore', () => {
totalOutputTokens: 20,
totalCacheReadTokens: 10,
totalCacheWriteTokens: 5,
+ totalCacheWrite1hTokens: 0,
locationBreakdown: [
{
locationKey: 'cwd:/outside/repo',
@@ -111,7 +112,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
- cacheWriteTokens: 5
+ cacheWriteTokens: 5,
+ cacheWrite1hTokens: 0
}
]
}
@@ -129,7 +131,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
- cacheWriteTokens: 5
+ cacheWriteTokens: 5,
+ cacheWrite1hTokens: 0
}
]
})
@@ -159,6 +162,7 @@ describe('ClaudeUsageStore', () => {
totalOutputTokens: 20,
totalCacheReadTokens: 10,
totalCacheWriteTokens: 5,
+ totalCacheWrite1hTokens: 0,
locationBreakdown: [
{
locationKey: 'worktree:repo-1::/workspace/repo-a',
@@ -169,7 +173,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
- cacheWriteTokens: 5
+ cacheWriteTokens: 5,
+ cacheWrite1hTokens: 0
}
]
}
@@ -187,7 +192,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
- cacheWriteTokens: 5
+ cacheWriteTokens: 5,
+ cacheWrite1hTokens: 0
}
]
})
@@ -213,7 +219,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
- cacheWriteTokens: 5
+ cacheWriteTokens: 5,
+ cacheWrite1hTokens: 0
}
]
})
@@ -239,7 +246,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
}
]
})
@@ -253,6 +261,37 @@ describe('ClaudeUsageStore', () => {
).toBeCloseTo(36.75)
})
+ it('prices the 1-hour cache-write share above the 5-minute rate', async () => {
+ const store = createStoreWithState({
+ dailyAggregates: [
+ {
+ day: '2026-04-09',
+ model: 'claude-opus-4-7-20260416',
+ projectKey: 'worktree:repo-1::/workspace/repo-a',
+ projectLabel: 'Repo A',
+ repoId: 'repo-1',
+ worktreeId: 'repo-1::/workspace/repo-a',
+ turnCount: 1,
+ zeroCacheReadTurnCount: 0,
+ inputTokens: 1_000_000,
+ outputTokens: 1_000_000,
+ cacheReadTokens: 1_000_000,
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 400_000
+ }
+ ]
+ })
+
+ const summary = await store.getSummary('orca', '30d')
+ const breakdown = await store.getBreakdown('orca', '30d', 'model')
+
+ // 5 + 25 + 0.5 + (0.6 * 6.25 + 0.4 * 10); the flat 5m rate would give 36.75.
+ expect(summary.estimatedCostUsd).toBeCloseTo(38.25)
+ expect(
+ breakdown.find((row) => row.key === 'claude-opus-4-7-20260416')?.estimatedCostUsd
+ ).toBeCloseTo(38.25)
+ })
+
it('prices Claude Opus 4.8 with current Anthropic rates', async () => {
const store = createStoreWithState({
dailyAggregates: [
@@ -268,7 +307,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
},
{
day: '2026-04-09',
@@ -282,7 +322,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
}
]
})
@@ -314,7 +355,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
},
{
day: '2026-04-09',
@@ -328,7 +370,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
},
{
day: '2026-04-09',
@@ -342,7 +385,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
}
]
})
@@ -357,7 +401,7 @@ describe('ClaudeUsageStore', () => {
).toBeCloseTo(73.5)
expect(
breakdown.find((row) => row.key === 'claude-sonnet-5-thinking')?.estimatedCostUsd
- ).toBeCloseTo(22.05)
+ ).toBeCloseTo(14.7)
})
it('prices Sonnet 5 long-context usage at flat rates', async () => {
@@ -375,7 +419,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 300_000,
outputTokens: 300_000,
cacheReadTokens: 300_000,
- cacheWriteTokens: 300_000
+ cacheWriteTokens: 300_000,
+ cacheWrite1hTokens: 0
}
]
})
@@ -383,7 +428,7 @@ describe('ClaudeUsageStore', () => {
const summary = await store.getSummary('orca', '30d')
// Why: Sonnet 4.6 and earlier bill above 200k at a premium; Sonnet 5 does not.
- expect(summary.estimatedCostUsd).toBeCloseTo(6.615)
+ expect(summary.estimatedCostUsd).toBeCloseTo(4.41)
})
it('does not collapse Opus 4.5 or Sonnet 4.5 usage into Claude 5 pricing', async () => {
@@ -400,7 +445,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 300_000,
outputTokens: 300_000,
cacheReadTokens: 300_000,
- cacheWriteTokens: 300_000
+ cacheWriteTokens: 300_000,
+ cacheWrite1hTokens: 0
}))
})
@@ -433,7 +479,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
},
{
day: '2026-04-09',
@@ -447,7 +494,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
}
]
})
@@ -472,7 +520,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
},
{
day: '2026-04-09',
@@ -486,7 +535,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
- cacheWriteTokens: 1_000_000
+ cacheWriteTokens: 1_000_000,
+ cacheWrite1hTokens: 0
}
]
})
@@ -511,7 +561,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 300_000,
outputTokens: 300_000,
cacheReadTokens: 300_000,
- cacheWriteTokens: 300_000
+ cacheWriteTokens: 300_000,
+ cacheWrite1hTokens: 0
}
]
})
@@ -546,6 +597,7 @@ describe('ClaudeUsageStore', () => {
totalOutputTokens: 500,
totalCacheReadTokens: 200,
totalCacheWriteTokens: 100,
+ totalCacheWrite1hTokens: 0,
locationBreakdown: [
{
locationKey: `worktree:${worktreeId}`,
@@ -556,7 +608,8 @@ describe('ClaudeUsageStore', () => {
inputTokens: 1000,
outputTokens: 500,
cacheReadTokens: 200,
- cacheWriteTokens: 100
+ cacheWriteTokens: 100,
+ cacheWrite1hTokens: 0
}
]
}
diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts
index 466b30571cb..921c09c8732 100644
--- a/src/main/claude-usage/store.ts
+++ b/src/main/claude-usage/store.ts
@@ -22,8 +22,9 @@ import { resolveAutomationRunUsage } from './claude-usage-automation-attribution
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
// caches either lack ownership or used narrower keys and can under/over-count
-// after fork reclaim (#8006).
-const SCHEMA_VERSION = 5
+// after fork reclaim (#8006). v6 adds the 1-hour cache-write split, which older
+// caches never recorded, so their cost estimates stay stuck at the 5m rate (#15993).
+const SCHEMA_VERSION = 6
// Why: capture the path after configureDevUserDataPath() but before app.setName()
// mutates Electron's derived userData location, matching the persistence/store pattern.
diff --git a/src/main/claude-usage/transcript-record-parser.ts b/src/main/claude-usage/transcript-record-parser.ts
index 50623fa295f..59b0e75be39 100644
--- a/src/main/claude-usage/transcript-record-parser.ts
+++ b/src/main/claude-usage/transcript-record-parser.ts
@@ -23,6 +23,11 @@ type ClaudeUsageSourceRecord = {
output_tokens?: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
+ /** TTL split of `cache_creation_input_tokens`; 1h writes bill at 2x base input. */
+ cache_creation?: {
+ ephemeral_5m_input_tokens?: number
+ ephemeral_1h_input_tokens?: number
+ }
}
}
}
@@ -43,7 +48,8 @@ export function stripClaudeSourceMetadata(
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
- cacheWriteTokens: turn.cacheWriteTokens
+ cacheWriteTokens: turn.cacheWriteTokens,
+ cacheWrite1hTokens: turn.cacheWrite1hTokens
}
}
@@ -64,6 +70,7 @@ function dedupeClaudeUsageTurns(
existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens)
existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens)
existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens)
+ existing.cacheWrite1hTokens = Math.max(existing.cacheWrite1hTokens, turn.cacheWrite1hTokens)
continue
}
}
@@ -101,6 +108,11 @@ function parseClaudeUsageSourceRecord(
const outputTokens = usage?.output_tokens ?? 0
const cacheReadTokens = usage?.cache_read_input_tokens ?? 0
const cacheWriteTokens = usage?.cache_creation_input_tokens ?? 0
+ // Why: clamp so the implied 5m remainder can never go negative on a partial row.
+ const cacheWrite1hTokens = Math.min(
+ usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0,
+ cacheWriteTokens
+ )
if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens <= 0) {
return null
@@ -119,7 +131,8 @@ function parseClaudeUsageSourceRecord(
inputTokens,
outputTokens,
cacheReadTokens,
- cacheWriteTokens
+ cacheWriteTokens,
+ cacheWrite1hTokens
}
}
diff --git a/src/main/claude-usage/types.ts b/src/main/claude-usage/types.ts
index ec497e5e098..0a4d061c332 100644
--- a/src/main/claude-usage/types.ts
+++ b/src/main/claude-usage/types.ts
@@ -15,6 +15,7 @@ export type ClaudeUsageLocationBreakdown = {
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
+ cacheWrite1hTokens: number
}
export type ClaudeUsageSession = {
@@ -31,6 +32,7 @@ export type ClaudeUsageSession = {
totalOutputTokens: number
totalCacheReadTokens: number
totalCacheWriteTokens: number
+ totalCacheWrite1hTokens: number
locationBreakdown: ClaudeUsageLocationBreakdown[]
}
@@ -47,6 +49,7 @@ export type ClaudeUsageDailyAggregate = {
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
+ cacheWrite1hTokens: number
}
export type ClaudeUsagePersistedState = {
@@ -86,6 +89,8 @@ export type ClaudeUsageParsedTurn = {
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
+ /** 1-hour-TTL subset of `cacheWriteTokens`; billed at 2x base input. */
+ cacheWrite1hTokens: number
}
export type ClaudeUsageAttributedTurn = ClaudeUsageParsedTurn & {
diff --git a/src/main/claude-usage/usage-aggregation.ts b/src/main/claude-usage/usage-aggregation.ts
index 5ead6af0177..7626845827a 100644
--- a/src/main/claude-usage/usage-aggregation.ts
+++ b/src/main/claude-usage/usage-aggregation.ts
@@ -30,6 +30,7 @@ export function mergeClaudeSessions(
existing.totalOutputTokens += session.totalOutputTokens
existing.totalCacheReadTokens += session.totalCacheReadTokens
existing.totalCacheWriteTokens += session.totalCacheWriteTokens
+ existing.totalCacheWrite1hTokens += session.totalCacheWrite1hTokens
for (const location of session.locationBreakdown) {
const existingLocation =
@@ -41,6 +42,7 @@ export function mergeClaudeSessions(
existingLocation.outputTokens += location.outputTokens
existingLocation.cacheReadTokens += location.cacheReadTokens
existingLocation.cacheWriteTokens += location.cacheWriteTokens
+ existingLocation.cacheWrite1hTokens += location.cacheWrite1hTokens
} else {
existing.locationBreakdown.push({ ...location })
}
@@ -65,6 +67,7 @@ export function mergeClaudeDailyAggregates(
existing.outputTokens += aggregate.outputTokens
existing.cacheReadTokens += aggregate.cacheReadTokens
existing.cacheWriteTokens += aggregate.cacheWriteTokens
+ existing.cacheWrite1hTokens += aggregate.cacheWrite1hTokens
}
}
@@ -113,6 +116,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
+ totalCacheWrite1hTokens: 0,
locationBreakdown: []
})
}
@@ -132,6 +136,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
session.totalOutputTokens += turn.outputTokens
session.totalCacheReadTokens += turn.cacheReadTokens
session.totalCacheWriteTokens += turn.cacheWriteTokens
+ session.totalCacheWrite1hTokens += turn.cacheWrite1hTokens
const location =
session.locationBreakdown.find((entry) => entry.locationKey === turn.projectKey) ?? null
@@ -141,6 +146,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
location.outputTokens += turn.outputTokens
location.cacheReadTokens += turn.cacheReadTokens
location.cacheWriteTokens += turn.cacheWriteTokens
+ location.cacheWrite1hTokens += turn.cacheWrite1hTokens
} else {
session.locationBreakdown.push({
locationKey: turn.projectKey,
@@ -151,7 +157,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
- cacheWriteTokens: turn.cacheWriteTokens
+ cacheWriteTokens: turn.cacheWriteTokens,
+ cacheWrite1hTokens: turn.cacheWrite1hTokens
})
}
@@ -166,6 +173,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
existingDaily.outputTokens += turn.outputTokens
existingDaily.cacheReadTokens += turn.cacheReadTokens
existingDaily.cacheWriteTokens += turn.cacheWriteTokens
+ existingDaily.cacheWrite1hTokens += turn.cacheWrite1hTokens
} else {
dailyByKey.set(dailyKey, {
day: turn.day,
@@ -179,7 +187,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
- cacheWriteTokens: turn.cacheWriteTokens
+ cacheWriteTokens: turn.cacheWriteTokens,
+ cacheWrite1hTokens: turn.cacheWrite1hTokens
})
}
}
From 6f8c5888b3acc4011c09498cda27cf3906edd91a Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:17:25 -0700
Subject: [PATCH 04/35] Run Node 26 compatibility daily instead of per PR
(#16946)
* Run Node 26 compatibility daily
* Update relocated unit workflow contracts
---
.github/workflows/node-next-compat.yml | 20 +++++++
.github/workflows/pr.yml | 57 ++----------------
.github/workflows/unit-tests.yml | 59 +++++++++++++++++++
...package-electron-runtime-contract.test.mjs | 9 +--
config/scripts/pr-code-change-scope.test.mjs | 6 +-
.../scripts/pr-workflow-parallelism.test.mjs | 37 ++++++++----
.../web/web-preload-api-runtime-calls.test.ts | 8 ++-
7 files changed, 124 insertions(+), 72 deletions(-)
create mode 100644 .github/workflows/node-next-compat.yml
create mode 100644 .github/workflows/unit-tests.yml
diff --git a/.github/workflows/node-next-compat.yml b/.github/workflows/node-next-compat.yml
new file mode 100644
index 00000000000..eb9de70dab7
--- /dev/null
+++ b/.github/workflows/node-next-compat.yml
@@ -0,0 +1,20 @@
+name: Node next compatibility
+
+on:
+ schedule:
+ # Full future-runtime coverage is useful, but not worth doubling every PR matrix.
+ - cron: '0 10 * * *'
+ workflow_dispatch:
+
+concurrency:
+ group: node-next-compat
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ uses: ./.github/workflows/unit-tests.yml
+ with:
+ node_versions: '["26"]'
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 9f3b14f5903..338bcda7dbd 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -444,16 +444,12 @@ jobs:
src/shared/posix-command-path-lookup.test.ts
# Cache-key input changes would otherwise make every shard compile the same
- # native addon concurrently. Prime each Node ABI once before the matrix fans out.
+ # native addon concurrently. Prime the supported Node ABI before the matrix fans out.
test_native_cache:
- name: prepare test native cache node ${{ matrix.node }}
+ name: prepare test native cache node 24
needs: [code_paths]
if: needs.code_paths.outputs.native_cache_changed == 'true'
runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- node: ['24', '26']
steps:
- name: Checkout
@@ -464,58 +460,17 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- node-version: ${{ matrix.node }}
+ node-version: '24'
test:
- name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
needs: [code_paths, test_native_cache]
if: >-
always() &&
needs.code_paths.outputs.test == 'true' &&
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- node: ['24', '26']
- shard: [1, 2, 3, 4, 5, 6, 7, 8]
- shard_total: [8]
-
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- with:
- persist-credentials: false
-
- - uses: ./.github/actions/install-node-dependencies
- with:
- native-runtime: node
- node-version: ${{ matrix.node }}
-
- - name: Install Electron package binary for tests
- run: node config/scripts/install-electron-package-binary.mjs
-
- - name: Test shard
- run: |
- pnpm exec vitest run --config config/vitest.config.ts \
- --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
- --exclude=src/main/daemon/shell-ready.test.ts \
- --exclude=src/main/daemon/node-pty-fd-leak.test.ts \
- --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
- --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
- --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
- --exclude=src/main/shell-startup-feature-channel.test.ts \
- --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
- --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
- --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
- --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
- --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
- --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
- --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
- --exclude=src/shared/startup-shell-portability.live-shell.test.ts \
- --exclude=src/shared/posix-command-path-lookup.test.ts \
- --exclude=tests/e2e/cross-version-wire/** \
- --shard=${{ matrix.shard }}/${{ matrix.shard_total }}
+ uses: ./.github/workflows/unit-tests.yml
+ with:
+ node_versions: '["24"]'
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
# would pay for it on every shard to run one file in whichever shard it landed in.
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
new file mode 100644
index 00000000000..9906b569324
--- /dev/null
+++ b/.github/workflows/unit-tests.yml
@@ -0,0 +1,59 @@
+name: Unit tests
+
+on:
+ workflow_call:
+ inputs:
+ node_versions:
+ description: JSON array of Node.js major versions to test.
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node: ${{ fromJSON(inputs.node_versions) }}
+ shard: [1, 2, 3, 4, 5, 6, 7, 8]
+ shard_total: [8]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
+ node-version: ${{ matrix.node }}
+
+ - name: Install Electron package binary for tests
+ run: node config/scripts/install-electron-package-binary.mjs
+
+ - name: Test shard
+ run: |
+ pnpm exec vitest run --config config/vitest.config.ts \
+ --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
+ --exclude=src/main/daemon/shell-ready.test.ts \
+ --exclude=src/main/daemon/node-pty-fd-leak.test.ts \
+ --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
+ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
+ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
+ --exclude=src/main/shell-startup-feature-channel.test.ts \
+ --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
+ --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
+ --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
+ --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
+ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
+ --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
+ --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
+ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \
+ --exclude=src/shared/posix-command-path-lookup.test.ts \
+ --exclude=tests/e2e/cross-version-wire/** \
+ --shard=${{ matrix.shard }}/${{ matrix.shard_total }}
diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs
index ef9745e476c..9c440280098 100644
--- a/config/scripts/package-electron-runtime-contract.test.mjs
+++ b/config/scripts/package-electron-runtime-contract.test.mjs
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
expect(copyStep.run).toContain('git add "$CASK_PATH"')
})
- it('installs the Electron package binary in PR checks without changing native module ABI', () => {
- const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
- const parsedWorkflow = parse(prWorkflow)
- const installStep = parsedWorkflow.jobs.test.steps.find(
+ it('installs the Electron package binary in the shared unit-test workflow', () => {
+ const unitTestWorkflow = parse(
+ readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
+ )
+ const installStep = unitTestWorkflow.jobs.test.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs
index 223929fc213..ea83796e6cb 100644
--- a/config/scripts/pr-code-change-scope.test.mjs
+++ b/config/scripts/pr-code-change-scope.test.mjs
@@ -281,7 +281,11 @@ describe('PR Checks skip wiring', () => {
expect(prWorkflow.jobs.test_native_cache.if).toBe(
"needs.code_paths.outputs.native_cache_changed == 'true'"
)
- expect(prWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
+ expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
+ const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+ expect(primerInstall.with['node-version']).toBe('24')
})
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs
index 88bf4165d63..29f532b9436 100644
--- a/config/scripts/pr-workflow-parallelism.test.mjs
+++ b/config/scripts/pr-workflow-parallelism.test.mjs
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
+const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
+const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
@@ -47,28 +49,34 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
- it('shards the general test suite across Node 24 and Node 26', () => {
- expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
- expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
- Array.from({ length: 8 }, (_, index) => index + 1)
+ it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
+ const sharedTest = unitTestWorkflow.jobs.test
+ const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
+ const installStep = sharedTest.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
)
- expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8])
- const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
- const installStep = workflow.jobs.test.steps.find(
+ const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
+ expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
+ expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
+ expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
+ expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
+ expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
+ expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
+ expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
+ expect(sharedTest.strategy.matrix.shard).toEqual(
+ Array.from({ length: 8 }, (_, index) => index + 1)
+ )
+ expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
- const primerInstall = workflow.jobs.test_native_cache.steps.find(
- (step) => step.uses === './.github/actions/install-node-dependencies'
- )
- expect(workflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
expect(primerInstall.with['native-runtime']).toBe('node')
- expect(primerInstall.with['node-version']).toBe('${{ matrix.node }}')
+ expect(primerInstall.with['node-version']).toBe('24')
expect(workflow.jobs.test.needs).toContain('test_native_cache')
})
@@ -241,13 +249,16 @@ describe('PR workflow parallelism', () => {
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
+ const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
- expect(installFor('test').with['native-runtime']).toBe('node')
+ expect(sharedTestInstall.with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
expect(installFor('package_windows').with['native-runtime']).toBe('node')
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts
index 6960ba5b83f..fcf24437595 100644
--- a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts
+++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts
@@ -65,7 +65,7 @@ describe('web preload runtime calls', () => {
).toMatchObject({ runtimeId: 'runtime-failure' })
})
- it('unwraps domain failures to their message after persisting failure metadata', async () => {
+ it('unwraps domain failures with their code after persisting failure metadata', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string): Promise> {
@@ -96,11 +96,13 @@ describe('web preload runtime calls', () => {
rejection = error
}
- expect(rejection).toEqual(new Error('Repository catalog is unavailable'))
+ expect(rejection).toEqual(
+ Object.assign(new Error('Repository catalog is unavailable'), { code: 'repo_unavailable' })
+ )
if (!(rejection instanceof Error)) {
throw new Error('Expected a domain Error rejection')
}
- expect(Reflect.get(rejection, 'code')).toBeUndefined()
+ expect(Reflect.get(rejection, 'code')).toBe('repo_unavailable')
expect(
JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}')
).toMatchObject({ runtimeId: 'runtime-domain-failure' })
From 6f3c5cc03995ceeb7181ba421dfa58a50ea77f71 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:18:04 -0700
Subject: [PATCH 05/35] =?UTF-8?q?fix(i18n):=20use=20=E5=B7=A5=E4=BD=9C?=
=?UTF-8?q?=E6=A0=91=20consistently=20for=20parent=20worktree=20strings=20?=
=?UTF-8?q?in=20zh=20locale=20(#16954)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: select parent worktree for nesting when creating workspace
Enable users to pick a parent workspace in the composer's Advanced drawer,
organizing newly created worktrees hierarchically in the sidebar. The backend
validates lineage relationships and gracefully retries without the parent if
it becomes unavailable during creation.
* feat: select parent worktree for nesting when creating workspace
- Record app-picked parents as manual actions, not CLI-flag equivalents,
ensuring the same user action carries consistent cleanup semantics across hosts
- Gracefully retry without parent if the selection goes stale, warn the
user instead of failing
- Refactor create into modules: parent resolution, payload building,
state merge
* Allow selecting parent worktree when creating workspace
- New parent worktree picker filtered by execution host and project
- Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership
* Allow selecting parent worktree when creating workspace
- Rename "Parent workspace" label to "Parent worktree"
- Filter candidates by execution host and project to prevent nesting across hosts
- Wire parentWorktreeId through composer state and creation request pipeline
- Update translations and add new copy for nesting-related messages
* fix(i18n): use 工作树 consistently for parent worktree strings in zh locale
Addresses PR #15420 review feedback: parent worktree strings mixed
工作区 (workspace) and 工作树 (worktree) terminology.
---
...ados-hardware-2set-hangul-batchim-migration-trace.json | 8 ++------
src/renderer/src/i18n/locales/zh.json | 6 +++---
2 files changed, 5 insertions(+), 9 deletions(-)
diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json
index 832e08ed997..c208ba06ff1 100644
--- a/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json
+++ b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json
@@ -4,11 +4,7 @@
"maxTouchPoints": 5,
"typed": "gksrmfRownla",
"expected": "한글깨쥠",
- "observedSent": [
- "한",
- "글",
- "깨쥠"
- ],
+ "observedSent": ["한", "글", "깨쥠"],
"events": [
{
"type": "keydown",
@@ -283,4 +279,4 @@
"value": "한글깨쥠"
}
]
-}
\ No newline at end of file
+}
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 4d4a7010809..3e81e69b8b3 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -269,7 +269,7 @@
"runtimeScopeForbiddenDescription": "移动端范围的配对无法使用工作区。请通过设置 → 远程 Orca 服务器 → 分享此 Orca 服务器中的浏览器访问链接重新连接。",
"createdWithoutParentNesting": "已创建,但未嵌套在“{{value0}}”下",
"createdWithoutParentNestingUnnamed": "已创建,但未嵌套在所选父工作树下",
- "createdWithoutParentNestingDetail": "父工作区已不可用。您可以从工作区菜单中重新设置。"
+ "createdWithoutParentNestingDetail": "父工作树已不可用。您可以从工作区菜单中重新设置。"
},
"repos": {
"2975400634": "请更新 Orca 服务器,以便在此运行时打开非 Git 文件夹。",
@@ -14504,8 +14504,8 @@
"ComposerParentWorktreePicker": {
"label": "父工作树",
"noParent": "无父工作树",
- "description": "在侧边栏中将此工作区嵌套到另一个工作区下。不会更改基础分支。",
- "searchPlaceholder": "搜索工作区...",
+ "description": "在侧边栏中将此工作区嵌套到另一个工作树下。不会更改基础分支。",
+ "searchPlaceholder": "搜索工作树...",
"noMatches": "无匹配项。"
}
},
From c54bf9218117e01d2ff1492b2b1a87830e309cd3 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:29:42 -0700
Subject: [PATCH 06/35] fix(window): stop will-navigate from blocking the
lazy-chunk recovery reload (#16944)
* wip: lazy-chunk-reload
* fix(window): reject non-document navigation schemes
---
.../privileged-window-navigation.test.ts | 96 +++++++++++++++++++
.../window/privileged-window-navigation.ts | 18 ++--
.../window/renderer-document-navigation.ts | 9 +-
3 files changed, 110 insertions(+), 13 deletions(-)
create mode 100644 src/main/window/privileged-window-navigation.test.ts
diff --git a/src/main/window/privileged-window-navigation.test.ts b/src/main/window/privileged-window-navigation.test.ts
new file mode 100644
index 00000000000..579d830f244
--- /dev/null
+++ b/src/main/window/privileged-window-navigation.test.ts
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const openExternal = vi.fn()
+vi.mock('electron', () => ({
+ shell: { openExternal: (url: string) => openExternal(url) }
+}))
+
+import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
+
+describe('privileged window navigation policy', () => {
+ function createFixture(currentUrl: string) {
+ const handlers = new Map void>()
+ const contents = {
+ getURL: () => currentUrl,
+ setWindowOpenHandler: vi.fn(),
+ on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
+ handlers.set(event, handler)
+ })
+ }
+ installPrivilegedWindowNavigationPolicy(contents as never)
+ return {
+ willNavigate(url: string) {
+ const event = { preventDefault: vi.fn() }
+ const handler = handlers.get('will-navigate')
+ // Why: the allow-cases assert preventDefault was *not* called, so a missing
+ // handler would pass them vacuously.
+ if (!handler) {
+ throw new Error('no will-navigate handler was registered')
+ }
+ handler(event, url)
+ return event
+ }
+ }
+ }
+
+ beforeEach(() => {
+ openExternal.mockClear()
+ })
+
+ it('lets the packaged renderer document reload itself', () => {
+ const appUrl =
+ 'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html'
+ const event = createFixture(appUrl).willNavigate(appUrl)
+
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ expect(openExternal).not.toHaveBeenCalled()
+ })
+
+ it('still blocks and hands off an external http target', () => {
+ const appUrl =
+ 'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html'
+ const event = createFixture(appUrl).willNavigate('https://example.com/')
+
+ expect(event.preventDefault).toHaveBeenCalled()
+ expect(openExternal).toHaveBeenCalledWith('https://example.com/')
+ })
+
+ it('lets the dev renderer origin navigate but blocks foreign documents', () => {
+ const fixture = createFixture('http://localhost:5173/')
+
+ expect(
+ fixture.willNavigate('http://localhost:5173/index.html').preventDefault
+ ).not.toHaveBeenCalled()
+ expect(fixture.willNavigate('https://example.com/').preventDefault).toHaveBeenCalled()
+ expect(
+ fixture.willNavigate('blob:http://localhost:5173/attacker-document').preventDefault
+ ).toHaveBeenCalled()
+ })
+
+ // Why: the packaged file: path is the whole privilege boundary, so a foreign host or a
+ // non-file scheme that reuses it must not read as "our own document".
+ it('blocks a foreign file host and a data: URL that reuse the renderer path', () => {
+ const appUrl =
+ 'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html'
+ const fixture = createFixture(appUrl)
+
+ expect(
+ fixture.willNavigate(
+ 'file://evil.example/Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html'
+ ).preventDefault
+ ).toHaveBeenCalled()
+ expect(
+ fixture.willNavigate('data:text/html,').preventDefault
+ ).toHaveBeenCalled()
+ expect(openExternal).not.toHaveBeenCalled()
+ })
+
+ it('still blocks navigation to an unrelated local file', () => {
+ const appUrl =
+ 'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html'
+ const event = createFixture(appUrl).willNavigate('file:///Users/someone/.ssh/id_rsa')
+
+ expect(event.preventDefault).toHaveBeenCalled()
+ expect(openExternal).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/window/privileged-window-navigation.ts b/src/main/window/privileged-window-navigation.ts
index a5afbbe0a98..d11268f0340 100644
--- a/src/main/window/privileged-window-navigation.ts
+++ b/src/main/window/privileged-window-navigation.ts
@@ -1,6 +1,6 @@
import { shell, type WebContents } from 'electron'
-import { is } from '@electron-toolkit/utils'
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
+import { isRendererDocumentNavigation } from './renderer-document-navigation'
/** Keep remote documents from inheriting an Orca window's privileged preload. */
export function installPrivilegedWindowNavigationPolicy(contents: WebContents): void {
@@ -13,19 +13,13 @@ export function installPrivilegedWindowNavigationPolicy(contents: WebContents):
})
contents.on('will-navigate', (event, url) => {
+ // Why: location.reload() is a renderer-initiated navigation, so blocking it here
+ // silently kills the lazy-chunk recovery reload with no unload-prevented signal.
+ if (isRendererDocumentNavigation(contents.getURL(), url)) {
+ return
+ }
const externalUrl = normalizeExternalBrowserUrl(url)
if (externalUrl) {
- if (is.dev && process.env.ELECTRON_RENDERER_URL) {
- try {
- const target = new URL(externalUrl)
- const allowed = new URL(process.env.ELECTRON_RENDERER_URL)
- if (target.origin === allowed.origin) {
- return
- }
- } catch {
- // Fall through and block malformed navigation targets.
- }
- }
void shell.openExternal(externalUrl)
}
event.preventDefault()
diff --git a/src/main/window/renderer-document-navigation.ts b/src/main/window/renderer-document-navigation.ts
index 4179e24a511..50fb128b633 100644
--- a/src/main/window/renderer-document-navigation.ts
+++ b/src/main/window/renderer-document-navigation.ts
@@ -1,6 +1,12 @@
import type { WebContents } from 'electron'
-function isRendererDocumentNavigation(currentUrl: string, nextUrl: string): boolean {
+/**
+ * True when the target stays inside the window's own privileged document: for `file:`
+ * the exact same host+path (query/hash may differ, as a reload keeps them), for http(s)
+ * the same origin. Also gates preload privilege in installPrivilegedWindowNavigationPolicy,
+ * so loosening it past a same-origin document hands a foreign page the Orca bridge.
+ */
+export function isRendererDocumentNavigation(currentUrl: string, nextUrl: string): boolean {
try {
const current = new URL(currentUrl)
const next = new URL(nextUrl)
@@ -13,6 +19,7 @@ function isRendererDocumentNavigation(currentUrl: string, nextUrl: string): bool
}
return (
(current.protocol === 'http:' || current.protocol === 'https:') &&
+ (next.protocol === 'http:' || next.protocol === 'https:') &&
next.origin === current.origin
)
} catch {
From c72afda498d4d29b69ebad2b5aa6bfea86e199f2 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:56:00 -0700
Subject: [PATCH 07/35] wip: react185-cascade (#16940)
---
.../activity/ActivityPrototypePage.tsx | 11 +
...vity-auto-mark-read-loop.react185.test.tsx | 224 +++++++++++++++++
...reeContextMenu.react185-bystander.test.tsx | 234 ++++++++++++++++++
.../SortableTab.update-depth-probe.test.tsx | 212 ++++++++++++++++
...-park-tab-model-identity.react185.test.tsx | 233 +++++++++++++++++
.../use-terminal-tab-cold-parking.ts | 15 +-
.../useAutoAckViewedAgent.clock-skew.test.ts | 112 +++++++++
...ab-agent-observed-signal-dispatch.test.tsx | 148 +++++++++++
src/renderer/src/lib/use-tab-agent.ts | 8 +-
.../ui-acknowledge-agents-clock-skew.test.ts | 144 +++++++++++
src/renderer/src/store/slices/ui.ts | 36 ++-
11 files changed, 1370 insertions(+), 7 deletions(-)
create mode 100644 src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx
create mode 100644 src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx
create mode 100644 src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx
create mode 100644 src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx
create mode 100644 src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts
create mode 100644 src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx
create mode 100644 src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts
diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx
index a6a1df6dc9f..623e41d4c89 100644
--- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx
+++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx
@@ -1426,6 +1426,8 @@ export default function ActivityPrototypePage(): React.JSX.Element {
const [groupBy, setGroupBy] = useState('status')
const [query, setQuery] = useState('')
const activityFilterInputRef = useRef(null)
+ // Why: bounds auto mark-read to one acknowledgement per selected thread turn.
+ const autoAcknowledgedTurnRef = useRef(null)
const [compactMode, setCompactMode] = useState(false)
const [selectedPaneKey, setSelectedPaneKey] = useState(null)
const [displayedPaneKey, setDisplayedPaneKey] = useState(null)
@@ -1731,11 +1733,20 @@ export default function ActivityPrototypePage(): React.JSX.Element {
) {
return
}
+ // Why (React #185): a turn stamped ahead of this clock (SSH/remote execution host) can never
+ // have its unread cleared, and each retry lands on a later millisecond, so acknowledgeAgents'
+ // `prev < now` guard rewrites the ack map every time and re-enters here forever through
+ // storeData. Auto-read is once per turn, not a retry.
+ const autoAcknowledgeKey = `${selectedThread.paneKey}:${selectedThread.latestTimestamp}`
+ if (autoAcknowledgedTurnRef.current === autoAcknowledgeKey) {
+ return
+ }
const selectedThreadHasDetailOnlyView =
!selectedHasLiveTab || selectedThread.migrationUnsupportedPtyId !== undefined
const selectedThreadIsVisibleTerminal =
visibleThread?.paneKey === effectiveSelectedPaneKey && visiblePortalReady
if (selectedThreadHasDetailOnlyView || selectedThreadIsVisibleTerminal) {
+ autoAcknowledgedTurnRef.current = autoAcknowledgeKey
storeData.acknowledgeAgents([selectedThread.paneKey])
}
}, [
diff --git a/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx
new file mode 100644
index 00000000000..9fdc4c0ecef
--- /dev/null
+++ b/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx
@@ -0,0 +1,224 @@
+/** @vitest-environment happy-dom */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { useAppStore } from '@/store'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+import type { Repo } from '../../../../shared/repo-types'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import ActivityPrototypePage from './ActivityPrototypePage'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'
+const TAB_A = 'tab-a'
+const PANE_A = makePaneKey(TAB_A, LEAF_A)
+const PROMPT = 'turn stamped by the execution host'
+
+const repo: Repo = {
+ id: 'repo-1',
+ path: '/repo',
+ displayName: 'Repo',
+ badgeColor: '#000',
+ addedAt: 1
+}
+
+const worktree: Worktree = {
+ id: 'wt-1',
+ repoId: repo.id,
+ path: '/repo/wt-1',
+ head: 'abc123',
+ branch: 'feature',
+ isBare: false,
+ isMainWorktree: false,
+ displayName: 'feature',
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1
+}
+
+const tab: TerminalTab = {
+ id: TAB_A,
+ ptyId: 'pty-a',
+ worktreeId: worktree.id,
+ title: 'Claude',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+}
+
+const initialState = useAppStore.getInitialState()
+let root: Root
+let seededContainer: HTMLElement
+
+beforeEach(() => {
+ useAppStore.setState(initialState, true)
+})
+
+afterEach(() => {
+ act(() => root?.unmount())
+ useAppStore.setState(initialState, true)
+ document.body.replaceChildren()
+})
+
+function seedRetainedThread(stampedAt: number, lastAssistantMessage: string): void {
+ useAppStore.setState({
+ repos: [repo],
+ worktreesByRepo: { [repo.id]: [worktree] },
+ tabsByWorktree: { [worktree.id]: [] },
+ retainedAgentsByPaneKey: {
+ [PANE_A]: {
+ entry: {
+ state: 'done',
+ prompt: PROMPT,
+ updatedAt: stampedAt,
+ stateStartedAt: stampedAt,
+ paneKey: PANE_A,
+ terminalTitle: 'Claude',
+ stateHistory: [],
+ agentType: 'claude',
+ lastAssistantMessage
+ },
+ worktreeId: worktree.id,
+ tab,
+ agentType: 'claude',
+ startedAt: stampedAt
+ }
+ },
+ activeRepoId: repo.id,
+ activeWorktreeId: worktree.id
+ })
+}
+
+function seedThreadStampedByLocalClock(): void {
+ seedRetainedThread(Date.now() - 60_000, 'finished locally')
+}
+
+// Why: an SSH/remote execution host stamps the turn with its own clock, so the renderer can
+// acknowledge a thread whose event still sorts as newer than the acknowledgement.
+function seedThreadStampedAheadOfLocalClock(): void {
+ seedRetainedThread(Date.now() + 60_000, 'finished on the execution host')
+}
+
+async function mountActivityPage(): Promise {
+ const container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+ await act(async () => {
+ root.render(
+
+
+
+ )
+ await new Promise((resolve) => setTimeout(resolve, 30))
+ })
+ seededContainer = container
+}
+
+async function selectSeededThread(): Promise {
+ const row = Array.from(seededContainer.querySelectorAll('[role="button"]')).find(
+ (element) => element.textContent?.includes(PROMPT)
+ )
+ expect(row).toBeDefined()
+ await act(async () => {
+ row?.click()
+ await new Promise((resolve) => setTimeout(resolve, 30))
+ })
+}
+
+function countAcknowledgeWrites(): { readonly count: () => number; stop: () => void } {
+ let writes = 0
+ const unsubscribe = useAppStore.subscribe((next, prev) => {
+ if (next.acknowledgedAgentsByPaneKey !== prev.acknowledgedAgentsByPaneKey) {
+ writes += 1
+ }
+ })
+ return { count: () => writes, stop: unsubscribe }
+}
+
+describe('Activity auto mark-read loop (React #185)', () => {
+ it('acknowledges a selected thread once even when its unread flag cannot clear', async () => {
+ seedThreadStampedAheadOfLocalClock()
+ await mountActivityPage()
+ const acknowledgeWrites = countAcknowledgeWrites()
+
+ try {
+ await selectSeededThread()
+ } finally {
+ acknowledgeWrites.stop()
+ }
+
+ expect(acknowledgeWrites.count()).toBe(1)
+ })
+
+ it('re-acknowledges when a new turn lands on the still-selected thread', async () => {
+ seedThreadStampedAheadOfLocalClock()
+ await mountActivityPage()
+ const acknowledgeWrites = countAcknowledgeWrites()
+
+ try {
+ await selectSeededThread()
+ expect(acknowledgeWrites.count()).toBe(1)
+
+ // Why: the per-turn guard must not swallow the next turn's auto mark-read.
+ const nextTurnAt = Date.now() + 120_000
+ await act(async () => {
+ useAppStore.setState((s) => {
+ const previous = s.retainedAgentsByPaneKey[PANE_A]
+ return {
+ retainedAgentsByPaneKey: {
+ [PANE_A]: {
+ ...previous,
+ startedAt: nextTurnAt,
+ entry: {
+ ...previous.entry,
+ updatedAt: nextTurnAt,
+ stateStartedAt: nextTurnAt,
+ lastAssistantMessage: 'second turn on the execution host'
+ }
+ }
+ }
+ }
+ })
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ })
+ } finally {
+ acknowledgeWrites.stop()
+ }
+
+ expect(acknowledgeWrites.count()).toBe(2)
+ })
+
+ it('still marks a locally stamped thread read on selection', async () => {
+ seedThreadStampedByLocalClock()
+ await mountActivityPage()
+ await selectSeededThread()
+
+ expect(useAppStore.getState().acknowledgedAgentsByPaneKey[PANE_A]).toBeGreaterThan(0)
+ })
+
+ it('leaves the selected thread unread after the user marks it unread', async () => {
+ seedThreadStampedByLocalClock()
+ await mountActivityPage()
+ await selectSeededThread()
+
+ // Why pinned: auto mark-read is once per turn, so an explicit mark-unread on the
+ // still-selected thread must survive — a guard keyed on unread instead of the turn
+ // would silently re-acknowledge it and make the menu action a no-op.
+ await act(async () => {
+ useAppStore.getState().unacknowledgeAgents([PANE_A])
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ })
+
+ expect(useAppStore.getState().acknowledgedAgentsByPaneKey[PANE_A]).toBeUndefined()
+ })
+})
diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx
new file mode 100644
index 00000000000..da48eb26ae0
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx
@@ -0,0 +1,234 @@
+/**
+ * @vitest-environment happy-dom
+ *
+ * Reproduces the shape of the production React #185 cluster filed under
+ * boundary_id `sidebar.worktrees` (component stack: RovingFocusGroupItem inside
+ * DropdownMenuItem inside the worktree row's context menu).
+ *
+ * The point of the test is that the menu is a BYSTANDER: it contains no loop of
+ * its own (first case), and it only takes the blame because Radix's roving-focus
+ * item runs `onFocusableItemAdd()` — a setState — from a mount LAYOUT effect,
+ * which is the next dispatch after an unrelated driver has already pushed
+ * React's root-global nested-update counter past its limit (second case).
+ */
+import React, { act, useLayoutEffect, useState } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { Worktree } from '../../../../shared/worktree/types'
+import { REACT_NESTED_UPDATE_LIMIT } from '../../../../shared/react-update-depth-attribution'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import WorktreeContextMenu from './WorktreeContextMenu'
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+
+const state = {
+ updateWorktreeMeta: vi.fn(),
+ setWorktreesPinnedAndReveal: vi.fn(),
+ workspaceStatuses: [
+ { id: 'todo', label: 'Todo' },
+ { id: 'doing', label: 'Doing' }
+ ],
+ openModal: vi.fn(),
+ projectGroups: [{ id: 'g1', name: 'Group 1' }],
+ createProjectGroup: vi.fn(),
+ moveProjectToGroup: vi.fn(),
+ deleteStateByWorktreeId: {},
+ worktreeLineageById: {},
+ workspaceLineageByChildKey: {},
+ updateWorktreeLineage: vi.fn(),
+ tabsByWorktree: {},
+ ptyIdsByTabId: {},
+ browserTabsByWorktree: {},
+ keybindings: {},
+ settings: { activeRuntimeEnvironmentId: null, openInApplications: [] },
+ openSettingsPage: vi.fn(),
+ openSettingsTarget: vi.fn()
+}
+
+vi.mock('@/store', () => ({
+ useAppStore: Object.assign((selector: (s: typeof state) => unknown) => selector(state), {
+ getState: () => state
+ })
+}))
+
+vi.mock('@/store/selectors', () => ({
+ useAllWorktrees: () => [],
+ useRepoById: (repoId?: string) =>
+ repoId ? { id: repoId, name: repoId, displayName: repoId, projectGroupId: null } : undefined,
+ useRepoMap: () => new Map(),
+ useWorktreeMap: () => new Map()
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback,
+ i18n: { language: 'en', on: () => {}, off: () => {} }
+}))
+
+vi.mock('./ProjectGroupNameDialog', () => ({ ProjectGroupNameDialog: () => null }))
+vi.mock('./WorktreeParentPickerPopover', () => ({ WorktreeParentPickerPopover: () => null }))
+
+const mounted: { container: HTMLDivElement; root: Root }[] = []
+
+afterEach(() => {
+ for (const { root, container } of mounted) {
+ act(() => root.unmount())
+ container.remove()
+ }
+ mounted.length = 0
+})
+
+function worktreeFixture(): Worktree {
+ return {
+ id: 'repo::wt-1',
+ repoId: 'repo',
+ name: 'wt-1',
+ displayName: 'wt-1',
+ path: '/path/to/wt-1',
+ isMainWorktree: false
+ } as unknown as Worktree
+}
+
+type Capture = { errors: Error[]; componentStacks: string[] }
+
+class SidebarBoundary extends React.Component<
+ { children: React.ReactNode; capture: Capture },
+ { failed: boolean }
+> {
+ state = { failed: false }
+ static getDerivedStateFromError(): { failed: boolean } {
+ return { failed: true }
+ }
+ componentDidCatch(error: Error, info: React.ErrorInfo): void {
+ this.props.capture.errors.push(error)
+ this.props.capture.componentStacks.push(info.componentStack ?? '')
+ }
+ render(): React.ReactNode {
+ return this.state.failed ? null : this.props.children
+ }
+}
+
+function mount(node: React.ReactNode, capture: Capture): HTMLDivElement {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container, {
+ onUncaughtError: (error) => capture.errors.push(error as Error),
+ onCaughtError: () => undefined
+ })
+ mounted.push({ container, root })
+ act(() => {
+ root.render(
+
+ {node}
+
+ )
+ })
+ return container
+}
+
+/**
+ * Stands in for whatever unrelated sidebar/app code kept sync lanes pending
+ * commit after commit in the field report. It is the driver, not the menu.
+ */
+function CommitCascadeDriver({
+ ticks,
+ onTick
+}: {
+ ticks: number
+ onTick: () => void
+}): React.JSX.Element {
+ useLayoutEffect(() => {
+ if (ticks < REACT_NESTED_UPDATE_LIMIT + 4) {
+ onTick()
+ }
+ })
+ return
{ticks}
+}
+
+describe('WorktreeContextMenu and React #185', () => {
+ it('opening the row context menu on its own does not trip the nested-update limit', () => {
+ const capture: Capture = { errors: [], componentStacks: [] }
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ const container = mount(
+
+
Card
+ ,
+ capture
+ )
+
+ const scope = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement
+ act(() => {
+ scope.dispatchEvent(
+ new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 })
+ )
+ })
+ consoleError.mockRestore()
+
+ expect(document.querySelector('[data-slot="dropdown-menu-content"]')).toBeTruthy()
+ expect(capture.errors.map((error) => error.message)).toEqual([])
+ })
+
+ // Reproduces the field report: the driver is elsewhere, the menu is blamed.
+ it('is blamed for a driver-owned cascade when its items mount past the limit', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ const blamedFrames: string[] = []
+
+ // Which commit the right-click lands on decides which mounting fiber
+ // dispatches first past the limit; sweep the window around it rather than
+ // pinning one React-version-specific offset.
+ for (
+ let openAt = REACT_NESTED_UPDATE_LIMIT - 6;
+ openAt <= REACT_NESTED_UPDATE_LIMIT;
+ openAt++
+ ) {
+ const capture: Capture = { errors: [], componentStacks: [] }
+
+ function Harness(): React.JSX.Element {
+ const [ticks, setTicks] = useState(0)
+ const hostRef = React.useRef(null)
+ // Right-click lands mid-cascade, so the menu content — and every
+ // RovingFocusGroupItem inside it — mounts while the counter is already deep.
+ useLayoutEffect(() => {
+ if (ticks !== openAt) {
+ return
+ }
+ hostRef.current
+ ?.querySelector('[data-worktree-context-menu-scope]')
+ ?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
+ }, [ticks])
+ return (
+ <>
+
+
+
Card
+
+
+ setTicks((value) => value + 1)} />
+ >
+ )
+ }
+
+ mount(, capture)
+
+ const depthErrors = capture.errors.filter((error) =>
+ error.message.includes('Maximum update depth exceeded')
+ )
+ expect(depthErrors.length).toBeGreaterThan(0)
+ blamedFrames.push(capture.componentStacks[0]?.split('\n')[1]?.trim() ?? '')
+ }
+ consoleError.mockRestore()
+
+ // Carried as the assertion message so a React/Radix version bump reports the
+ // frames it blamed instead of a bare `false`.
+ const blamed = blamedFrames.map((frame) => frame.split(' (')[0]).join(', ')
+ // The production report's innermost component_stack frame.
+ expect(
+ blamedFrames.some((frame) => frame.startsWith('at RovingFocusGroupItem')),
+ blamed
+ ).toBe(true)
+ // ...and the loop is never in menu code: only the driver owns a setState loop.
+ expect(
+ blamedFrames.some((frame) => frame.startsWith('at CommitCascadeDriver')),
+ blamed
+ ).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx
new file mode 100644
index 00000000000..acbd7ce38bc
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx
@@ -0,0 +1,212 @@
+// @vitest-environment happy-dom
+
+/**
+ * Pins the settling guarantee for every setState-in-effect path SortableTab owns
+ * (rename shortcut, title churn mid-rename, store write storms, StrictMode double
+ * invoke) under a real React root, where "Maximum update depth exceeded" throws.
+ *
+ * Written while triaging the React #185 crash cluster whose component_stack names
+ * SortableTab: these paths settle, which matches shared/react-update-depth-attribution.ts
+ * — #185 lands on whichever fiber dispatched next after a root-global counter tripped,
+ * so that stack names a bystander. Keep this green so the tab stays exonerated.
+ */
+import type { ReactElement, ReactNode } from 'react'
+import { cloneElement, isValidElement, StrictMode, useEffect, useState } from 'react'
+import { act, render, cleanup } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import type { TabDragItemData } from '../tab-group/useTabDragSplit'
+import { useAppStore } from '../../store'
+import SortableTab from './SortableTab'
+
+type ProbeState = {
+ unreadTerminalTabs: Record
+ unreadAgentCompletionPanes: Record
+ agentStatusByPaneKey: Record
+ agentStatusEpoch: number
+ runtimePaneTitlesByTabId: Record>
+ ptyIdsByTabId: Record
+ terminalLayoutsByTabId: Record
+ renamingTabId: string | null
+ keybindings: Record
+ setRenamingTabId: (tabId: string | null) => void
+}
+
+type StoreApiWithHook = {
+ (selector: (state: ProbeState) => unknown): unknown
+ getState: () => ProbeState
+ setState: (partial: Partial) => void
+}
+
+// Both specifiers resolve to the same store module; memoize so they share one instance.
+async function createProbeStore(): Promise {
+ const globalKey = '__sortableTabProbeStore'
+ const globals = globalThis as Record
+ if (!globals[globalKey]) {
+ const { create } = await import('zustand')
+ globals[globalKey] = create((set) => ({
+ unreadTerminalTabs: {},
+ unreadAgentCompletionPanes: {},
+ agentStatusByPaneKey: {},
+ agentStatusEpoch: 0,
+ runtimePaneTitlesByTabId: {},
+ ptyIdsByTabId: {},
+ terminalLayoutsByTabId: {},
+ renamingTabId: null,
+ keybindings: {},
+ setRenamingTabId: (tabId) => set({ renamingTabId: tabId })
+ }))
+ }
+ return globals[globalKey] as StoreApiWithHook
+}
+
+vi.mock('@/store', async () => ({ useAppStore: await createProbeStore() }))
+vi.mock('../../store', async () => ({ useAppStore: await createProbeStore() }))
+
+vi.mock('@dnd-kit/sortable', () => ({
+ useSortable: ({ id }: { id: string }) => ({
+ attributes: { role: 'tab', 'data-sortable-id': id },
+ listeners: { onPointerDown: vi.fn() },
+ setNodeRef: vi.fn()
+ })
+}))
+
+vi.mock('@/lib/use-tab-agent', () => ({ useTabAgent: () => null }))
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: ReactNode }) =>
,
+ TooltipTrigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) =>
+ asChild && isValidElement(children) ? (
+ cloneElement(children as ReactElement>)
+ ) : (
+ {children}
+ )
+}))
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuContent: () => null,
+ DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}>,
+ DropdownMenuSeparator: () => null,
+ DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => <>{children}>,
+ DropdownMenuLabel: ({ children }: { children?: ReactNode }) => <>{children}>,
+ DropdownMenuSub: ({ children }: { children?: ReactNode }) => <>{children}>,
+ DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => <>{children}>,
+ DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => <>{children}>,
+ DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}>
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: (props: Record) =>
+}))
+
+vi.mock('./shell-icons', () => ({ ShellIcon: () => }))
+vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => }))
+vi.mock('../sidebar/WorktreeCardHelpers', () => ({ FilledBellIcon: () => }))
+
+function makeTab(overrides: Partial = {}): TerminalTab {
+ return {
+ id: 'terminal-tab-1',
+ title: 'Terminal 1',
+ worktreeId: 'wt-1',
+ ...overrides
+ } as TerminalTab
+}
+
+const dragData: TabDragItemData = {
+ kind: 'tab',
+ worktreeId: 'wt-1',
+ groupId: 'group-1',
+ unifiedTabId: 'unified-1',
+ visibleTabId: 'terminal-tab-1',
+ tabType: 'terminal',
+ label: 'Terminal 1'
+}
+
+const probeStore = useAppStore as unknown as StoreApiWithHook
+
+let renderCount = 0
+
+function Harness({ tab }: { tab: TerminalTab }): ReactElement {
+ renderCount += 1
+ return (
+
+ )
+}
+
+/** Re-renders the tab with a new tab object on every store write, like the tab strip does. */
+function ChurningHarness({ titles }: { titles: string[] }): ReactElement {
+ const [index, setIndex] = useState(0)
+ useEffect(() => {
+ if (index < titles.length - 1) {
+ setIndex(index + 1)
+ }
+ }, [index, titles.length])
+ return
+}
+
+afterEach(() => {
+ cleanup()
+ probeStore.setState({ renamingTabId: null, unreadTerminalTabs: {}, agentStatusEpoch: 0 })
+ renderCount = 0
+})
+
+describe('SortableTab update-depth probe', () => {
+ it('settles when the rename shortcut arms renamingTabId', () => {
+ probeStore.setState({ renamingTabId: 'terminal-tab-1' })
+ const { container } = render()
+ expect(container.querySelector('[data-tab-rename-input]')).not.toBeNull()
+ expect(probeStore.getState().renamingTabId).toBeNull()
+ expect(renderCount).toBeLessThan(20)
+ })
+
+ it('settles under title churn while the rename editor is open', () => {
+ probeStore.setState({ renamingTabId: 'terminal-tab-1' })
+ render()
+ expect(renderCount).toBeLessThan(40)
+ })
+
+ it('settles under a store write storm', () => {
+ render()
+ const before = renderCount
+ act(() => {
+ for (let i = 0; i < 60; i += 1) {
+ probeStore.setState({ agentStatusEpoch: i, agentStatusByPaneKey: {} })
+ }
+ })
+ expect(renderCount - before).toBeLessThan(200)
+ })
+
+ it('settles in StrictMode double-invoked effects', () => {
+ probeStore.setState({ renamingTabId: 'terminal-tab-1' })
+ render(
+
+
+
+ )
+ expect(probeStore.getState().renamingTabId).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx
new file mode 100644
index 00000000000..87973f0d47a
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx
@@ -0,0 +1,233 @@
+/** @vitest-environment happy-dom */
+/**
+ * Crash cluster "React #185 in TerminalPaneOverlayLayer" (boundary terminal.workbench).
+ *
+ * The overlay's own park verdict does not oscillate (terminal-cold-park-verdict-loop
+ * covers that). What used to put it in the stack: its cold-park effect re-runs on
+ * every tab-model write — a runtime title publication, an unread bump re-mints
+ * tabsByWorktree — and each run dispatched setColdParkedTerminalTabIds even when
+ * the verdict was unchanged. React only bails on such a dispatch while the fiber
+ * has no pending lanes, so inside a cascade some other component drives, this hook
+ * is the one that trips the root-global nested-update counter and gets blamed (see
+ * src/shared/react-update-depth-attribution.ts).
+ */
+import { act, useEffect, useState, StrictMode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { REACT_NESTED_UPDATE_LIMIT } from '../../../../shared/react-update-depth-attribution'
+import type { Tab, TabGroup } from '../../../../shared/tab-types'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const harness = vi.hoisted(() => ({
+ worktreeId: 'repo::/cold-park-tab-model-identity',
+ parkEffectRuns: 0,
+ renderedParkedSets: [] as string[][]
+}))
+
+vi.mock('../../store', async () => {
+ const { create } = await import('zustand')
+ const useAppStore = create(() => ({
+ activeGroupIdByWorktree: {} as Record,
+ groupsByWorktree: {} as Record,
+ pendingStartupByTabId: {} as Record,
+ ptyIdsByTabId: {} as Record,
+ runtimeStatusByEnvironmentId: new Map(),
+ runtimePaneTitlesByTabId: {} as Record>,
+ settings: {} as Record,
+ sleepingAgentSessionsByPaneKey: {} as Record,
+ tabsByWorktree: {} as Record,
+ terminalLayoutsByTabId: {} as Record,
+ unifiedTabsByWorktree: {} as Record,
+ consumeSuppressedPtyExit: () => false,
+ focusGroup: () => {},
+ reconcileWorktreeTabModel: () => ({ renderableTabCount: 2 }),
+ setActiveWorktree: () => {}
+ }))
+ return { useAppStore }
+})
+
+vi.mock('../native-chat/use-native-chat-toggle-shortcut', () => ({
+ useNativeChatToggleShortcut: () => {}
+}))
+
+vi.mock('./TerminalOverlaySlot', () => ({ TerminalOverlaySlot: () => null }))
+
+vi.mock('./terminal-parked-tab-watchers', () => ({
+ canWatcherCoverParkedTerminalTab: () => true,
+ disposeParkedTerminalWatchersForWorktree: () => {},
+ syncParkedTerminalTabWatchers: (args: { parkedTabIds: ReadonlySet }) => {
+ harness.renderedParkedSets.push([...args.parkedTabIds].sort())
+ }
+}))
+
+// Counts cold-park effect executions: the effect clears and re-arms its recheck
+// timers on every run, so this is also the per-commit work the identity dep costs.
+vi.mock('./terminal-parking-e2e-overrides', () => ({
+ getTerminalParkingPolicyOverrides: () => {
+ harness.parkEffectRuns += 1
+ return { coldParkDelayMs: 0, hotRetainMs: 0 }
+ }
+}))
+
+vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ recordRendererCrashBreadcrumb: vi.fn() }))
+
+import { useAppStore } from '../../store'
+import TerminalPaneOverlayLayer from './TerminalPaneOverlayLayer'
+
+const TAB_IDS = ['tab-a', 'tab-b'] as const
+const GROUP_ID = 'group-a'
+/** Above React's nested-update limit so the cascade actually reaches the bail. */
+const CASCADE_COMMITS = REACT_NESTED_UPDATE_LIMIT * 2
+
+type ParkingStoreState = {
+ groupsByWorktree: Record
+ tabsByWorktree: Record
+ unifiedTabsByWorktree: Record
+}
+
+const parkingStore = useAppStore as unknown as {
+ getState: () => ParkingStoreState
+ setState: (partial: unknown) => void
+}
+
+function terminalTab(id: string): TerminalTab {
+ return {
+ id,
+ worktreeId: harness.worktreeId,
+ ptyId: `${harness.worktreeId}@@session-${id}`,
+ title: id,
+ generation: 0
+ } as TerminalTab
+}
+
+function unifiedTerminalTab(id: string): Tab {
+ return {
+ id: `unified-${id}`,
+ entityId: id,
+ worktreeId: harness.worktreeId,
+ groupId: GROUP_ID,
+ contentType: 'terminal',
+ label: id,
+ customLabel: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 0
+ } as Tab
+}
+
+/** An ordinary runtime title publication: re-mints tabsByWorktree, changes no park input. */
+function publishRuntimeTitle(revision: number): void {
+ parkingStore.setState((state: ParkingStoreState) => ({
+ tabsByWorktree: {
+ ...state.tabsByWorktree,
+ [harness.worktreeId]: state.tabsByWorktree[harness.worktreeId].map((tab) => ({
+ ...tab,
+ title: `${tab.id}-${revision}`
+ }))
+ }
+ }))
+}
+
+/**
+ * The cascade driver: a component with its own runaway passive effect, in no way
+ * related to terminal parking. It is the bug; the overlay is the bystander.
+ */
+function UnrelatedCascadeDriver(): null {
+ const [tick, setTick] = useState(0)
+ useEffect(() => {
+ if (tick >= CASCADE_COMMITS) {
+ return
+ }
+ publishRuntimeTitle(tick)
+ setTick((current) => current + 1)
+ }, [tick])
+ return null
+}
+
+function renderTree(root: Root): unknown {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ let error: unknown = null
+ try {
+ act(() => {
+ root.render(
+
+
+
+
+ )
+ })
+ } catch (thrown) {
+ error = thrown
+ }
+ consoleError.mockRestore()
+ return error
+}
+
+describe('cold-park effect vs. unrelated commit cascade', () => {
+ let container: HTMLDivElement
+ let root: Root | undefined
+ beforeEach(() => {
+ harness.parkEffectRuns = 0
+ harness.renderedParkedSets.length = 0
+ const tabs = TAB_IDS.map(terminalTab)
+ const unifiedTabs = TAB_IDS.map(unifiedTerminalTab)
+ parkingStore.setState({
+ activeGroupIdByWorktree: { [harness.worktreeId]: GROUP_ID },
+ groupsByWorktree: {
+ [harness.worktreeId]: [
+ {
+ id: GROUP_ID,
+ worktreeId: harness.worktreeId,
+ activeTabId: unifiedTabs[0].id,
+ tabOrder: unifiedTabs.map((tab) => tab.id),
+ recentTabIds: [unifiedTabs[0].id]
+ } as TabGroup
+ ]
+ },
+ tabsByWorktree: { [harness.worktreeId]: tabs },
+ unifiedTabsByWorktree: { [harness.worktreeId]: unifiedTabs }
+ })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ })
+
+ afterEach(() => {
+ try {
+ act(() => root?.unmount())
+ } catch {
+ /* a failed commit leaves no mounted tree */
+ }
+ root = undefined
+ container.remove()
+ })
+
+ it('dispatches no park-state update for tab-model writes that change no park verdict', () => {
+ root = createRoot(container)
+ const error = renderTree(root)
+
+ // The park verdict itself never churns: one empty -> parked transition,
+ // matching the field bundle, which carries no terminal_park_verdict_churn crumb.
+ expect(harness.renderedParkedSets.at(-1)).toEqual(['tab-b'])
+ expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual(
+ new Set(['', 'tab-b'])
+ )
+
+ // React #185 still fires — the driver is the bug — but it must land on the
+ // driver's own dispatch, not on the overlay's park state. Asserting the throw
+ // happened keeps the "not blamed" check below from passing on an empty stack.
+ expect(error instanceof Error ? error.message : '').toContain('Maximum update depth exceeded')
+ const stack = error instanceof Error ? (error.stack ?? '') : String(error ?? '')
+ expect(stack).not.toContain('use-terminal-tab-cold-parking')
+ expect(stack).toContain('terminal-cold-park-tab-model-identity')
+ // The effect itself still re-runs — coverage is re-derived from store state
+ // the park key cannot encode — it just stops dispatching an unchanged verdict.
+ expect(harness.parkEffectRuns).toBeGreaterThan(REACT_NESTED_UPDATE_LIMIT)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts
index 76434e024c1..0f25b61830e 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts
@@ -138,6 +138,8 @@ export function useTerminalTabColdParking(args: {
const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>(
() => new Set()
)
+ // Mirrors the committed park set; written only from the post-commit effect below.
+ const coldParkedTerminalTabIdsRef = useRef(coldParkedTerminalTabIds)
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
@@ -224,9 +226,16 @@ export function useTerminalTabColdParking(args: {
parkVerdictRecords: parkVerdictRecordsRef.current,
nowMs
})
- setColdParkedTerminalTabIds((current) =>
- haveSameTerminalTabIds(current, parkedTabIds) ? current : parkedTabIds
- )
+ // Why the ref and not the updater form: returning `current` still dispatches,
+ // and React only bails eagerly while the fiber has no pending lanes. This
+ // effect re-runs on every tab-model write (runtime titles, unread bumps),
+ // so inside any commit cascade the no-op dispatch was what tripped React's
+ // root-global nested-update counter — naming this hook in a #185 whose real
+ // driver is elsewhere (see src/shared/react-update-depth-attribution.ts).
+ if (!haveSameTerminalTabIds(coldParkedTerminalTabIdsRef.current, parkedTabIds)) {
+ coldParkedTerminalTabIdsRef.current = parkedTabIds
+ setColdParkedTerminalTabIds(parkedTabIds)
+ }
for (const candidate of candidates) {
if (
diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts
new file mode 100644
index 00000000000..d81c0c49ffa
--- /dev/null
+++ b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts
@@ -0,0 +1,112 @@
+// @vitest-environment happy-dom
+
+import { cleanup, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAutoAckViewedAgent } from './useAutoAckViewedAgent'
+import { useAppStore } from '../store'
+import { makeTab } from '../store/slices/store-test-helpers'
+import { makePaneKey } from '../../../shared/stable-pane-id'
+import type { AgentStatusEntry } from '../../../shared/agent-status-types'
+
+// Why: the hook re-scans on every store write and advances its diff refs to the PRE-write snapshot,
+// so its ref guard never suppresses the rescan an ack triggers. It only terminated because
+// acknowledgeAgents returned the same object within one millisecond — a scan costing >=1ms with a
+// turn stamped ahead of the local clock (SSH/remote host) re-acked forever (React #185).
+
+const TAB_ID = 'tab-main'
+const LEAF_ID = '11111111-1111-4111-8111-111111111111'
+const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
+const NOW = new Date('2026-06-02T12:00:00Z').getTime()
+const SKEW_MS = 90_000
+const ACK_CALL_CEILING = 20
+
+function seedFutureStampedTurn(stateStartedAt: number): void {
+ const entry: AgentStatusEntry = {
+ state: 'done',
+ prompt: 'remote turn',
+ updatedAt: stateStartedAt,
+ stateStartedAt,
+ agentType: 'codex',
+ paneKey: PANE_KEY,
+ stateHistory: []
+ }
+ useAppStore.setState({
+ activeView: 'terminal',
+ activeTabId: TAB_ID,
+ activeWorktreeId: 'wt-1',
+ activeTabIdByWorktree: {},
+ tabsByWorktree: { 'wt-1': [makeTab({ id: TAB_ID, worktreeId: 'wt-1' })] },
+ terminalLayoutsByTabId: {
+ [TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null }
+ },
+ agentStatusByPaneKey: { [PANE_KEY]: entry },
+ retainedAgentsByPaneKey: {},
+ acknowledgedAgentsByPaneKey: {},
+ unreadAgentCompletionPanes: {},
+ unreadTerminalTabs: {}
+ })
+}
+
+/** Wraps the real action, then circuit-breaks so a regression fails the assertion instead of hanging. */
+function instrumentAcknowledgeAgents(): string[][] {
+ const real = useAppStore.getState().acknowledgeAgents
+ const calls: string[][] = []
+ useAppStore.setState({
+ acknowledgeAgents: (paneKeys: string[]) => {
+ calls.push(paneKeys)
+ if (calls.length > ACK_CALL_CEILING) {
+ return
+ }
+ real(paneKeys)
+ }
+ })
+ return calls
+}
+
+describe('useAutoAckViewedAgent — clock-skewed execution host', () => {
+ let realAcknowledgeAgents: (paneKeys: string[]) => void
+
+ beforeEach(() => {
+ realAcknowledgeAgents = useAppStore.getState().acknowledgeAgents
+ vi.spyOn(document, 'hasFocus').mockReturnValue(true)
+ // Why a ticking clock: the loop self-terminated only while Date.now() stayed on one millisecond.
+ let clock = NOW
+ vi.spyOn(Date, 'now').mockImplementation(() => clock++)
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ useAppStore.setState({ acknowledgeAgents: realAcknowledgeAgents })
+ })
+
+ it('acks a turn stamped ahead of the local clock exactly once', () => {
+ seedFutureStampedTurn(NOW + SKEW_MS)
+ const calls = instrumentAcknowledgeAgents()
+
+ renderHook(() => useAutoAckViewedAgent(false))
+
+ expect(calls).toEqual([[PANE_KEY]])
+ const ackAt = useAppStore.getState().acknowledgedAgentsByPaneKey[PANE_KEY] ?? 0
+ expect(ackAt).toBeGreaterThanOrEqual(NOW + SKEW_MS)
+ })
+
+ it('leaves nothing to re-scan for a future-stamped turn on the next store write', () => {
+ seedFutureStampedTurn(NOW + SKEW_MS)
+ renderHook(() => useAutoAckViewedAgent(false))
+
+ const calls = instrumentAcknowledgeAgents()
+ useAppStore.getState().markTerminalTabUnread('tab-unrelated')
+
+ expect(calls).toEqual([])
+ })
+
+ it('still acks a normally stamped turn once', () => {
+ seedFutureStampedTurn(NOW - 5_000)
+ const calls = instrumentAcknowledgeAgents()
+
+ renderHook(() => useAutoAckViewedAgent(false))
+
+ expect(calls).toEqual([[PANE_KEY]])
+ })
+})
diff --git a/src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx b/src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx
new file mode 100644
index 00000000000..e7e6ec0012d
--- /dev/null
+++ b/src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx
@@ -0,0 +1,148 @@
+/** @vitest-environment happy-dom */
+/**
+ * Crash cluster "React #185 whose component_stack names SortableTab"
+ * (boundary terminal.workbench, app 1.4.190).
+ *
+ * useTabAgent's signal effect re-runs on every tab-model write that touches the
+ * title — a working agent republishes an OSC spinner frame many times a second,
+ * re-minting tabsByWorktree — and it used to dispatch setHasObservedAgentSignal(true)
+ * on every run, including the overwhelming majority where the flag was already true.
+ * That dispatch costs a second render+commit of SortableTab per title frame, and it
+ * puts a dispatchSetState on SortableTab's fiber inside commitHookEffectListMount —
+ * the exact frame the #185 reports blame (see shared/react-update-depth-attribution.ts:
+ * the throw lands on whoever dispatches next after a root-global counter trips).
+ *
+ * Same defect and same remedy as the cold-park fix in
+ * terminal-cold-park-tab-model-identity.react185.test.tsx.
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { useAppStore } from '@/store'
+import type { AgentStatusEntry } from '../../../shared/agent-status-types'
+import { makePaneKey } from '../../../shared/stable-pane-id'
+import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
+import type { TuiAgent } from '../../../shared/tui-agent'
+import { useTabAgent } from './use-tab-agent'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const initialAppState = useAppStore.getInitialState()
+const WORKTREE_ID = 'repo::/tab-agent-observed-signal'
+const TAB_ID = 'terminal-tab-observed-signal'
+const LEAF_ID = '11111111-1111-4111-8111-111111111111'
+const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
+const TITLE_PUBLICATIONS = 10
+
+function paneLayout(ptyId: string): TerminalLayoutSnapshot {
+ return {
+ root: null,
+ activeLeafId: LEAF_ID,
+ expandedLeafId: null,
+ ptyIdsByLeafId: { [LEAF_ID]: ptyId }
+ }
+}
+
+function workingAgentStatus(): AgentStatusEntry {
+ return {
+ state: 'working',
+ prompt: '',
+ updatedAt: Date.now(),
+ stateStartedAt: Date.now(),
+ agentType: 'codex',
+ paneKey: PANE_KEY,
+ stateHistory: []
+ } as AgentStatusEntry
+}
+
+function terminalTab(title: string): TerminalTab {
+ return {
+ id: TAB_ID,
+ worktreeId: WORKTREE_ID,
+ ptyId: 'pty-a',
+ title,
+ generation: 0
+ } as TerminalTab
+}
+
+/** An ordinary agent spinner frame: re-mints tabsByWorktree, changes no agent signal. */
+function publishRuntimeTitle(revision: number): void {
+ useAppStore.setState(
+ (state) =>
+ ({
+ tabsByWorktree: {
+ ...state.tabsByWorktree,
+ [WORKTREE_ID]: [terminalTab(`⠋ codex ${revision}`)]
+ }
+ }) as never
+ )
+}
+
+let probeRenders = 0
+let latestAgent: TuiAgent | null = null
+
+/** Stands in for SortableTab, useTabAgent's only production caller. */
+function TabAgentProbe(): null {
+ probeRenders += 1
+ const tab = useAppStore((state) => state.tabsByWorktree[WORKTREE_ID]?.[0]) as TerminalTab
+ latestAgent = useTabAgent(tab)
+ return null
+}
+
+describe('useTabAgent observed-signal dispatch', () => {
+ let container: HTMLDivElement
+ let root: Root | undefined
+
+ beforeEach(() => {
+ useAppStore.setState({
+ agentStatusByPaneKey: { [PANE_KEY]: workingAgentStatus() },
+ terminalLayoutsByTabId: { [TAB_ID]: paneLayout('pty-a') },
+ ptyIdsByTabId: { [TAB_ID]: ['pty-a'] },
+ tabsByWorktree: { [WORKTREE_ID]: [terminalTab('⠋ codex 0')] }
+ } as never)
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ probeRenders = 0
+ latestAgent = null
+ root = createRoot(container)
+ act(() => root!.render())
+ })
+
+ afterEach(() => {
+ act(() => root?.unmount())
+ root = undefined
+ container.remove()
+ useAppStore.setState(initialAppState, true)
+ })
+
+ it('schedules no extra commit for a title publication that changes no agent signal', () => {
+ probeRenders = 0
+ for (let revision = 1; revision <= TITLE_PUBLICATIONS; revision += 1) {
+ act(() => publishRuntimeTitle(revision))
+ }
+ // One commit per publication. Re-dispatching the unchanged observed-signal
+ // flag doubled this, and put SortableTab's fiber in every #185 stack.
+ expect(probeRenders).toBe(TITLE_PUBLICATIONS)
+ expect(latestAgent).toBe('codex')
+ })
+
+ it('still re-arms the observed signal after a pty respawn', () => {
+ act(() => {
+ useAppStore.setState({
+ agentStatusByPaneKey: {},
+ terminalLayoutsByTabId: { [TAB_ID]: paneLayout('pty-b') },
+ ptyIdsByTabId: { [TAB_ID]: ['pty-b'] },
+ tabsByWorktree: { [WORKTREE_ID]: [terminalTab('zsh')] }
+ } as never)
+ })
+ expect(latestAgent).toBeNull()
+
+ act(() => {
+ useAppStore.setState({
+ agentStatusByPaneKey: { [PANE_KEY]: workingAgentStatus() },
+ tabsByWorktree: { [WORKTREE_ID]: [terminalTab('⠋ codex 1')] }
+ } as never)
+ })
+ expect(latestAgent).toBe('codex')
+ })
+})
diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts
index b6c88400632..fef059ad616 100644
--- a/src/renderer/src/lib/use-tab-agent.ts
+++ b/src/renderer/src/lib/use-tab-agent.ts
@@ -284,7 +284,13 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
? explicitTitleAgent === tab.launchAgent
: Boolean(explicitTitleAgent || siblingHookAgent)
// Why: a recognized foreground process arms exit clearing even for agents with no hook or title integration.
- if (focusedHookAgent || completedHookEvidence || processAgent || fallbackAgentSignal) {
+ // Why the ref gate: this effect re-runs on every title frame, and re-dispatching an
+ // already-true flag costs SortableTab a second commit each time — and names its fiber
+ // in #185 stacks driven elsewhere (see shared/react-update-depth-attribution.ts).
+ if (
+ !hasObservedAgentSignalRef.current &&
+ (focusedHookAgent || completedHookEvidence || processAgent || fallbackAgentSignal)
+ ) {
hasObservedAgentSignalRef.current = true
setHasObservedAgentSignal(true)
}
diff --git a/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts b/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts
new file mode 100644
index 00000000000..669ebceb53e
--- /dev/null
+++ b/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts
@@ -0,0 +1,144 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createUIStore } from './ui-slice-test-harness'
+import type { AppState } from '../types'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+
+// Why: an SSH/remote execution host stamps turns with ITS clock. When that clock runs ahead, every
+// unread rule (`ackAt < turnTimestamp`) stayed true after an ack, so the row could never be marked
+// read and its auto-ack effect re-fired on each new millisecond — the React #185 update loop.
+
+const NOW = new Date('2026-06-02T12:00:00Z').getTime()
+const SKEW_MS = 90_000
+const LEAF_ID = '11111111-1111-4111-8111-111111111111'
+const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
+
+function makeAgentEntry(overrides: Partial = {}): AgentStatusEntry {
+ return {
+ state: 'done',
+ prompt: 'Review complete',
+ updatedAt: NOW,
+ stateStartedAt: NOW,
+ agentType: 'codex',
+ paneKey: PANE_KEY,
+ stateHistory: [],
+ ...overrides
+ }
+}
+
+function makeTerminalTab(id: string, worktreeId: string): TerminalTab {
+ return {
+ id,
+ worktreeId,
+ ptyId: null,
+ title: 'Terminal',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: NOW
+ }
+}
+
+describe('acknowledgeAgents with a clock-skewed execution host', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(NOW)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('clears unread for a live turn stamped ahead of the local clock in one acknowledge', () => {
+ const store = createUIStore()
+ const futureStartedAt = NOW + SKEW_MS
+ store.setState({
+ tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
+ agentStatusByPaneKey: {
+ [PANE_KEY]: makeAgentEntry({ stateStartedAt: futureStartedAt, updatedAt: futureStartedAt })
+ }
+ } as Partial)
+
+ store.getState().acknowledgeAgents([PANE_KEY])
+
+ const ackAt = store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]
+ expect(ackAt).toBe(futureStartedAt)
+ expect(ackAt < futureStartedAt).toBe(false)
+ })
+
+ it('stops rewriting the ack map once a future-stamped turn is acknowledged', () => {
+ const store = createUIStore()
+ const futureStartedAt = NOW + SKEW_MS
+ store.setState({
+ agentStatusByPaneKey: {
+ [PANE_KEY]: makeAgentEntry({ stateStartedAt: futureStartedAt, updatedAt: futureStartedAt })
+ }
+ } as Partial)
+
+ store.getState().acknowledgeAgents([PANE_KEY])
+ const firstMap = store.getState().acknowledgedAgentsByPaneKey
+
+ // Later millisecond, same turn: the retry that used to allocate a new map (and re-render forever).
+ vi.setSystemTime(NOW + 1_000)
+ store.getState().acknowledgeAgents([PANE_KEY])
+
+ expect(store.getState().acknowledgedAgentsByPaneKey).toBe(firstMap)
+ })
+
+ it('covers a retained row and a future history event on the same pane', () => {
+ const store = createUIStore()
+ const futureHistoryAt = NOW + SKEW_MS
+ store.setState({
+ retainedAgentsByPaneKey: {
+ [PANE_KEY]: {
+ entry: makeAgentEntry({
+ stateStartedAt: NOW + 1_000,
+ stateHistory: [{ state: 'blocked', prompt: 'p', startedAt: futureHistoryAt }]
+ }),
+ worktreeId: 'wt-1',
+ tab: makeTerminalTab('tab-1', 'wt-1'),
+ agentType: 'codex',
+ startedAt: NOW + 1_000
+ }
+ }
+ } as Partial)
+
+ store.getState().acknowledgeAgents([PANE_KEY])
+
+ expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(futureHistoryAt)
+ })
+
+ it('covers a future-stamped migration-unsupported row, which Activity renders as a blocked event', () => {
+ const store = createUIStore()
+ const futureUpdatedAt = NOW + SKEW_MS
+ store.setState({
+ migrationUnsupportedByPtyId: {
+ 'pty-1': {
+ ptyId: 'pty-1',
+ paneKey: PANE_KEY,
+ reason: 'legacy-numeric-pane-key',
+ source: 'ssh',
+ updatedAt: futureUpdatedAt
+ }
+ }
+ } as Partial)
+
+ store.getState().acknowledgeAgents([PANE_KEY])
+
+ expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(futureUpdatedAt)
+ })
+
+ it('still stamps the local clock when the turn is not ahead of it', () => {
+ const store = createUIStore()
+ store.setState({
+ agentStatusByPaneKey: {
+ [PANE_KEY]: makeAgentEntry({ stateStartedAt: NOW - 5_000, updatedAt: NOW - 5_000 })
+ }
+ } as Partial)
+
+ store.getState().acknowledgeAgents([PANE_KEY])
+
+ expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(NOW)
+ })
+})
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 466e3db9724..9c4c550194f 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -372,6 +372,23 @@ function collectAcknowledgedAgentNotificationId({
}
}
+function usableTimestamp(value: unknown): number {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
+}
+
+/** Newest turn timestamp an unread check can compare against for one agent row. */
+function latestAgentTurnTimestamp(entry: {
+ stateStartedAt?: number
+ stateHistory?: { startedAt?: number }[]
+}): number {
+ let latest = usableTimestamp(entry.stateStartedAt)
+ // Why history too: Activity renders one event per stateHistory entry, each with its own unread check.
+ for (const history of entry.stateHistory ?? []) {
+ latest = Math.max(latest, usableTimestamp(history.startedAt))
+ }
+ return latest
+}
+
function isPlainPersistedRecord(value: unknown): value is Record {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
@@ -1215,10 +1232,15 @@ export const createUISlice: StateCreator = (set, get)
return s
}
const now = Date.now()
- // Why: only reallocate if an ack advances; compare prev | null = null
for (const key of paneKeys) {
const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0
+ // Why not plain Date.now(): a remote/SSH execution host can stamp a turn ahead of this clock,
+ // and every unread rule is `ackAt < turnTimestamp`. A behind-the-turn ack can never clear the
+ // row, so its auto-ack effect re-fires on each new millisecond forever (React #185).
+ let stamp = now
const liveEntry = s.agentStatusByPaneKey?.[key]
if (liveEntry) {
collectAcknowledgedAgentNotificationId({
@@ -1228,6 +1250,7 @@ export const createUISlice: StateCreator = (set, get)
stateStartedAt: liveEntry.stateStartedAt,
previousAckAt: prev
})
+ stamp = Math.max(stamp, latestAgentTurnTimestamp(liveEntry))
}
const retained = s.retainedAgentsByPaneKey?.[key]
if (retained) {
@@ -1238,12 +1261,19 @@ export const createUISlice: StateCreator = (set, get)
stateStartedAt: retained.entry.stateStartedAt,
previousAckAt: prev
})
+ stamp = Math.max(stamp, latestAgentTurnTimestamp(retained.entry))
}
- if (prev < now) {
+ for (const unsupported of migrationUnsupported) {
+ // Why: Activity synthesizes a blocked row from this entry, stamped by the pane's host like any turn.
+ if (unsupported.paneKey === key) {
+ stamp = Math.max(stamp, usableTimestamp(unsupported.updatedAt))
+ }
+ }
+ if (prev < stamp) {
if (next === null) {
next = { ...s.acknowledgedAgentsByPaneKey }
}
- next[key] = now
+ next[key] = stamp
}
}
return next ? { acknowledgedAgentsByPaneKey: next } : s
From b19a397d3e7fe7a65dc4271379ed8b67fb14a58b Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Thu, 27 Aug 2026 21:27:01 -0700
Subject: [PATCH 08/35] feat(browser-preview): reland remote HTML document
previews (STA-5758) (#16920)
Reapply the reverted remote HTML document preview implementation so remote workspace files render locally over the orca-preview scheme.
---
config/reliability-gates.jsonc | 615 ++++++++++++---
electron.vite.config.ts | 1 +
.../browser-manager-annotation-bridge.test.ts | 210 +++++
.../browser-manager-guest-lifecycle.test.ts | 98 +++
...owser-manager-guest-policy-profile.test.ts | 250 ++++++
.../browser-manager-viewport-override.test.ts | 29 -
src/main/browser/browser-manager.ts | 106 ++-
...browser-session-partition-policies.test.ts | 185 +++++
.../browser-session-partition-policies.ts | 33 +-
.../doc-preview-download-block-notice.test.ts | 107 +++
.../doc-preview-download-block-notice.ts | 34 +
.../doc-preview-failure-notice.test.ts | 70 ++
.../browser/doc-preview-failure-notice.ts | 34 +
.../browser/doc-preview-file-reader.test.ts | 333 ++++++++
src/main/browser/doc-preview-file-reader.ts | 198 +++++
.../doc-preview-grant-registry.test.ts | 164 ++++
.../browser/doc-preview-grant-registry.ts | 208 +++++
.../browser/doc-preview-guest-policy.test.ts | 550 +++++++++++++
src/main/browser/doc-preview-guest-policy.ts | 247 ++++++
src/main/browser/doc-preview-protocol.test.ts | 336 ++++++++
src/main/browser/doc-preview-protocol.ts | 144 ++++
...ffscreen-browser-backend-lifecycle.test.ts | 85 +-
src/main/browser/offscreen-browser-backend.ts | 44 +-
src/main/index.ts | 11 +
src/main/ipc/browser-grab-ipc.ts | 19 +
src/main/ipc/browser-guest-view-ipc.ts | 26 +-
...browser-preview-tool-authorization.test.ts | 510 ++++++++++++
.../ipc/browser-tab-registration-wait.test.ts | 108 +++
src/main/ipc/browser-tab-registration-wait.ts | 13 +
src/main/ipc/browser.test.ts | 54 +-
src/main/ipc/browser.ts | 8 +
src/main/ipc/doc-preview-grant-ipc.test.ts | 159 ++++
src/main/ipc/doc-preview-grant-ipc.ts | 95 +++
.../main-window-webview-security.test.ts | 261 ++++++-
.../window/main-window-webview-security.ts | 61 +-
src/preload/api-types.ts | 2 +
src/preload/api/doc-preview-api.ts | 29 +
.../doc-preview-link-interception.test.ts | 269 +++++++
src/preload/doc-preview-link-interception.ts | 152 ++++
src/preload/doc-preview-link.ts | 15 +
src/preload/index.ts | 27 +
.../ClientHostedBrowserPagePane.tsx | 28 +-
.../browser-guest-annotate-overlays.tsx | 189 +++++
.../annotate/browser-page-grab-action.ts | 8 +-
.../guest-annotation-viewport-bridge.ts | 43 ++
.../use-browser-page-grab-annotations.ts | 16 +-
.../assemble-chrome/BrowserAddressBar.tsx | 4 +-
.../browser-chrome-address-slot.ts | 13 +
.../browser-chrome-toolbar.tsx | 206 +++++
.../browser-navigation-control-row.test.tsx | 66 +-
.../browser-navigation-control-row.tsx | 61 +-
.../assemble-chrome/browser-page-toolbar.tsx | 282 ++-----
.../browser-page-viewport-overlays.tsx | 163 +---
.../browser-reload-control.tsx | 95 +++
.../browser-workspace-pane.tsx | 49 +-
.../use-browser-page-webview-lifecycle.ts | 29 +-
.../host-guest/use-guest-drag-passthrough.ts | 32 +
.../browser-address-bar-navigation.test.ts | 27 +
.../browser-address-bar-navigation.ts | 24 +-
.../remote-browser-page-toolbar.tsx | 24 +-
.../use-remote-browser-page-navigation.ts | 2 +-
.../HtmlDocPreview.failure-message.test.tsx | 342 ++++++++
.../HtmlDocPreview.toolbar.test.tsx | 582 ++++++++++++++
.../workspace-doc/HtmlDocPreview.tsx | 413 ++++++++++
.../doc-preview-document-actions.test.ts | 206 +++++
.../doc-preview-document-actions.ts | 97 +++
.../doc-preview-document-chip.tsx | 68 ++
.../doc-preview-document-identity.test.ts | 67 ++
.../doc-preview-document-identity.ts | 38 +
.../doc-preview-failure-messages.ts | 70 ++
.../doc-preview-overflow-menu.tsx | 76 ++
.../workspace-doc/doc-preview-toolbar.tsx | 118 +++
.../doc-preview-webview-history.ts | 80 ++
.../use-doc-preview-guest-tools.test.ts | 134 ++++
.../use-doc-preview-guest-tools.ts | 103 +++
.../workspace-doc-page-pane.test.tsx | 109 +++
.../workspace-doc/workspace-doc-page-pane.tsx | 60 ++
.../editor/EditorPanelShell.header.test.tsx | 124 +++
.../components/editor/EditorPanelShell.tsx | 3 +-
.../src/components/editor/editor-header.ts | 5 +
.../tab-strip-pointer-activation.test.tsx | 38 +
.../tab-bar/tab-strip-pointer-activation.ts | 20 +-
.../TerminalLinkActionPopover.tsx | 6 +-
.../terminal-file-link-actions.test.ts | 62 ++
.../terminal-file-link-actions.ts | 66 +-
.../terminal-file-open-routing.ts | 48 +-
...minal-link-remote-runtime-ssh-open.test.ts | 64 +-
.../terminal-remote-file-download-open.ts | 37 +
.../ipc-events/browser-state-ipc-bridge.ts | 29 +
.../doc-preview-external-link-bridge.test.ts | 92 +++
.../src/hooks/useIpcEvents-lifecycle.test.ts | 2 +
src/renderer/src/i18n/locales/en.json | 39 +-
.../lib/browser-page-palette-activation.ts | 21 +-
.../lib/browser-workspace-tab-activation.ts | 30 +
.../src/lib/doc-preview-grants.test.ts | 169 ++++
src/renderer/src/lib/doc-preview-grants.ts | 86 +++
.../src/lib/execution-host-display-label.ts | 61 ++
.../file-preview-action-visibility.test.tsx | 85 ++
.../file-preview-capability-cleanup.test.ts | 91 ---
src/renderer/src/lib/file-preview.test.ts | 409 ++++++----
src/renderer/src/lib/file-preview.ts | 255 +++---
.../src/lib/workspace-file-host-routing.ts | 50 ++
...ync-runtime-graph-editor-diff-tabs.test.ts | 180 +++++
.../src/runtime/sync-runtime-graph.ts | 18 +-
.../browser-workspace-doc-location.test.ts | 412 ++++++++++
src/renderer/src/store/slices/browser.ts | 125 ++-
.../src/store/slices/tabs-hydration.test.ts | 63 ++
.../src/store/slices/tabs-hydration.ts | 9 +
src/shared/browser-page-doc-location.ts | 27 +
src/shared/browser-workspace-types.ts | 17 +
src/shared/doc-preview-scheme.test.ts | 45 ++
src/shared/doc-preview-scheme.ts | 105 +++
.../workspace-session-browser-schema.ts | 20 +-
.../helpers/paired-client-window-reveal.ts | 31 +
.../helpers/paired-html-preview-inventory.ts | 341 +++++---
.../paired-remote-html-browser-focus.spec.ts | 459 -----------
...d-remote-html-preview-local-render.spec.ts | 731 ++++++++++++++++++
117 files changed, 12585 insertions(+), 1714 deletions(-)
create mode 100644 src/main/browser/browser-manager-annotation-bridge.test.ts
create mode 100644 src/main/browser/browser-manager-guest-policy-profile.test.ts
create mode 100644 src/main/browser/browser-session-partition-policies.test.ts
create mode 100644 src/main/browser/doc-preview-download-block-notice.test.ts
create mode 100644 src/main/browser/doc-preview-download-block-notice.ts
create mode 100644 src/main/browser/doc-preview-failure-notice.test.ts
create mode 100644 src/main/browser/doc-preview-failure-notice.ts
create mode 100644 src/main/browser/doc-preview-file-reader.test.ts
create mode 100644 src/main/browser/doc-preview-file-reader.ts
create mode 100644 src/main/browser/doc-preview-grant-registry.test.ts
create mode 100644 src/main/browser/doc-preview-grant-registry.ts
create mode 100644 src/main/browser/doc-preview-guest-policy.test.ts
create mode 100644 src/main/browser/doc-preview-guest-policy.ts
create mode 100644 src/main/browser/doc-preview-protocol.test.ts
create mode 100644 src/main/browser/doc-preview-protocol.ts
create mode 100644 src/main/ipc/browser-preview-tool-authorization.test.ts
create mode 100644 src/main/ipc/browser-tab-registration-wait.test.ts
create mode 100644 src/main/ipc/doc-preview-grant-ipc.test.ts
create mode 100644 src/main/ipc/doc-preview-grant-ipc.ts
create mode 100644 src/preload/api/doc-preview-api.ts
create mode 100644 src/preload/doc-preview-link-interception.test.ts
create mode 100644 src/preload/doc-preview-link-interception.ts
create mode 100644 src/preload/doc-preview-link.ts
create mode 100644 src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx
create mode 100644 src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts
create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-address-slot.ts
create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx
create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx
create mode 100644 src/renderer/src/components/browser-pane/host-guest/use-guest-drag-passthrough.ts
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.ts
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-failure-messages.ts
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-history.ts
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx
create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx
create mode 100644 src/renderer/src/components/editor/EditorPanelShell.header.test.tsx
create mode 100644 src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts
create mode 100644 src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts
create mode 100644 src/renderer/src/lib/browser-workspace-tab-activation.ts
create mode 100644 src/renderer/src/lib/doc-preview-grants.test.ts
create mode 100644 src/renderer/src/lib/doc-preview-grants.ts
create mode 100644 src/renderer/src/lib/execution-host-display-label.ts
create mode 100644 src/renderer/src/lib/file-preview-action-visibility.test.tsx
delete mode 100644 src/renderer/src/lib/file-preview-capability-cleanup.test.ts
create mode 100644 src/renderer/src/lib/workspace-file-host-routing.ts
create mode 100644 src/renderer/src/store/slices/browser-workspace-doc-location.test.ts
create mode 100644 src/shared/browser-page-doc-location.ts
create mode 100644 src/shared/doc-preview-scheme.test.ts
create mode 100644 src/shared/doc-preview-scheme.ts
delete mode 100644 tests/e2e/paired-remote-html-browser-focus.spec.ts
create mode 100644 tests/e2e/paired-remote-html-preview-local-render.spec.ts
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 0fc9a8645ed..4d75e89a918 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -2817,16 +2817,16 @@
},
{
"id": "browser-session.remote-html-preview-ownership",
- "title": "Paired HTML previews keep one host page and one stable client split",
+ "title": "Paired HTML previews render locally as document browser tabs and create no host browser page",
"maturity": "experimental",
"protection": "partial",
"owner": "browser-runtime",
"layer": "renderer-paired-runtime-reconciliation",
"surfaces": [
- "paired remote HTML file preview",
- "background preview creation and explicit browser activation",
- "client-only split placement",
- "capability-unknown and mixed-version preview rejection cleanup",
+ "paired HTML file preview served locally over the orca-preview scheme",
+ "preview split placement that leaves editor focus alone",
+ "grant-scoped document reads, including truncation and out-of-worktree refusal",
+ "capability-unknown and mixed-version browser-creation rejection cleanup",
"post-create reconciliation failure and compensating rollback",
"remote browser close convergence"
],
@@ -2834,8 +2834,9 @@
"providers": ["remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["remote-runtime"],
- "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, a forced post-create reconciliation timeout, and navigation held beyond the client create deadline. The same reconciliation fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, canonical preallocated page identity, navigation-independent acknowledgement, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.",
+ "coverageNotes": "A macOS desktop host and a separate paired Electron client exercise the Explorer preview action end to end: the document renders inside the client-local orca-preview guest, the client gains exactly one browser workspace and it is the document one, the host's own page registry and tab snapshot gain nothing, the split does not take focus from the source editor, an explicit click activates the preview, and closing it with its X leaves the editor and terminal intact. Both host oracles are read in a run where an ordinary URL browser tab from the same client does arrive in them, because a host that cannot project browser pages at all would otherwise answer the absence question the same way. A second journey quits that client with a document tab open and relaunches it on the same profile, which is the restore the user's original report turned on: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit. The same journey pins the guest URL to the orca-preview scheme, so a regression back to file:// or to a host browser page is visible. The rewritten journey caught a live gap the old one could not see: since previews became browser pages they had no chrome to hand focus to their guest, so main refused every reported link click as coming from an unfocused guest and the one route out of a preview was dead. Main's own log answered it — registered true, grant live, focused false — after the embedder reported the anchor really taking six real presses. The pane now hands focus over when it is the reader's surface, and the journey routes. The preview's own components moved out of the editor folder into browser-pane/workspace-doc/ with this change, since a preview is a browser pane and no longer an editor one; their i18n keys keep the historical auto.components.editor.HtmlDocPreview prefix rather than dropping thirty translated strings across four locales for a tidier key path. Seven gate commands named those files by their old paths and were re-issued at the new ones, and the nine dated runs behind them were retired rather than left naming files that are no longer there, on the same principle this gate already applied twice: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. Each retired record's finding is carried into the replacement run recorded below it, including the red half of the entry/subresource pair, which was re-measured by re-applying its mutant at the new path rather than being dropped. The fence over the page-management channels was decided channel by channel before it was built, and that decision record is: of the 23 channels that can name a page, 6 are allowed for a workspace-document page — the tool channels the reader drives, which reach it through the one authority that reads both halves of the registry — 9 refuse (download accept and cancel, devtools, certificate proceed, client page metadata publish, viewport override, WebAuthn account response, registration probe, registration repair), 3 cannot name a page at all because they take a profile or a worktree instead, and the rest are the registration and activation doors, refused on membership. What shipped is not the per-channel guard that classification implies: eleven call sites read the browsing map directly, so a guard per channel is exactly the caller-flag seam that has to be remembered at every future call site. The registry was split in two instead, which makes the classification structural — a document guest is absent from the browsing map, so every refusing channel refuses by absence rather than by a guard, and a channel added later inherits the refusal without being classified at all. Unit contracts cover grant scoping and path containment, per-owner read routing, refusal of a truncated or over-cap read, the old-server binary degradation, scheme admission on will-attach-webview, the guest navigation policy, the plan layer's local/SSH/paired/out-of-worktree routing, and the distinct sentence the reader sees for each failure reason. The paired browser-creation surfaces this gate still owns — capability rejection cleanup, canonical page identity, reconciliation rollback, close convergence — keep their existing headed and headless journeys. Linux and Windows live journeys remain gaps. A refused subresource is a notice on a rendered document rather than a failure panel, since only the entry document's failure leaves the reader with nothing; the entry document is served as text by every owner and cannot be refused for its format at all. Grants die with the renderer document that minted them, at window creation and on every reload, because the renderer holds the only record of which preview owns which grant. The mint and revoke channels answer only the trusted renderer, and a preview tab is never republished to mobile clients, which have no grant to read it with. External URLs leave the preview only via a trusted user click on an anchor while the preview is focused; script-driven navigation and popups reach nothing. The guest's navigation sinks are deny-only and route no URL at all, and the guest carries a preview-only preload, pinned by will-attach-webview and denied to every other attach path, whose capture-phase click handler reports an anchor's URL to main; main answers only a report from a preview guest with a live bound grant that is the focused contents, and only for http(s). Two earlier reader-facing records from 2026-08-26, which measured a shape where a subresource failure was filtered out and the panel still carried an unsupported-asset sentence, were retired with that shape rather than left contradicting the 10/10 runs of the command they share. Two more were retired on the same principle when the external-link design changed: a record showing that removing a recent-input gate let the unattended document open two browser tabs, and a record showing that dropping the webview's allowpopups attribute left a target=_blank press with nothing to route. Both measured machinery that no longer exists — the input gate could not tell a script's navigation from the click it followed, which is the case now recorded below, and the preload intercepts the press before Chromium considers a popup at all, so allowpopups was removed. WebRTC is fenced by the guest's IP-handling policy alone: this Chromium answers a webrtc content-security-policy directive as unrecognized and gathers candidates anyway, so the directive is deliberately absent from the served policy and a unit assertion keeps it out. A standalone Electron probe on the same version gathered 7 candidates including a STUN reflexive one without the policy and 0 with it. Two command paths this gate used to carry no longer exist, and their structured evidence was retired with them rather than left naming files that are no longer there: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. The first is src/renderer/src/lib/file-preview-capability-cleanup.test.ts, deleted in STA-5557 when HTML previews stopped creating paired browser tabs: on 2026-08-12 its five-assertion STA-4063 oracle ran red 5/5 on scan e4c278eb52 and on main bc2e30000b, partially green 2/5 on pre-fix PR #14100, and green 5/5 on candidate 14b5877a75; the surviving half of that oracle is web-runtime-browser-capability-cleanup.test.ts, still commanded above. The second is tests/e2e/paired-remote-html-browser-focus.spec.ts, renamed in STA-5557 to paired-remote-html-preview-local-render.spec.ts because the journey no longer proves anything about browser focus: its 2026-08-12 runs established the owning-runtime routing and no-focus-steal behavior of the then-current browser-tab preview (red on main 346e59c879 and a90a18d43d, green on candidates d05217c40d and b7de9c0961, red again with only the side-preview focus or routing seam disabled), and its 2026-08-26 runs are recorded below under the current path. An adversarial pass after the journey landed found four coverage gaps rather than shipping defects, and all four are pinned here. The page url had a third door: the navigation commit wrote whatever url was reported, unfenced, and while nothing reaches it for a document page today it sat one report away from giving a document page a URL that persistence, the publish boundary and the address bar all read. The group projection was the load-bearing half of the mobile publish boundary that no test had: removing it alone left the whole suite green, because no test in it had tab groups, while the phone payload gained a group whose order and active tab named a document tab it was never sent. The focus handoff was covered only in the narrowing direction, so widening it — dropping the page-activity check, or either half of the reader-surface check — survived both suites. And the handoff itself was offered once and never again, so another app taking the front left the preview's one route out shut until the pane remounted, which is a desktop gap rather than a test-only one.",
"motivatingLinks": [
+ "https://linear.app/stably/issue/STA-5557/remote-html-preview-render-workspace-files-locally-over-the-file",
"https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server",
"https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr",
"https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909",
@@ -2843,19 +2844,35 @@
"https://github.com/stablyai/orca/pull/14363",
"https://github.com/stablyai/orca/pull/13876"
],
- "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.",
- "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Hold a real navigation response beyond the 15-second client deadline after host creation; require the first RPC to return the exact host inventory page ID, one host page, and no retry. Repeat reconciliation faults against headless serve. Retain the separate exact-page reconciliation rollback oracle.",
+ "invariant": "Opening one HTML preview from a paired client renders the workspace document in exactly one client-local browser tab, located by that document and served over the orca-preview scheme. The client gains exactly that one browser workspace and it is the document one — blank where a URL page carries a URL, named by the document, with the chip naming the file — while the host gains no browser page at all, neither in its own page registry nor in the tab snapshot its clients publish into. The preview occupies its own split without taking focus from the source editor; an explicit click activates it, and closing it removes only the preview. Following a document from a file link is the other half of that switch and does move the reader to it, tab group included, whether the preview is new or already open, because opening a file is a request to look at it. A document tab quit with the client comes back as the same row on a grant the relaunched client mints afresh. A preview is named by the browser page it is open in, not by a namespace of its own, and the page registry has two halves: a workspace-document guest is registered in its own map and is absent from the browsing one entirely. That absence is the fence. Page, session and profile management, agent tab enumeration and command targeting, download routing and certificate attribution all read the browsing map directly, in more places than a per-channel guard could be remembered in, so none of them can name a document page and none of them carries a guard. Browser tools the reader drives (element grab, hover describe, selection capture, the annotation viewport bridge) are the one operation that legitimately spans the halves, and they go through the single authority that reads both, keyed by the page and its hosting renderer. The halves are disjoint in both directions: browsing registration refuses a page the document half already holds, and minting a grant refuses a page the browsing half already holds, so one id can never name a surface in both. The headless backend acts on that refusal by destroying the window it had already opened rather than leaving a policy-less page behind an id nothing can drive, keeping nothing under that id for its own shutdown to hand back. Registration refuses on the same terms when the guest it was asked about is already gone. The exit door is guarded in both its halves: a preview withdraws by revoking its grant and never through the unregister channel, so a page the document half holds arriving there is refused before either the registration teardown or the grab-state disposal beside it, which would otherwise drop the intent an in-flight preview grab compares by identity and leave that grab answering ok without ever arming its guest. A bridge request whose guest does not resolve is refused without tearing down the page it named, so a misaddressed request cannot cancel a healthy page's in-flight downloads and grabs. The annotation viewport bridge resolves its guest when its serialized op actually runs rather than when the request arrived, so a cross-process navigation while it waited cannot leave the bridge installed in a retired guest while the reader looks at a new one. State main keys by a preview's page is disposed when that page's grant is revoked, which is the only signal a preview's surface is gone. A tool asking for a page whose guest has not attached yet waits for that registration and arms when it arrives, rather than answering not-ready at the reader; that wait resolves only the request already naming this page, never the worktree-wide or any-tab waits the CLI and agents use to ask for a browser tab to drive. Handing the previewed document to the reader's own machine routes on the owners its grant was minted against — the file's own connection owner and the worktree's own runtime owner, neither read from the tab's stored fields. Only a document proven to live on this machine reaches the client OS; one with a resolved remote owner is downloaded first; and one whose owner cannot be resolved at all, workspace root included, is refused with a message naming that, because the download route would otherwise read the same absolute path on the client and hand back a same-named local file under the remote document's name. A runtime-owned path that falls outside its worktree root is refused by that route itself and surfaces as a failure toast rather than a download. Nothing the document does writes a file to this machine either: the preview partition denies downloads outright instead of routing them through the browser download flow, which has no page to attribute a preview's bytes to and would otherwise reserve a name in this desktop's Downloads folder and write them there unprompted. That refusal is visible to the reader and invisible to the document: the preview's shell carries a fixed sentence saying downloads are off, published at most once per preview per interval so a document asking in a loop cannot fill Orca's chrome, while the page itself gets back exactly what it got before, which is nothing. The sentence names no file, because the document chooses the name it offers; and a refusal never takes the document away the way an entry document's own failure does, whatever it names. A preview is a browser tab, not an editor tab in a preview mode: it is named the way a browser tab is named — by the document it shows when that document declares a title, and by the file it shows when it does not — while the chip goes on naming the file and the host whatever the document calls itself. A title is refused on the same terms the url is: a document that declares none has Chromium report the grant URL as its title, and that title is stored, mirrored onto the tab and written to disk, so anything carrying the scheme falls back to the file instead. It is created by the preview action as a page located by its document, it carries the workspace-relative path copy the editor's path header owned, and closing it revokes the grant that made the document readable while a URL tab closing beside it revokes nothing. Chrome persisted by builds that made previews editor tabs is dropped on restore rather than coming back naming a surface no restore can produce, and the ordinary editor tab for the same document is left alone. A document tab is held back at the mobile publish boundary — no client holds its grant, and the wire has no tab kind for it — while an ordinary browser tab beside it still publishes. It is held back from the group projection that publishes tab order, recency and group activity as well as from the tab list itself, so no published group names a tab the phone is never sent. A browser page can be located by a workspace document instead of a URL, and the document is the whole of its stored identity. The grant and the orca-preview URL that document is served over are minted when the page mounts and replaced by a hard reload, so neither is ever written to the page's url, mirrored onto its tab, persisted or published: such a page's url is the blank URL from creation through restore, including when a session written elsewhere carries a grant URL in, and what the session carries is the worktree and path a restored page mints afresh against today's owners. Every door onto a page's url holds that line — creation, the title update, and the navigation commit alike — so a report about a document page cannot give it a URL it never had, and the title fallback and the loading affordance follow the url each door actually wrote. The mirror carries the document too, so a tab entry cannot go on naming a document its active page has left. Every guest in the app is policy-attached through one door: a workspace document takes a restricted profile there rather than a separate installer beside it, so the attachment bookkeeping that door owns — what registration refuses, and what teardown frees — covers a preview on the same terms as a browsing page, and a preview takes none of the browsing machinery that door installs. That authority answers from the moment the embedder hands the guest over rather than only after a later navigation: the guest binds to the grant it is already showing, so the tools reach the document the reader opened and not just one they navigated to. A read the host reports as truncated or over-cap is refused rather than served partially, and a document outside the paired worktree is refused with a message naming that boundary instead of a bare read failure. The rendered document reaches nothing off-machine on its own: every served response carries a self-only content security policy, the preview session cancels any request that is not in-document, subframes cannot navigate outside the grant, a guest no document has yet bound to a grant may not navigate at all, the guest gathers no ICE candidates, and an SSH path that canonicalizes outside the grant root is refused before it is read. The one route out is a link the reader presses: a trusted click on an anchor, reported by the preview's own preload from a guest that holds focus and is still bound to a live grant, leaves as an Orca browser tab rather than a native window or a dead click. The preview hands its guest that focus itself whenever it is the surface the reader is in — a browsing page gets it from the chrome around it, and a preview has no chrome to get it from — and it does so only then, so a preview mounted behind a terminal or an editor never takes the keyboard from what the reader is actually in. It offers again when the window itself takes focus back and nothing in the embedder has claimed that focus, because another app coming to the front lands focus on the embedder rather than the guest and the route out would otherwise stay shut until something remounted the pane — while the same window focus also arrives when the reader presses a tab, that being the guest's own blur returning, and taking focus back from there would fight the reader for their own click. Nothing else does. A navigation or popup the document starts by itself is swallowed whatever else is happening, including immediately after a genuine press elsewhere in the document, so a page that can read its grant cannot hand it to a browser tab; a middle click opens nothing; and a fragment link is answered inside the document. A preview attach carries the preview preload and no renderer-supplied one, and no other attach path can acquire it. A subresource the workspace will not send degrades the document to a notice naming that file, never to a failure panel over a page that rendered. A grant outlives neither the tab that owns it nor the renderer document that minted it, and only the trusted renderer can mint or revoke one. For the browser creations this gate still owns, owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback.",
+ "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both — the presence precondition without which “the host gained nothing” is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load — an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe — and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes — the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named — including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and anti-detection while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway — then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing — then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router — then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for an unfocused guest, a sender that is no preview guest, a guest with no bound or a revoked grant, and non-web URLs, and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce — the entry document fails only as truncated or unreadable, a subresource additionally as a refused format — and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts",
- "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts",
+ // The hardening round: the third url door, the group projection, and both directions of the focus handoff.
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx",
+ "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
+ "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3",
+ // The changed-spec e2e job's own command, which is the only Linux run this gate has.
+ "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-browser-create-navigation-deadline.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
- "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
- "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line"
],
@@ -2863,7 +2880,6 @@
"src/main/runtime/orca-runtime-browser.test.ts",
"src/main/runtime/rpc/methods/browser.test.ts",
"src/renderer/src/lib/file-preview.test.ts",
- "src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
"src/renderer/src/runtime/web-session-browser-placement.test.ts",
"src/renderer/src/runtime/web-runtime-browser-materialization.test.ts",
"src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts",
@@ -2874,7 +2890,38 @@
"src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts",
"src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts",
"src/renderer/src/store/slices/browser.test.ts",
- "tests/e2e/paired-remote-html-browser-focus.spec.ts",
+ "src/main/browser/doc-preview-grant-registry.test.ts",
+ "src/main/browser/doc-preview-file-reader.test.ts",
+ "src/main/browser/doc-preview-protocol.test.ts",
+ "src/main/browser/browser-session-partition-policies.test.ts",
+ "src/main/browser/doc-preview-download-block-notice.test.ts",
+ "src/main/browser/browser-manager-guest-policy-profile.test.ts",
+ "src/main/browser/doc-preview-guest-policy.test.ts",
+ "src/main/ipc/browser-tab-registration-wait.test.ts",
+ "src/main/browser/doc-preview-failure-notice.test.ts",
+ "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts",
+ "src/preload/doc-preview-link-interception.test.ts",
+ "src/main/window/main-window-webview-security.test.ts",
+ "src/main/ipc/browser-preview-tool-authorization.test.ts",
+ "src/main/browser/browser-manager-annotation-bridge.test.ts",
+ "src/main/browser/browser-manager-guest-lifecycle.test.ts",
+ "src/main/browser/offscreen-browser-backend-lifecycle.test.ts",
+ "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts",
+ "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts",
+ "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx",
+ "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx",
+ "src/shared/doc-preview-scheme.test.ts",
+ "src/renderer/src/lib/doc-preview-grants.test.ts",
+ "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx",
+ "src/renderer/src/lib/file-preview-action-visibility.test.tsx",
+ "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts",
+ "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts",
+ "src/main/ipc/doc-preview-grant-ipc.test.ts",
+ "src/renderer/src/store/slices/tabs-hydration.test.ts",
+ "src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx",
+ "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts",
+ "tests/e2e/paired-remote-html-preview-local-render.spec.ts",
"tests/e2e/paired-browser-create-navigation-deadline.spec.ts",
"tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts"
],
@@ -2886,18 +2933,13 @@
"direct creation removes the caller-declared new split without issuing an RPC"
]
},
- {
- "file": "src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
- "assertions": [
- "known unavailability reports visibly without creating a split",
- "capability loss after caller preflight reports visibly without duplicating transaction cleanup"
- ]
- },
{
"file": "src/renderer/src/lib/file-preview.test.ts",
"assertions": [
- "a failed side preview reports visibly without caller-owned split cleanup",
- "overlapping side previews reuse the split without the first failure deleting it"
+ "a paired-runtime HTML preview opens a local doc preview instead of a runtime browser tab",
+ "a local workspace still opens the embedded browser tab at a file URL",
+ "a paired document outside the worktree names that boundary and creates no split",
+ "an SSH document outside the worktree root still previews"
]
},
{
@@ -2973,11 +3015,152 @@
]
},
{
- "file": "tests/e2e/paired-remote-html-browser-focus.spec.ts",
+ "file": "tests/e2e/paired-remote-html-preview-local-render.spec.ts",
"assertions": [
- "one HTML open produces one client workspace, one client unified tab, and one host page",
- "the source editor and remote frame remain visible in separate groups",
- "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal"
+ "the host's HTML document renders inside the client-local orca-preview guest",
+ "neither the client browser-workspace count nor the host browser-tab count moves off baseline",
+ "the preview opens in its own split with mode html-preview and leaves the source editor active",
+ "an explicit click reactivates the preview, and closing it preserves the source editor, its group, and the terminal",
+ "the document reports having attempted both its unattended and its post-press egress, so neither absence assertion can pass on a document that never tried",
+ "a scripted window.open and location.href driven by a real press elsewhere in the document route nothing and move no browser count",
+ "a real target=_blank press in the rendered document routes out as an Orca browser tab while the preview stays open"
+ ]
+ },
+ {
+ "file": "src/main/browser/doc-preview-file-reader.test.ts",
+ "assertions": [
+ "a host-reported truncated read is refused instead of served as partial bytes",
+ "an over-cap binary and an empty binary body are reported as too-large and unsupported rather than a bare read failure",
+ "a document outside the paired worktree is refused without issuing an RPC",
+ "an SSH path that canonicalizes outside the grant root is refused before any read",
+ "a 0-byte file the host still typed is served as 0 bytes rather than reported as a refused format"
+ ]
+ },
+ {
+ "file": "src/main/browser/doc-preview-grant-registry.test.ts",
+ "assertions": [
+ "a path escaping the grant root resolves to nothing, including sibling-prefix roots",
+ "a revoked grant stops resolving"
+ ]
+ },
+ {
+ "file": "src/main/browser/doc-preview-protocol.test.ts",
+ "assertions": [
+ "an unknown or revoked grant is answered exactly like a missing file, with no read attempted",
+ "a failed read pushes its reason for the requested path while a served document pushes nothing",
+ "every served document carries the self-only content security policy",
+ "the preview session cancels any request that is not in-document",
+ "the scheme is handled on the preview session and never on the default one",
+ "the scheme claims exactly its five privileges, so a widened one fails"
+ ]
+ },
+ {
+ "file": "src/main/browser/doc-preview-failure-notice.test.ts",
+ "assertions": ["413 and 415 map to distinct reasons and everything else stays generic"]
+ },
+ {
+ "file": "src/renderer/src/lib/file-preview-action-visibility.test.tsx",
+ "assertions": [
+ "the Explorer row predicate and the preview plan return one answer for the same file"
+ ]
+ },
+ {
+ "file": "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts",
+ "assertions": [
+ "an external preview link becomes a browser tab, and a tab that is refused or that fails outright surfaces instead of being dropped"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx",
+ "assertions": [
+ "each entry-document failure reason surfaces its own sentence instead of one generic unavailable panel",
+ "a refused subresource names itself in a notice, deduplicated per path, while the document stays rendered",
+ "an entry-document failure replaces the asset notice with the panel",
+ "a failure for another preview's grant leaves the rendered document alone"
+ ]
+ },
+ {
+ "file": "src/main/browser/doc-preview-guest-policy.test.ts",
+ "assertions": [
+ "the guest may only navigate within its own live grant, and not at all until a document has bound it to one",
+ "every navigation and popup sink refuses without routing, whenever genuine input happened",
+ "a subframe cannot navigate outside the grant, and only the main frame binds the guest to one",
+ "a reported click routes exactly once, and only from a focused preview guest with a live bound grant",
+ "a reported click on a non-web URL, or from a destroyed guest, routes nothing",
+ "the guest is denied non-proxied WebRTC UDP when its policy is installed"
+ ]
+ },
+ {
+ "file": "src/preload/doc-preview-link-interception.test.ts",
+ "assertions": [
+ "a click the document dispatched itself is not trusted and routes nothing",
+ "a trusted press on an external anchor reports it once and stops the navigation, including through what the anchor wraps and an SVG animated href",
+ "a fragment link scrolls in the document, raw or percent-encoded, and a bare hash goes to the top",
+ "a sibling preview document is left to the guest's own navigation, and a middle click opens nothing whether or not it is trusted",
+ "an SVG link is resolved against the document, so its relative, rooted and fragment forms behave exactly like the identical HTML anchor",
+ "the preload reports on exactly the channel main listens on"
+ ]
+ },
+ {
+ "file": "src/main/window/main-window-webview-security.test.ts",
+ "assertions": [
+ "an orca-preview src is admitted only on the preview partition with a live grant",
+ "preview guests receive their own policy instead of the browser guest policies",
+ "a preview attach is pinned to the preview preload over any renderer-supplied value, and a browsing attach never receives it",
+ "grants a renderer can no longer release are cleared at window creation and on reload, and survive an in-document navigation"
+ ]
+ },
+ {
+ "file": "src/shared/doc-preview-scheme.test.ts",
+ "assertions": ["preview URLs round-trip per segment and reject a malformed grant id"]
+ },
+ {
+ "file": "src/renderer/src/lib/doc-preview-grants.test.ts",
+ "assertions": [
+ "grants are deduped per preview tab so a StrictMode double-mount cannot revoke the surviving webview",
+ "a mint that rejects after its tab was released and reopened leaves the reopened tab's grant revocable"
+ ]
+ },
+ {
+ "file": "src/main/ipc/doc-preview-grant-ipc.test.ts",
+ "assertions": [
+ "a sender that is not the trusted renderer mints no grant and revokes none",
+ "a reported link click reaches the guest policy with its own sender, and a non-string report reaches nothing"
+ ]
+ },
+ {
+ "file": "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts",
+ "assertions": [
+ "closing a document browser tab revokes its grant, and closing a URL tab beside it revokes nothing",
+ "a document page's url is blank in the store, in the written session and after restore",
+ "a navigation committing a url onto a document page leaves the url blank, the document title standing and nothing loading, while an ordinary page beside it takes the url it is given"
+ ]
+ },
+ {
+ "file": "src/renderer/src/store/slices/tabs-hydration.test.ts",
+ "assertions": [
+ "chrome for a preview tab written before previews were browser tabs is dropped, and the ordinary editor tab for the same document is kept"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts",
+ "assertions": [
+ "a browser tab located by a workspace document is not published to mobile clients, while an ordinary browser tab beside it is",
+ "with tab groups in play the document is kept out of the published groups too: no group's order, recency or active tab names a tab the snapshot does not carry, the held-back group leaves the published layout tree with it, and the grouped URL tab beside it still publishes"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx",
+ "assertions": [
+ "the pane offers its guest focus when the reader is in this very preview",
+ "it refuses while the reader is in a terminal in front of it, in a different browser tab, or while its own page is not the active one"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "assertions": [
+ "a preview that is the reader's surface hands its guest focus, and one that is not leaves focus alone",
+ "the window taking focus back re-offers it to the guest, and re-offers nothing for a preview that was refusing"
]
},
{
@@ -2999,22 +3182,301 @@
],
"evidenceRuns": [
{
- "date": "2026-08-12",
+ "date": "2026-08-27",
"runner": "local",
"platform": "macos",
- "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
"result": "failed",
- "durationSeconds": 0.72,
- "summary": "The unchanged STA-4063 oracle failed 5/5 on scan e4c278eb52 and latest main bc2e30000b: direct create never closed the new split, and side-preview rejection produced neither cleanup nor a toast. Pre-fix PR #14100 passed the two caller cases but still failed all three direct transaction cases."
+ "durationSeconds": 7,
+ "summary": "The adversarial pass answered the request for its own group-projection probe after this round landed, and it had measured a third observable this test was missing: the held-back group also has to leave the published layout tree, or the phone renders a split whose second pane can never hold anything. Adopted as a fourth assertion and re-run against the same sole delta, which now fails on the dangling-reference check and, with that check blinded, on the layout alone — group-left and group-right against the group-right the clean tree publishes. The two probes agreed on everything else, including that the tab list is unchanged under this mutant, which is why an assertion reading only the tab list cannot reach the site at all."
},
{
- "date": "2026-08-12",
+ "date": "2026-08-27",
"runner": "local",
"platform": "macos",
- "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "result": "passed",
+ "durationSeconds": 3,
+ "summary": "The adversarial pass's four coverage findings, landed and pinned: 58/58. The third url door now keeps a document page blank and names it by its document, the group projection holds a document back from published groups as well as from the tab list, the pane's focus handoff is pinned in the widening direction as well as the narrowing one, and the handoff is re-offered when the window takes focus back."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "result": "failed",
+ "durationSeconds": 3,
+ "summary": "Three sole-delta mutants on the third url door, each failing only the new store test: committing the reported url onto a document page, dropping the document from the title normalizer there (which renames the tab New Tab, since a blank url is what that fallback reads), and marking a blank document page loading. The ordinary-page half of the same test passes under all three, so the door is pinned as a document-only refusal rather than as a door that writes nothing."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "result": "failed",
+ "durationSeconds": 3,
+ "summary": "Two sole-delta mutants on the mobile publish boundary. Dropping the document from the group projection's browser ids fails only the new grouped test — the tab list stays correct because the per-tab guard still refuses the tab, and what breaks is a published group whose order and active tab name a tab the phone is never sent. This is the survivor the adversarial pass reported: it survives the whole suite without this test, because no other test in it has tab groups. Dropping the per-tab guard instead fails only the ungrouped test, so the two halves of the boundary are pinned separately."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "result": "failed",
+ "durationSeconds": 3,
+ "summary": "Three sole-delta mutants widening the focus handoff, the direction the adversarial pass found uncovered: dropping the page-activity half, dropping the active-tab-type half, and dropping the active-browser-tab half each fail exactly one of the pane's refusals — the inactive page, the terminal in front, and the other browser tab respectively. The presence half of that test passes under all three, so a pane that had stopped offering focus at all would still fail rather than pass the refusals vacuously."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "result": "failed",
+ "durationSeconds": 3,
+ "summary": "One sole-delta mutant dropping the window-focus re-offer: the guest is focused once at mount and never again after the window comes back, which is the desktop gap behind it — another app taking the front leaves the preview's only route out shut until the pane remounts. It fails only the new re-offer test, and the refusing-preview case beside it stays green, so the re-offer is pinned as gated rather than unconditional."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
+ "result": "passed",
+ "durationSeconds": 186,
+ "summary": "The rewritten journey passed 2/2. The first test proves the inverted claim: the client gains exactly one browser workspace and it is the document one — page and tab url blank, the document path mirrored onto both, the tab named by the document's own title, the chip still naming the file, no editor row of the retired preview species — while the host's page registry and tab snapshot both stay at their pre-action content. Both host oracles carry a presence precondition in the same run: an ordinary URL browser tab this client opens does arrive in browser.tabList and in session.tabs.list, and leaves both again when it is closed, so the preview's absence is a decision rather than an oracle that cannot see browser pages. The document still reaches nothing on its own (zero ICE candidates, unattended and post-press egress both swallowed), and the one route out still works: a real press on a target=_blank anchor routes exactly one call and opens a tab while the preview stays open. The second test quits the client with a document tab open and relaunches it on the same profile: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
+ "result": "failed",
+ "durationSeconds": 108,
+ "summary": "Candidate with the sole delta that the preview action also opens an ordinary browser tab through the runtime — what it did before STA-5557 — failed at the client-workspace count, 2 against the 1 the claim allows. It dies there rather than at the host oracle, so it proves the exactly-one-workspace half; the host half has no single line to mutate, since a document page never calls the runtime at all, and is protected instead by the presence precondition recorded above."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
+ "result": "failed",
+ "durationSeconds": 190,
+ "summary": "Candidate with the sole delta that the workspace row stops mirroring the document its active page shows failed in both tests on workspaceDocFilePath, null against the document path — the tab entry would go on naming nothing while the page it renders is a document. Two earlier mutants survived and are reported as ill-posed rather than as blind spots: flipping the mobile publish filter changes what this desktop publishes to its own phone clients, not what a paired client puts on its host, and flipping the document mirror in the workspace constructor is repaired on the next state write by the active-page mirror, which is the line the mutant above hits."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
+ "result": "failed",
+ "durationSeconds": 195,
+ "summary": "Candidate with the sole delta that the preview pane does not hand focus to its guest — the tree as it stood before this change — failed at the target=_blank press: six real presses reached the anchor, the embedder had the webview as its active element, and main's own log answered each report with registered true, grant live, focused false. This is the gap the rewrite found: since previews became browser pages they had no chrome to pass focus on, so the one route out of a preview was shut."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts",
+ "result": "passed",
+ "durationSeconds": 1.37,
+ "summary": "174/174 over the grant, reader, protocol, guest-policy, scheme-admission, plan-routing, action-visibility, failure-message, external-link and terminal-row contracts, re-issued at the relocated preview paths. This replaces the 2026-08-26 record of the same thirteen files at 152/152, whose command named the pre-relocation paths: it established the stale-mint rejection that must not evict a reopened preview tab, the CSP header and session request filter, the SSH symlink refusal, subframe containment, truncated and over-cap reads, the unservable-asset and empty-file split, the out-of-worktree paired refusal with SSH left unrestricted, reload re-minting a stale grant, and the guest deny-only navigation sinks. The count moved with the cases added since; nothing in those files changed but their directory."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx",
+ "result": "passed",
+ "durationSeconds": 1.78,
+ "summary": "64/64 on the visible download refusal, re-issued at the relocated path. This replaces the 2026-08-27 record of the same four files at 62/62, whose ten mutants — removing the notice from the deny listener, adding it to the routing listener, dropping the guest-registry lookup that names which preview to tell, dropping the shell branch and the row, letting the row survive the failure panel, removing the per-preview throttle, sharing one throttle across previews, never expiring the window, and publishing for a contents no preview is bound to — all failed as sole deltas. The relocation moved no line of any of them."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts",
+ "result": "passed",
+ "durationSeconds": 2.05,
+ "summary": "39/39 on the title-parity trio at the relocated path, replacing the 2026-08-27 record of the same three files, which killed five mutants as sole deltas: disabling the document branch restores New Tab for a named document, dropping the grant-URL refusal puts the scheme into the title, the tab mirror and the written session, returning New Tab instead of the file name breaks the fallback, removing the guest listener leaves the document unable to name its tab, and making the scheme test parse rather than prefix-match lets a title that merely looks like a preview URL through."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx",
+ "result": "passed",
+ "durationSeconds": 1.54,
+ "summary": "14/14 on the reader-facing failure suite at its relocated path. This and the red run below replace the 2026-08-26 pair recorded against the pre-relocation path (10/10 green, 3/10 red), re-measured today so the entry/subresource split keeps a red half rather than only a green one."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx",
+ "result": "failed",
+ "durationSeconds": 1.27,
+ "summary": "Candidate with the sole delta that every failure sets the blocking reason, asset or not, failed 4/14 on the asset-notice assertions — the same mutant the retired 2026-08-26 record measured at 3/10, re-run at the relocated path. A refused subresource must name itself over a document that stays mounted; only the entry document may take the page away."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "result": "passed",
+ "durationSeconds": 1.52,
+ "summary": "174/174 at the relocated paths, replacing the 2026-08-27 records of the same command at 162/162 and 158/158. Those established, as sole deltas: removing the unregister-channel refusal goes red because the grab disposal beside it drops the intent an in-flight preview grab compares by identity, while an ordinary browser page driven through the identical sequence still has its grab state disposed; removing registration’s already-gone-guest refusal goes red for both a missing and a destroyed guest; removing the map deletion on the refusal path goes red at shutdown; the bridge’s own unregisterGuest branch, restored, tears down a live page over a misaddressed request; and the download fall-through for an unresolved owner hands back a same-named local file under a remote document’s name."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx",
+ "result": "passed",
+ "durationSeconds": 1.5,
+ "summary": "175/175 at the relocated paths on the unified tool authority, replacing the 2026-08-27 record of the same command at 174/174. That run killed twelve of fourteen mutants as sole deltas — dropping the document branch from the one authority door, removing either browsing-registration refusal or the mint-side refusal that is its converse, deleting the registration notification or widening it to the worktree-wide and any-tab waits, disposing revoked state by grant instead of by page, dropping the hosting-renderer or destroyed-contents check, rebuilding the renderer tool target from the grant, and deleting the teardown identity check under a re-mint — and reported two as ill-posed rather than as kills. The relocation changed no file it names except their directory."
+ },
+{
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts",
+ "result": "passed",
+ "durationSeconds": 22.09,
+ "summary": "29/29 on the candidate that makes the preview a browser tab. The preview action now creates a page located by the document; reopening the same document activates the tab it is already in rather than minting a second grant on one file; and closing that tab revokes its grant, which nothing else does now that the editor tab's close hook is gone. Red-green with each mutant as the sole delta: dropping the reuse lookup opens a second tab for a document already on screen, and dropping the release on close leaves the document readable through a grant nothing revokes until the process ends. Both are paired with presence preconditions in the same runs — a second, different document still gets its own tab, and a URL tab closed beside the document tab revokes nothing, so a release fired for every close would fail rather than pass."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts",
+ "result": "passed",
+ "durationSeconds": 4.77,
+ "summary": "40/40 on the candidate that removes the editor preview species and moves its two boundary duties onto the browser tab. The publish filter moved with the tab: it used to exclude an editor file by mode, and now excludes a browser workspace by whether it is located by a document — asserted at both the group projection and the tab loop, because a mutant that filters only one of them still publishes. Migration is the other half: sessions written by builds that made previews editor tabs carry chrome whose entity id encodes the document, and whose document was never persisted, so it has always come back naming a surface no restore produces; that chrome is now dropped. Each mutant as the sole delta: filtering neither place publishes the document tab to mobile, filtering only the projection still publishes it, and removing the migration leaves the stale strip entry. Presence preconditions in the same runs: an ordinary browser tab beside the document tab does publish, and the ordinary editor tab for the very same document survives hydration."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts",
+ "result": "passed",
+ "durationSeconds": 1.54,
+ "summary": "10/10 on the candidate that gives a browser page a workspace-document location. The trap this is built around: page.url is read by persistence, the mobile publisher, history and the address bar, so a grant URL parked there would outlive the grant that named it and travel to readers that cannot resolve it. Creation overrides the url rather than trusting its caller, and restore re-asserts the same thing, so a session written by another build cannot carry one in. Red-green with each mutant as the sole delta: dropping the creation override writes the grant URL into the store and into the session payload; dropping the restore override brings a foreign session's grant URL back; leaving docLocation out of either browser schema strips it on load and restores the document as a blank New Tab under a strip entry that still names it (both schemas are plain z.object, and this is the trap that already cost one round on the editor schema); dropping it from the workspace build or the mirror leaves the tab entry naming a document its page has left, which the publish boundary reads; dropping the salvage-path copy loses the document when only the tab row survived; and dropping the address-bar exclusion focuses an address bar the surface does not have. The blank-url assertions are paired with a presence precondition in the same run: an ordinary tab created through the same call keeps its URL and does ask for the address bar."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.32,
+ "summary": "55/55 on the candidate that gives every guest one attach door. A preview used to reach its policies through an installer of its own, which meant the manager's attachment bookkeeping — the set registration consults and the cleanup teardown runs — never knew a preview existed. It is now a profile on `attachGuestPolicies`, and the doc policy is what that profile installs. Red-green with each mutant as the sole delta: deleting the profile branch hands a previewed document the browsing machinery (clicked-link routing, frame routing, popup child windows, anti-detection over the debugger) and lets it navigate off-grant; dropping the manager's destroyed wiring leaves the guest answerable through the grant-keyed authority after it dies and its id marked attached forever; and making the window fork ask for the default profile is caught at the fork itself. The absence assertions are paired with a presence precondition in the same run: a browsing guest attached through the same method observably takes every one of those things, so a profile that fenced nothing, or an attach path that had stopped installing anything at all, fails rather than passing empty."
+ },
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.17,
+ "summary": "22/22 on the candidate that stops a previewed document writing into this desktop's Downloads folder. The hole was read out of the shipping path, not guessed: the preview partition took the shared browser partition policies whole, including will-download, and a preview guest joins no browser-page registry, so handleGuestWillDownload resolved no owner context, routeBrowserClientDownload answered local for a non-client-hosted contents, and the item got a reserved Downloads name with no prompt and no tab to attribute it to. Downloads are now the one policy the preview partition does not inherit. Red-green with each mutant as the sole delta: making the installer ignore the deny option routes the preview partition's download to the router again, and making the preview protocol installer stop asking for the deny leaves the option unset. The absence half is paired with a presence precondition in the same run — an ordinary browsing partition installed beside the preview one in one test still reaches the download router, so a deny that fenced everything, or a download path that had been removed entirely, would fail rather than pass quietly."
+ },
+{
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts",
"result": "passed",
"durationSeconds": 0.7,
- "summary": "Rebased candidate 14b5877a75 passed 5/5: absent, unknown, mixed-version, preflight-known, and post-preflight capability failures all reported failure and retained no new empty split."
+ "summary": "99/99 on the candidate that binds a preview guest to its grant when the policy installs, not only on a later navigation. Live on a dev instance against a real SSH workspace, the previous shape refused every tool with not-ready for as long as the reader stayed on the page they opened: the embedder hands the guest over after it has already started loading its src, so the did-start-navigation latch never saw the only navigation most previews make. Following an in-document link bound it and the tools began answering, which is what identified the miss. Two cases pin it: a guest already reporting its URL at install, and one whose first navigation only reaches the commit."
+ },
+ {
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
+ "result": "passed",
+ "durationSeconds": 23.1,
+ "summary": "The journey passed 1/1 on the candidate that requires the guest to be bound to a grant before any navigation is allowed, rather than admitting an unbound one. The document still rendered and the target=_blank press still routed out, which is the half that would break if the renderer-set src reached will-navigate: it does not, because that load is browser-initiated, so the permissive branch it was written for is unreachable."
+ },
+ {
+ "date": "2026-08-26",
+ "runner": "ci",
+ "platform": "linux",
+ "command": "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1",
+ "result": "failed",
+ "durationSeconds": 72,
+ "summary": "The journey failed on Ubuntu at the post-press presence precondition, with #post-input-egress still reading 'idle' after 30 seconds. The trace shows the press was dispatched at 769.5,143.9, a point the guest's own hit test had just confirmed was on its heading, and the focus report before it was already isFocused true, so neither focus nor coordinates were the break: the press never entered the guest at all. A standalone Electron 43 probe under Xvfb with this suite's Linux flags reproduced it in roughly two runs in five when pressing immediately after guest attach — the embedder received the pointerdown with the webview element as its target, meaning the browser resolved the press against the embedder because the freshly attached guest had not yet registered its hit-test region. The same probe never dropped a press on macOS, which is why local runs of either project could not see this. The spec now presses until the document answers, mirroring the target=_blank phase."
+ },
+ {
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3",
+ "result": "passed",
+ "durationSeconds": 72,
+ "summary": "The journey passed 3/3 on the trusted-click candidate, which removed the recent-input gate, made both navigation sinks deny-only, dropped the webview's allowpopups attribute and gave the preview guest its own pinned preload. The fixture now also attempts a window.open and location.href from inside a pointerdown handler, so the egress rides a press the reader really made: it reported 'attempted' every run while both browser counts stayed at that phase's baseline and nothing routed, and the later real press on the target=_blank link still opened exactly one Orca browser tab with the preview still open."
+ },
+ {
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
+ "result": "failed",
+ "durationSeconds": 26.9,
+ "summary": "Candidate with the sole delta that the navigation sinks route again behind a two-second recent-input gate failed on clientBrowserWorkspaceCountAllWorktrees 1 versus baseline 0: the document's location.href, run from inside the handler for the reader's press on the heading, opened a real browser tab. That is the case the earlier design could not distinguish from the press's own effect, and it is why the sinks route nothing at all now. The same run still refused the unattended on-load egress, so this measures the new half specifically."
+ },
+ {
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.97,
+ "summary": "97/97 passed over the preview tool-authorization seam. The channel census asserts the split is total, so a new browser channel cannot be added without classifying it: all six tool channels resolved the preview guest and refused both a renderer that does not host the preview and a grant no preview rendered, while all twenty-three page/session channels never reached the preview authority at all. Five mutations were killed \u2014 dropping the preview branch (12 failures), turning the two authorities into a fallback chain (13), ignoring the hosting renderer id (6), ignoring grant revocation (1), and removing one channel from the census (1)."
+ },
+ {
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.36,
+ "summary": "The four surfaces the trusted-click route is built from passed 63/63: the preload's click policy over a real document, including an untrusted dispatched click, an untrusted middle click, an SVG animated href resolved against the document so a relative, rooted or fragment SVG link behaves exactly like the identical HTML anchor, fragment and percent-encoded fragment targets and a trusted middle click; the guest policy's deny-only sinks, its refusal of a navigation that arrives before a document has bound the guest to a grant, and its report gate for an unfocused guest, an unregistered sender, a missing or revoked grant and non-web URLs; will-attach-webview pinning the preview preload in both directions; and the click channel handing main its own sender without a trusted-renderer check."
+ },
+{
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
+ "result": "passed",
+ "durationSeconds": 22.8,
+ "summary": "The journey passed 1/1 with the fixture document now attempting its own egress on every load: an unattended window.open and location.href to an off-machine URL, and an ICE gathering probe. Both browser counts held at baseline and the probe reported candidates=0, while the document still rendered and the later real target=_blank press still routed out as an Orca browser tab."
+ },
+ {
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
+ "result": "failed",
+ "durationSeconds": 23.6,
+ "summary": "Candidate with the sole delta that the preview guest no longer receives the disable_non_proxied_udp IP-handling policy failed on candidates=6 versus 0, proving the document can gather ICE — including a STUN reflexive candidate against an off-machine server in the standalone probe — whenever that one main-process call is missing."
+ },
+ {
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3",
+ "result": "passed",
+ "durationSeconds": 66,
+ "summary": "The journey passed 3/3 again under --repeat-each=3 on the candidate that clears grants on every fresh shell document and gates the mint channel on the trusted renderer, so neither change disturbs a live preview: the document still rendered, both browser counts held at baseline, and the target=_blank press still routed out as an Orca browser tab."
+ },
+{
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3",
+ "result": "passed",
+ "durationSeconds": 59.7,
+ "summary": "The renamed journey passed 3/3 under --repeat-each=3 on the hardened candidate: the host document rendered in the orca-preview guest, both browser counts held at baseline through the split, focus, reactivation and close phases, and the final phase's real target=_blank press routed out as an Orca browser tab with the routing call recorded as opened while the preview tab stayed open."
},
{
"date": "2026-08-12",
@@ -3070,78 +3532,6 @@
"durationSeconds": 3.48,
"summary": "Seven focused host and renderer contract files passed 183/183 tests, covering owning-runtime routing, focused and unfocused creation, placement, reconciliation, failure cleanup, and teardown."
},
- {
- "date": "2026-08-12",
- "runner": "local",
- "platform": "macos",
- "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
- "result": "passed",
- "durationSeconds": 16.1,
- "summary": "The candidate passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal."
- },
- {
- "date": "2026-08-12",
- "runner": "local",
- "platform": "macos",
- "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "result": "failed",
- "durationSeconds": 11.6,
- "summary": "Latest-main a90a18d43da333e1cc327779a3f9912a78308848 failed the byte-identical 5806b23f oracle because the HTML preview collapsed into the source editor group instead of retaining its requested split."
- },
- {
- "date": "2026-08-12",
- "runner": "local",
- "platform": "macos",
- "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "result": "passed",
- "durationSeconds": 6.5,
- "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e passed the byte-identical 5806b23f oracle: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close."
- },
- {
- "date": "2026-08-12",
- "runner": "local",
- "platform": "macos",
- "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "result": "failed",
- "durationSeconds": 10.9,
- "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e with only the side-preview owning-runtime routing seam disabled failed the byte-identical 5806b23f oracle with two client workspaces."
- },
- {
- "date": "2026-08-12",
- "runner": "local",
- "platform": "macos",
- "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "result": "passed",
- "durationSeconds": 7.6,
- "summary": "Final candidate d5767481b982b736e7cc5b4653b2a32a61280638 on merge-base 36d45af062ec4022265aabac8fc6a9d36eecedf1 passed the byte-identical 5806b23f oracle after final review and rebase: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close."
- },
- {
- "date": "2026-08-12",
- "runner": "local",
- "platform": "macos",
- "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "result": "failed",
- "durationSeconds": 11.8,
- "summary": "Latest-main 346e59c879 failed the byte-identical 2ec1dcf1 oracle with two client HTML browser workspaces instead of one."
- },
- {
- "date": "2026-08-12",
- "runner": "local",
- "platform": "macos",
- "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "result": "passed",
- "durationSeconds": 8.4,
- "summary": "Candidate d05217c40d passed the byte-identical 2ec1dcf1 oracle: one host page, one client workspace, stable split, rendered DOM, editor focus after creation, explicit-click browser focus, and browser-only close."
- },
- {
- "date": "2026-08-12",
- "runner": "local",
- "platform": "macos",
- "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
- "result": "failed",
- "durationSeconds": 12.3,
- "summary": "Candidate d05217c40d with only the side-preview focusOnCreate:false call disabled failed the byte-identical 2ec1dcf1 oracle because preview creation activated the browser instead of preserving editor focus."
- },
{
"date": "2026-08-13",
"runner": "local",
@@ -3171,7 +3561,7 @@
},
"redGreenEvidence": {
"status": "complete",
- "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing."
+ "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing. For the STA-5557 preview behavior the rewritten journey is green 1/1 on the candidate and red when the sole delta makes the same preview action also create a browser tab, failing on clientBrowserWorkspaceCount 1 versus baseline 0. That absence assertion is gated on first reading the rendered document out of the guest, so it cannot pass by the preview never happening. Against pre-STA-5557 builds the journey is red by construction rather than by measurement: those builds have no html-preview tab mode, so the run would fail earlier, at materialization, and it has not been executed against an old build. The reader-facing half is red-green at the unit layer. The 6/6 green and 2/6 red pair recorded earlier on 2026-08-26 measured the superseded shape, where a subresource failure was filtered out entirely; the shipping shape is green 10/10 and red 3/10 when the sole delta makes every failure, asset or entry, set the blocking reason. The containment lines the second review found uncovered are red-green too: installing the scheme handler on the default session instead of the preview session, and widening the scheme privileges with allowServiceWorkers and bypassCSP, each fail the protocol suite as the sole delta, and deleting the html-preview case from the closed-tab cleanup fails the hook suite. The external-link phase added on 2026-08-26 is red-green by measurement: green 3/3 on the trusted-click design, in which the guest's own preload reports a trusted press on an anchor and main answers only a focused preview guest holding a live grant. Its earlier shape depended on the webview carrying allowpopups and went red with zero recorded routing calls when only that attribute was dropped; the preload now intercepts the press before Chromium considers a popup, so the attribute is gone and that measurement retired with it. One further run failed in the paired-client harness before reaching any preview step, when the client window never exposed its store within the launch timeout; that is a harness flake, not a preview signal. The two egress fences added on 2026-08-26 are red-green by measurement in the same journey, whose fixture attempts its own escape on every load and again from inside the handler for the reader's press on the heading: with deny-only navigation sinks the document opens no browser tab and the counts hold at baseline, and with the sole delta that those sinks route again behind a two-second recent-input gate the post-press location.href opens a real tab, clientBrowserWorkspaceCountAllWorktrees 1 against a baseline of 0. An earlier measurement of that same gate, taken when it was the shipping design, showed the unattended on-load egress opening two tabs once the gate was removed entirely; it was retired with the gate, because a gate that cannot tell a script's navigation from the click it followed was never the fence the claim needed. With the guest's IP-handling policy the document reports candidates=0, and with the sole delta that the policy call is removed it reports candidates=6. Both mutants were applied to the shipping module, rebuilt, and run against the live paired journey rather than reasoned about."
},
"performanceBudget": {
"required": true,
@@ -3184,9 +3574,14 @@
"knownGaps": [
"Live paired-server validation currently covers macOS only.",
"Multiple simultaneous viewers are not exercised by the Electron journey.",
- "Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path."
+ "Local workspaces intentionally keep the embedded browser tab at a file URL and are not covered by the preview journey.",
+ "The SSH preview owner has unit coverage only; no live SSH host renders a preview in this gate.",
+ "The preview journey exercises one self-contained document. Relative subresources, reload re-reading the workspace disk, and the truncated and out-of-worktree refusals are covered by unit contracts only.",
+ "Grant revocation on tab close is asserted at the unit layer; the journey observes the tab and open file disappearing, not the main-process registry.",
+ "The external-link route trusts a real press on an anchor in a focused preview guest, so it proves the reader pressed that anchor, not that the anchor's visible text matches its target. The tab it opens is visible, which is the remaining containment.",
+ "WebRTC is fenced by the IP-handling policy rather than by removing the API; the guest can still construct a peer connection, and the journey measures that it gathers nothing."
],
- "demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close."
+ "demotionRule": "Demote if a paired HTML preview creates a browser workspace on either side, renders from anything other than the orca-preview scheme, serves a read the host reported as truncated or over-cap, serves a document without the self-only content security policy, lets the preview session carry an off-machine request, reads an SSH path that canonicalizes outside its grant root, steals focus from the source editor when it opens, removes unrelated editor or terminal state on close, hides a document that rendered because one subresource was refused, leaves a grant readable after the tab or renderer that owns it is gone, mints one for a sender that is not the trusted renderer, opens a browser tab for a navigation the document started with no one at the keyboard, or lets the guest gather an ICE candidate; or if a paired browser creation this gate still owns produces duplicate client or host identities or loses its requested split."
},
{
"id": "browser-network-tunnel.bounded-fail-closed-route",
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index f81dbb93041..4faa844ee4c 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
+ 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
diff --git a/src/main/browser/browser-manager-annotation-bridge.test.ts b/src/main/browser/browser-manager-annotation-bridge.test.ts
new file mode 100644
index 00000000000..2d9aa5e50fe
--- /dev/null
+++ b/src/main/browser/browser-manager-annotation-bridge.test.ts
@@ -0,0 +1,210 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const browserMocks = vi.hoisted(() => ({
+ appGetPathMock: vi.fn(() => '/downloads'),
+ shellOpenExternalMock: vi.fn(),
+ browserWindowFromWebContentsMock: vi.fn(),
+ menuBuildFromTemplateMock: vi.fn(),
+ guestOffMock: vi.fn(),
+ guestOnMock: vi.fn(),
+ guestSetBackgroundThrottlingMock: vi.fn(),
+ guestSetWindowOpenHandlerMock: vi.fn(),
+ guestOpenDevToolsMock: vi.fn(),
+ webContentsFromIdMock: vi.fn(),
+ screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
+ openPopupWithOriginBarMock: vi.fn()
+}))
+
+vi.mock('electron', () => ({
+ app: { getPath: browserMocks.appGetPathMock },
+ BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
+ clipboard: { writeText: vi.fn() },
+ shell: { openExternal: browserMocks.shellOpenExternalMock },
+ Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
+ screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
+ webContents: { fromId: browserMocks.webContentsFromIdMock }
+}))
+
+vi.mock('./popup-origin-bar-window', () => ({
+ openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
+}))
+
+import { browserManager } from './browser-manager'
+import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge'
+import {
+ rendererWebContentsId,
+ resetBrowserManagerMocks,
+ resetBrowserManagerState
+} from './browser-manager-test-harness'
+import {
+ createViewportGuestFactory,
+ flushViewportOps
+} from './browser-manager-viewport-test-fixtures'
+
+const { webContentsFromIdMock } = browserMocks
+const makeGuest = createViewportGuestFactory(browserMocks)
+
+const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
+ emitViewport: false,
+ enabled: true,
+ markers: [],
+ token: 'annotationviewporttoken'
+}
+
+function registerPage(pageId: string, guest: Record): void {
+ webContentsFromIdMock.mockReturnValue(guest)
+ browserManager.attachGuestPolicies(guest as never)
+ browserManager.registerGuest({
+ browserPageId: pageId,
+ webContentsId: guest.id as number,
+ rendererWebContentsId
+ })
+}
+
+/** The production resolver's shape: read the registry now, not when the request was made. */
+function resolveFromRegistry(pageId: string): () => Electron.WebContents | null {
+ return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId)
+}
+
+describe('browserManager.setAnnotationViewportBridge', () => {
+ beforeEach(() => {
+ resetBrowserManagerMocks(browserMocks)
+ resetBrowserManagerState()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('installs the bridge in an isolated world', async () => {
+ const { guest } = makeGuest(4646)
+ registerPage('tab-annotations', guest)
+
+ const ok = await browserManager.setAnnotationViewportBridge(
+ 'tab-annotations',
+ BRIDGE_OPTIONS,
+ resolveFromRegistry('tab-annotations')
+ )
+
+ expect(ok).toBe(true)
+ expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
+ expect.any(Number),
+ [
+ expect.objectContaining({
+ code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
+ })
+ ],
+ false
+ )
+ })
+
+ // Why this is the case that matters: a cross-process navigation re-registers the same page id
+ // with a new WebContents and leaves the retired one alive, so a bridge op that resolved its
+ // guest when the request arrived would inject into a page nobody is looking at — and the
+ // annotation badges would stop tracking the document on screen.
+ it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => {
+ const { guest: firstGuest } = makeGuest(5101)
+ let releaseFirstInjection = (): void => {}
+ // One shared gate for every call, so a wrongly-routed second injection still settles and the
+ // test fails on where it landed rather than on a timeout.
+ const firstInjectionGate = new Promise((resolve) => {
+ releaseFirstInjection = () => resolve()
+ })
+ firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate)
+ registerPage('tab-swap', firstGuest)
+
+ const firstDone = browserManager.setAnnotationViewportBridge(
+ 'tab-swap',
+ BRIDGE_OPTIONS,
+ resolveFromRegistry('tab-swap')
+ )
+ await flushViewportOps()
+
+ // A second request arrives while the first still holds the chain — at this moment the page is
+ // still on the first guest, which is the guest a request-time resolution would capture.
+ const secondDone = browserManager.setAnnotationViewportBridge(
+ 'tab-swap',
+ BRIDGE_OPTIONS,
+ resolveFromRegistry('tab-swap')
+ )
+ await flushViewportOps()
+
+ // Only now does the page swap renderer processes, while the second op is still queued.
+ const { guest: secondGuest } = makeGuest(5102)
+ registerPage('tab-swap', secondGuest)
+
+ releaseFirstInjection()
+ await expect(firstDone).resolves.toBe(true)
+ await expect(secondDone).resolves.toBe(true)
+
+ expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
+ expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
+ })
+
+ // Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
+ // is the one that can see the guest is gone. The bridge only reports the refusal.
+ it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
+ const { guest } = makeGuest(5103)
+ registerPage('tab-dies', guest)
+ expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103)
+
+ webContentsFromIdMock.mockReturnValue(null)
+ await expect(
+ browserManager.setAnnotationViewportBridge(
+ 'tab-dies',
+ BRIDGE_OPTIONS,
+ resolveFromRegistry('tab-dies')
+ )
+ ).resolves.toBe(false)
+
+ // Why assert the registry and not just the answer: a stale guest has to clear every per-tab
+ // entry, or the page keeps a dead WebContents id that later ops resolve against.
+ expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull()
+ })
+
+ // Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by
+ // the wrong renderer names a page that is alive and on screen, and answering it with teardown
+ // would cancel that page's in-flight downloads and grabs over a misaddressed message.
+ it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => {
+ const { guest } = makeGuest(5105)
+ registerPage('tab-mismatch', guest)
+ const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
+
+ await expect(
+ browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () =>
+ browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1)
+ )
+ ).resolves.toBe(false)
+
+ expect(unregisterGuest).not.toHaveBeenCalled()
+ expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105)
+ expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
+ })
+
+ it('refuses a destroyed guest without injecting into it', async () => {
+ const { guest } = makeGuest(5104)
+ registerPage('tab-destroyed', guest)
+ ;(guest.isDestroyed as ReturnType).mockReturnValue(true)
+
+ await expect(
+ browserManager.setAnnotationViewportBridge(
+ 'tab-destroyed',
+ BRIDGE_OPTIONS,
+ () => guest as never
+ )
+ ).resolves.toBe(false)
+ expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
+ })
+
+ // Why: a document page owns no browsing state, so treating its refusal as a stale page would run
+ // teardown against an id the browsing registry never held.
+ it('does not run page teardown when a document page resolves to nothing', async () => {
+ const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
+
+ await expect(
+ browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null)
+ ).resolves.toBe(false)
+
+ expect(unregisterGuest).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts
index d1a6a16c085..6d71db94585 100644
--- a/src/main/browser/browser-manager-guest-lifecycle.test.ts
+++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts
@@ -45,6 +45,32 @@ import {
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
+import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy'
+import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
+import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
+
+/**
+ * A page the document half of the registry really holds. Built rather than named: membership is
+ * what both doors refuse on now, so an id that merely looks like a preview's would be admitted.
+ */
+function registerWorkspaceDocPage(browserPageId: string): void {
+ const grant = mintDocPreviewGrant({
+ owner: { kind: 'ssh', connectionId: 'ssh-1' },
+ root: '/home/alice/docs',
+ entryRelativePath: 'index.html',
+ browserPageId
+ })
+ const guest = {
+ isFocused: () => false,
+ isDestroyed: () => false,
+ getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
+ on: vi.fn(),
+ once: vi.fn(),
+ setWindowOpenHandler: vi.fn(),
+ setWebRTCIPHandlingPolicy: vi.fn()
+ }
+ installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() })
+}
const {
guestOffMock,
@@ -60,6 +86,7 @@ describe('browserManager', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
+ revokeAllDocPreviewGrants()
})
afterEach(() => {
@@ -92,6 +119,77 @@ describe('browserManager', () => {
expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work')
})
+ // Why both doors: one id in both halves of the registry would make the tool door answer with a
+ // document guest for a page the reader is browsing in.
+ it.each(['registerGuest', 'registerOffscreenGuest'] as const)(
+ 'refuses %s for a page the document registry already holds',
+ (entryPoint) => {
+ const guest = {
+ id: 129,
+ isDestroyed: vi.fn(() => false),
+ getType: vi.fn(() => 'webview'),
+ setBackgroundThrottling: guestSetBackgroundThrottlingMock,
+ setWindowOpenHandler: guestSetWindowOpenHandlerMock,
+ on: guestOnMock,
+ off: guestOffMock,
+ openDevTools: guestOpenDevToolsMock
+ }
+ webContentsFromIdMock.mockReturnValue(guest)
+ browserManager.attachGuestPolicies(guest as never)
+ const browserPageId = 'doc-page-1'
+ registerWorkspaceDocPage(browserPageId)
+
+ if (entryPoint === 'registerGuest') {
+ expect(
+ browserManager.registerGuest({
+ browserPageId,
+ webContentsId: guest.id,
+ rendererWebContentsId
+ })
+ ).toBe(false)
+ } else {
+ expect(
+ browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id })
+ ).toBe(false)
+ }
+
+ expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
+ }
+ )
+
+ // Why this answer is load-bearing: the headless backend destroys its window on false, so a true
+ // for a guest that is already gone would leave a page id registered onto nothing.
+ it.each(['missing', 'destroyed'] as const)(
+ 'refuses registerOffscreenGuest when the named guest is %s',
+ (guestState) => {
+ webContentsFromIdMock.mockReturnValue(
+ guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) }
+ )
+
+ expect(
+ browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 })
+ ).toBe(false)
+
+ expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull()
+ }
+ )
+
+ // Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
+ // id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
+ it('refuses unregisterGuest for a page the document registry holds', () => {
+ registerWorkspaceDocPage('doc-page-2')
+ const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp')
+
+ browserManager.unregisterGuest('doc-page-2')
+
+ expect(cancelGrabOp).not.toHaveBeenCalled()
+
+ // The presence half: the same door does evict a browsing page's grab.
+ browserManager.unregisterGuest('browser-page-1')
+ expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted')
+ cancelGrabOp.mockRestore()
+ })
+
it('blocks non-web guest navigations after attach', () => {
const guest = {
isDestroyed: vi.fn(() => false),
diff --git a/src/main/browser/browser-manager-guest-policy-profile.test.ts b/src/main/browser/browser-manager-guest-policy-profile.test.ts
new file mode 100644
index 00000000000..47871f64ea9
--- /dev/null
+++ b/src/main/browser/browser-manager-guest-policy-profile.test.ts
@@ -0,0 +1,250 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const browserMocks = vi.hoisted(() => ({
+ appGetPathMock: vi.fn(() => '/downloads'),
+ shellOpenExternalMock: vi.fn(),
+ browserWindowFromWebContentsMock: vi.fn(),
+ menuBuildFromTemplateMock: vi.fn(),
+ guestOffMock: vi.fn(),
+ guestOnMock: vi.fn(),
+ guestSetBackgroundThrottlingMock: vi.fn(),
+ guestSetWindowOpenHandlerMock: vi.fn(),
+ guestOpenDevToolsMock: vi.fn(),
+ webContentsFromIdMock: vi.fn(),
+ screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
+ openPopupWithOriginBarMock: vi.fn()
+}))
+
+vi.mock('electron', () => ({
+ app: { getPath: browserMocks.appGetPathMock },
+ BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
+ clipboard: { writeText: vi.fn() },
+ shell: { openExternal: browserMocks.shellOpenExternalMock },
+ Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
+ screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
+ webContents: { fromId: browserMocks.webContentsFromIdMock }
+}))
+vi.mock('./popup-origin-bar-window', () => ({
+ openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
+}))
+
+import { browserManager } from './browser-manager'
+import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness'
+import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy'
+import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
+import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
+
+type GuestFake = {
+ id: number
+ url: string
+ listeners: Map void)[]>
+ windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
+ webRtcPolicy: string | null
+ isDestroyed: () => boolean
+ isFocused: () => boolean
+ getURL: () => string
+ getType: () => string
+ setBackgroundThrottling: (value: boolean) => void
+ setWebRTCIPHandlingPolicy: (policy: string) => void
+ setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void
+ executeJavaScriptInIsolatedWorld: ReturnType
+ debugger: {
+ isAttached: () => boolean
+ attach: ReturnType
+ sendCommand: ReturnType
+ }
+ on: (event: string, listener: (...args: never[]) => void) => void
+ once: (event: string, listener: (...args: never[]) => void) => void
+ off: (event: string, listener: (...args: never[]) => void) => void
+}
+
+function createGuest(id: number, url: string): GuestFake {
+ const listeners = new Map void)[]>()
+ const guest: GuestFake = {
+ id,
+ url,
+ listeners,
+ windowOpenHandler: null,
+ webRtcPolicy: null,
+ isDestroyed: () => false,
+ isFocused: () => true,
+ getURL: () => guest.url,
+ getType: () => 'webview',
+ setBackgroundThrottling: vi.fn(),
+ setWebRTCIPHandlingPolicy: (policy: string) => {
+ guest.webRtcPolicy = policy
+ },
+ setWindowOpenHandler: (handler) => {
+ guest.windowOpenHandler = handler
+ },
+ executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined),
+ debugger: {
+ isAttached: () => true,
+ attach: vi.fn(),
+ sendCommand: vi.fn(async () => undefined)
+ },
+ on: (event, listener) => {
+ listeners.set(event, [...(listeners.get(event) ?? []), listener])
+ },
+ once: (event, listener) => {
+ listeners.set(event, [...(listeners.get(event) ?? []), listener])
+ },
+ off: (event, listener) => {
+ listeners.set(
+ event,
+ (listeners.get(event) ?? []).filter((entry) => entry !== listener)
+ )
+ }
+ }
+ return guest
+}
+
+function listenerCount(guest: GuestFake, event: string): number {
+ return guest.listeners.get(event)?.length ?? 0
+}
+
+/** Drives the guest's own will-navigate listeners and reports whether they refused. */
+function navigateTo(guest: GuestFake, url: string): boolean {
+ let prevented = false
+ const event = { preventDefault: () => (prevented = true) } as never
+ for (const listener of guest.listeners.get('will-navigate') ?? []) {
+ ;(listener as (event: unknown, url: string) => void)(event, url)
+ }
+ return prevented
+}
+
+const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents
+
+beforeEach(() => {
+ resetBrowserManagerMocks(browserMocks)
+ resetBrowserManagerState()
+ revokeAllDocPreviewGrants()
+ browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined)
+})
+
+describe('guest policy profiles', () => {
+ function attachPreviewGuest(id = 301): {
+ guest: GuestFake
+ grantId: string
+ browserPageId: string
+ } {
+ const browserPageId = `doc-page-${id}`
+ const grant = mintDocPreviewGrant({
+ owner: { kind: 'ssh', connectionId: 'ssh-1' },
+ root: '/home/alice/docs',
+ entryRelativePath: 'index.html',
+ browserPageId
+ })
+ const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html'))
+ browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host })
+ return { guest, grantId: grant.id, browserPageId }
+ }
+
+ // The presence half of every absence below: a browsing guest observably takes all of it through
+ // the same method, so a profile that fenced nothing — or an attach path that stopped installing
+ // anything at all — cannot pass these by being uniformly empty.
+ it('gives a browsing guest link routing, popups and anti-detection', () => {
+ const guest = createGuest(300, 'https://example.com/')
+
+ browserManager.attachGuestPolicies(guest as never)
+
+ expect(listenerCount(guest, 'dom-ready')).toBe(1)
+ expect(listenerCount(guest, 'frame-created')).toBe(1)
+ expect(listenerCount(guest, 'did-create-window')).toBe(1)
+ expect(guest.debugger.sendCommand).toHaveBeenCalled()
+ expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
+ })
+
+ it('gives a workspace-document guest none of it', () => {
+ const { guest } = attachPreviewGuest()
+
+ expect(listenerCount(guest, 'dom-ready')).toBe(0)
+ expect(listenerCount(guest, 'frame-created')).toBe(0)
+ expect(listenerCount(guest, 'did-create-window')).toBe(0)
+ expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
+ expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
+ })
+
+ it('holds a workspace-document guest inside the grant it is showing', () => {
+ const { guest, grantId } = attachPreviewGuest()
+
+ expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false)
+ expect(navigateTo(guest, 'https://example.com/')).toBe(true)
+ expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp')
+ })
+
+ it('denies every window a workspace-document guest asks for', () => {
+ const { guest } = attachPreviewGuest()
+
+ expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({
+ action: 'deny'
+ })
+ })
+
+ // Why teardown and not just install: registration and teardown both key on the guest having been
+ // policy-attached, so a profile that installs outside that bookkeeping leaves the id marked
+ // attached forever and stays answerable to tools after the surface is gone.
+ it('tears a workspace-document guest down through the same policy cleanup', () => {
+ const { guest, browserPageId } = attachPreviewGuest(302)
+ expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never)
+
+ for (const listener of guest.listeners.get('destroyed') ?? []) {
+ listener()
+ }
+
+ expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull()
+ // The manager's own bookkeeping was reached too: a second attach is refused while the id is
+ // still marked policy-attached, and accepted once its teardown has run.
+ browserManager.attachGuestPolicies(guest as never)
+ expect(listenerCount(guest, 'dom-ready')).toBe(1)
+ })
+
+ // The seam the whole split rests on: one public door answers for both halves, and each page id
+ // resolves in exactly one of them. Asserted against the real manager, because the IPC census
+ // test has to mock it.
+ describe('the one door across both halves of the registry', () => {
+ const BROWSING_PAGE_ID = 'browser-page-1'
+
+ function registerBrowsingGuest(id = 400): GuestFake {
+ const guest = createGuest(id, 'https://example.com/')
+ browserMocks.webContentsFromIdMock.mockReturnValue(guest)
+ browserManager.attachGuestPolicies(guest as never)
+ browserManager.registerGuest({
+ browserPageId: BROWSING_PAGE_ID,
+ workspaceId: 'workspace-1',
+ worktreeId: 'wt-1',
+ webContentsId: id,
+ rendererWebContentsId: host.id
+ })
+ return guest
+ }
+
+ it('answers each page with the guest of its own half', () => {
+ const browsing = registerBrowsingGuest()
+ const { guest: document, browserPageId } = attachPreviewGuest()
+
+ expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never)
+ expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never)
+ })
+
+ // Why this is the containment claim and not a lookup detail: page management, agent commands,
+ // download routing and certificate attribution all read the browsing map directly, so a
+ // document page being absent from it is what fences them without a guard of their own.
+ it('keeps a document page out of the browsing map entirely', () => {
+ registerBrowsingGuest()
+ const { browserPageId } = attachPreviewGuest()
+
+ expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
+ expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID])
+ })
+ })
+
+ it('attaches a guest once whatever profile it was asked for', () => {
+ const { guest } = attachPreviewGuest(303)
+
+ browserManager.attachGuestPolicies(guest as never)
+
+ expect(listenerCount(guest, 'dom-ready')).toBe(0)
+ expect(listenerCount(guest, 'will-navigate')).toBe(1)
+ })
+})
diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts
index c72936cf6a3..b7d3bbabe0a 100644
--- a/src/main/browser/browser-manager-viewport-override.test.ts
+++ b/src/main/browser/browser-manager-viewport-override.test.ts
@@ -882,34 +882,5 @@ describe('browserManager', () => {
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
expect(debuggerSendCommand).not.toHaveBeenCalled()
})
-
- it('installs annotation viewport bridge in an isolated world', async () => {
- const { guest } = makeGuest(4646)
- webContentsFromIdMock.mockReturnValue(guest)
- browserManager.attachGuestPolicies(guest as never)
- browserManager.registerGuest({
- browserPageId: 'tab-annotations',
- webContentsId: guest.id as number,
- rendererWebContentsId
- })
-
- const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', {
- emitViewport: false,
- enabled: true,
- markers: [],
- token: 'annotationviewporttoken'
- })
-
- expect(ok).toBe(true)
- expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
- expect.any(Number),
- [
- expect.objectContaining({
- code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
- })
- ],
- false
- )
- })
})
})
diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts
index 7ac64b485b9..2b43e17a371 100644
--- a/src/main/browser/browser-manager.ts
+++ b/src/main/browser/browser-manager.ts
@@ -63,6 +63,11 @@ import {
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
buildBrowserAnnotationViewportBridgeScript
} from '../../shared/browser-annotation-viewport-bridge'
+import {
+ getWorkspaceDocPageGuest,
+ installDocPreviewGuestPolicy,
+ isWorkspaceDocPageId
+} from './doc-preview-guest-policy'
import type { KeybindingOverrides } from '../../shared/keybindings'
import {
BrowserCertificateTrustController,
@@ -157,6 +162,15 @@ type PopupOwnerContext = {
browserTabId: string
rootGuestWebContentsId: number
}
+/**
+ * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
+ * anti-detection all apply. A workspace-document guest renders one granted document and gets none
+ * of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
+ */
+export type BrowserGuestPolicy =
+ | { profile: 'browsing' }
+ | { profile: 'workspace-doc'; host: Electron.WebContents }
+const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' }
type PendingMainFrameNavigation = {
currentUrl: string
supersededUrls: string[]
@@ -671,12 +685,20 @@ export class BrowserManager {
attachGuestPolicies(
guest: Electron.WebContents,
- inheritedOwnerContext: PopupOwnerContext | null = null
+ inheritedOwnerContext: PopupOwnerContext | null = null,
+ policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
): void {
if (this.policyAttachedGuestIds.has(guest.id)) {
return
}
this.policyAttachedGuestIds.add(guest.id)
+ // Why one door with a profile rather than a second installer beside it: whether a guest was
+ // policy-attached at all is what registration and teardown both key on, so a guest that took
+ // another path into the app is invisible to both.
+ if (policy.profile === 'workspace-doc') {
+ this.attachWorkspaceDocGuestPolicies(guest, policy.host)
+ return
+ }
if (inheritedOwnerContext) {
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
}
@@ -1016,6 +1038,30 @@ export class BrowserManager {
})
}
+ /**
+ * A workspace document is not the web: no popups, no link routing, no anti-detection, and no
+ * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
+ * this method's teardown, so a retired preview drops its listeners on the same path.
+ */
+ private attachWorkspaceDocGuestPolicies(
+ guest: Electron.WebContents,
+ host: Electron.WebContents
+ ): void {
+ const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host)
+ const handleDestroyed = (): void => {
+ this.cleanupGuestPolicyAttachment(guest.id)
+ }
+ guest.on('destroyed', handleDestroyed)
+ this.policyCleanupByGuestId.set(guest.id, () => {
+ disposeDocPolicy()
+ try {
+ guest.off('destroyed', handleDestroyed)
+ } catch {
+ // guest may already be destroyed
+ }
+ })
+ }
+
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
// must be matched here per navigation or the two layers disagree — itself a bot tell.
@@ -1318,7 +1364,9 @@ export class BrowserManager {
rendererWebContentsId
}: BrowserGuestRegistration): boolean {
const browserTabId = browserPageId ?? legacyBrowserTabId
- if (!browserTabId) {
+ // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
+ // id resolves in both and the tool door silently prefers the document guest over the page.
+ if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
return false
}
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
@@ -1377,6 +1425,12 @@ export class BrowserManager {
}
unregisterGuest(browserTabId: string): void {
+ // Why the check on the exit door too: a document page withdraws by revoking its grant, never
+ // through here, so its id arriving is misaddressed — and the cancel below would evict that
+ // preview's live grab on the strength of it.
+ if (isWorkspaceDocPageId(browserTabId)) {
+ return
+ }
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
this.cancelGrabOp(browserTabId, 'evicted')
@@ -1444,10 +1498,15 @@ export class BrowserManager {
sessionProfileId?: string | null
userAgentMode?: BrowserSessionUserAgentMode
webContentsId: number
- }): void {
+ }): boolean {
+ // Why the same check on both registration doors: one id resolving in both halves is the exact
+ // confusion the split registries exist to prevent.
+ if (isWorkspaceDocPageId(browserPageId)) {
+ return false
+ }
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
- return
+ return false
}
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
this.offscreenGuestIds.add(webContentsId)
@@ -1468,6 +1527,7 @@ export class BrowserManager {
this.worktreeIdByTabId.set(browserPageId, worktreeId)
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
+ return true
}
unregisterAll(): void {
@@ -1843,12 +1903,13 @@ export class BrowserManager {
async setAnnotationViewportBridge(
browserTabId: string,
- options: BrowserAnnotationViewportBridgeOptions
+ options: BrowserAnnotationViewportBridgeOptions,
+ resolveGuest: () => Electron.WebContents | null
): Promise {
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
- .then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
+ .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
@@ -1859,18 +1920,22 @@ export class BrowserManager {
}
}
+ // Why the caller resolves the guest: the same bridge serves browsing pages and workspace
+ // documents, which live in different halves of the page registry.
+ // Why a resolver and not the guest itself: this op may have waited behind another one, and a
+ // cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
+ // injecting into the guest the request named would bridge a page nobody is looking at.
+ // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
+ // taking an id it cannot act on would invite the next reader to act on it.
private async doSetAnnotationViewportBridgeImpl(
- browserTabId: string,
- options: BrowserAnnotationViewportBridgeOptions
+ options: BrowserAnnotationViewportBridgeOptions,
+ resolveGuest: () => Electron.WebContents | null
): Promise {
- const webContentsId = this.webContentsIdByTabId.get(browserTabId)
- if (!webContentsId) {
- return false
- }
- const guest = webContents.fromId(webContentsId)
+ // Why no teardown here: the resolver already unregisters a page whose guest died, and the only
+ // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
+ // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
+ const guest = resolveGuest()
if (!guest || guest.isDestroyed()) {
- // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
- this.unregisterGuest(browserTabId)
return false
}
@@ -1977,10 +2042,21 @@ export class BrowserManager {
// --- Browser Context Grab — main-owned operations ---
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
+ /**
+ * The guest a request from `senderWebContentsId` may act on, across both halves of the page
+ * registry. This is the only door taught about workspace-document guests: they are kept out of
+ * the browsing maps entirely, so page management, agent commands, download routing and
+ * certificate attribution all miss them without a guard of their own — and a reader who opens a
+ * tool on the document in front of them still gets an answer.
+ */
getAuthorizedGuest(
browserTabId: string,
senderWebContentsId: number
): Electron.WebContents | null {
+ const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId)
+ if (docGuest) {
+ return docGuest
+ }
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
return null
diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts
new file mode 100644
index 00000000000..a3667154c9b
--- /dev/null
+++ b/src/main/browser/browser-session-partition-policies.test.ts
@@ -0,0 +1,185 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { BrowserSessionProfile } from '../../shared/browser-workspace-types'
+
+const mocks = vi.hoisted(() => ({
+ handleGuestWillDownload: vi.fn(),
+ noticeDocPreviewDownloadBlocked: vi.fn()
+}))
+
+type WillDownloadListener = (
+ event: { preventDefault: () => void },
+ item: { id: string },
+ webContents: { id: number }
+) => void
+
+type FakeSession = {
+ listeners: WillDownloadListener[]
+ on: ReturnType
+ removeListener: ReturnType
+ getUserAgent: () => string
+ setUserAgent: ReturnType
+ setPermissionRequestHandler: ReturnType
+ setPermissionCheckHandler: ReturnType
+ setDisplayMediaRequestHandler: ReturnType
+}
+
+const sessionsByPartition = new Map()
+
+function fakeSession(): FakeSession {
+ const listeners: WillDownloadListener[] = []
+ return {
+ listeners,
+ on: vi.fn((event: string, listener: WillDownloadListener) => {
+ if (event === 'will-download') {
+ listeners.push(listener)
+ }
+ }),
+ removeListener: vi.fn((event: string, listener: WillDownloadListener) => {
+ if (event !== 'will-download') {
+ return
+ }
+ const index = listeners.indexOf(listener)
+ if (index !== -1) {
+ listeners.splice(index, 1)
+ }
+ }),
+ getUserAgent: () => 'Mozilla/5.0 Orca',
+ setUserAgent: vi.fn(),
+ setPermissionRequestHandler: vi.fn(),
+ setPermissionCheckHandler: vi.fn(),
+ setDisplayMediaRequestHandler: vi.fn()
+ }
+}
+
+vi.mock('electron', () => ({
+ session: {
+ fromPartition: (partition: string) => {
+ const existing = sessionsByPartition.get(partition)
+ if (existing) {
+ return existing
+ }
+ const created = fakeSession()
+ sessionsByPartition.set(partition, created)
+ return created
+ }
+ }
+}))
+vi.mock('./browser-manager', () => ({
+ browserManager: {
+ handleGuestWillDownload: mocks.handleGuestWillDownload,
+ installCertificateRequestGuard: vi.fn(),
+ removeCertificateRequestGuard: vi.fn(),
+ notifyPermissionDenied: vi.fn()
+ }
+}))
+vi.mock('./doc-preview-download-block-notice', () => ({
+ noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked
+}))
+vi.mock('./browser-media-access', () => ({
+ hasSystemMediaAccess: () => false,
+ requestSystemMediaAccess: async () => false
+}))
+vi.mock('./browser-session-ua', () => ({
+ cleanElectronUserAgent: (userAgent: string) => userAgent,
+ setupClientHintsOverride: vi.fn()
+}))
+vi.mock('./browser-session-user-agent-mode', () => ({
+ setBrowserSessionUserAgentMode: vi.fn()
+}))
+vi.mock('./browser-webauthn-access', () => ({
+ allowsBrowserWebAuthnPermission: () => false,
+ clearBrowserWebAuthnAccessHandlers: vi.fn(),
+ installBrowserWebAuthnAccessHandlers: vi.fn()
+}))
+
+type PartitionPolicyInstaller = (
+ profile: BrowserSessionProfile,
+ options?: { downloads?: 'route' | 'deny' }
+) => void
+
+// Why imported per test rather than at the top: the installer remembers which partitions it has
+// already configured in module state, so a shared import would make the second test's install a
+// no-op and leave it reading the first test's listener.
+async function loadInstaller(): Promise {
+ const module = await import('./browser-session-partition-policies')
+ return module.installBrowserSessionPartitionPolicies
+}
+
+function profileFor(partition: string): BrowserSessionProfile {
+ return {
+ id: partition,
+ scope: 'isolated',
+ partition,
+ label: partition,
+ source: null,
+ userAgentMode: 'clean'
+ }
+}
+
+/** Fires the partition's real `will-download` listener and reports what it decided. */
+function fireWillDownload(partition: string): { cancelled: boolean } {
+ const sess = sessionsByPartition.get(partition)
+ if (!sess || sess.listeners.length !== 1) {
+ throw new Error(`expected exactly one will-download listener on ${partition}`)
+ }
+ let cancelled = false
+ sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 })
+ return { cancelled }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ sessionsByPartition.clear()
+ vi.resetModules()
+})
+
+describe('partition download policy', () => {
+ // The presence half: without it, a deny assertion passes for a partition that installed no
+ // listener at all, and would keep passing if the whole download path were removed.
+ it('routes a download on a partition that did not ask for the deny', async () => {
+ const install = await loadInstaller()
+ install(profileFor('persist:browsing-1'))
+
+ expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
+ expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith(
+ expect.objectContaining({ guestWebContentsId: 42 })
+ )
+ })
+
+ it('cancels a download on a partition that asked for the deny, routing nothing', async () => {
+ const install = await loadInstaller()
+ install(profileFor('orca-doc-preview'), { downloads: 'deny' })
+
+ expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
+ expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
+ })
+
+ // Why in the same run as the routing test above: a refusal the reader cannot see is a pressed
+ // button that does nothing, and a notice on the routing partition would announce a download that
+ // is about to arrive normally.
+ it('tells the reader about the refusal, and only on the partition that refused', async () => {
+ const install = await loadInstaller()
+ install(profileFor('orca-doc-preview'), { downloads: 'deny' })
+ install(profileFor('persist:browsing-1'))
+
+ fireWillDownload('persist:browsing-1')
+ expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled()
+
+ fireWillDownload('orca-doc-preview')
+ expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 42 })
+ )
+ })
+
+ // Why both partitions in one run: the listener is module state shared across sessions, so a deny
+ // installed for one partition must not follow the next partition that installs after it.
+ it('keeps each partition on its own decision', async () => {
+ const install = await loadInstaller()
+ install(profileFor('orca-doc-preview'), { downloads: 'deny' })
+ install(profileFor('persist:browsing-1'))
+
+ expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
+ expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
+ expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts
index 2118c62ed68..cf542665ae4 100644
--- a/src/main/browser/browser-session-partition-policies.ts
+++ b/src/main/browser/browser-session-partition-policies.ts
@@ -11,6 +11,7 @@ import {
clearBrowserWebAuthnAccessHandlers,
installBrowserWebAuthnAccessHandlers
} from './browser-webauthn-access'
+import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice'
// Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart.
const configuredPartitions = new Set()
@@ -22,6 +23,23 @@ const handleWillDownload = (
browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item })
}
+/**
+ * Why a second listener instead of a branch inside the shared one: `will-download` is a session
+ * event that names no partition, so the only place the decision can be keyed by partition is which
+ * listener that partition's session got. A workspace-document guest has no page of its own to
+ * attribute a download to, so routing one lands it in this desktop's Downloads folder under a
+ * remote-authored name that nothing in the UI accounts for.
+ */
+const handleDeniedWillDownload = (
+ event: Electron.Event,
+ _item: Electron.DownloadItem,
+ webContents: Electron.WebContents
+): void => {
+ event.preventDefault()
+ // The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing.
+ noticeDocPreviewDownloadBlocked(webContents)
+}
+
function resolvePermissionNoticeUrl(
webContents: Electron.WebContents,
details: Electron.PermissionRequest | undefined
@@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl(
}
}
-export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void {
+/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
+export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
+
+export function installBrowserSessionPartitionPolicies(
+ profile: BrowserSessionProfile,
+ options?: { downloads?: BrowserPartitionDownloadPolicy }
+): void {
const { partition } = profile
const sess = session.fromPartition(partition)
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
@@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr
callback({ video: undefined, audio: undefined })
})
sess.removeListener('will-download', handleWillDownload)
- sess.on('will-download', handleWillDownload)
+ sess.removeListener('will-download', handleDeniedWillDownload)
+ sess.on(
+ 'will-download',
+ options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload
+ )
configuredPartitions.add(partition)
}
@@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se
configuredPartitions.delete(partition)
browserManager.removeCertificateRequestGuard(sess)
sess.removeListener('will-download', handleWillDownload)
+ sess.removeListener('will-download', handleDeniedWillDownload)
clearBrowserWebAuthnAccessHandlers(sess)
sess.setPermissionRequestHandler(null)
sess.setPermissionCheckHandler(null)
diff --git a/src/main/browser/doc-preview-download-block-notice.test.ts b/src/main/browser/doc-preview-download-block-notice.test.ts
new file mode 100644
index 00000000000..9fc4396b329
--- /dev/null
+++ b/src/main/browser/doc-preview-download-block-notice.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ publishDocPreviewFailure: vi.fn(),
+ boundGrantIdByGuest: new Map