Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/main/runtime/workspace-session-worktree-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Re-keying a workspace's session rows must not invent state the user never created.
*
* `tabsByWorktree` carries three states, not two: an absent row is "never initialized" and an
* explicit empty row is the closed-last-terminal tombstone that `shouldAutoCreateInitialTerminal`
* honours. Writing `[]` for a source that had no row turns the first into the second, and the
* workspace then never gets its initial terminal.
*/
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id'

const SOURCE = 'repo-1::/old/path'
const TARGET = 'repo-1::/new/path'

function tab(id: string, worktreeId: string) {
return {
id,
ptyId: `pty-${id}`,
worktreeId,
title: id,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}

function session(overrides: Partial<WorkspaceSessionState> = {}): WorkspaceSessionState {
return { ...getDefaultWorkspaceSession(), ...overrides }
}

describe('canonicalizeTerminalSessionWorktreeId', () => {
it('does not fabricate a tombstone for a workspace that had no tabs row', () => {
const next = session({ tabsByWorktree: {} })

canonicalizeTerminalSessionWorktreeId(next, SOURCE, TARGET)

expect(Object.hasOwn(next.tabsByWorktree, TARGET)).toBe(false)
})

it('carries a real closed-last-terminal tombstone across the re-key', () => {
// The other direction: an explicit empty row is user intent and must survive, so the guard has
// to be about the source row's presence and not about the tabs being empty.
const next = session({ tabsByWorktree: { [SOURCE]: [] } })

canonicalizeTerminalSessionWorktreeId(next, SOURCE, TARGET)

expect(Object.hasOwn(next.tabsByWorktree, TARGET)).toBe(true)
expect(next.tabsByWorktree[TARGET]).toEqual([])
expect(Object.hasOwn(next.tabsByWorktree, SOURCE)).toBe(false)
})

it('re-keys the tabs and their own worktree ids', () => {
const next = session({ tabsByWorktree: { [SOURCE]: [tab('tab-1', SOURCE)] } })

canonicalizeTerminalSessionWorktreeId(next, SOURCE, TARGET)

expect(next.tabsByWorktree[TARGET]?.map((entry) => entry.worktreeId)).toEqual([TARGET])
})
})
18 changes: 12 additions & 6 deletions src/main/runtime/workspace-session-worktree-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ export function canonicalizeTerminalSessionWorktreeId(
if (sourceWorktreeId === targetWorktreeId) {
return
}
const tabs = session.tabsByWorktree[sourceWorktreeId] ?? []
delete session.tabsByWorktree[sourceWorktreeId]
session.tabsByWorktree[targetWorktreeId] = tabs.map((tab) => ({
...tab,
worktreeId: targetWorktreeId
}))
// Why presence and not `?? []`: an absent row means never initialized, an explicit empty one is
// the closed-last-terminal tombstone. Writing `[]` for a source that had no row invents that
// tombstone, and the workspace then never gets its initial terminal. Same guard the keyed maps
// below already use.
if (Object.hasOwn(session.tabsByWorktree, sourceWorktreeId)) {
const tabs = session.tabsByWorktree[sourceWorktreeId] ?? []
delete session.tabsByWorktree[sourceWorktreeId]
session.tabsByWorktree[targetWorktreeId] = tabs.map((tab) => ({
...tab,
worktreeId: targetWorktreeId
}))
}

const groups = session.tabGroups?.[sourceWorktreeId]
if (groups) {
Expand Down
18 changes: 17 additions & 1 deletion src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
endWebRuntimeWakeTerminalRespawn
} from '@/runtime/web-runtime-wake-terminal-respawn'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'

export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void {
const state = useAppStore.getState()
Expand Down Expand Up @@ -39,8 +40,23 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): v
return
}

// Why the branch split: two states used to share one line. With NO rows the workspace is being
// seeded for the first time, and that decision belongs to the canonical predicate — an explicit
// empty row is the closed-last-terminal tombstone, not "never initialized", and this is the only
// door a tombstoned workspace reaches (the stream-frame path returns before it). With rows
// present the question is instead whether a woke workspace's tab chrome outlived its PTYs, which
// the tombstone has nothing to say about.
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
if (tabs.length > 0 && renderableTabCount === 0) {
if (tabs.length === 0) {
if (
!shouldAutoCreateInitialTerminal(
renderableTabCount,
Object.hasOwn(state.tabsByWorktree, worktreeId)
)
) {
return
}
} else if (renderableTabCount === 0) {
return
}

Expand Down
147 changes: 147 additions & 0 deletions src/renderer/src/lib/worktree-activation-empty-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,151 @@ describe('empty remote worktree activation', () => {
})
)
})

