Merge remote-tracking branch 'origin/main' into brennanb2025/mobile-i18n-audit

This commit is contained in:
Brennan Benson
2026-07-31 16:08:18 -07:00
27 changed files with 2316 additions and 298 deletions
+7
View File
@@ -2187,6 +2187,13 @@
"runFailed": "Couldn't run {{commandLabel}}"
}
},
"sourceControl": {
"reviewStatus": {
"checking": "Checking review status…",
"unavailable": "Review status unavailable",
"unsupportedProvider": "Review creation unavailable for this provider"
}
},
"accounts": {
"selectedManagedAccount": "the selected managed account"
},
@@ -13,6 +13,7 @@ export function MobileSourceControlCreatePrEntry({ action }: Props) {
return null
}
const enabled = !action.disabled
const copy = action.hint ?? action.label
return (
<View style={styles.createPrBlock}>
<Pressable
@@ -36,15 +37,17 @@ export function MobileSourceControlCreatePrEntry({ action }: Props) {
strokeWidth={2.2}
/>
)}
<Text style={[styles.createPrButtonText, !enabled && styles.createPrButtonTextDisabled]}>
{action.label}
<Text
style={[
styles.createPrButtonText,
!enabled && styles.createPrButtonTextDisabled,
action.hint && styles.createPrButtonHint
]}
numberOfLines={2}
>
{copy}
</Text>
</Pressable>
{action.hint ? (
<Text style={styles.createPrHint} numberOfLines={2}>
{action.hint}
</Text>
) : null}
</View>
)
}
@@ -4,7 +4,10 @@ import type {
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../../src/shared/hosted-review'
import { buildMobileCreatePrAction } from './mobile-create-pr-action'
import {
buildMobileCreatePrAction,
type MobileCreatePrEligibilityState
} from './mobile-create-pr-action'
function eligibility(
overrides: Partial<HostedReviewCreationEligibility> = {}
@@ -108,26 +111,21 @@ describe('buildMobileCreatePrAction', () => {
'unsupported_provider',
'detached_head',
null
])('hides %s', (blockedReason) => {
])('keeps a disabled status row for %s', (blockedReason) => {
const { descriptor } = action({
eligibility: eligibility({ canCreate: false, blockedReason })
})
expect(descriptor.visible).toBe(false)
expect(descriptor).toMatchObject({ visible: true, disabled: true })
expect(descriptor.hint).toBeTruthy()
})
it('hides cold loading, errors, and missing branches', () => {
const onCreatePr = vi.fn()
expect(
buildMobileCreatePrAction({
branch: 'feature',
eligibilityState: { kind: 'loading', eligibility: null },
busyAction: null,
onCreatePr
}).visible
).toBe(false)
expect(action({ eligibility: null }).descriptor.visible).toBe(false)
it('keeps an unavailable row after errors but hides missing branches', () => {
expect(action({ eligibility: null }).descriptor).toMatchObject({
visible: true,
disabled: true,
label: 'Review status unavailable'
})
expect(action({ branch: null }).descriptor.visible).toBe(false)
})
@@ -146,12 +144,16 @@ describe('buildMobileCreatePrAction', () => {
expect(onCreatePr).not.toHaveBeenCalled()
})
it('hides any provider that does not support hosted-review creation', () => {
it('keeps a disabled status row for providers without review creation', () => {
const { descriptor } = action({
eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider })
})
expect(descriptor.visible).toBe(false)
expect(descriptor).toMatchObject({
visible: true,
disabled: true,
label: 'Review creation unavailable for this provider'
})
})
it('keeps a creatable button visible but disabled while a newer eligibility loads', () => {
@@ -167,3 +169,76 @@ describe('buildMobileCreatePrAction', () => {
expect(descriptor).toMatchObject({ visible: true, disabled: true, loading: false })
})
})
// Issue #8411: the Create PR entry renders directly above the Stage All row and
// the changed-files list, so appearing late shifts the list down by
// createPrBlock marginTop (12) + createPrButton height (42) = 54pt while the
// user is already reading it. The row must keep its footprint across the cold
// eligibility fetch.
describe('cold-mount layout stability (issue #8411)', () => {
// useMobileHostedReviewEligibility's real cold-mount sequence for a creatable
// branch: no prior snapshot exists, so `loading` carries eligibility: null.
const coldMount: MobileCreatePrEligibilityState[] = [
{ kind: 'loading', eligibility: null },
{ kind: 'ready', eligibility: eligibility({ canCreate: true }) }
]
it('reserves the button row while the first eligibility request is in flight', () => {
const descriptors = coldMount.map((eligibilityState) =>
buildMobileCreatePrAction({
branch: 'feature',
eligibilityState,
busyAction: null,
onCreatePr: vi.fn()
})
)
const footprint = descriptors.map(({ visible }) => visible)
// On the buggy parent this was [false, true] -- the false -> true step is the jump.
expect(footprint).toEqual([true, true])
})
it.each([
[
'existing review',
{
kind: 'ready',
eligibility: eligibility({ canCreate: false, blockedReason: 'existing_review' })
} satisfies MobileCreatePrEligibilityState
],
[
'unsupported provider',
{
kind: 'ready',
eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider })
} satisfies MobileCreatePrEligibilityState
],
['eligibility error', { kind: 'error' } satisfies MobileCreatePrEligibilityState]
])('keeps the cold-load footprint when %s resolves', (_label, resolvedState) => {
const footprint = [coldMount[0], resolvedState].map((eligibilityState) => {
const descriptor = buildMobileCreatePrAction({
branch: 'feature',
eligibilityState,
busyAction: null,
onCreatePr: vi.fn()
})
return descriptor.visible
})
expect(footprint).toEqual([true, true])
})
it('does not offer a tappable action against unresolved eligibility', () => {
const descriptor = buildMobileCreatePrAction({
branch: 'feature',
eligibilityState: { kind: 'loading', eligibility: null },
busyAction: null,
onCreatePr: vi.fn()
})
// Reserving space must not make the placeholder actionable: there is no
// canCreate/pushFirst answer yet, so a tap has nothing correct to do.
expect(descriptor.disabled).toBe(true)
})
})
@@ -1,7 +1,4 @@
import type {
HostedReviewCreationBlockedReason,
HostedReviewCreationEligibility
} from '../../../src/shared/hosted-review'
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
import { supportsHostedReviewCreation } from '../../../src/shared/hosted-review-creation-providers'
import { hostedReviewCopy } from './hosted-review-copy'
import { getMobilePrCreateBlockMessage } from './mobile-pr-create'
@@ -30,13 +27,6 @@ export type BuildMobileCreatePrActionArgs = {
onCreatePr: (pushFirst: boolean) => void
}
const HIDDEN_BLOCKED_REASONS = new Set<HostedReviewCreationBlockedReason>([
'detached_head',
'existing_review',
'unsupported_provider',
null
])
const BUSY_ACTIONS = new Set(['create-pr', 'push-create-pr'])
function hiddenAction(onPress: () => void): MobileCreatePrAction {
@@ -57,18 +47,42 @@ export function buildMobileCreatePrAction({
onCreatePr
}: BuildMobileCreatePrActionArgs): MobileCreatePrAction {
const noop = () => {}
if (!branch || eligibilityState.kind === 'idle' || eligibilityState.kind === 'error') {
if (!branch || eligibilityState.kind === 'idle') {
return hiddenAction(noop)
}
if (eligibilityState.kind === 'error') {
return {
visible: true,
label: t('sourceControl.reviewStatus.unavailable'),
disabled: true,
loading: false,
pushFirst: false,
onPress: noop
}
}
const eligibility = eligibilityState.eligibility
if (!eligibility) {
return hiddenAction(noop)
return {
visible: true,
label: t('sourceControl.reviewStatus.checking'),
disabled: true,
loading: true,
pushFirst: false,
onPress: noop
}
}
// Why: mirror desktop's structural provider gate (supportsHostedReviewCreation)
// instead of relying on the host always emitting a hidden blockedReason for
// non-creatable providers like bitbucket.
if (!supportsHostedReviewCreation(eligibility.provider)) {
return hiddenAction(noop)
return {
visible: true,
label: t('sourceControl.reviewStatus.unsupportedProvider'),
disabled: true,
loading: false,
pushFirst: false,
onPress: noop
}
}
const copy = hostedReviewCopy(eligibility.provider)
const label = t('m.aDtTv18', { value0: copy.titleLabel })
@@ -96,10 +110,6 @@ export function buildMobileCreatePrAction({
}
}
if (HIDDEN_BLOCKED_REASONS.has(eligibility.blockedReason)) {
return hiddenAction(noop)
}
const hint =
getMobilePrCreateBlockMessage({
provider: eligibility.provider,
@@ -25,7 +25,8 @@ import {
isMobileGitStageableEntry,
type MobileGitFileStatus,
type MobileGitStatusEntry,
type MobileGitStatusResult
type MobileGitStatusResult,
type MobileGitUpstreamStatus
} from './mobile-git-status'
import { t } from '@/i18n/mobile-i18n'
@@ -135,6 +136,17 @@ export function formatBranchLabel(branch: string | undefined, head: string | und
return branch || head?.slice(0, 7) || t('m.wL23gbE')
}
export function formatMobileUpstreamSyncLabel(
upstream: MobileGitUpstreamStatus | undefined
): string | null {
if (!upstream) {
return null
}
return upstream.hasUpstream
? t('m.1YRKLD4', { value0: upstream.ahead, value1: upstream.behind })
: t('m.GXYBPqk')
}
export function statusColor(status: MobileGitFileStatus): string {
switch (status) {
case 'added':
@@ -207,11 +207,10 @@ const baseStyles = StyleSheet.create({
fontWeight: '600'
},
createPrBlock: {
marginTop: spacing.md,
gap: spacing.xs
marginTop: spacing.md
},
createPrButton: {
minHeight: 42,
height: 42,
borderRadius: radii.button,
backgroundColor: colors.textPrimary,
alignItems: 'center',
@@ -236,10 +235,12 @@ const baseStyles = StyleSheet.create({
createPrButtonTextDisabled: {
color: colors.textSecondary
},
createPrHint: {
color: colors.textMuted,
createPrButtonHint: {
fontSize: typography.metaSize,
lineHeight: 16
fontWeight: '600',
lineHeight: 16,
textAlign: 'center',
flexShrink: 1
}
})
@@ -1,10 +1,48 @@
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
import {
acceptsMobileHostedReviewEligibilityLoad,
buildMobileHostedReviewEligibilityLoadKey,
eligibilityStateAfterMobileHostedReviewError,
shouldFetchMobileHostedReviewEligibility
renderedMobileHostedReviewEligibilityState,
shouldFetchMobileHostedReviewEligibility,
useMobileHostedReviewEligibility,
type MobileHostedReviewEligibilityLoadKey,
type MobileHostedReviewEligibilityLoadSnapshot
} from './use-mobile-hosted-review-eligibility'
import type { MobileCreatePrEligibilityState } from './mobile-create-pr-action'
function eligibility(
overrides: Partial<HostedReviewCreationEligibility> = {}
): HostedReviewCreationEligibility {
return {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
defaultBaseRef: 'main',
title: 'feature',
body: '',
...overrides
}
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
function loadSnapshot(
key: MobileHostedReviewEligibilityLoadKey,
state: MobileCreatePrEligibilityState
): MobileHostedReviewEligibilityLoadSnapshot {
return { key, state }
}
describe('mobile hosted review eligibility loader core', () => {
it('does not fetch while disconnected or detached', () => {
@@ -24,8 +62,9 @@ describe('mobile hosted review eligibility loader core', () => {
).toBe(false)
})
it('accepts only the latest generation for the current worktree branch identity', () => {
it('builds distinct keys for different branches', () => {
const first = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature-a',
hasUpstream: true,
@@ -34,6 +73,7 @@ describe('mobile hosted review eligibility loader core', () => {
hasUncommittedChanges: false
})
const second = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature-b',
hasUpstream: true,
@@ -42,17 +82,281 @@ describe('mobile hosted review eligibility loader core', () => {
hasUncommittedChanges: false
})
expect(first.identity).not.toBe(second.identity)
expect(first.fetch).not.toBe(second.fetch)
})
it('scopes load identity to the paired host', () => {
const input = {
worktreeId: 'repo-1::/workspace',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
}
const local = buildMobileHostedReviewEligibilityLoadKey({ ...input, hostId: 'local' })
const ssh = buildMobileHostedReviewEligibilityLoadKey({ ...input, hostId: 'ssh-builder' })
expect(local.identity).not.toBe(ssh.identity)
expect(local.fetch).not.toBe(ssh.fetch)
})
it('renders a superseded same-identity snapshot as loading', () => {
const input = {
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0
}
const older = buildMobileHostedReviewEligibilityLoadKey({
...input,
hasUncommittedChanges: false
})
const newest = buildMobileHostedReviewEligibilityLoadKey({
...input,
hasUncommittedChanges: true
})
expect(older.identity).toBe(newest.identity)
expect(
acceptsMobileHostedReviewEligibilityLoad({
generation: 1,
currentGeneration: 2,
identity: first.identity,
currentIdentity: second.identity
renderedMobileHostedReviewEligibilityState({
snapshot: loadSnapshot(older, { kind: 'ready', eligibility: eligibility() }),
key: newest,
shouldFetch: true
})
).toBe(false)
).toMatchObject({ kind: 'loading', eligibility: eligibility() })
})
it('fails closed after errors', () => {
expect(eligibilityStateAfterMobileHostedReviewError()).toEqual({ kind: 'error' })
})
})
// #8411: what the hook returns is what paints. A fetch-imminent frame must not
// render as `idle` (hidden row) or the Create PR row pops in a frame later.
describe('rendered eligibility state', () => {
it('renders fetch-imminent idle as an in-flight load', () => {
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
})
expect(
renderedMobileHostedReviewEligibilityState({
snapshot: loadSnapshot(key, { kind: 'idle' }),
key,
shouldFetch: true
})
).toEqual({ kind: 'loading', eligibility: null })
})
it('passes resolved and refetch states through untouched', () => {
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
})
const ready = { kind: 'ready', eligibility: eligibility() } as const
const refetch = { kind: 'loading', eligibility: eligibility() } as const
const error = { kind: 'error' } as const
for (const state of [ready, refetch, error]) {
expect(
renderedMobileHostedReviewEligibilityState({
snapshot: loadSnapshot(key, state),
key,
shouldFetch: true
})
).toBe(state)
}
})
it('renders idle when a fetch is not possible, hiding stale snapshots in the same render', () => {
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
})
expect(
renderedMobileHostedReviewEligibilityState({
snapshot: loadSnapshot(key, { kind: 'ready', eligibility: eligibility() }),
key,
shouldFetch: false
})
).toEqual({ kind: 'idle' })
})
})
describe('eligibility request ordering', () => {
let renderer: ReactTestRenderer | null = null
let renderedState: MobileCreatePrEligibilityState = { kind: 'idle' }
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('does not let an older response overwrite the newest state', async () => {
type Response = { ok: true; result: HostedReviewCreationEligibility }
const requests: ReturnType<typeof deferred<Response>>[] = []
const client = {
sendRequest: vi.fn(() => {
const request = deferred<Response>()
requests.push(request)
return request.promise
})
}
const newest = eligibility({ canCreate: false, blockedReason: 'existing_review' })
const older = eligibility()
function Harness({ dirty }: { dirty: boolean }): null {
renderedState = useMobileHostedReviewEligibility({
client: client as never,
connState: 'connected',
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: dirty
})
return null
}
await act(async () => {
renderer = create(createElement(Harness, { dirty: false }))
await Promise.resolve()
})
await act(async () => {
renderer?.update(createElement(Harness, { dirty: true }))
await Promise.resolve()
})
expect(requests).toHaveLength(2)
await act(async () => {
requests[1]!.resolve({ ok: true, result: newest })
await Promise.resolve()
})
await act(async () => {
requests[0]!.resolve({ ok: true, result: older })
await Promise.resolve()
})
expect(renderedState).toMatchObject({ kind: 'ready', eligibility: newest })
})
it('disables a ready snapshot in the render that changes its fetch key', async () => {
type Response = { ok: true; result: HostedReviewCreationEligibility }
const requests: ReturnType<typeof deferred<Response>>[] = []
const client = {
sendRequest: vi.fn(() => {
const request = deferred<Response>()
requests.push(request)
return request.promise
})
}
const ready = eligibility()
function Harness({ dirty }: { dirty: boolean }): null {
renderedState = useMobileHostedReviewEligibility({
client: client as never,
connState: 'connected',
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: dirty
})
return null
}
await act(async () => {
renderer = create(createElement(Harness, { dirty: false }))
await Promise.resolve()
})
await act(async () => {
requests[0]!.resolve({ ok: true, result: ready })
await Promise.resolve()
})
expect(renderedState).toMatchObject({ kind: 'ready', eligibility: ready })
await act(async () => {
renderer?.update(createElement(Harness, { dirty: true }))
await Promise.resolve()
})
expect(renderedState).toMatchObject({ kind: 'loading', eligibility: ready })
})
it('invalidates a request when its hook instance unmounts', async () => {
type Response = { ok: true; result: HostedReviewCreationEligibility }
const requests: ReturnType<typeof deferred<Response>>[] = []
const client = {
sendRequest: vi.fn(() => {
const request = deferred<Response>()
requests.push(request)
return request.promise
})
}
const newest = eligibility({ canCreate: false, blockedReason: 'existing_review' })
function Harness(): null {
renderedState = useMobileHostedReviewEligibility({
client: client as never,
connState: 'connected',
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
})
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
act(() => renderer?.unmount())
renderer = null
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
expect(requests).toHaveLength(2)
await act(async () => {
requests[1]!.resolve({ ok: true, result: newest })
await Promise.resolve()
})
await act(async () => {
requests[0]!.resolve({ ok: true, result: eligibility() })
await Promise.resolve()
})
expect(renderedState).toMatchObject({ kind: 'ready', eligibility: newest })
})
})
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -11,6 +11,7 @@ import type { MobileCreatePrEligibilityState } from './mobile-create-pr-action'
export type MobileHostedReviewEligibilityLoaderInput = {
client: RpcClient | null
connState: ConnectionState
hostId: string
worktreeId: string
branch: string | null | undefined
hasUpstream: boolean | undefined
@@ -24,13 +25,19 @@ export type MobileHostedReviewEligibilityLoadKey = {
fetch: string
}
export type MobileHostedReviewEligibilityLoadSnapshot = {
key: MobileHostedReviewEligibilityLoadKey
state: MobileCreatePrEligibilityState
}
export function buildMobileHostedReviewEligibilityLoadKey(
input: Omit<MobileHostedReviewEligibilityLoaderInput, 'client' | 'connState'>
): MobileHostedReviewEligibilityLoadKey {
const branch = input.branch ?? ''
return {
identity: `${input.worktreeId}\0${branch}`,
identity: `${input.hostId}\0${input.worktreeId}\0${branch}`,
fetch: [
input.hostId,
input.worktreeId,
branch,
String(input.hasUpstream ?? ''),
@@ -47,25 +54,42 @@ export function shouldFetchMobileHostedReviewEligibility(
return input.connState === 'connected' && input.client !== null && !!input.branch
}
export function acceptsMobileHostedReviewEligibilityLoad(args: {
generation: number
currentGeneration: number
identity: string
currentIdentity: string
}): boolean {
return args.generation === args.currentGeneration && args.identity === args.currentIdentity
}
export function eligibilityStateAfterMobileHostedReviewError(): MobileCreatePrEligibilityState {
return { kind: 'error' }
}
export function renderedMobileHostedReviewEligibilityState(args: {
snapshot: MobileHostedReviewEligibilityLoadSnapshot
key: MobileHostedReviewEligibilityLoadKey
shouldFetch: boolean
}): MobileCreatePrEligibilityState {
if (!args.shouldFetch) {
return { kind: 'idle' }
}
const { snapshot, key } = args
if (snapshot.key.identity !== key.identity) {
return { kind: 'loading', eligibility: null }
}
const { state } = snapshot
if (snapshot.key.fetch !== key.fetch) {
return {
kind: 'loading',
eligibility: state.kind === 'ready' || state.kind === 'loading' ? state.eligibility : null
}
}
if (state.kind === 'idle') {
return { kind: 'loading', eligibility: null }
}
return state
}
export function useMobileHostedReviewEligibility(
input: MobileHostedReviewEligibilityLoaderInput
): MobileCreatePrEligibilityState {
const {
client,
connState,
hostId,
worktreeId,
branch,
hasUpstream,
@@ -74,11 +98,8 @@ export function useMobileHostedReviewEligibility(
hasUncommittedChanges
} = input
const shouldFetch = shouldFetchMobileHostedReviewEligibility({ client, connState, branch })
const [state, setState] = useState<MobileCreatePrEligibilityState>({ kind: 'idle' })
const generationRef = useRef(0)
const currentIdentityRef = useRef('')
const lastResetIdentityRef = useRef('')
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId,
worktreeId,
branch,
hasUpstream,
@@ -86,38 +107,35 @@ export function useMobileHostedReviewEligibility(
behind,
hasUncommittedChanges
})
if (lastResetIdentityRef.current !== key.identity) {
lastResetIdentityRef.current = key.identity
setState({ kind: 'idle' })
}
currentIdentityRef.current = key.identity
const [snapshot, setSnapshot] = useState<MobileHostedReviewEligibilityLoadSnapshot>({
key: { identity: '', fetch: '' },
state: { kind: 'idle' }
})
useEffect(() => {
const generation = generationRef.current + 1
generationRef.current = generation
const isCurrent = () =>
acceptsMobileHostedReviewEligibilityLoad({
generation,
currentGeneration: generationRef.current,
identity: key.identity,
currentIdentity: currentIdentityRef.current
})
let active = true
if (!shouldFetch) {
if (isCurrent()) {
setState({ kind: 'idle' })
setSnapshot({ key, state: { kind: 'idle' } })
return () => {
active = false
}
return
}
if (!client || !branch) {
return
return () => {
active = false
}
}
setState((prev) => ({
kind: 'loading',
eligibility: prev.kind === 'ready' ? prev.eligibility : null
}))
setSnapshot((previous) => {
const previousState = previous.state
const eligibility =
previous.key.identity === key.identity &&
(previousState.kind === 'ready' || previousState.kind === 'loading')
? previousState.eligibility
: null
return { key, state: { kind: 'loading', eligibility } }
})
const requestInput: MobileHostedReviewEligibilityInput = {
branch,
hasUncommittedChanges,
@@ -127,20 +145,23 @@ export function useMobileHostedReviewEligibility(
}
void fetchMobileHostedReviewEligibility(client, worktreeId, requestInput)
.then((eligibility: HostedReviewCreationEligibility | null) => {
if (!isCurrent()) {
if (!active) {
return
}
if (!eligibility) {
setState({ kind: 'error' })
setSnapshot({ key, state: eligibilityStateAfterMobileHostedReviewError() })
return
}
setState({ kind: 'ready', eligibility })
setSnapshot({ key, state: { kind: 'ready', eligibility } })
})
.catch(() => {
if (isCurrent()) {
setState(eligibilityStateAfterMobileHostedReviewError())
if (active) {
setSnapshot({ key, state: eligibilityStateAfterMobileHostedReviewError() })
}
})
return () => {
active = false
}
}, [
ahead,
behind,
@@ -149,6 +170,7 @@ export function useMobileHostedReviewEligibility(
connState,
hasUncommittedChanges,
hasUpstream,
hostId,
key.fetch,
key.identity,
shouldFetch,
@@ -159,5 +181,9 @@ export function useMobileHostedReviewEligibility(
// snapshot in the same render, before the effect posts `idle` — otherwise the
// Create PR button could stay enabled for one paint after the worktree is
// no longer fetchable.
return shouldFetch ? state : { kind: 'idle' }
return renderedMobileHostedReviewEligibilityState({
snapshot,
key,
shouldFetch
})
}
@@ -6,6 +6,7 @@ import type { MobileGitStatusResult } from './mobile-git-status'
type Params = {
client: Parameters<typeof useMobileHostedReviewEligibility>[0]['client']
connState: Parameters<typeof useMobileHostedReviewEligibility>[0]['connState']
hostId: string
worktreeId: string
status: MobileGitStatusResult | null
hasUncommittedChanges: boolean
@@ -16,6 +17,7 @@ type Params = {
export function useMobileSourceControlCreatePrAction({
client,
connState,
hostId,
worktreeId,
status,
hasUncommittedChanges,
@@ -26,6 +28,7 @@ export function useMobileSourceControlCreatePrAction({
const eligibilityState = useMobileHostedReviewEligibility({
client,
connState,
hostId,
worktreeId,
branch: status?.branch,
hasUpstream: upstream?.hasUpstream,
@@ -29,9 +29,9 @@ import { useMobileSourceControlCommitFailure } from './use-mobile-source-control
import {
buildMobileGitStatusEntryViews,
formatBranchLabel,
formatMobileUpstreamSyncLabel,
type MobileBranchEntryView
} from './mobile-source-control-screen-state'
import { t } from '@/i18n/mobile-i18n'
type MobileGitLocalBranches = RuntimeGitLocalBranches
@@ -164,12 +164,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const branchLabel = formatBranchLabel(status?.branch, status?.head)
const upstream = status?.upstreamStatus
const upstreamKnown = upstream !== undefined
const syncLabel =
upstream && upstream.hasUpstream
? t('m.1YRKLD4', { value0: upstream.ahead, value1: upstream.behind })
: upstream && !upstream.hasUpstream
? t('m.GXYBPqk')
: null
const syncLabel = formatMobileUpstreamSyncLabel(upstream)
const { sendGitRequest, sendCommitRequest, runGitSyncSteps } = useMobileGitRequests({
client,
@@ -210,6 +205,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const createPrAction = useMobileSourceControlCreatePrAction({
client,
connState,
hostId,
worktreeId,
status,
hasUncommittedChanges: entries.length > 0,
+433 -4
View File
@@ -1409,14 +1409,16 @@ function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionSt
persistPtyBinding: ReturnType<typeof vi.fn>
}
getSession: () => WorkspaceSessionState
setSession: (next: WorkspaceSessionState) => void
} {
let session = initialSession
const setSession = (next: WorkspaceSessionState): void => {
session = next
}
const runtimeStore = {
...store,
getWorkspaceSession: () => session,
setWorkspaceSession: vi.fn((next: WorkspaceSessionState) => {
session = next
}),
setWorkspaceSession: vi.fn(setSession),
persistPtyBinding: vi.fn(
(args: { worktreeId: string; tabId: string; leafId: string; ptyId: string }) => {
const tabs = session.tabsByWorktree[args.worktreeId] ?? []
@@ -1446,7 +1448,7 @@ function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionSt
}
)
}
return { runtimeStore, getSession: () => session }
return { runtimeStore, getSession: () => session, setSession }
}
function makeWorkspaceSessionWithHeadlessTerminal(
@@ -24830,6 +24832,267 @@ describe('OrcaRuntimeService', () => {
])
})
it('leases renderer publication for a paired create and preserves host-owned inventory', async () => {
const leafId = '91919191-9191-4919-8919-919191919191'
const spawn = vi.fn()
const setBackgroundThrottling = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const webContents: {
isDestroyed: () => boolean
setBackgroundThrottling: typeof setBackgroundThrottling
send: ReturnType<typeof vi.fn>
} = {
isDestroyed: () => false,
setBackgroundThrottling,
send: vi.fn()
}
webContents.send.mockImplementation((_channel: string, payload: { requestId: string }) => {
expect(setBackgroundThrottling).toHaveBeenCalledWith(false)
runtime.registerPty('pty-paired-headed', TEST_WORKTREE_ID, null, {
tabId: 'tab-paired-headed',
leafId
})
ipcMain.emit(
'terminal:tabCreateReply',
{ sender: webContents },
{ requestId: payload.requestId, tabId: 'tab-paired-headed', title: 'Terminal' }
)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-paired-headed',
worktreeId: TEST_WORKTREE_ID,
title: 'Terminal',
activeLeafId: leafId,
layout: null
}
],
leaves: [
{
tabId: 'tab-paired-headed',
worktreeId: TEST_WORKTREE_ID,
leafId,
paneRuntimeId: 1,
ptyId: 'pty-paired-headed'
}
],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer:paired-headed',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: null,
activeTabType: null,
tabs: [
{
type: 'terminal',
id: `tab-paired-headed::${leafId}`,
parentTabId: 'tab-paired-headed',
leafId,
ptyId: 'pty-paired-headed',
title: 'Terminal',
viewMode: 'chat',
isActive: false
}
]
}
]
})
})
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents
})
const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
clientNavigationId: 'device-a',
navigation: 'caller',
activate: false,
select: false,
viewMode: 'chat'
})
expect(webContents.send).toHaveBeenCalledWith(
'terminal:requestTabCreate',
expect.objectContaining({ source: 'runtime-session', viewMode: 'chat' })
)
expect(spawn).not.toHaveBeenCalled()
expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]])
expect(result.tab).toMatchObject({
type: 'terminal',
status: 'ready',
ptyId: 'pty-paired-headed',
terminal: expect.stringMatching(/^term_/),
viewMode: 'chat'
})
const host = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const clientA = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`, 'device-a')
const clientB = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`, 'device-b')
const inventory = (snapshot: RuntimeMobileSessionTabsResult) =>
snapshot.tabs.map((tab) =>
tab.type === 'terminal'
? {
id: tab.id,
type: tab.type,
leafId: tab.leafId,
parentTabId: tab.parentTabId,
ptyId: tab.ptyId
}
: { id: tab.id, type: tab.type }
)
expect(inventory(clientA)).toEqual(inventory(host))
expect(inventory(clientB)).toEqual(inventory(host))
runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
expect(runtime['syncMobileSessionTabs']([])).toEqual(new Set([TEST_WORKTREE_ID]))
expect(runtime['mobileSessionTabsByWorktree'].get(TEST_WORKTREE_ID)?.tabs).toEqual([
expect.objectContaining({
parentTabId: 'tab-paired-headed',
leafId,
ptyId: 'pty-paired-headed'
})
])
const restoredHost = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const restoredClientA = await runtime.listMobileSessionTabs(
`id:${TEST_WORKTREE_ID}`,
'device-a'
)
const restoredClientB = await runtime.listMobileSessionTabs(
`id:${TEST_WORKTREE_ID}`,
'device-b'
)
expect(inventory(restoredHost)).toEqual(inventory(host))
expect(inventory(restoredClientA)).toEqual(inventory(host))
expect(inventory(restoredClientB)).toEqual(inventory(host))
expect(spawn).not.toHaveBeenCalled()
})
it.each([
['paired', 'reloading'],
['paired', 'unavailable'],
['unpaired', 'reloading'],
['unpaired', 'unavailable']
] as const)(
'does not spawn a %s terminal while the headed renderer graph is %s',
async (caller, graphStatus) => {
const spawn = vi.fn()
const send = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send, setBackgroundThrottling: vi.fn() }
})
if (graphStatus === 'reloading') {
runtime.markRendererReloading(1)
} else {
runtime.markGraphUnavailable(1)
}
await expect(
runtime.createMobileSessionTerminal(
`id:${TEST_WORKTREE_ID}`,
caller === 'paired'
? { clientNavigationId: 'device-a', navigation: 'caller' as const }
: {}
)
).rejects.toThrow('runtime_unavailable')
expect(spawn).not.toHaveBeenCalled()
expect(send).not.toHaveBeenCalled()
}
)
it('rejects a paired create if renderer authority changes across async resolution', async () => {
const spawn = vi.fn()
const send = vi.fn()
const setBackgroundThrottling = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send, setBackgroundThrottling }
})
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
let resolutionStarted = (): void => {}
const started = new Promise<void>((resolve) => {
resolutionStarted = resolve
})
let releaseResolution = (): void => {}
vi.mocked(listWorktrees).mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseResolution = () => resolve(MOCK_GIT_WORKTREES)
resolutionStarted()
})
)
const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
clientNavigationId: 'device-a',
navigation: 'caller'
})
await started
expect(spawn).not.toHaveBeenCalled()
runtime.markRendererReloading(1)
releaseResolution()
await expect(create).rejects.toThrow('runtime_unavailable')
expect(send).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
expect(setBackgroundThrottling).not.toHaveBeenCalled()
})
it('cancels a paired renderer-owned create before publication when its client disconnects', async () => {
const spawn = vi.fn()
const send = vi.fn()
const setBackgroundThrottling = vi.fn()
const abort = new AbortController()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send, setBackgroundThrottling }
})
abort.abort()
await expect(
runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
clientNavigationId: 'device-a',
signal: abort.signal
})
).rejects.toThrow('client_disconnected')
expect(spawn).not.toHaveBeenCalled()
expect(send).not.toHaveBeenCalled()
expect(setBackgroundThrottling).not.toHaveBeenCalled()
})
it('selects a created terminal only for the paired caller', async () => {
let spawnIndex = 0
const runtime = new OrcaRuntimeService(store)
@@ -25790,6 +26053,172 @@ describe('OrcaRuntimeService', () => {
})
})
it('leases renderer publication until a paired whole-tab close is acknowledged', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
)
const acknowledged = makeDeferred()
const closeTerminalTab = vi.fn(() => acknowledged.promise)
const setBackgroundThrottling = vi.fn()
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
title: 'Durable',
activeLeafId: HEADLESS_LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
leafId: HEADLESS_LEAF_ID,
paneRuntimeId: 1,
ptyId: 'persisted-pty'
}
],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer:paired-close',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `host-tab::${HEADLESS_LEAF_ID}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `host-tab::${HEADLESS_LEAF_ID}`,
parentTabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
ptyId: 'persisted-pty',
title: 'Durable',
isActive: true
}
]
}
]
})
runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
setBackgroundThrottling
}
})
const pending = runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
reason: 'user',
clientNavigationId: 'device-a'
})
await vi.waitFor(() => expect(closeTerminalTab).toHaveBeenCalledWith('host-tab'))
expect(setBackgroundThrottling.mock.calls).toEqual([[false]])
acknowledged.resolve()
await expect(pending).resolves.toEqual({ closed: true })
expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]])
})
it('accepts a paired close after the renderer already persisted its removal', async () => {
const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
)
const acknowledged = makeDeferred()
const closeTerminalTab = vi.fn(() => acknowledged.promise)
const kill = vi.fn(() => true)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never)
runtime.setPtyController({
write: () => true,
kill,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
title: 'Durable',
activeLeafId: HEADLESS_LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
leafId: HEADLESS_LEAF_ID,
paneRuntimeId: 1,
ptyId: 'persisted-pty'
}
],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer:paired-close',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `host-tab::${HEADLESS_LEAF_ID}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `host-tab::${HEADLESS_LEAF_ID}`,
parentTabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
ptyId: 'persisted-pty',
title: 'Durable',
isActive: true
}
]
}
]
})
runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
setBackgroundThrottling: vi.fn()
}
})
const pending = runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
reason: 'user',
clientNavigationId: 'device-a'
})
await vi.waitFor(() => expect(closeTerminalTab).toHaveBeenCalledWith('host-tab'))
const session = getSession()
setSession({
...session,
tabsByWorktree: { ...session.tabsByWorktree, [TEST_WORKTREE_ID]: [] },
terminalLayoutsByTabId: {}
})
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
acknowledged.resolve()
await expect(pending).resolves.toEqual({ closed: true })
expect(kill).toHaveBeenCalledWith('persisted-pty')
})
it('reuses pane close for live PTYs that do not own a renderer tab', async () => {
const kill = vi.fn(() => true)
const closeTerminalTab = vi.fn(async () => {})
+274 -136
View File
@@ -541,6 +541,7 @@ import {
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { collectMemorySnapshot } from '../memory/collector'
import { app, BrowserWindow, ipcMain, Notification } from 'electron'
import { RendererPublicationThrottle } from '../window/renderer-publication-throttle'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { BrowserBackend } from '../browser/browser-backend'
import { BrowserError } from '../browser/cdp-bridge'
@@ -2640,6 +2641,8 @@ export class OrcaRuntimeService {
private rendererGraphEpoch = 0
private graphStatus: RuntimeGraphStatus = 'unavailable'
private authoritativeWindowId: number | null = null
// Why: paired graph transactions need foreground timer cadence only until their publication settles.
private readonly rendererPublicationThrottle = new RendererPublicationThrottle()
private tabs = new Map<string, RuntimeSyncedTab>()
private mobileSessionTabsByWorktree = new Map<string, RuntimeMobileSessionTabsSnapshot>()
// Why: renderer publication ordering must be judged against the renderer's
@@ -2689,6 +2692,7 @@ export class OrcaRuntimeService {
string,
{
activate: boolean
paired: boolean
selectIfNoActiveTab: boolean
viewMode?: 'terminal' | 'chat'
}
@@ -2804,6 +2808,7 @@ export class OrcaRuntimeService {
// iterates them all. Listeners are cleaned up via subscriptionCleanups.
private notificationListeners = new Set<(event: MobileNotificationEvent) => void>()
private ptysById = new Map<string, RuntimePtyWorktreeRecord>()
private readonly pairedRendererSessionOwnedPtyIds = new Set<string>()
private wslDistroByPtyId = new Map<string, string>()
private titleObservationSequence = 0
private headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()
@@ -5431,6 +5436,7 @@ export class OrcaRuntimeService {
})
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId)
await this.refreshMobileSessionPtyRecords(explicitWorktreeId)
this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId)
return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId)
}
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
@@ -5440,6 +5446,7 @@ export class OrcaRuntimeService {
})
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id)
await this.refreshMobileSessionPtyRecords()
this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id)
return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId)
}
@@ -6008,6 +6015,7 @@ export class OrcaRuntimeService {
const pty = this.ptysById.get(ptyId)
if (pty?.worktreeId === worktreeId && pty.tabId === parentTabId) {
pty.runtimeSessionOwned = false
this.setPairedRendererSessionOwnership(pty.ptyId, false)
}
}
}
@@ -6154,6 +6162,7 @@ export class OrcaRuntimeService {
startupCwd?: string
viewMode?: 'terminal' | 'chat'
split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' }
notify?: boolean
}
): void {
if (
@@ -6262,7 +6271,9 @@ export class OrcaRuntimeService {
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, next)
this.notifyMobileSessionTabsChanged(worktreeId)
if (args.notify !== false) {
this.notifyMobileSessionTabsChanged(worktreeId)
}
}
private touchMobileSessionSnapshotsForPty(
@@ -6879,7 +6890,11 @@ export class OrcaRuntimeService {
}
}
private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): string[] {
private removePersistedHeadlessTerminalTab(
worktreeId: string,
parentTabId: string,
options: { allowMissing?: boolean } = {}
): string[] {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session || !this.store?.setWorkspaceSession) {
throw new Error('workspace_session_unavailable')
@@ -6889,6 +6904,9 @@ export class OrcaRuntimeService {
throw new Error('terminal_tab_pinned')
}
if (!result.closed) {
if (options.allowMissing) {
return []
}
throw new Error('tab_not_found')
}
this.setWorkspaceSessionForWorktree(
@@ -7049,7 +7067,7 @@ export class OrcaRuntimeService {
}
}
try {
await this.createHeadlessMobileSessionTerminal(worktreeId, targetsHost, undefined, {
await this.createRuntimeOwnedMobileSessionTerminal(worktreeId, targetsHost, undefined, {
identity: {
tabId: tab.parentTabId,
leafId: tab.leafId,
@@ -7317,13 +7335,19 @@ export class OrcaRuntimeService {
reason?: RuntimeSessionTabCloseReason
expectedPublicationEpoch?: string
expectedTerminalHandle?: string
clientNavigationId?: string
} = {}
): Promise<RuntimeMobileSessionTabCloseResult> {
const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
const observedPtyIds = await this.refreshMobileSessionPtyRecords()
if (graphEpoch !== null) {
this.assertStableReadyGraph(graphEpoch)
}
this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktreeId)
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) {
// Why: keep-on-unknown must also restore the mirror the caller already pruned.
@@ -7455,7 +7479,19 @@ export class OrcaRuntimeService {
if (closingWholeParent && this.notifier?.closeTerminalTab) {
// Why: whole-tab close is a lifecycle transaction. The renderer reply
// arrives only after canonical retirement and a forced session flush.
await this.notifier.closeTerminalTab(tab.parentTabId)
const win = this.getAvailableAuthoritativeWindow()
if (win?.webContents.isDestroyed?.()) {
throw new Error('runtime_unavailable')
}
const releasePublicationThrottle =
options.clientNavigationId && win
? this.rendererPublicationThrottle.acquire(win.webContents)
: () => {}
try {
await this.notifier.closeTerminalTab(tab.parentTabId)
} finally {
releasePublicationThrottle()
}
const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const remainingTab = remainingSnapshot?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
@@ -7467,7 +7503,10 @@ export class OrcaRuntimeService {
this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab)
) {
// Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface.
this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab)
this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, {
// Why: the renderer may already have durably removed the tab before acknowledging.
allowMissingPersistedTab: true
})
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
this.store?.flushOrThrow?.()
}
@@ -7641,11 +7680,13 @@ export class OrcaRuntimeService {
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionTerminalTab,
options: { killPtys?: boolean } = {}
options: { allowMissingPersistedTab?: boolean; killPtys?: boolean } = {}
): void {
const closedParentTabId = tab.parentTabId
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId)
const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId)
const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId, {
allowMissing: options.allowMissingPersistedTab
})
// Why: local provider ids can be reused after restart, so a dormant
// persisted id is not kill authority. SSH relay ids remain durable exact
// identities even before pane metadata reconnects.
@@ -12847,6 +12888,7 @@ export class OrcaRuntimeService {
if (pty) {
pty.connected = false
pty.runtimeSessionOwned = false
this.setPairedRendererSessionOwnership(pty.ptyId, false)
pty.disconnectedAt = Date.now()
pty.lastExitCode = exitCode
this.resolvePtyExitWaiters(pty, ptyId)
@@ -24651,11 +24693,13 @@ export class OrcaRuntimeService {
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
activate?: boolean
clientNavigationId?: string
clientMutationId?: string
signal?: AbortSignal
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
this.assertGraphReady()
const pairedCreate = Boolean(opts.clientNavigationId)
const graphEpoch = this.captureReadyGraphEpoch()
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const worktreeId = workspace.id
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
@@ -24670,10 +24714,13 @@ export class OrcaRuntimeService {
afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id
}
const startupCommand = await this.resolveMobileSessionTerminalCommand(workspace, opts)
this.assertStableReadyGraph(graphEpoch)
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
const win = this.getAvailableAuthoritativeWindow()
if (!win) {
return await this.createHeadlessMobileSessionTerminal(
return await this.createRuntimeOwnedMobileSessionTerminal(
worktreeId,
opts.activate !== false,
opts.afterTabId,
@@ -24686,142 +24733,155 @@ export class OrcaRuntimeService {
launchAgent: startupCommand.launchAgent,
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
launchConfig: startupCommand.launchConfig
launchConfig: startupCommand.launchConfig,
signal: opts.signal
}
)
}
const requestId = randomUUID()
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
reject(new Error('Terminal creation timed out'))
}, 10_000)
// Why: a dead client connection cancels the wait; the renderer tab (and
// its shell) stays alive for the host and mirrors on reconnect (#7718).
const onAbort = (): void => {
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('client_disconnected'))
}
const handler = (
event: Electron.IpcMainEvent,
r: { requestId: string; tabId?: string; title?: string; error?: string }
): void => {
if (event.sender !== win.webContents || r.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
if (r.error) {
reject(new Error(r.error))
} else {
resolve({ tabId: r.tabId!, title: r.title ?? '' })
}
}
opts.signal?.addEventListener('abort', onAbort, { once: true })
ipcMain.on('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
afterTabId: afterDesktopTabId,
targetGroupId: opts.targetGroupId,
command: startupCommand.command,
cwd,
...(startupCommand.env ? { env: startupCommand.env } : {}),
...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}),
...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}),
...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: startupCommand.startupCommandDelivery,
source: 'runtime-session',
activate: opts.activate
})
})
if (opts.activate !== false) {
this.notifier?.focusTerminal(reply.tabId, worktreeId, null)
if (win.webContents.isDestroyed?.()) {
throw new Error('runtime_unavailable')
}
// Why: register the wait before the renderer's PTY spawn arrives so that
// spawn (registerPty) can publish the pty-backed surface main-side even if
// graph-sync is stalled (#7587). Removed in the finally below.
const pendingCreateKey = `${worktreeId}::${reply.tabId}`
// Why: a rescue publishes into the active group (opts.targetGroupId is not
// threaded); the renderer's reconciling publication then moves the tab to the
// requested group, so any wrong-group placement is cosmetic and stall-window-only.
this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, {
activate: opts.activate !== false,
selectIfNoActiveTab: true,
...(opts.viewMode ? { viewMode: opts.viewMode } : {})
})
const releasePublicationThrottle = pairedCreate
? this.rendererPublicationThrottle.acquire(win.webContents)
: () => {}
try {
// Why: the PTY spawn and the tabCreate reply race on independent IPC
// channels; if the spawn already registered, publish immediately so the
// wait resolves without depending on a graph sync.
this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS,
signal: opts.signal
})
if (this.isReadyMobileTerminalSurface(surface)) {
return surface
}
const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS,
requireReady: true,
signal: opts.signal
}).catch(() => null)
if (readySurface) {
return readySurface
}
if (opts.signal?.aborted) {
// Why: nobody awaits this create anymore; don't materialize or roll back — the renderer's own publication settles the tab.
throw new Error('client_disconnected')
}
const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId)
if (!pendingSurface) {
throw new Error('Timed out waiting for terminal surface after creation')
}
// Why: a hidden renderer can publish the tab shell before the PTY spawns; reuse the same identity so later focus adopts instead of creating another tab.
return await this.createHeadlessMobileSessionTerminal(
worktreeId,
opts.activate !== false,
opts.afterTabId,
{
const requestId = randomUUID()
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
reject(new Error('Terminal creation timed out'))
}, 10_000)
// Why: a dead client connection cancels the wait; the renderer tab (and
// its shell) stays alive for the host and mirrors on reconnect (#7718).
const onAbort = (): void => {
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('client_disconnected'))
}
const handler = (
event: Electron.IpcMainEvent,
r: { requestId: string; tabId?: string; title?: string; error?: string }
): void => {
if (event.sender !== win.webContents || r.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
if (r.error) {
reject(new Error(r.error))
} else {
resolve({ tabId: r.tabId!, title: r.title ?? '' })
}
}
opts.signal?.addEventListener('abort', onAbort, { once: true })
ipcMain.on('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
afterTabId: afterDesktopTabId,
targetGroupId: opts.targetGroupId,
command: startupCommand.command,
cwd,
env: startupCommand.env,
envToDelete: startupCommand.envToDelete,
...(startupCommand.env ? { env: startupCommand.env } : {}),
...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}),
...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}),
...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: startupCommand.startupCommandDelivery,
identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId },
launchAgent: startupCommand.launchAgent,
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
launchConfig: startupCommand.launchConfig
}
)
} catch (error) {
// Why: publication latency (hidden renderer) can trip the surface timeout; rescue only when a live PTY backs the tab, else a ghost tab skips rollback (#7587).
if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) {
const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
if (rescued) {
return rescued
}
source: 'runtime-session',
activate: opts.activate
})
})
if (opts.activate !== false) {
this.notifier?.focusTerminal(reply.tabId, worktreeId, null)
}
// Why: don't roll back on a client disconnect or a live shell already backing the tab — that would kill a visible terminal ("tab dies after ~10s", #7718).
if (
isClientDisconnectedError(error) ||
this.hasLiveShellForRendererTab(worktreeId, reply.tabId)
) {
// Why: register the wait before the renderer's PTY spawn arrives so that
// spawn (registerPty) can publish the pty-backed surface main-side even if
// graph-sync is stalled (#7587). Removed in the finally below.
const pendingCreateKey = `${worktreeId}::${reply.tabId}`
// Why: a rescue publishes into the active group (opts.targetGroupId is not
// threaded); the renderer's reconciling publication then moves the tab to the
// requested group, so any wrong-group placement is cosmetic and stall-window-only.
this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, {
activate: opts.activate !== false,
paired: pairedCreate,
selectIfNoActiveTab: true,
...(opts.viewMode ? { viewMode: opts.viewMode } : {})
})
try {
// Why: the PTY spawn and the tabCreate reply race on independent IPC
// channels; if the spawn already registered, publish immediately so the
// wait resolves without depending on a graph sync.
this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS,
signal: opts.signal
})
if (this.isReadyMobileTerminalSurface(surface)) {
return surface
}
const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS,
requireReady: true,
signal: opts.signal
}).catch(() => null)
if (readySurface) {
return readySurface
}
if (opts.signal?.aborted) {
// Why: nobody awaits this create anymore; don't materialize or roll back — the renderer's own publication settles the tab.
throw new Error('client_disconnected')
}
const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId)
if (!pendingSurface) {
throw new Error('Timed out waiting for terminal surface after creation')
}
// Why: a hidden renderer can publish the tab shell before the PTY spawns; reuse the same identity so later focus adopts instead of creating another tab.
return await this.createRuntimeOwnedMobileSessionTerminal(
worktreeId,
opts.activate !== false,
opts.afterTabId,
{
command: startupCommand.command,
cwd,
env: startupCommand.env,
envToDelete: startupCommand.envToDelete,
startupCommandDelivery: startupCommand.startupCommandDelivery,
identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId },
launchAgent: startupCommand.launchAgent,
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
launchConfig: startupCommand.launchConfig,
signal: opts.signal
}
)
} catch (error) {
// Why: publication latency (hidden renderer) can trip the surface timeout; rescue only when a live PTY backs the tab, else a ghost tab skips rollback (#7587).
if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) {
const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
if (rescued) {
return rescued
}
}
// Why: don't roll back on a client disconnect or a live shell already backing the tab — that would kill a visible terminal ("tab dies after ~10s", #7718).
if (
isClientDisconnectedError(error) ||
this.hasLiveShellForRendererTab(worktreeId, reply.tabId)
) {
throw error
}
// Why: renderer made the tab but no live PTY backs it (real spawn/handle failure); roll it back so it can't linger as a ghost in mobile snapshots.
this.notifier?.closeTerminal(reply.tabId)
throw error
} finally {
this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey)
}
// Why: renderer made the tab but no live PTY backs it (real spawn/handle failure); roll it back so it can't linger as a ghost in mobile snapshots.
this.notifier?.closeTerminal(reply.tabId)
throw error
} finally {
this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey)
releasePublicationThrottle()
}
}
@@ -24909,7 +24969,7 @@ export class OrcaRuntimeService {
}
}
private async createHeadlessMobileSessionTerminal(
private async createRuntimeOwnedMobileSessionTerminal(
worktreeId: string,
activate: boolean,
afterTabId?: string,
@@ -24924,6 +24984,7 @@ export class OrcaRuntimeService {
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
launchConfig?: SleepingAgentLaunchConfig
signal?: AbortSignal
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
@@ -24952,7 +25013,8 @@ export class OrcaRuntimeService {
: {}),
persistHostSessionBinding: true,
// Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash.
deferMobileSessionPublish: true
deferMobileSessionPublish: true,
signal: opts.signal
})
const livePty = this.getLivePtyForHandle(terminal.handle)
if (!livePty) {
@@ -25148,6 +25210,9 @@ export class OrcaRuntimeService {
const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId)
if (pty) {
pty.runtimeSessionOwned = true
if (pending.paired) {
this.setPairedRendererSessionOwnership(pty.ptyId, true)
}
}
if (
existing &&
@@ -25176,6 +25241,69 @@ export class OrcaRuntimeService {
return this.findMobileTerminalSurface(worktreeId, tabId)
}
private restoreLivePairedRendererSessionOwnedMobileTerminals(
worktreeId: string | null,
options: { missingSnapshotOnly?: boolean; notify?: boolean } = {}
): void {
for (const ptyId of this.pairedRendererSessionOwnedPtyIds) {
const pty = this.ptysById.get(ptyId)
if (
!pty?.connected ||
!pty.tabId ||
(worktreeId !== null && !runtimeWorktreeIdsEqual(pty.worktreeId, worktreeId))
) {
continue
}
const targetWorktreeId = worktreeId ?? pty.worktreeId
const pane = parsePaneKey(pty.paneKey ?? '')
if (!pane || pane.tabId !== pty.tabId) {
continue
}
const existing = this.mobileSessionTabsByWorktree.get(targetWorktreeId)
if (existing && options.missingSnapshotOnly) {
continue
}
if (
existing?.tabs.some(
(tab) =>
tab.type === 'terminal' &&
(tab.ptyId === pty.ptyId ||
(tab.parentTabId === pty.tabId && tab.leafId === pane.leafId))
)
) {
continue
}
if (!existing) {
this.mobileSessionTabsByWorktree.set(targetWorktreeId, {
worktree: targetWorktreeId,
publicationEpoch: `renderer-rescue:${Date.now().toString(36)}`,
snapshotVersion: 0,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabGroups: [],
tabs: []
})
}
this.publishPtyBackedMobileSessionTerminal(targetWorktreeId, pty, {
tabId: pty.tabId,
leafId: pane.leafId,
title: null,
activate: false,
selectIfNoActiveTab: false,
notify: options.notify
})
}
}
private setPairedRendererSessionOwnership(ptyId: string, owned: boolean): void {
if (owned) {
this.pairedRendererSessionOwnedPtyIds.add(ptyId)
} else {
this.pairedRendererSessionOwnedPtyIds.delete(ptyId)
}
}
private findLiveRegisteredPtyForRendererTab(
worktreeId: string,
tabId: string
@@ -25602,6 +25730,10 @@ export class OrcaRuntimeService {
createdPty.tabId = parentTabId
createdPty.paneKey = paneKey
createdPty.runtimeSessionOwned = pty.runtimeSessionOwned
this.setPairedRendererSessionOwnership(
createdPty.ptyId,
this.pairedRendererSessionOwnedPtyIds.has(pty.ptyId)
)
}
try {
@@ -25617,6 +25749,7 @@ export class OrcaRuntimeService {
splitTelemetrySource: opts.telemetrySource
})
} catch (error) {
this.setPairedRendererSessionOwnership(result.id, false)
this.ptyController.kill?.(result.id)
throw error
}
@@ -27978,6 +28111,7 @@ export class OrcaRuntimeService {
private dropDisconnectedPtyRecord(ptyId: string): void {
// Why: pruning can remove a PTY without the normal exit callback.
this.advancePtyLifecycleGeneration(ptyId)
this.pairedRendererSessionOwnedPtyIds.delete(ptyId)
this.ptysById.delete(ptyId)
this.recentPtyOutputById.delete(ptyId)
this.setupCompletionTokenByPtyId.delete(ptyId)
@@ -28110,6 +28244,10 @@ export class OrcaRuntimeService {
// renderer resends before they replace an entry — so reference identity
// before/after detects exactly the entries that actually changed.
const before = new Map(this.mobileSessionTabsByWorktree)
this.restoreLivePairedRendererSessionOwnedMobileTerminals(null, {
missingSnapshotOnly: true,
notify: false
})
const worktreeIdsToHydrate = this.getKnownWorkspaceSessionWorktreeIds()
for (const snapshot of snapshots) {
worktreeIdsToHydrate.add(snapshot.worktree)
@@ -28,7 +28,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
const result = await context.runtime.closeMobileSessionTab(
params.worktree,
params.tabId,
{ reason: 'user' }
{
reason: 'user',
...(context.pairedDeviceId ? { clientNavigationId: context.pairedDeviceId } : {})
}
)
span.setAttribute(
'decision',
@@ -68,7 +71,8 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
{
reason: params.reason,
expectedPublicationEpoch: params.publicationEpoch,
expectedTerminalHandle: params.terminal
expectedTerminalHandle: params.terminal,
...(context.pairedDeviceId ? { clientNavigationId: context.pairedDeviceId } : {})
}
)
span.setAttribute(
@@ -136,7 +136,8 @@ describe('session tab RPC methods', () => {
expect(replies).toHaveLength(1)
expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', {
reason: 'user'
reason: 'user',
clientNavigationId: 'current-runtime'
})
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
})
@@ -180,7 +181,8 @@ describe('session tab RPC methods', () => {
expect(replies).toHaveLength(1)
expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', {
reason: 'user'
reason: 'user',
clientNavigationId: 'legacy-runtime'
})
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
})
+41
View File
@@ -3419,6 +3419,47 @@ describe('OrcaRuntimeRpcServer', () => {
)
})
it('rejects unpaired terminal creates before runtime dispatch', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const createMobileSessionTerminal = vi.fn()
const runtime = {
getRuntimeId: () => 'test-runtime',
createMobileSessionTerminal
} as unknown as OrcaRuntimeService
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
const replies: Record<string, unknown>[] = []
const send = async (id: string, deviceToken?: string): Promise<void> => {
await server['handleWebSocketMessage'](
JSON.stringify({
id,
method: 'session.tabs.createTerminal',
...(deviceToken ? { deviceToken } : {}),
params: { worktree: 'id:wt-1' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
}
await send('req_missing')
await send('req_invalid', 'invalid-token')
expect(replies).toEqual([
expect.objectContaining({
id: 'req_missing',
error: expect.objectContaining({ code: 'unauthorized' }),
ok: false
}),
expect.objectContaining({
id: 'req_invalid',
error: expect.objectContaining({ code: 'unauthorized' }),
ok: false
})
])
expect(createMobileSessionTerminal).not.toHaveBeenCalled()
})
it('allows runtime-scoped WebSocket tokens to use the full RPC surface', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true })
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
RendererPublicationThrottle,
type RendererPublicationThrottleTarget
} from './renderer-publication-throttle'
function createTarget(): RendererPublicationThrottleTarget & {
calls: boolean[]
destroyed: boolean
} {
const calls: boolean[] = []
return {
calls,
destroyed: false,
isDestroyed() {
return this.destroyed
},
setBackgroundThrottling(allowed) {
calls.push(allowed)
}
}
}
describe('RendererPublicationThrottle', () => {
it('unthrottles only while a publication lease is active', () => {
const target = createTarget()
const throttle = new RendererPublicationThrottle()
const release = throttle.acquire(target)
release()
expect(target.calls).toEqual([false, true])
})
it('keeps overlapping publications unthrottled until the last release', () => {
const target = createTarget()
const throttle = new RendererPublicationThrottle()
const releaseFirst = throttle.acquire(target)
const releaseSecond = throttle.acquire(target)
releaseFirst()
releaseSecond()
releaseSecond()
expect(target.calls).toEqual([false, true])
})
it('does not restore throttling on a destroyed renderer', () => {
const target = createTarget()
const throttle = new RendererPublicationThrottle()
const release = throttle.acquire(target)
target.destroyed = true
release()
expect(target.calls).toEqual([false])
})
})
@@ -0,0 +1,32 @@
export type RendererPublicationThrottleTarget = {
isDestroyed?: () => boolean
setBackgroundThrottling: (allowed: boolean) => void
}
export class RendererPublicationThrottle {
private readonly leasesByTarget = new Map<RendererPublicationThrottleTarget, number>()
acquire(target: RendererPublicationThrottleTarget): () => void {
const leaseCount = this.leasesByTarget.get(target) ?? 0
if (leaseCount === 0) {
target.setBackgroundThrottling(false)
}
this.leasesByTarget.set(target, leaseCount + 1)
let released = false
return () => {
if (released) {
return
}
released = true
const remaining = (this.leasesByTarget.get(target) ?? 1) - 1
if (remaining > 0) {
this.leasesByTarget.set(target, remaining)
return
}
this.leasesByTarget.delete(target)
if (target.isDestroyed?.() !== true) {
target.setBackgroundThrottling(true)
}
}
}
}
@@ -592,10 +592,14 @@ export function RuntimeEnvironmentsPane({
await window.api.runtimeEnvironments.disconnect({ selector: environment.id })
// Why: disconnect is non-destructive; keep the saved server but show the
// user that this live client is no longer attached to it.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: null,
checkedAt: Date.now()
})
useAppStore.getState().setRuntimeEnvironmentStatus(
environment.id,
{
status: null,
checkedAt: Date.now()
},
{ suppressDisconnectToast: true }
)
if (mountedRef.current) {
setDetailsByEnvironmentId((current) => ({
...current,
@@ -765,7 +765,7 @@ describe('buildRows with pinned worktrees', () => {
)
})
it('splits all project setups when one host has duplicate checkouts', () => {
it('splits only the surface with duplicate checkouts', () => {
const localRepoB: Repo = {
...repo,
id: 'repo-local-b',
@@ -822,10 +822,143 @@ describe('buildRows with pinned worktrees', () => {
expect(headers.map((row) => row.key)).toEqual([
'project:github:stablyai/orca::setup:repo-1',
'project:github:stablyai/orca::setup:repo-local-b',
'project:github:stablyai/orca::setup:repo-remote'
'project:github:stablyai/orca'
])
})
it('counts projection twins for one directory as one checkout', () => {
const runtimeHostId = 'runtime:m2-air'
const runtimeRepo: Repo = {
...remoteRepo,
id: 'repo-runtime',
path: '/home/alice/orca-runtime',
connectionId: undefined,
executionHostId: runtimeHostId
}
const runtimeWorktree: Worktree = {
...remoteWorktree,
id: 'wt-runtime',
repoId: runtimeRepo.id,
path: '/home/alice/orca-runtime-feature'
}
const runtimeSetup: ProjectHostSetup = {
...projectHostSetups[1]!,
id: runtimeRepo.id,
repoId: runtimeRepo.id,
path: runtimeRepo.path,
hostId: runtimeHostId,
executionHostId: runtimeHostId
}
const derivedSetup: ProjectHostSetup = {
...projectHostSetups[1]!,
hostId: runtimeHostId,
connectionId: 'intel mac',
executionHostId: runtimeHostId
}
const authoritativeSetup: ProjectHostSetup = {
...derivedSetup,
id: 'setup-authoritative',
path: `${derivedSetup.path}/`,
connectionId: null,
executionHostId: 'ssh:intel%20mac'
}
const grouping = {
projects: [{ ...project, sourceRepoIds: [remoteRepo.id, runtimeRepo.id] }],
projectHostSetups: [runtimeSetup, derivedSetup, authoritativeSetup]
}
expect([
getGroupKeyForWorktree(
'repo',
remoteWorktree,
new Map([[remoteRepo.id, remoteRepo]]),
null,
undefined,
undefined,
grouping
),
getGroupKeyForWorktree(
'repo',
runtimeWorktree,
new Map([[runtimeRepo.id, runtimeRepo]]),
null,
undefined,
undefined,
grouping
)
]).toEqual(['project:github:stablyai/orca', 'project:github:stablyai/orca'])
})
it('keeps Git hosts grouped when folder setups share the project identity', () => {
const windowsHostId = 'runtime:windows-server'
const windowsRepo: Repo = {
...repo,
id: 'repo-windows',
path: 'C:\\Users\\neil\\orca\\orca',
executionHostId: windowsHostId
}
const folderRepoA: Repo = {
...repo,
id: 'folder-qa-a',
path: '/tmp/pr11751-folder-qa',
displayName: 'pr11751-folder-qa',
kind: 'folder'
}
const folderRepoB: Repo = {
...folderRepoA,
id: 'folder-qa-b',
path: '/tmp/pr11767-runtime-folder',
displayName: 'pr11767-runtime-folder'
}
const setups: ProjectHostSetup[] = [
projectHostSetups[0]!,
{ ...projectHostSetups[0]!, id: 'projection-twin' },
projectHostSetups[1]!,
{
...projectHostSetups[0]!,
id: windowsRepo.id,
repoId: windowsRepo.id,
path: windowsRepo.path,
hostId: windowsHostId,
executionHostId: windowsHostId
},
{
...projectHostSetups[0]!,
id: folderRepoA.id,
repoId: folderRepoA.id,
path: folderRepoA.path,
displayName: folderRepoA.displayName,
kind: 'folder'
},
{
...projectHostSetups[0]!,
id: folderRepoB.id,
repoId: folderRepoB.id,
path: folderRepoB.path,
displayName: folderRepoB.displayName,
kind: 'folder'
}
]
const repos = [repo, remoteRepo, windowsRepo, folderRepoA, folderRepoB]
const grouping = {
projects: [{ ...project, sourceRepoIds: repos.map((entry) => entry.id) }],
projectHostSetups: setups
}
const groupKeys = repos.map((entry) =>
getGroupKeyForWorktree(
'repo',
{ ...worktree, id: `wt-${entry.id}`, repoId: entry.id, path: entry.path },
new Map([[entry.id, entry]]),
null,
undefined,
undefined,
grouping
)
)
expect(new Set(groupKeys)).toEqual(new Set(['project:github:stablyai/orca']))
})
it('keeps a provisioned runtime copy under the project header alongside a same-host checkout', () => {
const runtimeRepoB: Repo = {
...repo,
@@ -39,10 +39,14 @@ import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
getWorktreeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { parseWslUncPath } from '../../../../shared/wsl-paths'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import {
isWindowsAbsolutePathLike,
normalizeRuntimePathForComparison
} from '../../../../shared/cross-platform-path'
import {
getCyclicProjectedWorktreeLineageIds,
getLineageRenderInfo
@@ -177,28 +181,43 @@ type WorktreeGroupEntry = {
type ProjectGroupingIndex = {
projectById: Map<string, Project>
setupByRepoId: Map<string, ProjectHostSetup>
projectIdsRequiringSetupGroups: Set<string>
surfaceKeysRequiringSetupGroups: Set<string>
}
const projectGroupingIndexCache = new WeakMap<ProjectGroupingModel, ProjectGroupingIndex | null>()
// Why: `provisioned` setups are ephemeral recipe-created runtime copies that
// nest under the project header; every other method is a real user checkout. See #5374.
// Why: provisioned and folder setups are not independent Git checkouts.
function isDistinctUserCheckout(setup: ProjectHostSetup): boolean {
return setup.setupMethod !== 'provisioned'
return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder'
}
// Why: execution target and filesystem namespace independently identify a surface.
function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string {
return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}`
}
function getExecutionSurface(setup: ProjectHostSetup): string {
const connectionId = setup.connectionId?.trim()
if (connectionId) {
return toSshExecutionHostId(connectionId)
}
return setup.executionHostId?.trim() || setup.hostId
}
// Why: projection twins differ by row identity, not checkout directory.
function getCheckoutIdentity(setup: ProjectHostSetup): string {
return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id
}
function getPathSurface(setup: ProjectHostSetup): string {
const wslPath = parseWslUncPath(setup.path)
if (wslPath) {
// Why: Windows host and WSL on one machine are separate execution surfaces;
// only duplicate checkouts within one surface make project grouping ambiguous.
return `${setup.projectId}::${setup.hostId}::wsl:${wslPath.distro.toLowerCase()}`
return `wsl:${wslPath.distro.toLowerCase()}`
}
if (isWindowsAbsolutePathLike(setup.path)) {
return `${setup.projectId}::${setup.hostId}::windows-host`
return 'windows-host'
}
return `${setup.projectId}::${setup.hostId}::default`
return 'default'
}
function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null {
@@ -215,9 +234,7 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin
projectGroupingIndexCache.set(model, null)
return null
}
// Count real user checkouts per host surface (provisioned copies excluded so
// they keep nesting); more than one on a surface makes that project ambiguous.
const checkoutsByProjectSurface = new Map<string, { projectId: string; count: number }>()
const checkoutsByProjectSurface = new Map<string, Set<string>>()
for (const setup of projectHostSetups) {
if (!isDistinctUserCheckout(setup)) {
continue
@@ -225,21 +242,21 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin
const key = getProjectSetupSurfaceKey(setup)
const existing = checkoutsByProjectSurface.get(key)
if (existing) {
existing.count += 1
existing.add(getCheckoutIdentity(setup))
} else {
checkoutsByProjectSurface.set(key, { projectId: setup.projectId, count: 1 })
checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)]))
}
}
const projectIdsRequiringSetupGroups = new Set<string>()
for (const { projectId, count } of checkoutsByProjectSurface.values()) {
if (count > 1) {
projectIdsRequiringSetupGroups.add(projectId)
const surfaceKeysRequiringSetupGroups = new Set<string>()
for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) {
if (checkouts.size > 1) {
surfaceKeysRequiringSetupGroups.add(surfaceKey)
}
}
const index = {
projectById: new Map(projects.map((project) => [project.id, project])),
setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])),
projectIdsRequiringSetupGroups
surfaceKeysRequiringSetupGroups
}
projectGroupingIndexCache.set(model, index)
return index
@@ -268,11 +285,10 @@ function getProjectGroupingForRepo(
}
}
if (
projectIndex?.projectIdsRequiringSetupGroups.has(setup.projectId) &&
projectIndex?.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) &&
isDistinctUserCheckout(setup)
) {
// Why: independent user checkouts of one project on the same host surface
// can't be safely merged, so each keeps its own sidebar entry. See #5374.
// Why: only the ambiguous surface needs checkout-specific headers.
return {
key: `project:${project.id}::setup:${repoId}`,
label: repo?.displayName ?? setup.displayName,
@@ -254,7 +254,11 @@ export function SshStatusSegment({
async (environmentId: string): Promise<void> => {
try {
await window.api.runtimeEnvironments.disconnect({ selector: environmentId })
setRuntimeEnvironmentStatus(environmentId, { status: null, checkedAt: Date.now() })
setRuntimeEnvironmentStatus(
environmentId,
{ status: null, checkedAt: Date.now() },
{ suppressDisconnectToast: true }
)
recordFeatureInteraction('ssh')
} catch (err) {
toast.error(
+5
View File
@@ -322,6 +322,11 @@
"f518e89aa5": "Project already exists in that profile",
"f03ae7f27b": "Failed to transfer project"
}
},
"runtime": {
"status": {
"runtimeHostDisconnectedDescription": "Workspaces and terminals on this server are unavailable."
}
}
}
},
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { AppState } from '../types'
import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers'
@@ -15,6 +16,11 @@ vi.mock('@/lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: vi.fn()
}))
vi.mock('@/lib/web-client-location', () => ({
isWebClientLocation: () =>
Boolean((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
}))
const pairedWebFlag = globalThis as { __ORCA_WEB_CLIENT__?: boolean }
function seedActiveWorkspace(store: ReturnType<typeof createTestStore>): void {
@@ -165,6 +171,61 @@ describe('Cmd+J lifted creation actions', () => {
expect(store.getState().tabsByWorktree['wt-1'] ?? []).toEqual([])
})
it('does not create a local folder terminal while paired ownership is unresolved', async () => {
const folderId = 'folder-1'
const workspaceKey = folderWorkspaceKey(folderId)
const store = createTestStore()
seedStore(store, {
activeWorktreeId: workspaceKey,
settings: { activeRuntimeEnvironmentId: null } as AppState['settings'],
folderWorkspaces: [
{
id: folderId,
projectGroupId: 'group-1',
name: 'Folder workspace',
folderPath: '/tmp/folder',
connectionId: null,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
],
projectGroups: [
{
id: 'group-1',
name: 'Folder group',
parentPath: '/tmp',
connectionId: null,
executionHostId: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
groupsByWorktree: {
[workspaceKey]: [
{ id: 'tab-group-1', worktreeId: workspaceKey, activeTabId: null, tabOrder: [] }
]
},
activeGroupIdByWorktree: { [workspaceKey]: 'tab-group-1' }
})
await store.getState().openNewTerminalTabInActiveWorkspace('tab-group-1')
expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled()
expect(store.getState().tabsByWorktree[workspaceKey] ?? []).toEqual([])
})
it('creates desktop remote-server terminal tabs through the owning runtime', async () => {
delete pairedWebFlag.__ORCA_WEB_CLIENT__
createWebRuntimeSessionTerminalMock.mockResolvedValue(false)
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import { toast } from 'sonner'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { createCompatibleRuntimeStatusResponse } from '../../runtime/runtime-compatibility-test-fixture'
import {
callRuntimeRpc,
@@ -12,6 +14,10 @@ import {
getRuntimeEnvironmentConnectionGeneration
} from './runtime-status'
vi.mock('sonner', () => ({
toast: { warning: vi.fn(), dismiss: vi.fn() }
}))
function createSliceStore() {
return create<RuntimeStatusSlice>()((...a) => ({
...createRuntimeStatusSlice(...(a as unknown as Parameters<typeof createRuntimeStatusSlice>))
@@ -32,6 +38,22 @@ function makeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
} as RuntimeStatus
}
function makeEnvironment(
overrides: Partial<PublicKnownRuntimeEnvironment> = {}
): PublicKnownRuntimeEnvironment {
return {
id: 'env-a',
name: 'Dev Box',
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [{ id: 'ws-a', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }],
preferredEndpointId: 'ws-a',
...overrides
}
}
function stubRuntimeEnvironmentApi({
getStatus = vi.fn(),
list = vi.fn()
@@ -50,7 +72,13 @@ function stubRuntimeEnvironmentApi({
return { getStatus, list }
}
beforeEach(() => {
vi.mocked(toast.warning).mockReset()
vi.mocked(toast.dismiss).mockReset()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
@@ -142,6 +170,140 @@ describe('runtime-status slice', () => {
expect(map.get('env-a')).toEqual({ status: null, checkedAt: 5, connectionGeneration: 1 })
})
it('does not toast when the first probe finds a saved server offline', () => {
const store = createSliceStore()
store.setState({ runtimeEnvironments: [makeEnvironment()] })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 1 })
expect(toast.warning).not.toHaveBeenCalled()
})
it('toasts once when a connected server becomes unavailable', () => {
const store = createSliceStore()
store.setState({ runtimeEnvironments: [makeEnvironment()] })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 3 })
expect(toast.warning).toHaveBeenCalledTimes(1)
expect(toast.warning).toHaveBeenCalledWith(
'Dev Box disconnected',
expect.objectContaining({
id: 'runtime-environment-disconnected:env-a',
description: 'Workspaces and terminals on this server are unavailable.',
action: expect.objectContaining({ label: 'Retry' })
})
)
})
it('dismisses the disconnect toast when the server recovers', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 3 })
expect(toast.dismiss).toHaveBeenCalledWith('runtime-environment-disconnected:env-a')
})
it('keeps the keyed action toast visible when an offline retry settles', async () => {
vi.useFakeTimers()
const toastId = 'runtime-environment-disconnected:env-a'
const visibleToastIds = new Set<string | number>()
vi.mocked(toast.warning).mockImplementation((_title, options) => {
if (options?.id !== undefined) {
visibleToastIds.add(options.id)
}
return options?.id ?? ''
})
const getStatus = vi.fn().mockRejectedValue(new Error('closed'))
stubRuntimeEnvironmentApi({ getStatus })
const store = createSliceStore()
store.setState({ runtimeEnvironments: [makeEnvironment()] })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 })
const options = vi.mocked(toast.warning).mock.calls[0]?.[1] as unknown as {
action: { onClick: (event: { preventDefault: () => void }) => void }
}
const clickAction = (): { defaultPrevented: boolean } => {
const event = {
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true
}
}
options.action.onClick(event)
if (!event.defaultPrevented) {
setTimeout(() => visibleToastIds.delete(toastId), 200)
}
return event
}
const firstClick = clickAction()
const secondClick = clickAction()
await vi.advanceTimersByTimeAsync(0)
expect(firstClick.defaultPrevented).toBe(true)
expect(secondClick.defaultPrevented).toBe(true)
expect(getStatus).toHaveBeenCalledWith({ selector: 'env-a', timeoutMs: 10_000 })
await vi.advanceTimersByTimeAsync(200)
expect(visibleToastIds.has(toastId)).toBe(true)
expect(getStatus).toHaveBeenCalledTimes(1)
expect(toast.warning).toHaveBeenCalledTimes(3)
expect(vi.mocked(toast.warning).mock.calls.map((call) => call[1]?.duration)).toEqual([
4_000,
Number.POSITIVE_INFINITY,
4_000
])
expect(vi.mocked(toast.warning).mock.calls.every((call) => call[1]?.id === toastId)).toBe(true)
})
it('does not report removal or explicit status clearing as a disconnect', () => {
const store = createSliceStore()
store.setState({ runtimeEnvironments: [makeEnvironment()] })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().clearRuntimeEnvironmentStatus('env-a')
store.getState().setRuntimeEnvironments([])
expect(toast.warning).not.toHaveBeenCalled()
})
it('does not report an intentional disconnect as an outage', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store
.getState()
.setRuntimeEnvironmentStatus(
'env-a',
{ status: null, checkedAt: 2 },
{ suppressDisconnectToast: true }
)
expect(toast.warning).not.toHaveBeenCalled()
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBeNull()
})
it('dismisses an outage toast opened while an intentional disconnect was pending', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 })
store
.getState()
.setRuntimeEnvironmentStatus(
'env-a',
{ status: null, checkedAt: 3 },
{ suppressDisconnectToast: true }
)
expect(toast.warning).toHaveBeenCalledTimes(1)
expect(toast.dismiss).toHaveBeenCalledWith('runtime-environment-disconnected:env-a')
})
it('clears a single environment entry', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
+109 -8
View File
@@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import type { AppState } from '../types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
@@ -8,6 +9,7 @@ import {
unwrapRuntimeRpcResult
} from '@/runtime/runtime-rpc-client'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import { translate } from '@/i18n/i18n'
/** Live status for one saved runtime environment, as last observed by the
* renderer. `status === null` records a probe that failed or timed out so the
@@ -44,7 +46,11 @@ export type RuntimeStatusSlice = {
* retires state owned by any environment that just left the saved list. */
setRuntimeEnvironments: (environments: PublicKnownRuntimeEnvironment[]) => void
/** Merges one environment's status. Replaces the prior entry for that id. */
setRuntimeEnvironmentStatus: (environmentId: string, status: RuntimeEnvironmentStatus) => void
setRuntimeEnvironmentStatus: (
environmentId: string,
status: RuntimeEnvironmentStatus,
options?: { suppressDisconnectToast?: boolean }
) => void
/** Drops a removed environment so stale hosts don't linger in the registry. */
clearRuntimeEnvironmentStatus: (environmentId: string) => void
/** Drops every entry whose id is not in the saved-environments set. */
@@ -57,6 +63,85 @@ export type RuntimeStatusSlice = {
}
const connectionGenerationByEnvironment = new Map<string, number>()
const activeRuntimeDisconnectedToasts = new Map<string, symbol>()
const RUNTIME_DISCONNECTED_TOAST_DURATION_MS = 4_000
function getRuntimeDisconnectedToastId(environmentId: string): string {
return `runtime-environment-disconnected:${environmentId}`
}
function showRuntimeDisconnectedToast(environmentId: string, getState: () => AppState): void {
const environment = getState().runtimeEnvironments.find((entry) => entry.id === environmentId)
const toastId = getRuntimeDisconnectedToastId(environmentId)
const activation = Symbol(toastId)
const title = environment?.name
? translate(
'auto.components.sidebar.WorktreeCard.runtimeHostDisconnectedNamed',
'{{hostName}} disconnected',
{ hostName: environment.name }
)
: translate(
'auto.components.sidebar.WorktreeCard.runtimeHostDisconnected',
'Server disconnected'
)
activeRuntimeDisconnectedToasts.set(toastId, activation)
const clearActiveToast = (): void => {
if (activeRuntimeDisconnectedToasts.get(toastId) === activation) {
activeRuntimeDisconnectedToasts.delete(toastId)
}
}
let retrying = false
const showToast = (duration = RUNTIME_DISCONNECTED_TOAST_DURATION_MS): void => {
toast.warning(title, {
id: toastId,
description: translate(
'auto.store.slices.runtime.status.runtimeHostDisconnectedDescription',
'Workspaces and terminals on this server are unavailable.'
),
duration,
action: {
label: translate('browser.loadFailure.retry', 'Retry'),
onClick: (event) => {
// Why: Sonner otherwise deletes the keyed toast after the action callback.
event.preventDefault()
if (retrying) {
return
}
retrying = true
showToast(Number.POSITIVE_INFINITY)
void getState()
.refreshRuntimeEnvironmentStatus(environmentId)
.then((reachable) => {
const stillSaved = getState().runtimeEnvironments.some(
(entry) => entry.id === environmentId
)
if (
!reachable &&
stillSaved &&
activeRuntimeDisconnectedToasts.get(toastId) === activation
) {
showToast()
}
})
.finally(() => {
retrying = false
})
}
},
onDismiss: clearActiveToast,
onAutoClose: clearActiveToast
})
}
showToast()
}
function dismissRuntimeDisconnectedToast(environmentId: string): void {
const toastId = getRuntimeDisconnectedToastId(environmentId)
if (!activeRuntimeDisconnectedToasts.delete(toastId)) {
return
}
toast.dismiss?.(toastId)
}
export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number {
return connectionGenerationByEnvironment.get(environmentId) ?? 0
@@ -158,10 +243,12 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
const retiredEnvironmentIds = [...new Set([...removedIds, ...replacedEnvironmentIds])]
if (retiredEnvironmentIds.length > 0) {
get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds)
retiredEnvironmentIds.forEach(dismissRuntimeDisconnectedToast)
}
},
setRuntimeEnvironmentStatus: (environmentId, status) => {
setRuntimeEnvironmentStatus: (environmentId, status, options) => {
const previous = get().runtimeStatusByEnvironmentId.get(environmentId)
// Why: a non-null status proves the runtime just answered, so drop any stale
// "offline" compat failure before this online transition fires the
// reuse-flagged background refetches — a recovered host must re-probe.
@@ -170,7 +257,6 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
}
set((s) => {
const next = new Map(s.runtimeStatusByEnvironmentId)
const previous = next.get(environmentId)
const connectionChanged =
status.status !== null &&
(previous?.status == null || previous.status.runtimeId !== status.status.runtimeId)
@@ -185,9 +271,17 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
})
return { runtimeStatusByEnvironmentId: next }
})
if (options?.suppressDisconnectToast) {
dismissRuntimeDisconnectedToast(environmentId)
} else if (previous?.status === null && status.status !== null) {
dismissRuntimeDisconnectedToast(environmentId)
} else if (previous && previous.status !== null && status.status === null) {
showRuntimeDisconnectedToast(environmentId, get)
}
},
clearRuntimeEnvironmentStatus: (environmentId) =>
clearRuntimeEnvironmentStatus: (environmentId) => {
dismissRuntimeDisconnectedToast(environmentId)
set((s) => {
advanceRuntimeEnvironmentConnectionGeneration(environmentId)
if (!s.runtimeStatusByEnvironmentId.has(environmentId)) {
@@ -196,11 +290,17 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
const next = new Map(s.runtimeStatusByEnvironmentId)
next.delete(environmentId)
return { runtimeStatusByEnvironmentId: next }
}),
})
},
retainRuntimeEnvironmentStatuses: (environmentIds) =>
retainRuntimeEnvironmentStatuses: (environmentIds) => {
const keep = new Set(environmentIds)
for (const id of get().runtimeStatusByEnvironmentId.keys()) {
if (!keep.has(id)) {
dismissRuntimeDisconnectedToast(id)
}
}
set((s) => {
const keep = new Set(environmentIds)
let changed = false
const next = new Map(s.runtimeStatusByEnvironmentId)
for (const id of next.keys()) {
@@ -210,7 +310,8 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
}
}
return changed ? { runtimeStatusByEnvironmentId: next } : s
}),
})
},
refreshRuntimeEnvironmentStatus: async (environmentId, timeoutMs = 10_000) => {
try {
@@ -124,6 +124,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route'
import { isWebClientLocation } from '@/lib/web-client-location'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
import { resolveAgentPaneAuthorityKey } from './agent-pane-authority'
@@ -1527,6 +1528,9 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
return
}
if (isWebClientLocation() && worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) {
return
}
const terminal = get().createTab(worktreeId, groupId)
get().setActiveTab(terminal.id)
get().setActiveTabType('terminal')
@@ -1,7 +1,8 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { PNG } from 'pngjs'
import type { RuntimeTerminalRead } from '../../src/shared/runtime-types'
import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
import { expect, test } from './helpers/orca-app'
@@ -12,6 +13,7 @@ import {
import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal'
const MIN_EXHAUSTED_ACK_BYTES = 400 * 1024
const PUBLICATION_DEADLINE_MS = 10_000
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-stalled-stream-'))
const fixturePath = path.join(scratch, 'stalled-stream-terminal.mjs')
writeFileSync(
@@ -30,7 +32,7 @@ writeFileSync(
" process.stdout.write('HOST_FLOOD_COMPLETE\\r\\n')",
' continue',
' }',
' process.stdout.write(`LIVE:${input}\\r\\n`)',
" process.stdout.write(`\\x1b[48;2;24;96;144mLIVE:${input}${' '.repeat(48)}\\x1b[0m\\r\\n`)",
' }',
'})',
'process.stdin.resume()'
@@ -65,11 +67,169 @@ async function callRuntime<TResult>(page: Page, method: string, params: unknown)
) as Promise<TResult>
}
type AppResourceProxies = {
gpuCpuPercent: number
gpuIdleWakeupsPerSecond: number
processCount: number
totalCpuPercent: number
totalIdleWakeupsPerSecond: number
}
async function getAppResourceProxies(
electronApp: ElectronApplication
): Promise<AppResourceProxies> {
return electronApp.evaluate(({ app }) => {
const metrics = app.getAppMetrics()
const gpu = metrics.find((metric) => metric.type === 'GPU')
return {
gpuCpuPercent: gpu?.cpu.percentCPUUsage ?? 0,
gpuIdleWakeupsPerSecond: gpu?.cpu.idleWakeupsPerSecond ?? 0,
processCount: metrics.length,
totalCpuPercent: metrics.reduce((total, metric) => total + metric.cpu.percentCPUUsage, 0),
totalIdleWakeupsPerSecond: metrics.reduce(
(total, metric) => total + metric.cpu.idleWakeupsPerSecond,
0
)
}
})
}
async function minimizeHeadedHost(electronApp: ElectronApplication, page: Page): Promise<void> {
const host = await electronApp.browserWindow(page)
await host.evaluate((window) => window.minimize())
await expect
.poll(() =>
host.evaluate((window) => ({
backgroundThrottling: window.webContents.getBackgroundThrottling(),
minimized: window.isMinimized(),
visible: window.isVisible()
}))
)
.toEqual({ backgroundThrottling: true, minimized: true, visible: false })
}
async function restoreHeadedHost(electronApp: ElectronApplication, page: Page): Promise<void> {
const host = await electronApp.browserWindow(page)
await host.evaluate((window) => {
window.restore()
window.show()
window.focus()
})
await expect
.poll(() =>
host.evaluate((window) => ({
backgroundThrottling: window.webContents.getBackgroundThrottling(),
minimized: window.isMinimized(),
visible: window.isVisible()
}))
)
.toEqual({ backgroundThrottling: true, minimized: false, visible: true })
await expect.poll(() => page.evaluate(() => document.visibilityState)).toBe('visible')
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
)
}
async function showHeadedClient(electronApp: ElectronApplication, page: Page): Promise<void> {
const clientWindow = await electronApp.browserWindow(page)
await clientWindow.evaluate((window) => {
window.show()
window.focus()
})
await expect.poll(() => clientWindow.evaluate((window) => window.isVisible())).toBe(true)
}
function countForegroundPixels(buffer: Buffer): number {
const image = PNG.sync.read(buffer)
const buckets = new Map<string, { count: number; red: number; green: number; blue: number }>()
for (let offset = 0; offset < image.data.length; offset += 4) {
if ((image.data[offset + 3] ?? 0) < 128) {
continue
}
const red = image.data[offset] ?? 0
const green = image.data[offset + 1] ?? 0
const blue = image.data[offset + 2] ?? 0
const key = `${red >> 3},${green >> 3},${blue >> 3}`
const bucket = buckets.get(key) ?? { count: 0, red, green, blue }
bucket.count += 1
buckets.set(key, bucket)
}
const background = [...buckets.values()].sort((a, b) => b.count - a.count)[0]
if (!background) {
return 0
}
let foregroundPixels = 0
for (let offset = 0; offset < image.data.length; offset += 4) {
if ((image.data[offset + 3] ?? 0) < 128) {
continue
}
const distance =
Math.abs((image.data[offset] ?? 0) - background.red) +
Math.abs((image.data[offset + 1] ?? 0) - background.green) +
Math.abs((image.data[offset + 2] ?? 0) - background.blue)
if (distance > 48) {
foregroundPixels += 1
}
}
return foregroundPixels
}
function countVisualMarkerPixels(buffer: Buffer): number {
const image = PNG.sync.read(buffer)
let count = 0
for (let offset = 0; offset < image.data.length; offset += 4) {
const red = image.data[offset] ?? 0
const green = image.data[offset + 1] ?? 0
const blue = image.data[offset + 2] ?? 0
if (
(image.data[offset + 3] ?? 0) >= 245 &&
blue >= 100 &&
blue - red >= 50 &&
blue - green >= 20 &&
green - red >= 10
) {
count += 1
}
}
return count
}
async function findHostPaneWithMarker(
page: Page,
marker: string
): Promise<{ paneId: number; ptyId: string; tabId: string }> {
let target: { paneId: number; ptyId: string; tabId: string } | null = null
await expect
.poll(
async () => {
target = await page.evaluate((expectedMarker) => {
for (const [tabId, manager] of window.__paneManagers?.entries() ?? []) {
for (const pane of manager.getPanes?.() ?? []) {
const content = pane.serializeAddon?.serialize?.() ?? ''
const ptyId = pane.container?.dataset?.ptyId
if (content.includes(expectedMarker) && ptyId) {
return { paneId: pane.id, ptyId, tabId }
}
}
}
return null
}, marker)
return target !== null
},
{ timeout: 30_000, message: 'host renderer never mirrored the paired terminal marker' }
)
.toBe(true)
if (!target) {
throw new Error('Host terminal marker target disappeared')
}
return target
}
test('restarts one ACK-starved paired terminal stream without replacing its PTY @headful', async ({
electronApp,
orcaPage
}) => {
test.setTimeout(120_000)
}, testInfo) => {
test.setTimeout(150_000)
const liveMarker = `PAIRED_STALL_RECOVERED_${Date.now()}`
const worktree = await orcaPage.evaluate(() => {
const state = window.__store?.getState()
@@ -80,11 +240,13 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY
}
return { id: active.id }
})
const noClientResources = await getAppResourceProxies(electronApp)
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const client = await launchPairedWebClient(electronApp, offer, {
disableRemoteTerminalStallRecovery:
process.env.ORCA_E2E_DISABLE_REMOTE_TERMINAL_STALL_RECOVERY === '1'
})
let observer: Awaited<ReturnType<typeof launchPairedWebClient>> | null = null
let terminal: string | null = null
try {
await expect
@@ -101,8 +263,29 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY
{ timeout: 30_000 }
)
.toBe(true)
const observerOffer = await createRuntimeDesktopPairingOffer(orcaPage)
observer = await launchPairedWebClient(electronApp, observerOffer)
await showHeadedClient(electronApp, client.page)
await showHeadedClient(electronApp, observer.page)
await expect
.poll(
() =>
observer?.page.evaluate(
(worktreeId) =>
window.__store
?.getState()
.allWorktrees()
.some((candidate) => candidate.id === worktreeId),
worktree.id
),
{ timeout: 30_000 }
)
.toBe(true)
const connectedIdleResources = await getAppResourceProxies(electronApp)
await minimizeHeadedHost(electronApp, orcaPage)
const createStartedAt = performance.now()
const created = await callRuntime<{
tab: { parentTabId: string; terminal: string | null }
tab: { id: string; parentTabId: string; terminal: string | null }
}>(client.page, 'session.tabs.createTerminal', {
worktree: `id:${worktree.id}`,
command: fixtureCommand(),
@@ -110,6 +293,8 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY
select: false,
navigation: 'caller'
})
const createLatencyMs = performance.now() - createStartedAt
expect(createLatencyMs).toBeLessThan(PUBLICATION_DEADLINE_MS)
terminal = created.tab.terminal
if (!terminal) {
throw new Error('Paired runtime did not publish the stalled-stream fixture')
@@ -128,6 +313,42 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY
{ timeout: 30_000 }
)
.toBe(true)
await expect
.poll(
() =>
observer?.page.evaluate(
({ tabId, worktreeId }) =>
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
(tab) => tab.id === tabId
),
{ tabId: webTabId, worktreeId: worktree.id }
),
{ timeout: 30_000 }
)
.toBe(true)
await expect
.poll(
() =>
orcaPage.evaluate(
({ tabId, worktreeId }) =>
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
(tab) => tab.id === tabId
),
{ tabId: created.tab.parentTabId, worktreeId: worktree.id }
),
{ timeout: 30_000 }
)
.toBe(true)
expect(
(
await callRuntime<{ tabs: { terminal?: string | null }[] }>(
client.page,
'session.tabs.list',
{ worktree: `id:${worktree.id}` }
)
).tabs.some((candidate) => candidate.terminal === terminal),
'authoritative inventory dropped the terminal before ACK starvation'
).toBe(true)
await client.page.evaluate(
(worktreeId) => window.__store?.getState().setActiveWorktree(worktreeId),
worktree.id
@@ -139,6 +360,20 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain('PAIRED_STALL_READY')
await observer.page.evaluate(
(worktreeId) => window.__store?.getState().setActiveWorktree(worktreeId),
worktree.id
)
const observerTab = observer.page.locator(
`[data-testid="sortable-tab"][data-tab-id="${webTabId}"]`
)
await expect(observerTab).toBeVisible({ timeout: 30_000 })
await observerTab.click()
const observerOriginalPtyId = await waitForActivePanePtyId(observer.page, 30_000)
expect(observerOriginalPtyId.split('@@').at(-1)).toBe(originalPtyId.split('@@').at(-1))
await expect
.poll(() => getTerminalContent(observer.page), { timeout: 30_000 })
.toContain('PAIRED_STALL_READY')
await client.page.evaluate((target) => {
const gate = (
@@ -233,6 +468,157 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY
.toContain(`LIVE:${liveMarker}`)
expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId)
await expect(tab).toHaveAttribute('data-active', 'true')
await expect
.poll(() => getTerminalContent(observer.page), { timeout: 30_000 })
.toContain(`LIVE:${liveMarker}`)
expect(await waitForActivePanePtyId(observer.page, 30_000)).toBe(observerOriginalPtyId)
expect(
(
await callRuntime<{ tabs: { terminal?: string | null }[] }>(
client.page,
'session.tabs.list',
{ worktree: `id:${worktree.id}` }
)
).tabs.some((candidate) => candidate.terminal === terminal),
'authoritative inventory dropped the terminal during ACK recovery'
).toBe(true)
await restoreHeadedHost(electronApp, orcaPage)
await orcaPage.evaluate(
(worktreeId) => window.__store?.getState().setActiveWorktree(worktreeId),
worktree.id
)
const hostTab = orcaPage.locator(
`[data-testid="sortable-tab"][data-tab-id="${created.tab.parentTabId}"]`
)
await expect(hostTab).toBeVisible({ timeout: 30_000 })
await hostTab.click()
const hostPane = await findHostPaneWithMarker(orcaPage, `LIVE:${liveMarker}`)
expect(hostPane.tabId).toBe(created.tab.parentTabId)
await orcaPage.evaluate(({ paneId, tabId }) => {
const manager = window.__paneManagers?.get(tabId)
manager?.setActivePane?.(paneId, { focus: true })
}, hostPane)
await expect
.poll(() => getTerminalContent(orcaPage), { timeout: 30_000 })
.toContain(`LIVE:${liveMarker}`)
const restoredTerminalScreenshot = await orcaPage
.locator(
`[data-terminal-tab-id="${hostPane.tabId}"] .pane[data-pane-id="${hostPane.paneId}"] .xterm-screen`
)
.screenshot({ animations: 'disabled' })
await testInfo.attach('host-terminal-after-create-restore', {
body: restoredTerminalScreenshot,
contentType: 'image/png'
})
expect(
countVisualMarkerPixels(restoredTerminalScreenshot),
'host terminal marker did not repaint after the background publication toggle'
).toBeGreaterThan(500)
expect(
(
await callRuntime<{ tabs: { terminal?: string | null }[] }>(
client.page,
'session.tabs.list',
{ worktree: `id:${worktree.id}` }
)
).tabs.some((candidate) => candidate.terminal === terminal),
'authoritative inventory dropped the terminal while restoring the host'
).toBe(true)
await minimizeHeadedHost(electronApp, orcaPage)
await showHeadedClient(electronApp, observer.page)
const authoritativeInventory = await callRuntime<{
tabs: { id: string; parentTabId?: string; terminal?: string | null }[]
}>(client.page, 'session.tabs.list', { worktree: `id:${worktree.id}` })
const authoritativeTab = authoritativeInventory.tabs.find(
(candidate) => candidate.terminal === terminal
)
if (!authoritativeTab) {
throw new Error(
`Paired terminal was absent from authoritative inventory before close: ${JSON.stringify(
authoritativeInventory.tabs.map((candidate) => ({
id: candidate.id,
parentTabId: candidate.parentTabId,
terminal: candidate.terminal
}))
)}`
)
}
const closeStartedAt = performance.now()
await callRuntime(client.page, 'session.tabs.close', {
worktree: `id:${worktree.id}`,
tabId: authoritativeTab.id,
reason: 'user',
navigation: 'caller'
})
const closeLatencyMs = performance.now() - closeStartedAt
expect(closeLatencyMs).toBeLessThan(PUBLICATION_DEADLINE_MS)
await expect
.poll(
() =>
Promise.all([
orcaPage.evaluate(
({ tabId, worktreeId }) =>
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
(candidate) => candidate.id === tabId
),
{ tabId: created.tab.parentTabId, worktreeId: worktree.id }
),
client.page.evaluate(
({ tabId, worktreeId }) =>
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
(candidate) => candidate.id === tabId
),
{ tabId: webTabId, worktreeId: worktree.id }
),
observer.page.evaluate(
({ tabId, worktreeId }) =>
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
(candidate) => candidate.id === tabId
),
{ tabId: webTabId, worktreeId: worktree.id }
)
]),
{ timeout: 30_000 }
)
.toEqual([false, false, false])
terminal = null
await restoreHeadedHost(electronApp, orcaPage)
const restoredHostScreenshot = await orcaPage.screenshot({ fullPage: true })
expect(
countForegroundPixels(restoredHostScreenshot),
'host compositor remained blank after the background close toggle'
).toBeGreaterThan(1_000)
await testInfo.attach('host-window-after-close-restore', {
body: restoredHostScreenshot,
contentType: 'image/png'
})
const afterPublicationResources = await getAppResourceProxies(electronApp)
const evidencePath = test.info().outputPath('publication-evidence.json')
writeFileSync(
evidencePath,
`${JSON.stringify(
{
afterPublicationResources,
closeLatencyMs,
connectedIdleResources,
createLatencyMs,
hostMinimizedDuringCreateAndClose: true,
hostRestoredAfterEachPublication: true,
noClientResources
},
null,
2
)}\n`
)
await test.info().attach('publication-evidence', {
contentType: 'application/json',
path: evidencePath
})
} finally {
await client.page
.evaluate(() => {
@@ -243,6 +629,7 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY
).__remoteTerminalMultiplexAckGate?.release()
})
.catch(() => undefined)
await observer?.dispose()
if (terminal) {
await callRuntime(client.page, 'terminal.closeTab', { terminal }).catch(() => undefined)
}