diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8bce0d733..7be0e3894 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -163,6 +163,11 @@ import { import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name' +import { + acceptSessionSnapshot, + applyClosedTabTombstones, + type AppliedSnapshotMarker +} from '../../../../src/session/session-tab-snapshot-gate' import { buildMarkdownDiskFallbackDoc, shouldReadMarkdownFromDiskAfterReadTabFailure @@ -908,6 +913,16 @@ export default function SessionScreen() { const terminalsRef = useRef([]) const [sessionTabs, setSessionTabs] = useState([]) const sessionTabsRef = useRef([]) + // Why: subscription, 2s polling, and post-mutation refetch race to apply tab + // snapshots. Track the last applied (publicationEpoch, snapshotVersion) so a + // late-arriving older snapshot from the same publisher can't overwrite (and + // resurrect closed tabs in) a newer one. See session-tab-snapshot-gate. + const appliedSnapshotMarkerRef = useRef({ epoch: null, version: -1 }) + // Why: after an optimistic local close, suppress the tab until the publisher + // confirms its absence, so an in-flight snapshot generated before the close + // propagated (and thus newer by version) can't flash the tab back. Maps tab id + // to an expiry timestamp so a failed host-side close can't hide a tab forever. + const closedTabTombstonesRef = useRef>(new Map()) const [terminalsLoaded, setTerminalsLoaded] = useState(false) const [input, setInput] = useState('') // Why: baseline terminal zoom, reloaded on focus so a Settings → Terminal change @@ -941,6 +956,10 @@ export default function SessionScreen() { const [pendingDiffNotesDelivery, setPendingDiffNotesDelivery] = useState(null) const [creating, setCreating] = useState(false) + // Why: React state isn't a synchronous lock — a fast double-tap can fire two + // creates before `creating` re-renders. This ref blocks the second one in the + // same tick (server idempotency only dedupes identical clientMutationIds). + const creatingTerminalRef = useRef(false) const [creatingBrowser, setCreatingBrowser] = useState(false) const [creatingMarkdown, setCreatingMarkdown] = useState(false) const [createError, setCreateError] = useState('') @@ -1648,7 +1667,16 @@ export default function SessionScreen() { const applySessionTabs = useCallback( (result: SessionTabsResult) => { - let nextTabs = result.tabs + // Reject out-of-order snapshots, then suppress just-closed tabs until the + // publisher confirms their absence. See session-tab-snapshot-gate. + if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) { + return + } + let nextTabs = applyClosedTabTombstones( + result.tabs, + closedTabTombstonesRef.current, + Date.now() + ) const presentTabIds = new Set(nextTabs.map((tab) => tab.id)) const orphanedDraftTabs: MobileSessionTab[] = [] const currentMarkdownDocs = markdownDocsRef.current @@ -2541,6 +2569,12 @@ export default function SessionScreen() { pendingBrowserFocusPageIdRef.current = null pendingTerminalActivationAttemptRef.current = null initialEmptySessionAutoCreateRef.current = null + // Why: snapshot version floor and close tombstones are per-worktree. This + // screen can be reused across worktrees, so a prior worktree's high version + // would reject the next one's first snapshot (same renderer epoch) and stale + // tombstones could suppress same-id tabs. + appliedSnapshotMarkerRef.current = { epoch: null, version: -1 } + closedTabTombstonesRef.current.clear() for (const queued of terminalGestureInputQueuesRef.current.values()) { if (queued.timer) { clearTimeout(queued.timer) @@ -3730,17 +3764,27 @@ export default function SessionScreen() { agent?: MobileNewTabAgentOption['agent'], options?: { initialPrompt?: string; onPromptSent?: () => void } ) { - if (!client || creating) { + if (!client || creatingTerminalRef.current) { return } + creatingTerminalRef.current = true setCreating(true) setCreateError('') + // Why: idempotency key so a transport-level retry (reconnect replay) of this + // create resolves to the same terminal instead of spawning a duplicate. Kept + // compact (no worktree id) to stay under the schema's length cap; the ref + // guard above blocks concurrent taps synchronously. + const clientMutationId = `mobile-create:${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 10)}` + try { const response = await client.sendRequest('session.tabs.createTerminal', { worktree: `id:${worktreeId}`, afterTabId: activeSessionTabId ?? undefined, + clientMutationId, ...(agent ? { agent } : {}) }) if (response.ok) { @@ -3829,6 +3873,7 @@ export default function SessionScreen() { } catch { setCreateError('Failed to create terminal') } finally { + creatingTerminalRef.current = false setCreating(false) } } @@ -4019,7 +4064,6 @@ export default function SessionScreen() { subscribeToTerminal(replacement.handle) } } - scheduleDelayedAction(() => void fetchTerminals(), 300) } } catch { // Close failed — keep the local tab list unchanged. @@ -4042,13 +4086,16 @@ export default function SessionScreen() { initializedHandlesRef.current.delete(tab.terminal) } setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) + // Why: tombstone the closed tab and rely on the subscription/poll + // snapshot (gated by snapshotVersion) instead of a blind 300ms refetch + // that re-applied whatever the host had — often the not-yet-closed list. + closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) if (activeSessionTabId === tab.id) { activeSessionTabTypeRef.current = null setActiveSessionTabId(null) activeHandleRef.current = null setActiveHandle(null) } - scheduleDelayedAction(() => void fetchSessionTabs(), 300) } } catch { // Close failed — keep the authoritative session snapshot visible. diff --git a/mobile/src/session/session-tab-snapshot-gate.test.ts b/mobile/src/session/session-tab-snapshot-gate.test.ts new file mode 100644 index 000000000..5d3fc763d --- /dev/null +++ b/mobile/src/session/session-tab-snapshot-gate.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { + acceptSessionSnapshot, + applyClosedTabTombstones, + type AppliedSnapshotMarker +} from './session-tab-snapshot-gate' + +describe('acceptSessionSnapshot', () => { + it('accepts a newer version from the same publisher and advances the floor', () => { + const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 5 } + expect(acceptSessionSnapshot({ publicationEpoch: 'renderer:a', snapshotVersion: 6 }, marker)).toBe( + true + ) + expect(marker).toEqual({ epoch: 'renderer:a', version: 6 }) + }) + + it('rejects a strictly older version from the same publisher without moving the floor', () => { + const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 42 } + expect( + acceptSessionSnapshot({ publicationEpoch: 'renderer:a', snapshotVersion: 41 }, marker) + ).toBe(false) + expect(marker).toEqual({ epoch: 'renderer:a', version: 42 }) + }) + + it('accepts an equal version (reprocess) so close tombstones can expire and clear', () => { + const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 42 } + // Polling returns the same cached version repeatedly; reprocessing is needed + // so applyClosedTabTombstones can run its TTL/clear pass. + expect( + acceptSessionSnapshot({ publicationEpoch: 'renderer:a', snapshotVersion: 42 }, marker) + ).toBe(true) + expect(marker).toEqual({ epoch: 'renderer:a', version: 42 }) + }) + + it('accepts any version from a new publisher (epoch change) and resets the floor', () => { + const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 99 } + // Lower version but different epoch (renderer reload / headless) → accepted. + expect( + acceptSessionSnapshot({ publicationEpoch: 'headless:b', snapshotVersion: 1 }, marker) + ).toBe(true) + expect(marker).toEqual({ epoch: 'headless:b', version: 1 }) + }) + + it('treats a missing epoch as its own publisher key', () => { + const marker: AppliedSnapshotMarker = { epoch: null, version: -1 } + expect(acceptSessionSnapshot({ snapshotVersion: 5 }, marker)).toBe(true) + // A strictly older snapshot from the same (null) publisher is rejected. + expect(acceptSessionSnapshot({ snapshotVersion: 4 }, marker)).toBe(false) + expect(marker).toEqual({ epoch: null, version: 5 }) + }) +}) + +describe('applyClosedTabTombstones', () => { + const tab = (id: string): { id: string } => ({ id }) + + it('returns the tabs untouched when there are no tombstones', () => { + const tabs = [tab('a'), tab('b')] + expect(applyClosedTabTombstones(tabs, new Map(), 1000)).toBe(tabs) + }) + + it('suppresses a tombstoned tab while it is still present and not expired', () => { + const tombstones = new Map([['a', 5000]]) + const result = applyClosedTabTombstones([tab('a'), tab('b')], tombstones, 1000) + expect(result.map((t) => t.id)).toEqual(['b']) + // Still present in the publisher snapshot → tombstone retained for next time. + expect(tombstones.has('a')).toBe(true) + }) + + it('clears the tombstone once the publisher snapshot no longer includes the tab', () => { + const tombstones = new Map([['a', 5000]]) + const result = applyClosedTabTombstones([tab('b')], tombstones, 1000) + expect(result.map((t) => t.id)).toEqual(['b']) + expect(tombstones.has('a')).toBe(false) + }) + + it('stops suppressing and clears an expired tombstone even if still present', () => { + const tombstones = new Map([['a', 5000]]) + const result = applyClosedTabTombstones([tab('a'), tab('b')], tombstones, 5000) + expect(result.map((t) => t.id)).toEqual(['a', 'b']) + expect(tombstones.has('a')).toBe(false) + }) +}) diff --git a/mobile/src/session/session-tab-snapshot-gate.ts b/mobile/src/session/session-tab-snapshot-gate.ts new file mode 100644 index 000000000..f3d7601ef --- /dev/null +++ b/mobile/src/session/session-tab-snapshot-gate.ts @@ -0,0 +1,70 @@ +// Pure helpers for reconciling incoming session-tab snapshots on mobile. Kept +// free of react-native imports so they stay unit-testable in the node test env. +// Two races motivate these: out-of-order snapshots (subscription vs poll vs +// post-mutation refetch) and the brief window after a local close before the +// publisher's snapshot reflects it. + +/** The last snapshot applied from a publisher, used to reject older ones. */ +export type AppliedSnapshotMarker = { epoch: string | null; version: number } + +/** + * Whether an incoming snapshot should be applied. Rejects only snapshots + * STRICTLY older than the last applied one from the same publisher (epoch) so an + * out-of-order response can't revive a tab a newer snapshot dropped. Equal + * versions are accepted (and reprocessed) — polling returns the same cached + * version repeatedly, and reprocessing is what lets close tombstones expire and + * clear; rejecting equal versions would strand them. A different epoch is a new + * publisher (renderer reload / headless), accepted as the new floor. Mutates + * `marker` to record the accepted snapshot. + */ +export function acceptSessionSnapshot( + incoming: { publicationEpoch?: string; snapshotVersion: number }, + marker: AppliedSnapshotMarker +): boolean { + const incomingEpoch = incoming.publicationEpoch ?? null + if (incomingEpoch === marker.epoch) { + if (incoming.snapshotVersion < marker.version) { + return false + } + } else { + marker.epoch = incomingEpoch + } + marker.version = incoming.snapshotVersion + return true +} + +/** + * Drops tabs the user just closed locally (tombstoned) until the publisher's + * snapshot also drops them or the tombstone expires. Mutates `tombstones`, + * clearing entries the publisher has confirmed gone (absent from `tabs`) or + * whose TTL elapsed — the TTL guards against a failed host-side close hiding a + * tab forever. + */ +export function applyClosedTabTombstones( + tabs: T[], + tombstones: Map, + now: number +): T[] { + if (tombstones.size === 0) { + return tabs + } + const suppressed = new Set() + const next = tabs.filter((tab) => { + const expiry = tombstones.get(tab.id) + if (expiry === undefined) { + return true + } + if (now >= expiry) { + tombstones.delete(tab.id) + return true + } + suppressed.add(tab.id) + return false + }) + for (const [id, expiry] of tombstones) { + if (!suppressed.has(id) || now >= expiry) { + tombstones.delete(id) + } + } + return next +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5eae08671..05c0b392b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -13887,6 +13887,250 @@ describe('OrcaRuntimeService', () => { expect(result.tab).toMatchObject({ parentTabId: 'tab-renderer', isActive: false }) }) + it('dedupes concurrent mobile terminal creates that share a clientMutationId', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + focusTerminal: vi.fn(), + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal: vi.fn(), + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'tab-renderer::pane:1', + parentTabId: 'tab-renderer', + leafId: 'pane:1', + title: 'Terminal', + isActive: false + } + ] + } + ] + }) + ipcMain.emit( + 'terminal:tabCreateReply', + {}, + { requestId: payload.requestId, tabId: 'tab-renderer', title: 'Terminal' } + ) + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send } + }) + + const [first, second] = await Promise.all([ + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false, + clientMutationId: 'mutation-1' + }), + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false, + clientMutationId: 'mutation-1' + }) + ]) + + const createRequests = send.mock.calls.filter( + ([channel]) => channel === 'terminal:requestTabCreate' + ) + expect(createRequests).toHaveLength(1) + expect(second).toBe(first) + expect(first.tab).toMatchObject({ parentTabId: 'tab-renderer' }) + }) + + it('does not dedupe mobile terminal creates across worktrees with the same clientMutationId', async () => { + const otherWorktreeId = `${TEST_REPO_ID}::/tmp/worktree-b` + vi.mocked(listWorktrees).mockResolvedValue([ + ...MOCK_GIT_WORKTREES, + { + path: '/tmp/worktree-b', + head: 'def', + branch: 'feature/bar', + isBare: false, + isMainWorktree: false + } + ]) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => ({ + [TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID], + [otherWorktreeId]: makeWorktreeMeta({ displayName: 'other' }) + }) + } + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setNotifier({ + focusTerminal: vi.fn(), + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal: vi.fn(), + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const send = vi.fn((_channel: string, payload: { requestId: string; worktreeId: string }) => { + const parentTabId = + payload.worktreeId === TEST_WORKTREE_ID ? 'tab-renderer-a' : 'tab-renderer-b' + ipcMain.emit( + 'terminal:tabCreateReply', + {}, + { requestId: payload.requestId, tabId: parentTabId, title: 'Terminal' } + ) + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send } + }) + + const firstCreate = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false, + clientMutationId: 'mutation-1' + }) + const secondCreate = runtime.createMobileSessionTerminal(`id:${otherWorktreeId}`, { + activate: false, + clientMutationId: 'mutation-1' + }) + await vi.waitFor(() => { + const createRequests = send.mock.calls.filter( + ([channel]) => channel === 'terminal:requestTabCreate' + ) + expect(createRequests).toHaveLength(2) + }) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-a', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'tab-renderer-a::pane:1', + parentTabId: 'tab-renderer-a', + leafId: 'pane:1', + title: 'Terminal', + isActive: false + } + ] + }, + { + worktree: otherWorktreeId, + publicationEpoch: 'epoch-b', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'tab-renderer-b::pane:1', + parentTabId: 'tab-renderer-b', + leafId: 'pane:1', + title: 'Terminal', + isActive: false + } + ] + } + ] + }) + const [first, second] = await Promise.all([firstCreate, secondCreate]) + + const createRequests = send.mock.calls.filter( + ([channel]) => channel === 'terminal:requestTabCreate' + ) + expect(createRequests).toHaveLength(2) + expect(first.tab).toMatchObject({ parentTabId: 'tab-renderer-a' }) + expect(second.tab).toMatchObject({ parentTabId: 'tab-renderer-b' }) + }) + + it('rolls back a half-created terminal whose surface never publishes', async () => { + vi.useFakeTimers() + try { + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + focusTerminal: vi.fn(), + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal, + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + // Why: reply with a tabId but never sync a matching surface graph, so + // waitForMobileTerminalSurface times out and the rollback path runs. + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + {}, + { requestId: payload.requestId, tabId: 'tab-ghost', title: 'Terminal' } + ) + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send } + }) + + const pending = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false + }) + const settled = pending.then( + () => ({ ok: true as const }), + (error: Error) => ({ ok: false as const, error }) + ) + await vi.advanceTimersByTimeAsync(11_000) + const outcome = await settled + + expect(outcome.ok).toBe(false) + expect(closeTerminal).toHaveBeenCalledWith('tab-ghost') + } finally { + vi.useRealTimers() + } + }) + it('reports browser tab creation as unsupported for a windowless host with no offscreen backend', async () => { const runtime = new OrcaRuntimeService(store) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 234433996..def687256 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1784,6 +1784,12 @@ export class OrcaRuntimeService { private authoritativeWindowId: number | null = null private tabs = new Map() private mobileSessionTabsByWorktree = new Map() + // Why: idempotency map for mobile terminal creation — a retried create with the + // same clientMutationId returns the in-flight operation instead of duplicating. + private mobileTerminalCreateByMutationId = new Map< + string, + Promise + >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by @@ -14641,6 +14647,47 @@ export class OrcaRuntimeService { launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent activate?: boolean + clientMutationId?: string + } = {} + ): Promise { + const mutationId = opts.clientMutationId + if (!mutationId) { + return this.runCreateMobileSessionTerminal(worktreeSelector, opts) + } + const mutationKey = `${worktreeSelector}\0${mutationId}` + // Why: a retried create (double-tap, reconnect replay) with the same + // idempotency key must return the in-flight operation instead of spawning a + // duplicate terminal. Settled entries are dropped so a later retry — after a + // failure or after the result is consumed — can start a fresh create. + const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) + if (inflight) { + return inflight + } + const run = this.runCreateMobileSessionTerminal(worktreeSelector, opts) + this.mobileTerminalCreateByMutationId.set(mutationKey, run) + void run + .catch(() => {}) + .finally(() => { + if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { + this.mobileTerminalCreateByMutationId.delete(mutationKey) + } + }) + return run + } + + private async runCreateMobileSessionTerminal( + worktreeSelector: string, + opts: { + afterTabId?: string + targetGroupId?: string + command?: string + env?: Record + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + agent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + activate?: boolean + clientMutationId?: string } = {} ): Promise { this.assertGraphReady() @@ -14713,7 +14760,16 @@ export class OrcaRuntimeService { if (opts.activate !== false) { this.notifier?.focusTerminal(reply.tabId, worktreeId, null) } - return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId) + try { + return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId) + } catch (error) { + // Why: the renderer created the tab but its terminal surface never + // published (PTY spawn/handle failure). Roll the half-created tab back via + // the renderer close path so it can't linger as a ghost in mobile + // snapshots, then surface the failure to the caller. + this.notifier?.closeTerminal(reply.tabId) + throw error + } } private async resolveMobileSessionTerminalCommand( diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 4071db4a1..6e614e5ce 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -125,7 +125,10 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ message: 'Unknown launch agent' }) .optional(), - activate: z.boolean().optional() + activate: z.boolean().optional(), + // Why: idempotency key so a retried create (double-tap, reconnect replay) + // returns the in-flight operation instead of spawning a duplicate terminal. + clientMutationId: z.string().min(1).max(128).optional() }) const MoveTabBase = { diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 3f8c03788..6d53555e4 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -50,7 +50,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), - activate: params.activate + activate: params.activate, + clientMutationId: params.clientMutationId }) }), defineMethod({