it('does not create a terminal for a workspace the user emptied', async () => {
// The second re-seed door. The stream-frame path declines on the tombstone, but it only runs
// when the mirror has rows to send; an emptied workspace routes exclusively here, and
// activation calls this on every `activateAndRevealWorktree`, not only after a wake. Reading
// the explicit empty row as "never initialized" re-seeds the workspace on every focus, which is
// the defect this PR exists to close.
const worktree = makeWorktree()
const callRuntimeEnvironment = vi.fn()
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
vi.stubGlobal('window', {
api: { runtimeEnvironments: { call: callRuntimeEnvironment, subscribe: vi.fn() } }
})

useAppStore.setState({
repos: [
{
id: 'repo-1',
path: REPO_PATH,
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { 'repo-1': [worktree] },
tabsByWorktree: { [worktree.id]: [] },
ptyIdsByTabId: {},
settings: {
...getDefaultSettings(ORCA_WORKSPACES_PATH),
activeRuntimeEnvironmentId: 'web-runtime-1'
},
reconcileWorktreeTabModel: vi.fn(() => ({
renderableTabCount: 0,
activeRenderableTabId: null
}))
})

ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
await new Promise((resolve) => setTimeout(resolve, 0))

expect(callRuntimeEnvironment).not.toHaveBeenCalled()
})

it('declines when tab rows exist but none of them render', async () => {
// The arm the seed guard must not swallow in the other direction: rows are present and nothing
// renders, so the model is mid-reconcile and a create here duplicates a pane. Pinned because the
// restructure moved this line, and reverting only this arm broke no other test.
const worktree = makeWorktree()
const callRuntimeEnvironment = vi.fn()
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
vi.stubGlobal('window', {
api: { runtimeEnvironments: { call: callRuntimeEnvironment, subscribe: vi.fn() } }
})

useAppStore.setState({
repos: [
{
id: 'repo-1',
path: REPO_PATH,
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { 'repo-1': [worktree] },
tabsByWorktree: {
[worktree.id]: [
{
id: 'local-tab',
ptyId: 'dead-pty',
worktreeId: worktree.id,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
ptyIdsByTabId: {},
settings: {
...getDefaultSettings(ORCA_WORKSPACES_PATH),
activeRuntimeEnvironmentId: 'web-runtime-1'
},
reconcileWorktreeTabModel: vi.fn(() => ({
renderableTabCount: 0,
activeRenderableTabId: null
}))
})

ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
await new Promise((resolve) => setTimeout(resolve, 0))

expect(callRuntimeEnvironment).not.toHaveBeenCalled()
})

it('still respawns a woke workspace whose tab rows outlived their PTYs', async () => {
// The other branch that used to share the same line: rows are present and renderable, the host
// PTYs are gone. Tombstone-irrelevant, and the case the seed guard must not swallow.
const worktree = makeWorktree()
const callRuntimeEnvironment = vi.fn().mockResolvedValueOnce({ ok: true, result: {} })
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
vi.stubGlobal('window', {
api: { runtimeEnvironments: { call: callRuntimeEnvironment, subscribe: vi.fn() } }
})

useAppStore.setState({
repos: [
{
id: 'repo-1',
path: REPO_PATH,
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { 'repo-1': [worktree] },
tabsByWorktree: {
[worktree.id]: [
{
id: 'local-tab',
ptyId: 'dead-pty',
worktreeId: worktree.id,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
ptyIdsByTabId: {},
settings: {
...getDefaultSettings(ORCA_WORKSPACES_PATH),
activeRuntimeEnvironmentId: 'web-runtime-1'
},
reconcileWorktreeTabModel: vi.fn(() => ({
renderableTabCount: 1,
activeRenderableTabId: 'local-tab'
}))
})

ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
await vi.waitFor(() => {
expect(callRuntimeEnvironment).toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useAppStore } from '../store'
import { createWebRuntimeSessionTerminal } from './web-runtime-session'
import type { WebRuntimeTerminalCreateOutcome } from './web-runtime-session-types'
import {
beginWebRuntimeInitialTerminalBootstrap,
endWebRuntimeInitialTerminalBootstrap,
markWebRuntimeInitialTerminalBootstrapAwaitingMirror
} from './web-runtime-initial-terminal-bootstrap'

/**
* Claim the initial-terminal latch for this environment's worktree, create the terminal, and decide
* how the latch is released.
*
* A thrown or returned failure releases it at once: nothing was created, so a later focus may retry.
* A success whose mirrored `tabsByWorktree` row already exists releases too. A success with no row
* yet is neither: the refresh the create awaits can resolve on an empty, unconfirmed frame while the
* host does hold the tab, and releasing there let the next empty frame seed a duplicate. That case
* is parked as awaiting-mirror and released by the next frame the mirror accepts for the worktree
* (see web-runtime-initial-terminal-bootstrap.ts).
*
* Returns true only when this call owned a create that was not reported as failed, because the
* caller latches a closure-local flag on it. A failure must report false whichever way it arrives:
* releasing the shared latch alone still leaves the subscription that issued the failed create
* unable to retry for as long as its closure lives, which is the same suppression one level up.
*/
export async function dispatchWebRuntimeInitialTerminalBootstrap(
environmentId: string,
worktreeId: string
): Promise<boolean> {
if (!beginWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)) {
return false
}
let outcome: WebRuntimeTerminalCreateOutcome
try {
outcome = await createWebRuntimeSessionTerminal({ worktreeId, environmentId, activate: true })
} catch (error) {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
throw error
}
// Why check the outcome: the create reports RPC and network failures as `{ status: 'failed' }`
// rather than throwing, so the catch above never sees them. Both arms report the same way.
if (outcome.status === 'failed') {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
return false
}
if (Object.hasOwn(useAppStore.getState().tabsByWorktree, worktreeId)) {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
} else {
markWebRuntimeInitialTerminalBootstrapAwaitingMirror(environmentId, worktreeId)
}
return true
Comment thread
pullfrog[bot] marked this conversation as resolved.
}
Loading
Loading