+ {/* Why: translate="no" keeps browser/OS page-translation from swapping
+ text nodes react owns, which otherwise triggers the same
+ insertBefore/removeChild reconciliation crash (237acef1). */}
+
{/* Why: remarkFrontmatter strips front matter from normal markdown
output. When the user opts in from the preview actions menu, render the
raw metadata as a compact read-only block above the document body. */}
diff --git a/src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx b/src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx
new file mode 100644
index 00000000000..9bbe4a91824
--- /dev/null
+++ b/src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx
@@ -0,0 +1,193 @@
+// @vitest-environment happy-dom
+//
+// Regression guards for crash 237acef1: search highlighting must not mutate the
+// DOM react-markdown owns. Injecting
by splitting react's text nodes (and
+// normalize()-merging them on clear) left react with stale child pointers, so
+// the next streamed-content commit threw
+// NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before
+// which the new node is to be inserted is not a child of this node.
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ applyMarkdownPreviewSearchHighlights,
+ clearMarkdownPreviewSearchHighlights,
+ setActiveMarkdownPreviewSearchMatch
+} from './markdown-preview-search'
+
+const SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-match'
+const ACTIVE_SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-active-match'
+
+function MarkdownBody({ parts }: { parts: readonly string[] }): React.JSX.Element {
+ // Mirror react-markdown: a whose children are text nodes react owns.
+ return (
+
+
{parts.map((part) => part)}
+
+ )
+}
+
+describe('markdown preview search highlighting keeps react-owned DOM intact (crash 237acef1)', () => {
+ let container: HTMLDivElement
+ let root: Root
+ const instance = {}
+
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ clearMarkdownPreviewSearchHighlights(instance)
+ })
+
+ function render(parts: readonly string[]): HTMLElement {
+ act(() => root.render())
+ const body = container.querySelector('.markdown-body')
+ if (!body) {
+ throw new Error('missing markdown body')
+ }
+ return body
+ }
+
+ it('applies highlights without mutating the react-owned subtree', () => {
+ const body = render(['hello world'])
+ const matches = applyMarkdownPreviewSearchHighlights(instance, body, 'lo')
+ expect(matches.length).toBe(1)
+ // No is injected; the text react owns is byte-for-byte intact.
+ expect(body.querySelector('mark')).toBeNull()
+ expect(body.textContent).toBe('hello world')
+ })
+
+ it('survives a streamed re-render while highlights are active', () => {
+ const body = render(['alpha ', 'beta'])
+ applyMarkdownPreviewSearchHighlights(instance, body, 'beta')
+ // Highlights stay registered while streamed content rewrites the paragraph.
+ // When they mutated react's text nodes this commit threw NotFoundError; with
+ // no DOM mutation it is clean. (afterEach clears the instance.)
+ expect(() => render(['alpha ', 'gamma', ' delta'])).not.toThrow()
+ })
+})
+
+// The paint path only runs where the CSS Custom Highlight API exists (prod
+// Electron), which jsdom/happy-dom lack — so stub a faithful, Electron-shaped
+// registry + Highlight to exercise it. The stub Highlight throws if handed any
+// constructor argument, catching any accidental `new Highlight(...ranges)`
+// spread (which overflows V8's arg stack on large docs).
+class StubHighlight {
+ readonly ranges = new Set()
+ constructor(...args: Range[]) {
+ if (args.length > 0) {
+ throw new RangeError('Maximum call stack size exceeded')
+ }
+ }
+ add(range: Range): void {
+ this.ranges.add(range)
+ }
+}
+
+class StubHighlightRegistry {
+ readonly entries = new Map()
+ set(name: string, highlight: StubHighlight): void {
+ this.entries.set(name, highlight)
+ }
+ delete(name: string): void {
+ this.entries.delete(name)
+ }
+}
+
+describe('markdown preview search painting with the CSS Custom Highlight API', () => {
+ let registry: StubHighlightRegistry
+
+ beforeEach(() => {
+ registry = new StubHighlightRegistry()
+ vi.stubGlobal('Highlight', StubHighlight)
+ vi.stubGlobal('CSS', { highlights: registry })
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ function bodyWithText(text: string): HTMLElement {
+ const div = document.createElement('div')
+ div.className = 'markdown-body'
+ div.textContent = text
+ document.body.append(div)
+ return div
+ }
+
+ it('paints many matches without a constructor-arg spread', () => {
+ const instance = {}
+ const body = bodyWithText('a a a a a a a a')
+ // A spread paint (new Highlight(...ranges)) would throw via the stub.
+ expect(() => applyMarkdownPreviewSearchHighlights(instance, body, 'a')).not.toThrow()
+ expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(8)
+ clearMarkdownPreviewSearchHighlights(instance)
+ body.remove()
+ })
+
+ it('unions highlights across previews so a second Find does not clobber the first', () => {
+ const a = {}
+ const b = {}
+ const bodyA = bodyWithText('alpha alpha')
+ const bodyB = bodyWithText('alpha')
+
+ const matchesA = applyMarkdownPreviewSearchHighlights(a, bodyA, 'alpha')
+ const matchesB = applyMarkdownPreviewSearchHighlights(b, bodyB, 'alpha')
+ expect(matchesA.length).toBe(2)
+ expect(matchesB.length).toBe(1)
+ // The registry paints the union (A's 2 + B's 1), not just the last writer's.
+ expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(3)
+
+ // Closing B keeps A's highlights.
+ clearMarkdownPreviewSearchHighlights(b)
+ expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(2)
+
+ clearMarkdownPreviewSearchHighlights(a)
+ expect(registry.entries.has(SEARCH_HIGHLIGHT_NAME)).toBe(false)
+ setActiveMarkdownPreviewSearchMatch(a, matchesA, -1)
+ bodyA.remove()
+ bodyB.remove()
+ })
+
+ it('navigation repaints only the active highlight, not the full match set', () => {
+ const instance = {}
+ const body = bodyWithText('a a a a')
+ const matches = applyMarkdownPreviewSearchHighlights(instance, body, 'a')
+ const paintedAfterApply = registry.entries.get(SEARCH_HIGHLIGHT_NAME)
+ setActiveMarkdownPreviewSearchMatch(instance, matches, 0)
+ setActiveMarkdownPreviewSearchMatch(instance, matches, 1)
+ // The SEARCH highlight object is untouched by navigation (not rebuilt each Next/Prev)...
+ expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)).toBe(paintedAfterApply)
+ // ...while the ACTIVE highlight tracks the current match.
+ expect(registry.entries.get(ACTIVE_SEARCH_HIGHLIGHT_NAME)?.ranges.has(matches[1])).toBe(true)
+ clearMarkdownPreviewSearchHighlights(instance)
+ body.remove()
+ })
+
+ it('re-apply drops the active highlight until the caller repaints it (same-count rerender)', () => {
+ const instance = {}
+ const body = bodyWithText('one two one two')
+ const first = applyMarkdownPreviewSearchHighlights(instance, body, 'one')
+ expect(first.length).toBe(2)
+ setActiveMarkdownPreviewSearchMatch(instance, first, 0)
+ expect(registry.entries.has(ACTIVE_SEARCH_HIGHLIGHT_NAME)).toBe(true)
+
+ // A streamed rerender / new query re-applies and clears the active range even
+ // when the match count is unchanged (regression for the vanishing active mark).
+ const second = applyMarkdownPreviewSearchHighlights(instance, body, 'two')
+ expect(second.length).toBe(first.length)
+ expect(registry.entries.has(ACTIVE_SEARCH_HIGHLIGHT_NAME)).toBe(false)
+
+ // MarkdownPreview repaints via its searchRevision effect; the module restores it.
+ setActiveMarkdownPreviewSearchMatch(instance, second, 0)
+ expect(registry.entries.get(ACTIVE_SEARCH_HIGHLIGHT_NAME)?.ranges.has(second[0])).toBe(true)
+ clearMarkdownPreviewSearchHighlights(instance)
+ body.remove()
+ })
+})
diff --git a/src/renderer/src/components/editor/markdown-preview-search.ts b/src/renderer/src/components/editor/markdown-preview-search.ts
index aeaa55df256..1b92f958fa8 100644
--- a/src/renderer/src/components/editor/markdown-preview-search.ts
+++ b/src/renderer/src/components/editor/markdown-preview-search.ts
@@ -166,92 +166,170 @@ function buildLocaleLowercaseIndex(text: string): {
return { text: normalized, originalStartByNormalizedOffset, originalEndByNormalizedOffset }
}
-export function clearMarkdownPreviewSearchHighlights(root: HTMLElement): void {
- const highlights = root.querySelectorAll('[data-markdown-preview-search-match]')
- for (const highlight of highlights) {
- const textNode = document.createTextNode(highlight.textContent ?? '')
- highlight.replaceWith(textNode)
+// Why: react-markdown owns the preview DOM. Injecting by splitting its
+// text nodes (and normalize()-merging them on clear) left react holding stale
+// child pointers, so the next streamed-content commit threw NotFoundError
+// ("insertBefore ... not a child of this node"; crash 237acef1). Paint matches
+// with the CSS Custom Highlight API instead — it highlights Ranges without
+// mutating the DOM react manages. The static names below must match the
+// ::highlight() selectors in markdown-preview.css.
+const SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-match'
+const ACTIVE_SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-active-match'
+
+type HighlightLike = { add(range: Range): void }
+type HighlightRegistryLike = {
+ set(name: string, highlight: HighlightLike): void
+ delete(name: string): void
+}
+
+// Accessed via globalThis so the code degrades to a no-op where the API is
+// absent (older Chromium, jsdom/happy-dom in tests) — match counting and
+// navigation still work off the returned Ranges; only the paint is skipped.
+function getHighlightApi(): {
+ registry: HighlightRegistryLike
+ create: (ranges: readonly Range[]) => HighlightLike
+} | null {
+ const scope = globalThis as {
+ CSS?: { highlights?: HighlightRegistryLike }
+ Highlight?: new () => HighlightLike
+ }
+ const registry = scope.CSS?.highlights
+ const HighlightCtor = scope.Highlight
+ if (!registry || typeof HighlightCtor !== 'function') {
+ return null
+ }
+ return {
+ registry,
+ // Why: build with .add() rather than new Highlight(...ranges). A big doc +
+ // short query yields 100k+ ranges, and spreading that many constructor
+ // args overflows V8's argument stack (RangeError) — the same large-content
+ // regime as the bug this file fixes.
+ create: (ranges) => {
+ const highlight = new HighlightCtor()
+ for (const range of ranges) {
+ highlight.add(range)
+ }
+ return highlight
+ }
+ }
+}
+
+// Why: CSS.highlights is a document-global registry keyed by a static name, but
+// several MarkdownPreview instances can be open at once (split panes, floating
+// window). Track each instance's ranges by its own token and paint the UNION,
+// so a second preview's Find does not clobber the first's highlights. Ranges
+// live in each instance's own subtree, so the union paints every pane correctly.
+const searchRangesByInstance = new Map
diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx
index 9f7c8da1398..07027c00dd5 100644
--- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx
+++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx
@@ -296,7 +296,7 @@ export function OnboardingInlineCommandTerminal({
isVisible
onPtyExit={() => {
onTerminalExit?.()
- closeTab(tabId, { recordInteraction: false })
+ closeTab(tabId, { recordInteraction: false, reason: 'pty-exit' })
}}
onCloseTab={() => closeTab(tabId, { recordInteraction: false })}
/>
diff --git a/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts b/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts
index 09de670c689..226d787c642 100644
--- a/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts
+++ b/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { useAppStore, type AppState } from '@/store'
+import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import {
runKillAllTerminalSurfaces,
snapshotKillAllTerminalSurfaceIds,
@@ -29,6 +30,10 @@ function state(overrides: Partial
= {}): KillAllTer
tabsByWorktree: {},
unifiedTabsByWorktree: {},
ptyIdsByTabId: {},
+ terminalLayoutsByTabId: {},
+ lastKnownRelayPtyIdByTabId: {},
+ deferredSshSessionIdsByTabId: {},
+ pendingReconnectPtyIdByTabId: {},
...overrides
}
}
@@ -120,7 +125,7 @@ describe('runKillAllTerminalSurfaces', () => {
const targetIds = snapshotKillAllTerminalSurfaceIds(current)
const calls: string[] = []
const killDaemonSessions = vi.fn(() => management.promise)
- const closeSurface = vi.fn((targetId: string) => {
+ const closeSurface = vi.fn((targetId: string, _options: unknown) => {
calls.push(`close:${targetId}`)
removeSurface(current, targetId)
})
@@ -166,10 +171,10 @@ describe('runKillAllTerminalSurfaces', () => {
'wt-new-active': [unified('later-visible', 'later-tab', 'wt-new-active')]
},
ptyIdsByTabId: {
- 'background-target': ['pty-shared', 'remote:runtime-only'],
+ 'background-target': ['pty-shared', 'pty-external', 'remote:runtime-only'],
'unified-only-target': ['pty-unified', 'pty-shared'],
'moved-target': ['ssh:host@@pty-last', 'pty-shared'],
- 'later-tab': ['pty-later']
+ 'later-tab': ['pty-later', 'pty-external']
}
})
;(
@@ -177,29 +182,40 @@ describe('runKillAllTerminalSurfaces', () => {
terminalLayoutsByTabId: Record }>
}
).terminalLayoutsByTabId = {
- 'moved-target': { ptyIdsByLeafId: { leaf: 'layout-restore-hint' } }
+ 'moved-target': {
+ root: null,
+ activeLeafId: null,
+ expandedLeafId: null,
+ ptyIdsByLeafId: { leaf: 'layout-restore-hint' }
+ }
}
management.resolve({ killedCount: 2, remainingCount: 1 })
- await vi.waitFor(() => expect(killPty).toHaveBeenCalledTimes(3))
+ await vi.waitFor(() => expect(killPty).toHaveBeenCalledTimes(5))
- expect(closeSurface.mock.calls).toEqual([
- ['background-target', { force: true }],
- ['unified-only-target', { force: true }],
- ['moved-target', { force: true }]
- ])
- expect(calls.slice(0, 3)).toEqual([
- 'close:background-target',
- 'close:unified-only-target',
- 'close:moved-target'
+ expect(closeSurface.mock.calls.map(([targetId]) => targetId)).toEqual([
+ 'background-target',
+ 'unified-only-target',
+ 'moved-target'
])
+ for (const [, options] of closeSurface.mock.calls) {
+ expect(options).toEqual(
+ expect.objectContaining({
+ force: true,
+ localPtyTeardownOwnedExternally: true,
+ precomputedRetirementPlan: expect.any(Object)
+ })
+ )
+ }
+ expect(calls.indexOf('kill:pty-shared')).toBeLessThan(calls.indexOf('close:moved-target'))
expect(killPty).toHaveBeenCalledWith('pty-shared')
expect(killPty).toHaveBeenCalledWith('pty-unified')
expect(killPty).toHaveBeenCalledWith('ssh:host@@pty-last')
expect(killPty).not.toHaveBeenCalledWith('remote:runtime-only')
expect(killPty).not.toHaveBeenCalledWith('pty-later')
+ expect(killPty).not.toHaveBeenCalledWith('pty-external')
expect(killPty).not.toHaveBeenCalledWith('stale-pty')
- expect(killPty).not.toHaveBeenCalledWith('tab-restore-hint')
- expect(killPty).not.toHaveBeenCalledWith('layout-restore-hint')
+ expect(killPty).toHaveBeenCalledWith('tab-restore-hint')
+ expect(killPty).toHaveBeenCalledWith('layout-restore-hint')
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later-tab'])
let settled = false
@@ -215,7 +231,7 @@ describe('runKillAllTerminalSurfaces', () => {
closeAttemptCount: 3,
absentTargetCount: 4,
failedCloseAttemptCount: 0,
- exactKillAcceptedCount: 2,
+ exactKillAcceptedCount: 4,
exactKillRejectedCount: 1,
daemon: { status: 'fulfilled', killedCount: 2, remainingCount: 1 }
})
@@ -240,11 +256,42 @@ describe('runKillAllTerminalSurfaces', () => {
expect(summary.daemon).toEqual({ status: 'rejected' })
expect(summary.absentTargetCount).toBe(1)
- expect(closeSurface).toHaveBeenCalledWith('target', { force: true })
+ expect(closeSurface).toHaveBeenCalledWith(
+ 'target',
+ expect.objectContaining({
+ force: true,
+ localPtyTeardownOwnedExternally: true,
+ precomputedRetirementPlan: expect.any(Object),
+ precomputedCloseState: expect.any(Object)
+ })
+ )
expect(killPty).toHaveBeenCalledWith('local-pty')
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later'])
})
+ it('does not exact-kill daemon sessions already settled by management', async () => {
+ const current = state({
+ tabsByWorktree: { wt: [terminal('target', 'wt')] },
+ ptyIdsByTabId: { target: ['daemon-pty', 'ssh:host@@relay-pty'] }
+ })
+ const killPty = vi.fn().mockResolvedValue(undefined)
+
+ await runKillAllTerminalSurfaces(['target'], {
+ getState: () => current,
+ killDaemonSessions: vi.fn().mockResolvedValue({
+ killedCount: 1,
+ remainingCount: 0,
+ killedSessionIds: ['daemon-pty']
+ }),
+ closeSurface: (targetId) => removeSurface(current, targetId),
+ killPty,
+ reportSummary: vi.fn()
+ })
+
+ expect(killPty).toHaveBeenCalledOnce()
+ expect(killPty).toHaveBeenCalledWith('ssh:host@@relay-pty')
+ })
+
it('reports the informational zero state without provider fanout', async () => {
const killDaemonSessions = vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 })
const closeSurface = vi.fn()
@@ -326,7 +373,135 @@ describe('runKillAllTerminalSurfaces', () => {
})
})
- it('records bounded count and latency evidence for 100 terminal tabs', async () => {
+ it('replans after a pre-mutation close failure and keeps the failed tab as a survivor', async () => {
+ const current = state({
+ activeWorktreeId: 'wt',
+ tabsByWorktree: { wt: [terminal('fails', 'wt'), terminal('closes', 'wt')] },
+ ptyIdsByTabId: { fails: ['pty-fails'], closes: ['pty-closes'] }
+ })
+ const closeSurface = vi.fn(
+ (
+ targetId: string,
+ _options: { precomputedCloseState: { terminalCountBeforeClose: number } }
+ ) => {
+ if (targetId === 'fails') {
+ throw new Error('pre-mutation failure')
+ }
+ removeSurface(current, targetId)
+ }
+ )
+ const killPty = vi.fn().mockResolvedValue(undefined)
+
+ const summary = await runKillAllTerminalSurfaces(['fails', 'closes'], {
+ getState: () => current,
+ killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
+ closeSurface,
+ killPty,
+ yieldToRenderer: vi.fn().mockResolvedValue(undefined),
+ reportSummary: vi.fn()
+ })
+
+ expect(closeSurface.mock.calls[1]?.[1].precomputedCloseState).toEqual({
+ owningWorktreeId: 'wt',
+ terminalCountBeforeClose: 2,
+ nextTerminalTabId: 'fails'
+ })
+ expect(killPty).not.toHaveBeenCalledWith('pty-fails')
+ expect(killPty).toHaveBeenCalledWith('pty-closes')
+ expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['fails'])
+ expect(summary).toMatchObject({
+ closeAttemptCount: 2,
+ absentTargetCount: 1,
+ failedCloseAttemptCount: 1,
+ closeYieldCount: 1
+ })
+ })
+
+ it('revalidates remaining ownership after a yield before killing an exact PTY', async () => {
+ let current = state({
+ activeWorktreeId: 'wt',
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ tabsByWorktree: {
+ wt: [terminal('first', 'wt'), terminal('second', 'wt'), terminal('third', 'wt')]
+ },
+ ptyIdsByTabId: {
+ first: ['pty-first', 'remote:terminal-1'],
+ second: ['pty-second'],
+ third: ['pty-shared-after-yield', 'remote:env-1@@terminal-1']
+ }
+ })
+ const closeSurface = vi.fn(
+ (
+ targetId: string,
+ options: {
+ precomputedRetirementPlan: {
+ runtimeTerminals: unknown[]
+ cleanupOnlyPtyIds: string[]
+ }
+ precomputedCloseState: {
+ terminalCountBeforeClose: number
+ nextTerminalTabId: string | null
+ }
+ }
+ ) => {
+ removeSurface(current, targetId)
+ if (targetId === 'second') {
+ current = { ...current }
+ }
+ expect(options.precomputedCloseState.terminalCountBeforeClose).toBeGreaterThan(0)
+ }
+ )
+ const killPty = vi.fn().mockResolvedValue(undefined)
+ const yieldToRenderer = vi.fn(async () => {
+ current = state({
+ activeWorktreeId: 'wt',
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ tabsByWorktree: {
+ wt: [terminal('third', 'wt'), terminal('survivor', 'wt')]
+ },
+ ptyIdsByTabId: {
+ third: ['pty-shared-after-yield', 'remote:env-1@@terminal-1'],
+ survivor: ['pty-shared-after-yield']
+ }
+ })
+ })
+
+ const summary = await runKillAllTerminalSurfaces(['first', 'second', 'third'], {
+ getState: () => current,
+ killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
+ closeSurface,
+ killPty,
+ yieldToRenderer,
+ reportSummary: vi.fn()
+ })
+
+ expect(closeSurface.mock.calls.map(([targetId]) => targetId)).toEqual([
+ 'first',
+ 'second',
+ 'third'
+ ])
+ expect(closeSurface.mock.calls[2]?.[1].precomputedCloseState).toEqual({
+ owningWorktreeId: 'wt',
+ terminalCountBeforeClose: 2,
+ nextTerminalTabId: 'survivor'
+ })
+ expect(closeSurface.mock.calls[2]?.[1].precomputedRetirementPlan).toMatchObject({
+ runtimeTerminals: [],
+ cleanupOnlyPtyIds: ['remote:env-1@@terminal-1']
+ })
+ expect(killPty).toHaveBeenCalledWith('pty-first')
+ expect(killPty).toHaveBeenCalledWith('pty-second')
+ expect(killPty).not.toHaveBeenCalledWith('pty-shared-after-yield')
+ expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['survivor'])
+ expect(summary).toMatchObject({
+ closeAttemptCount: 3,
+ absentTargetCount: 3,
+ exactKillAcceptedCount: 2,
+ closeYieldCount: 1
+ })
+ })
+
+ it('records bounded fanout and two-close batches for 100 terminal tabs', async () => {
const tabs = Array.from({ length: 100 }, (_, index) => terminal(`tab-${index}`, 'wt'))
const ptyIdsByTabId = Object.fromEntries(tabs.map((tab, index) => [tab.id, [`pty-${index}`]]))
const previousState = useAppStore.getState()
@@ -340,11 +515,25 @@ describe('runKillAllTerminalSurfaces', () => {
const unsubscribe = useAppStore.subscribe(() => {
zustandWrites += 1
})
- const killPty = vi.fn().mockResolvedValue(undefined)
+ const providerKill = vi.fn().mockResolvedValue(undefined)
+ vi.stubGlobal('window', { api: { pty: { kill: providerKill } } })
+ let nowMs = 0
try {
const summary = await runKillAllTerminalSurfaces(snapshotKillAllTerminalSurfaceIds(), {
- killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
- killPty,
+ killDaemonSessions: vi.fn().mockResolvedValue({
+ killedCount: 0,
+ remainingCount: 0,
+ killedSessionIds: []
+ }),
+ closeSurface: (tabId, options) => {
+ closeTerminalTab(tabId, options)
+ nowMs += 1
+ },
+ killPty: providerKill,
+ now: () => nowMs,
+ yieldToRenderer: async () => {
+ nowMs += 0.1
+ },
reportSummary: vi.fn()
})
@@ -352,12 +541,13 @@ describe('runKillAllTerminalSurfaces', () => {
expect(summary.absentTargetCount).toBe(100)
expect(summary.exactKillAcceptedCount).toBe(100)
expect(summary.closeDurationMs).toBeGreaterThanOrEqual(0)
- expect(summary.maxCloseBatchDurationMs).toBeLessThanOrEqual(50)
+ expect(summary.maxCloseBatchDurationMs).toBeLessThanOrEqual(2.1)
expect(summary.closeYieldCount).toBe(49)
expect(summary.closePhaseExceededLongTaskBudget).toBe(false)
expect(zustandWrites).toBeGreaterThanOrEqual(100)
- expect(killPty).toHaveBeenCalledTimes(100)
+ expect(providerKill).toHaveBeenCalledTimes(100)
} finally {
+ vi.unstubAllGlobals()
unsubscribe()
useAppStore.setState(previousState, true)
}
diff --git a/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts b/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts
index 4509b2017a1..8d280ba3f73 100644
--- a/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts
+++ b/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts
@@ -1,17 +1,25 @@
import { useAppStore, type AppState } from '@/store'
-import { closeTerminalTab } from '../terminal/terminal-tab-actions'
+import {
+ closeTerminalTab,
+ type PrecomputedTerminalCloseState
+} from '../terminal/terminal-tab-actions'
+import {
+ buildTerminalTabRetirementPlans,
+ type TerminalTabRetirementPlan,
+ type TerminalTabRetirementState
+} from '@/store/slices/terminal-tab-retirement'
+import { reserveTerminalRetirementTeardowns } from '@/store/slices/terminal-retirement-teardown-reservation'
const CLOSE_BATCH_SIZE = 2
type DaemonKillAllResult = {
killedCount: number
remainingCount: number
+ killedSessionIds?: string[]
}
-export type KillAllTerminalSurfaceState = Pick<
- AppState,
- 'activeWorktreeId' | 'ptyIdsByTabId' | 'tabsByWorktree' | 'unifiedTabsByWorktree'
->
+export type KillAllTerminalSurfaceState = TerminalTabRetirementState &
+ Pick
export type KillAllTerminalSurfacesSummary = {
targetCount: number
@@ -34,7 +42,15 @@ export type KillAllTerminalSurfacesSummary = {
type KillAllTerminalSurfaceDependencies = {
getState: () => KillAllTerminalSurfaceState
killDaemonSessions: () => Promise
- closeSurface: (tabId: string, options: { force: true }) => void
+ closeSurface: (
+ tabId: string,
+ options: {
+ force: true
+ localPtyTeardownOwnedExternally: true
+ precomputedRetirementPlan: TerminalTabRetirementPlan
+ precomputedCloseState: PrecomputedTerminalCloseState
+ }
+ ) => void
killPty: (ptyId: string) => Promise
now: () => number
yieldToRenderer: () => Promise
@@ -60,20 +76,31 @@ export function snapshotKillAllTerminalSurfaceIds(
return [...targetIds]
}
-function getTargetOwners(
+function getTargetIndex(
state: KillAllTerminalSurfaceState,
targetIds: ReadonlySet
-): Map {
+): {
+ ownerByTargetId: Map
+ terminalIdsByWorktree: Map>
+} {
const ownerByTargetId = new Map()
+ const terminalIdsByWorktree = new Map>()
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
+ const ids = terminalIdsByWorktree.get(worktreeId) ?? new Set()
for (const tab of tabs) {
+ ids.add(tab.id)
if (targetIds.has(tab.id) && !ownerByTargetId.has(tab.id)) {
ownerByTargetId.set(tab.id, worktreeId)
}
}
+ terminalIdsByWorktree.set(worktreeId, ids)
}
for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) {
+ const ids = terminalIdsByWorktree.get(worktreeId) ?? new Set()
for (const tab of tabs) {
+ if (tab.contentType === 'terminal') {
+ ids.add(tab.entityId)
+ }
if (
tab.contentType === 'terminal' &&
targetIds.has(tab.entityId) &&
@@ -82,12 +109,18 @@ function getTargetOwners(
ownerByTargetId.set(tab.entityId, worktreeId)
}
}
+ terminalIdsByWorktree.set(worktreeId, ids)
}
- return ownerByTargetId
+ return { ownerByTargetId, terminalIdsByWorktree }
}
-function isTargetPresent(state: KillAllTerminalSurfaceState, targetId: string): boolean {
- return snapshotKillAllTerminalSurfaceIds(state).includes(targetId)
+function getNextTerminalId(ids: ReadonlySet, closingId: string): string | null {
+ for (const id of ids) {
+ if (id !== closingId) {
+ return id
+ }
+ }
+ return null
}
function createDefaultDependencies(): KillAllTerminalSurfaceDependencies {
@@ -117,7 +150,6 @@ export async function runKillAllTerminalSurfaces(
): Promise {
const deps = { ...createDefaultDependencies(), ...dependencies }
const targetIds = [...new Set(snapshotTargetIds)]
- const targetIdSet = new Set(targetIds)
let daemon: KillAllTerminalSurfacesSummary['daemon']
try {
@@ -127,53 +159,105 @@ export async function runKillAllTerminalSurfaces(
}
const cleanupState = deps.getState()
- const ownerByTargetId = getTargetOwners(cleanupState, targetIdSet)
- const presentTargetIds = targetIds.filter((targetId) => ownerByTargetId.has(targetId))
- const activeWorktreeId = cleanupState.activeWorktreeId
- // Why: keeping the active worktree until its targets close lets the existing
- // tab action choose editor/browser/deactivation without spawning a replacement.
- const closeOrder = [
- ...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) !== activeWorktreeId),
- ...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) === activeWorktreeId)
- ]
-
- const exactPtyIds = new Set()
- for (const targetId of presentTargetIds) {
- for (const ptyId of cleanupState.ptyIdsByTabId[targetId] ?? []) {
- if (ptyId.length > 0 && !ptyId.startsWith('remote:')) {
- exactPtyIds.add(ptyId)
+ const remainingTargetIds = new Set(targetIds)
+ const createCloseWave = (state: KillAllTerminalSurfaceState) => {
+ const remainingIdSet = new Set(remainingTargetIds)
+ const { ownerByTargetId, terminalIdsByWorktree } = getTargetIndex(state, remainingIdSet)
+ const presentTargetIds = [...remainingTargetIds].filter((targetId) =>
+ ownerByTargetId.has(targetId)
+ )
+ for (const targetId of remainingTargetIds) {
+ if (!ownerByTargetId.has(targetId)) {
+ remainingTargetIds.delete(targetId)
}
}
+ const activeWorktreeId = state.activeWorktreeId
+ // Why: keeping the active worktree until its targets close lets the existing
+ // tab action choose editor/browser/deactivation without spawning a replacement.
+ const closeOrder = [
+ ...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) !== activeWorktreeId),
+ ...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) === activeWorktreeId)
+ ]
+ return {
+ state,
+ ownerByTargetId,
+ terminalIdsByWorktree,
+ closeOrder,
+ retirementPlans: buildTerminalTabRetirementPlans(state, closeOrder)
+ }
}
- let failedCloseAttemptCount = 0
+ let closeWave = createCloseWave(cleanupState)
+ const daemonKilledSessionIds = new Set(
+ daemon.status === 'fulfilled' ? (daemon.killedSessionIds ?? []) : []
+ )
+ const scheduledPtyOwners = new Set()
+ const exactKillTasks: Promise[] = []
+ const attemptedTargetIds: string[] = []
+ const failedCloseTargetIds = new Set()
const closeStartedAt = deps.now()
let closeBatchStartedAt = closeStartedAt
let maxCloseBatchDurationMs = 0
let closeYieldCount = 0
- for (let index = 0; index < closeOrder.length; index += 1) {
- const targetId = closeOrder[index]
- let failed = false
- try {
- deps.closeSurface(targetId, { force: true })
- } catch {
- failed = true
+ while (closeWave.closeOrder.length > 0) {
+ const batchTargetIds = closeWave.closeOrder.splice(0, CLOSE_BATCH_SIZE)
+ let mustReplanAfterYield = false
+ for (const targetId of batchTargetIds) {
+ remainingTargetIds.delete(targetId)
+ attemptedTargetIds.push(targetId)
+ const owningWorktreeId = closeWave.ownerByTargetId.get(targetId)!
+ const remainingTerminalIds =
+ closeWave.terminalIdsByWorktree.get(owningWorktreeId) ?? new Set()
+ const nextTerminalTabId = getNextTerminalId(remainingTerminalIds, targetId)
+ const { plan: retirementPlan, newlyScheduledPtyOwners } = reserveTerminalRetirementTeardowns(
+ closeWave.state,
+ closeWave.retirementPlans.get(targetId)!,
+ scheduledPtyOwners
+ )
+ let closeFailed = false
+ try {
+ deps.closeSurface(targetId, {
+ force: true,
+ localPtyTeardownOwnedExternally: true,
+ precomputedRetirementPlan: retirementPlan,
+ precomputedCloseState: {
+ owningWorktreeId,
+ terminalCountBeforeClose: remainingTerminalIds.size,
+ nextTerminalTabId
+ }
+ })
+ } catch {
+ closeFailed = true
+ failedCloseTargetIds.add(targetId)
+ }
+ const failedTargetSurvived =
+ closeFailed && snapshotKillAllTerminalSurfaceIds(deps.getState()).includes(targetId)
+ if (failedTargetSurvived) {
+ // Why: a pre-mutation failure leaves the tab as a live non-target owner.
+ // Replan before touching siblings so it still protects counts and PTYs.
+ for (const owner of newlyScheduledPtyOwners) {
+ scheduledPtyOwners.delete(owner)
+ }
+ mustReplanAfterYield = true
+ break
+ }
+ remainingTerminalIds.delete(targetId)
+ for (const ptyId of retirementPlan.localOrSshPtyIds) {
+ if (daemonKilledSessionIds.has(ptyId)) {
+ continue
+ }
+ try {
+ exactKillTasks.push(deps.killPty(ptyId))
+ } catch (error) {
+ exactKillTasks.push(Promise.reject(error))
+ }
+ }
}
- try {
- failed ||= isTargetPresent(deps.getState(), targetId)
- } catch {
- failed = true
- }
- if (failed) {
- failedCloseAttemptCount += 1
- }
- const isBatchEnd = (index + 1) % CLOSE_BATCH_SIZE === 0 || index + 1 === closeOrder.length
- if (isBatchEnd) {
- maxCloseBatchDurationMs = Math.max(maxCloseBatchDurationMs, deps.now() - closeBatchStartedAt)
- }
- if (isBatchEnd && index + 1 < closeOrder.length) {
+ maxCloseBatchDurationMs = Math.max(maxCloseBatchDurationMs, deps.now() - closeBatchStartedAt)
+ if (remainingTargetIds.size > 0) {
// Why: closeTab cascades clone several store maps, so large confirmed
// snapshots yield between bounded batches instead of monopolizing a frame.
+ const stateAfterBatch = deps.getState()
try {
await deps.yieldToRenderer()
} catch {
@@ -181,21 +265,30 @@ export async function runKillAllTerminalSurfaces(
}
closeYieldCount += 1
closeBatchStartedAt = deps.now()
+ const stateAfterYield = deps.getState()
+ if (mustReplanAfterYield || stateAfterYield !== stateAfterBatch) {
+ // Why: a yield lets tabs move, detach, or appear. Replanning the
+ // remaining snapshot prevents stale ownership from killing a survivor.
+ closeWave = createCloseWave(stateAfterYield)
+ }
}
}
const closeDurationMs = Math.max(0, deps.now() - closeStartedAt)
- const exactKillResults = await Promise.allSettled(
- [...exactPtyIds].map((ptyId) => Promise.resolve().then(() => deps.killPty(ptyId)))
- )
+ // Why: the management sweep already settled daemon-owned IDs; awaiting only
+ // reserved exact kills keeps each provider at one request per ownership identity.
+ const exactKillResults = await Promise.allSettled(exactKillTasks)
const exactKillAcceptedCount = exactKillResults.filter(
(result) => result.status === 'fulfilled'
).length
const finalTargetIds = new Set(snapshotKillAllTerminalSurfaceIds(deps.getState()))
const absentTargetCount = targetIds.filter((targetId) => !finalTargetIds.has(targetId)).length
+ const failedCloseAttemptCount = attemptedTargetIds.filter(
+ (targetId) => failedCloseTargetIds.has(targetId) || finalTargetIds.has(targetId)
+ ).length
const summary: KillAllTerminalSurfacesSummary = {
targetCount: targetIds.length,
- closeAttemptCount: closeOrder.length,
+ closeAttemptCount: attemptedTargetIds.length,
absentTargetCount,
failedCloseAttemptCount,
exactKillAcceptedCount,
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts
index b30c7815c1d..00fbbe84067 100644
--- a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts
+++ b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts
@@ -68,6 +68,57 @@ describe('groupWorkspaceKanbanWorktrees', () => {
expect(grouped.get('doing')?.map((item) => item.id)).toEqual(['b', 'c', 'a'])
})
+ it('does not crash when a worktree is missing its displayName under Manual sort', () => {
+ // Repro for crash 99657ab1: a worktree reached the sidebar with an
+ // undefined displayName, so `a.displayName.localeCompare(...)` threw
+ // `Cannot read properties of undefined (reading 'localeCompare')`.
+ expect(() =>
+ groupWorkspaceKanbanWorktrees({
+ worktrees: [
+ worktree({
+ id: 'a',
+ displayName: undefined as unknown as string,
+ workspaceStatus: 'doing',
+ manualOrder: 100
+ }),
+ worktree({
+ id: 'b',
+ displayName: undefined as unknown as string,
+ workspaceStatus: 'doing',
+ manualOrder: 100
+ })
+ ],
+ visibleWorktreeIds: new Set(['a', 'b']),
+ workspaceStatuses: statuses,
+ sortBy: 'manual'
+ })
+ ).not.toThrow()
+ })
+
+ it('does not crash when a worktree is missing its displayName outside Manual sort', () => {
+ expect(() =>
+ groupWorkspaceKanbanWorktrees({
+ worktrees: [
+ worktree({
+ id: 'a',
+ displayName: undefined as unknown as string,
+ workspaceStatus: 'doing',
+ lastActivityAt: 10
+ }),
+ worktree({
+ id: 'b',
+ displayName: undefined as unknown as string,
+ workspaceStatus: 'doing',
+ lastActivityAt: 10
+ })
+ ],
+ visibleWorktreeIds: new Set(['a', 'b']),
+ workspaceStatuses: statuses,
+ sortBy: 'recent'
+ })
+ ).not.toThrow()
+ })
+
it('keeps pinned then recent ordering outside Manual sort', () => {
const grouped = groupWorkspaceKanbanWorktrees({
worktrees: [
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts
index ffa17cbde9c..3a23c176ba7 100644
--- a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts
+++ b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts
@@ -2,15 +2,20 @@ import type { WorkspaceStatus, WorkspaceStatusDefinition, Worktree } from '../..
import type { SortBy } from './smart-sort'
import { getWorkspaceStatus } from './workspace-status'
+// Why: displayName is typed non-optional, but persisted/discovered worktrees
+// have reached the sidebar with an undefined name (crash 99657ab1), which made
+// `displayName.localeCompare(...)` throw and took down the sidebar. Compare on a
+// safe string so a missing name only affects tie-break order, never crashes.
+function compareDisplayName(a: Worktree, b: Worktree): number {
+ return (a.displayName ?? '').localeCompare(b.displayName ?? '')
+}
+
function sortBoardWorktrees(a: Worktree, b: Worktree): number {
- return b.lastActivityAt - a.lastActivityAt || a.displayName.localeCompare(b.displayName)
+ return b.lastActivityAt - a.lastActivityAt || compareDisplayName(a, b)
}
function sortManualBoardWorktrees(a: Worktree, b: Worktree): number {
- return (
- (b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) ||
- a.displayName.localeCompare(b.displayName)
- )
+ return (b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) || compareDisplayName(a, b)
}
export function groupWorkspaceKanbanWorktrees(params: {
diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts
index a8d3fd0a571..da137c5d6af 100644
--- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts
+++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts
@@ -72,7 +72,8 @@ vi.mock('../../runtime/web-runtime-session', () => ({
closeWebRuntimeSessionTab: mocks.closeWebRuntimeSessionTab,
createWebRuntimeSessionBrowserTab: vi.fn(),
createWebRuntimeSessionTerminal: vi.fn(),
- isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive
+ isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive,
+ toHostSessionTabId: (tabId: string) => tabId
}))
vi.mock('../../store/slices/browser-webview-cleanup', () => ({
@@ -233,6 +234,72 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => {
expect(event.detail).toEqual({ tabId: 'terminal-1' })
})
+ it('revokes local terminal state before paired-host bulk close', async () => {
+ const secondTerminal = {
+ id: 'terminal-2',
+ ptyId: 'remote:env-1@@pty-2',
+ worktreeId: 'wt-1',
+ title: 'Terminal 2',
+ defaultTitle: 'Terminal 2',
+ customTitle: null,
+ color: null,
+ sortOrder: 1,
+ createdAt: 1
+ }
+ const secondUnified = {
+ id: 'unified-terminal-2',
+ entityId: secondTerminal.id,
+ groupId: 'group-1',
+ worktreeId: 'wt-1',
+ contentType: 'terminal',
+ label: 'Terminal 2',
+ customLabel: null,
+ color: null,
+ sortOrder: 1,
+ createdAt: 1
+ }
+ const currentState = storeBox.state as {
+ tabsByWorktree: Record
+ unifiedTabsByWorktree: Record
+ }
+ const firstUnified = currentState.unifiedTabsByWorktree['wt-1'][0]
+ storeBox.state = {
+ ...storeBox.state,
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ tabsByWorktree: {
+ 'wt-1': [...currentState.tabsByWorktree['wt-1'], secondTerminal]
+ },
+ unifiedTabsByWorktree: {
+ 'wt-1': [firstUnified, secondUnified]
+ },
+ groupsByWorktree: {
+ 'wt-1': [
+ {
+ id: 'group-1',
+ worktreeId: 'wt-1',
+ activeTabId: firstUnified.id,
+ tabOrder: [firstUnified.id, secondUnified.id]
+ }
+ ]
+ }
+ }
+ mocks.isWebRuntimeSessionActive.mockReturnValue(true)
+ const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel')
+ const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' })
+
+ model.commands.closeOthers(firstUnified.id)
+
+ expect(mocks.closeTab).toHaveBeenCalledWith(
+ 'terminal-2',
+ expect.objectContaining({ remoteCloseOwnedByHost: true })
+ )
+ expect(mocks.closeWebRuntimeSessionTab).toHaveBeenCalledWith({
+ worktreeId: 'wt-1',
+ tabId: 'terminal-2',
+ environmentId: 'env-1'
+ })
+ })
+
it('records terminal split completion when splitting a single terminal tab group', async () => {
mocks.createEmptySplitGroup.mockReturnValue('group-2')
mocks.createTab.mockReturnValue({ id: 'terminal-2' })
diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
index 87cbc63b0a0..5920077ac52 100644
--- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
+++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
@@ -316,11 +316,9 @@ export function useTabGroupWorkspaceModel({
worktreeId
)
if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) {
- void closeWebRuntimeSessionTab({
- worktreeId,
- tabId: item.entityId,
- environmentId: runtimeEnvironmentId
- })
+ // Why: paired-host bulk close must revoke local resume and hook
+ // authority before asking the host to remove its canonical tab.
+ closeTerminalTab(item.entityId)
continue
}
if (item.contentType === 'browser') {
diff --git a/src/renderer/src/components/task-page-work-item-pagination.test.ts b/src/renderer/src/components/task-page-work-item-pagination.test.ts
new file mode 100644
index 00000000000..b3bc3301d94
--- /dev/null
+++ b/src/renderer/src/components/task-page-work-item-pagination.test.ts
@@ -0,0 +1,280 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { GitHubWorkItem } from '../../../shared/types'
+import { accumulateWorkItemPages, workItemIdentity } from './task-page-work-item-pagination'
+
+function item(repoId: string, id: string, updatedAt: string): GitHubWorkItem {
+ return {
+ id,
+ type: 'issue',
+ number: Number.parseInt(id.split(':')[1] ?? '0', 10),
+ title: id,
+ state: 'open',
+ url: `https://example.test/${id}`,
+ labels: [],
+ updatedAt,
+ author: null,
+ repoId
+ }
+}
+
+// Build a run of issues numbered `from`..`to` (inclusive) with strictly
+// decreasing updatedAt, on repo `r`, so `at(-1)` is the oldest.
+function run(r: string, from: number, to: number): GitHubWorkItem[] {
+ const out: GitHubWorkItem[] = []
+ for (let n = from; n <= to; n += 1) {
+ // Larger number => newer; encode as a descending timestamp so sort order is
+ // unambiguous and distinct.
+ const ts = `2026-01-01T00:00:${String(1000 - n).padStart(4, '0')}Z`
+ out.push(item(r, `issue:${n}`, ts))
+ }
+ return out
+}
+
+describe('workItemIdentity', () => {
+ it('qualifies the bare id with the repo so cross-repo collisions are distinct', () => {
+ expect(workItemIdentity(item('repo-a', 'issue:9', 't'))).toBe('repo-a:issue:9')
+ expect(workItemIdentity(item('repo-b', 'issue:9', 't'))).not.toBe(
+ workItemIdentity(item('repo-a', 'issue:9', 't'))
+ )
+ })
+})
+
+describe('accumulateWorkItemPages', () => {
+ it('drops the re-fetched boundary row that shares the previous page cursor', async () => {
+ const boundary = item('r', 'issue:2', '2026-07-02')
+ const existing = [[item('r', 'issue:1', '2026-07-03'), boundary]]
+ // Inclusive cursor re-returns issue:2 (boundary) then genuinely older rows,
+ // enough to fill a full page of size 2 after dedup.
+ const fetchPage = vi.fn().mockResolvedValue({
+ items: [boundary, item('r', 'issue:3', '2026-07-01'), item('r', 'issue:4', '2026-07-00')]
+ })
+
+ const result = await accumulateWorkItemPages({
+ existingPages: existing,
+ initialCursor: '2026-07-02',
+ targetPage: 1,
+ pageSize: 2,
+ fetchPage,
+ isCancelled: () => false
+ })
+
+ expect(result.cancelled).toBe(false)
+ if (result.cancelled) {
+ return
+ }
+ expect(result.newPages).toEqual([
+ [item('r', 'issue:3', '2026-07-01'), item('r', 'issue:4', '2026-07-00')]
+ ])
+ expect(result.loadedPages).toBe(2)
+ expect(fetchPage).toHaveBeenCalledWith('2026-07-02')
+ })
+
+ it('backfills across fetches so a deduped page stays full and the tail stays reachable', async () => {
+ // Regression: pageSize 3. Page 0 = issues 1..3. Inclusive cursor re-fetches
+ // the boundary (issue:3), so a naive "one fetch = one page" scheme would emit
+ // a 2-item page and the count-derived totalPages would strand the tail.
+ const existing = [run('r', 1, 3)]
+ const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
+ const fetchPage = vi
+ .fn()
+ // <=cursor0 re-returns issue:3 (boundary) + 4,5 => 2 fresh, not yet a page.
+ .mockResolvedValueOnce({ items: run('r', 3, 5) })
+ // Backfill fetches again from issue:5's ts and gets 5 (boundary) + 6 => 1 fresh.
+ .mockResolvedValueOnce({ items: run('r', 5, 6) })
+
+ const result = await accumulateWorkItemPages({
+ existingPages: existing,
+ initialCursor: cursor0,
+ targetPage: 1,
+ pageSize: 3,
+ fetchPage,
+ isCancelled: () => false
+ })
+
+ expect(result.cancelled).toBe(false)
+ if (result.cancelled) {
+ return
+ }
+ // One uniform full page of the 3 genuinely-new items 4,5,6 — nothing stranded.
+ expect(result.newPages).toEqual([run('r', 4, 6)])
+ expect(result.loadedPages).toBe(2)
+ expect(fetchPage).toHaveBeenCalledTimes(2)
+ expect(fetchPage).toHaveBeenNthCalledWith(2, run('r', 3, 5).at(-1)!.updatedAt)
+ })
+
+ it('does not confuse same-numbered items from different repos', async () => {
+ const existing = [[item('repo-a', 'issue:9', '2026-07-02')]]
+ // repo-b's issue:9 is a different item and must survive dedup.
+ const fetchPage = vi
+ .fn()
+ .mockResolvedValue({ items: [item('repo-b', 'issue:9', '2026-07-01')] })
+
+ const result = await accumulateWorkItemPages({
+ existingPages: existing,
+ initialCursor: '2026-07-02',
+ targetPage: 1,
+ pageSize: 1,
+ fetchPage,
+ isCancelled: () => false
+ })
+
+ expect(result.cancelled).toBe(false)
+ if (result.cancelled) {
+ return
+ }
+ expect(result.newPages).toEqual([[item('repo-b', 'issue:9', '2026-07-01')]])
+ expect(result.loadedPages).toBe(2)
+ })
+
+ it('flushes a short final page when the source is exhausted mid-page', async () => {
+ const existing = [run('r', 1, 3)]
+ const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
+ const fetchPage = vi
+ .fn()
+ // 2 fresh rows, short of pageSize 3...
+ .mockResolvedValueOnce({ items: run('r', 3, 5) })
+ // ...then the source is exhausted.
+ .mockResolvedValueOnce({ items: [] })
+
+ const result = await accumulateWorkItemPages({
+ existingPages: existing,
+ initialCursor: cursor0,
+ targetPage: 1,
+ pageSize: 3,
+ fetchPage,
+ isCancelled: () => false
+ })
+
+ expect(result.cancelled).toBe(false)
+ if (result.cancelled) {
+ return
+ }
+ // Short final page rather than dropping issues 4,5.
+ expect(result.newPages).toEqual([run('r', 4, 5)])
+ expect(result.loadedPages).toBe(2)
+ })
+
+ it('stops (flushing progress) when a full page yields nothing new', async () => {
+ const existing = [run('r', 1, 3)]
+ const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
+ // First fetch adds issues 4,5 (2 fresh); second fetch re-returns only
+ // already-seen rows (a >pageSize same-timestamp run) — no forward progress.
+ const fetchPage = vi
+ .fn()
+ .mockResolvedValueOnce({ items: run('r', 3, 5) })
+ .mockResolvedValueOnce({ items: run('r', 4, 5) })
+
+ const result = await accumulateWorkItemPages({
+ existingPages: existing,
+ initialCursor: cursor0,
+ targetPage: 5,
+ pageSize: 3,
+ fetchPage,
+ isCancelled: () => false
+ })
+
+ expect(result.cancelled).toBe(false)
+ if (result.cancelled) {
+ return
+ }
+ // The 2 buffered fresh rows are flushed rather than lost.
+ expect(result.newPages).toEqual([run('r', 4, 5)])
+ expect(fetchPage).toHaveBeenCalledTimes(2)
+ })
+
+ it('chains full pages until the target page is reached', async () => {
+ const existing = [run('r', 1, 2)]
+ const cursor0 = run('r', 1, 2).at(-1)!.updatedAt
+ const fetchPage = vi
+ .fn()
+ .mockResolvedValueOnce({ items: run('r', 2, 4) }) // boundary 2 + 3,4
+ .mockResolvedValueOnce({ items: run('r', 4, 6) }) // boundary 4 + 5,6
+
+ const result = await accumulateWorkItemPages({
+ existingPages: existing,
+ initialCursor: cursor0,
+ targetPage: 2,
+ pageSize: 2,
+ fetchPage,
+ isCancelled: () => false
+ })
+
+ expect(result.cancelled).toBe(false)
+ if (result.cancelled) {
+ return
+ }
+ expect(result.newPages).toEqual([run('r', 3, 4), run('r', 5, 6)])
+ expect(result.loadedPages).toBe(3)
+ })
+
+ it('stops at an empty page (source exhausted) with no buffered rows', async () => {
+ const existing = [[item('r', 'issue:1', '2026-07-02')]]
+ const fetchPage = vi.fn().mockResolvedValue({ items: [] })
+
+ const result = await accumulateWorkItemPages({
+ existingPages: existing,
+ initialCursor: '2026-07-02',
+ targetPage: 3,
+ pageSize: 3,
+ fetchPage,
+ isCancelled: () => false
+ })
+
+ expect(result).toEqual({ cancelled: false, newPages: [], loadedPages: 1 })
+ })
+
+ it('recovers leftover rows discarded at the target boundary on the next call', async () => {
+ const pageSize = 3
+ // Call 1: page0 = 1..3, target page 1. The fetch returns more than one
+ // page of fresh rows; issue 7 is past the emitted page and gets discarded
+ // (it belongs to page 2, which the user hasn't requested yet).
+ const existing1 = [run('r', 1, 3)]
+ const fetch1 = vi.fn().mockResolvedValueOnce({ items: run('r', 3, 7) }) // boundary 3 + 4,5,6,7
+ const call1 = await accumulateWorkItemPages({
+ existingPages: existing1,
+ initialCursor: run('r', 1, 3).at(-1)!.updatedAt,
+ targetPage: 1,
+ pageSize,
+ fetchPage: fetch1,
+ isCancelled: () => false
+ })
+ expect(call1.cancelled).toBe(false)
+ if (call1.cancelled) {
+ return
+ }
+ expect(call1.newPages).toEqual([run('r', 4, 6)]) // 7 discarded
+
+ // Call 2: page1 is now on screen. The inclusive cursor re-fetches the
+ // boundary (6, deduped) plus the discarded 7 and beyond — 7 must reappear
+ // exactly once, in order, with no duplicate of 4..6.
+ const call2 = await accumulateWorkItemPages({
+ existingPages: [...existing1, ...call1.newPages],
+ initialCursor: call1.newPages.at(-1)!.at(-1)!.updatedAt, // ts(6)
+ targetPage: 2,
+ pageSize,
+ fetchPage: vi.fn().mockResolvedValueOnce({ items: run('r', 6, 9) }), // boundary 6 + 7,8,9
+ isCancelled: () => false
+ })
+ expect(call2.cancelled).toBe(false)
+ if (call2.cancelled) {
+ return
+ }
+ expect(call2.newPages).toEqual([run('r', 7, 9)]) // 7 recovered, deduped, in order
+ })
+
+ it('reports cancellation and discards fetched pages when superseded', async () => {
+ const existing = [[item('r', 'issue:1', '2026-07-02')]]
+ const fetchPage = vi.fn().mockResolvedValue({ items: [item('r', 'issue:2', '2026-07-01')] })
+
+ const result = await accumulateWorkItemPages({
+ existingPages: existing,
+ initialCursor: '2026-07-02',
+ targetPage: 1,
+ pageSize: 1,
+ fetchPage,
+ isCancelled: () => true
+ })
+
+ expect(result).toEqual({ cancelled: true })
+ })
+})
diff --git a/src/renderer/src/components/task-page-work-item-pagination.ts b/src/renderer/src/components/task-page-work-item-pagination.ts
new file mode 100644
index 00000000000..a332fe675e2
--- /dev/null
+++ b/src/renderer/src/components/task-page-work-item-pagination.ts
@@ -0,0 +1,125 @@
+import type { GitHubWorkItem } from '../../../shared/types'
+
+/**
+ * Cross-repo Tasks pagination is cursor-based on `updatedAt`: each page's oldest
+ * row seeds the next fetch as `updated:<=`. The bound is inclusive so
+ * items sharing the boundary row's exact timestamp aren't skipped between pages
+ * (#8649) — which means the boundary rows come back on the next fetch and must
+ * be deduped by identity here.
+ *
+ * Because dedup removes the re-fetched overlap, a single fetch no longer yields
+ * a full page. If we emitted each deduped fetch as its own page, pages 1+ would
+ * be one row short of `pageSize` while `totalPages` (count ÷ pageSize) still
+ * assumed full pages — stranding the tail items. So we backfill: accumulate
+ * fresh rows across as many fetches as needed and emit uniform `pageSize` pages,
+ * flushing a short final page only when the source is exhausted.
+ *
+ * Kept as a pure function (out of the TaskPage component) so the dedup +
+ * cursor-advance + fixed-page contract is unit-testable without a DOM.
+ *
+ * Tradeoff: when the per-repo fetch size equals `pageSize` the boundary dedup
+ * costs one extra fetch per page (and re-fetches the sub-page leftover on the
+ * next advance). Acceptable for interactive pagination and bounded by the gh
+ * rate-limit guard; persisting the cursor/buffer across calls to avoid the
+ * re-fetch is a possible follow-up.
+ */
+
+// Why: `item.id` (e.g. "issue:9") is only unique within a repo — two selected
+// repos can carry the same bare id. Key dedup on repo + id, matching the row
+// key the table renders with.
+export function workItemIdentity(item: Pick): string {
+ return `${item.repoId}:${item.id}`
+}
+
+export type WorkItemPageFetchResult = { items: GitHubWorkItem[] }
+
+export type AccumulateWorkItemPagesArgs = {
+ /** Pages already on screen; their items seed the dedup set. */
+ existingPages: readonly GitHubWorkItem[][]
+ /** updatedAt of the oldest currently-loaded row — the first fetch cursor. */
+ initialCursor: string
+ /** 0-indexed page the user is trying to reach. */
+ targetPage: number
+ /** Uniform display page size (the component's effectivePageSize). */
+ pageSize: number
+ /** Fetch one page for the given inclusive cursor. */
+ fetchPage: (cursor: string) => Promise
+ /** Returns true if the request was superseded and results should be dropped. */
+ isCancelled: () => boolean
+}
+
+export type AccumulateWorkItemPagesResult =
+ | { cancelled: true }
+ | {
+ cancelled: false
+ /** Freshly fetched, deduped, uniform-sized pages to append. */
+ newPages: GitHubWorkItem[][]
+ /** Total loaded page count after appending (existing + new). */
+ loadedPages: number
+ }
+
+/**
+ * Fetch pages until `targetPage` is reached (or the source is exhausted),
+ * deduping re-fetched boundary rows and emitting uniform `pageSize` pages so
+ * every item lands on exactly one page and page sizes stay consistent with the
+ * count-derived `totalPages`.
+ */
+export async function accumulateWorkItemPages(
+ args: AccumulateWorkItemPagesArgs
+): Promise {
+ const { existingPages, initialCursor, targetPage, pageSize, fetchPage, isCancelled } = args
+
+ const seen = new Set()
+ for (const page of existingPages) {
+ for (const item of page) {
+ seen.add(workItemIdentity(item))
+ }
+ }
+
+ let cursor = initialCursor
+ let loadedPages = existingPages.length
+ const newPages: GitHubWorkItem[][] = []
+ // Fresh rows not yet emitted as a page, held until they fill `pageSize`.
+ let buffer: GitHubWorkItem[] = []
+
+ const emitFullPages = (): void => {
+ while (buffer.length >= pageSize && loadedPages <= targetPage) {
+ newPages.push(buffer.slice(0, pageSize))
+ buffer = buffer.slice(pageSize)
+ loadedPages += 1
+ }
+ }
+
+ while (loadedPages <= targetPage) {
+ const { items } = await fetchPage(cursor)
+ if (isCancelled()) {
+ return { cancelled: true }
+ }
+ if (items.length === 0) {
+ break
+ }
+ const fresh = items.filter((item) => !seen.has(workItemIdentity(item)))
+ // Advance from the raw fetch, not the deduped rows, so the cursor tracks
+ // real data. If a full page yields nothing new the cursor can't move past
+ // this timestamp (a rare >pageSize same-timestamp run), so stop rather than
+ // re-fetch the same window forever.
+ cursor = items.at(-1)!.updatedAt
+ if (fresh.length === 0) {
+ break
+ }
+ for (const item of fresh) {
+ seen.add(workItemIdentity(item))
+ }
+ buffer.push(...fresh)
+ emitFullPages()
+ }
+
+ // Source exhausted (or stalled) with a partial page still buffered: emit it as
+ // a short final page so those items remain reachable.
+ if (buffer.length > 0 && loadedPages <= targetPage) {
+ newPages.push(buffer)
+ loadedPages += 1
+ }
+
+ return { cancelled: false, newPages, loadedPages }
+}
diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
index c30f5754327..af3468484ca 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
@@ -15,6 +15,7 @@ import { shouldMountBackgroundWorktreeTab } from '../terminal/background-termina
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
+import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement'
type TerminalOverlayAssignment = {
unifiedTabId: string
@@ -61,7 +62,7 @@ type TerminalOverlaySlotProps = {
activityTerminalPortal: ActivityTerminalPortalTarget | null
onFocusOwningGroup: ((groupId: string) => void) | undefined
consumeSuppressedPtyExit: (ptyId: string) => boolean
- closeTab: (tabId: string) => void
+ closeTab: (tabId: string, options?: { reason?: TerminalTabCloseReason }) => void
leaveWorktreeIfEmpty: () => void
}
@@ -246,7 +247,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
return
}
- closeTab(terminalTabId)
+ closeTab(terminalTabId, { reason: 'pty-exit' })
leaveWorktreeIfEmpty()
}}
onCloseTab={() => {
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index ad6830d3adb..5717f2cf17f 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -200,6 +200,7 @@ type StoreState = {
setAgentStatus: ReturnType
removeAgentStatus: ReturnType
dropAgentStatus: ReturnType
+ retireAgentPaneAuthority: ReturnType
setPaneForegroundAgent: ReturnType
clearPaneForegroundAgent: ReturnType
markTerminalTabUnread: ReturnType
@@ -828,6 +829,7 @@ describe('connectPanePty', () => {
),
removeAgentStatus: vi.fn(),
dropAgentStatus: vi.fn(),
+ retireAgentPaneAuthority: vi.fn(),
setPaneForegroundAgent: vi.fn((paneKey: string, entry: PaneForegroundAgentEntry) => {
mockStoreState.paneForegroundAgentByPaneKey[paneKey] = entry
}),
diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts
index d6dd76e817a..53086e28361 100644
--- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts
@@ -62,6 +62,13 @@ export function disposeParkedTabWatchers(tabId: string): void {
entry.disposersByPtyId.clear()
}
+export function retireParkedTerminalTab(tabId: string): void {
+ // Why: explicit tab retirement permanently invalidates both live parked
+ // observers and unmounted-pane candidates; neither may reattach later.
+ disposeParkedTabWatchers(tabId)
+ capturedPanesByTabId.delete(tabId)
+}
+
/**
* Synchronously disposes any parked watcher subscribed to these PTYs.
* shutdownWorktreeTerminals silences the live transports' final teardown
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
index d3f35c8f5e1..744fc1d0f60 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
@@ -151,7 +151,7 @@ describe('shouldDetachPaneTransportOnUnmount', () => {
).toBe(true)
})
- it('destroys when the tab is gone and no replacement owns the PTY', () => {
+ it('detaches when closeTab already owns provider shutdown for the removed tab', () => {
expect(
shouldDetachPaneTransportOnUnmount({
tabStillExists: false,
@@ -159,6 +159,17 @@ describe('shouldDetachPaneTransportOnUnmount', () => {
ptyId: 'remote:env@@term-1',
worktreeTabs: []
})
+ ).toBe(true)
+ })
+
+ it('destroys an ID-less transport so a pending spawn cannot outlive unmount', () => {
+ expect(
+ shouldDetachPaneTransportOnUnmount({
+ tabStillExists: false,
+ tabId: 'tab-1',
+ ptyId: null,
+ worktreeTabs: []
+ })
).toBe(false)
})
})
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index feeedbc6cd0..3430580e68d 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -459,15 +459,9 @@ export function shouldDetachPaneTransportOnUnmount(args: {
ptyId: string | null
worktreeTabs: readonly TerminalTab[] | undefined
}): boolean {
- if (!args.ptyId) {
- return false
- }
- if (args.tabStillExists) {
- return true
- }
- return Boolean(
- args.worktreeTabs?.some((tab) => tab.id !== args.tabId && tab.ptyId === args.ptyId)
- )
+ // Why: mounted transport teardown is renderer-only; closeTab or the pane-close
+ // action already owns provider shutdown. Destroy only pending, ID-less spawns.
+ return Boolean(args.ptyId)
}
/**
@@ -1296,16 +1290,10 @@ export function useTerminalPaneLifecycle({
}
const leafId = closedPane?.leafId
if (leafId && !isDetachedToTab) {
- // Why: closing a pane is user-initiated teardown of this row — drop
- // (not remove) so any retained `done` snapshot for this pane is also
- // cleared and a same-frame live→gone transition cannot re-snapshot
- // it via the retention sync. This is pane-keyed state, so it must
- // clear even if the PTY transport was already removed.
+ // Why: pane close permanently revokes only this pane's authority;
+ // an exact tombstone blocks queued hooks without suppressing siblings.
const paneKey = makePaneKey(tabId, leafId)
- useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
- clearTerminalPaneUnread(paneKey)
- useAppStore.getState().dropAgentStatus(paneKey)
- useAppStore.getState().clearPaneForegroundAgent(paneKey)
+ useAppStore.getState().retireAgentPaneAuthority(paneKey)
}
if (transport) {
if (isDetachedToTab) {
diff --git a/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts b/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts
new file mode 100644
index 00000000000..80cb239a57b
--- /dev/null
+++ b/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts
@@ -0,0 +1,45 @@
+import { useAppStore } from '@/store'
+import type {
+ TerminalTabCloseReason,
+ TerminalTabRetirementPlan
+} from '@/store/slices/terminal-tab-retirement'
+
+export function closeLocalTerminalTabState(
+ terminalTabId: string,
+ options?: {
+ reason?: TerminalTabCloseReason
+ remoteCloseOwnedByHost?: boolean
+ localPtyTeardownOwnedExternally?: boolean
+ precomputedRetirementPlan?: TerminalTabRetirementPlan
+ }
+): void {
+ const state = useAppStore.getState()
+ if (
+ options?.precomputedRetirementPlan?.tabId === terminalTabId ||
+ Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === terminalTabId))
+ ) {
+ if (
+ options?.reason ||
+ options?.remoteCloseOwnedByHost ||
+ options?.localPtyTeardownOwnedExternally ||
+ options?.precomputedRetirementPlan
+ ) {
+ state.closeTab(terminalTabId, options)
+ } else {
+ state.closeTab(terminalTabId)
+ }
+ return
+ }
+
+ for (const tabs of Object.values(state.unifiedTabsByWorktree ?? {})) {
+ const unified = tabs.find(
+ (tab) =>
+ tab.contentType === 'terminal' &&
+ (tab.entityId === terminalTabId || tab.id === terminalTabId)
+ )
+ if (unified) {
+ state.closeTab(unified.entityId, options)
+ return
+ }
+ }
+}
diff --git a/src/renderer/src/components/terminal/terminal-close-target.ts b/src/renderer/src/components/terminal/terminal-close-target.ts
new file mode 100644
index 00000000000..580cd6a9ebd
--- /dev/null
+++ b/src/renderer/src/components/terminal/terminal-close-target.ts
@@ -0,0 +1,66 @@
+import type { AppState } from '@/store'
+import type { TerminalTabRetirementPlan } from '@/store/slices/terminal-tab-retirement'
+
+export type PrecomputedTerminalCloseState = {
+ owningWorktreeId: string
+ terminalCountBeforeClose: number
+ nextTerminalTabId: string | null
+}
+
+export type TerminalCloseTarget = {
+ worktreeId: string
+ terminalTabId: string
+}
+
+export function validatePrecomputedTerminalCloseState(
+ tabId: string,
+ retirementPlan: TerminalTabRetirementPlan | undefined,
+ closeState: PrecomputedTerminalCloseState | undefined
+): PrecomputedTerminalCloseState | undefined {
+ return retirementPlan?.tabId === tabId &&
+ retirementPlan.worktreeId === closeState?.owningWorktreeId
+ ? closeState
+ : undefined
+}
+
+export function resolveTerminalCloseTarget(
+ state: Pick,
+ tabId: string,
+ precomputed: PrecomputedTerminalCloseState | undefined
+): TerminalCloseTarget | null {
+ if (precomputed) {
+ return { worktreeId: precomputed.owningWorktreeId, terminalTabId: tabId }
+ }
+ for (const [worktreeId, worktreeTabs] of Object.entries(state.tabsByWorktree)) {
+ if (worktreeTabs.some((tab) => tab.id === tabId)) {
+ return { worktreeId, terminalTabId: tabId }
+ }
+ }
+ for (const [worktreeId, unifiedTabs] of Object.entries(state.unifiedTabsByWorktree ?? {})) {
+ const unified = unifiedTabs.find(
+ (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId)
+ )
+ if (unified) {
+ return { worktreeId, terminalTabId: unified.entityId }
+ }
+ }
+ return null
+}
+
+// Why: host-backed terminals may exist only in unified state, so sibling
+// selection must merge both representations into one terminal entity set.
+export function getWorktreeTerminalTabIds(
+ state: Pick,
+ worktreeId: string
+): string[] {
+ const ids = new Set()
+ for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
+ ids.add(tab.id)
+ }
+ for (const tab of state.unifiedTabsByWorktree?.[worktreeId] ?? []) {
+ if (tab.contentType === 'terminal') {
+ ids.add(tab.entityId)
+ }
+ }
+ return [...ids]
+}
diff --git a/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts
index a8ec093c60d..3c9710682b0 100644
--- a/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts
+++ b/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts
@@ -59,6 +59,7 @@ describe('closeTerminalTab kill-all routing', () => {
})
it('force-closes a pinned terminal without opening a second confirmation', () => {
+ const closeTab = vi.fn()
const closeUnifiedTab = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
getStateMock.mockReturnValue(
@@ -74,6 +75,7 @@ describe('closeTerminalTab kill-all routing', () => {
}
]
},
+ closeTab,
closeUnifiedTab,
requestPinnedTabCloseConfirm
})
@@ -82,7 +84,8 @@ describe('closeTerminalTab kill-all routing', () => {
closeTerminalTab('terminal-1', { force: true })
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
- expect(closeUnifiedTab).toHaveBeenCalledWith('visible-pinned')
+ expect(closeTab).toHaveBeenCalledWith('terminal-1', { reason: undefined })
+ expect(closeUnifiedTab).not.toHaveBeenCalled()
})
it('routes the last active terminal to an existing editor without closing it', () => {
diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts
index a070cdc9331..540635961c2 100644
--- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts
+++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts
@@ -197,7 +197,10 @@ describe('closeTerminalTab', () => {
closeTerminalTab('local-tab-1')
- expect(closeTab).toHaveBeenCalledWith('local-tab-1')
+ expect(closeTab).toHaveBeenCalledWith('local-tab-1', {
+ reason: undefined,
+ remoteCloseOwnedByHost: true
+ })
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'host-tab-1',
@@ -206,6 +209,7 @@ describe('closeTerminalTab', () => {
})
it('closes unified-only terminal tabs when tabsByWorktree is missing the row', () => {
+ const closeTab = vi.fn()
const closeUnifiedTab = vi.fn()
getStateMock.mockReturnValue({
settings: { activeRuntimeEnvironmentId: null },
@@ -232,7 +236,7 @@ describe('closeTerminalTab', () => {
activeTabId: 'terminal-entity-1',
openFiles: [],
browserTabsByWorktree: {},
- closeTab: vi.fn(),
+ closeTab,
closeUnifiedTab,
setActiveTab: vi.fn(),
setActiveWorktree: vi.fn()
@@ -240,10 +244,12 @@ describe('closeTerminalTab', () => {
closeTerminalTab('terminal-entity-1')
- expect(closeUnifiedTab).toHaveBeenCalledWith('unified-tab-1')
+ expect(closeTab).toHaveBeenCalledWith('terminal-entity-1', { reason: undefined })
+ expect(closeUnifiedTab).not.toHaveBeenCalled()
})
it('activates the next unified terminal tab when closing the active unified-only tab', () => {
+ const closeTab = vi.fn()
const closeUnifiedTab = vi.fn()
const setActiveTab = vi.fn()
getStateMock.mockReturnValue({
@@ -285,7 +291,7 @@ describe('closeTerminalTab', () => {
activeTabId: 'terminal-entity-1',
openFiles: [],
browserTabsByWorktree: {},
- closeTab: vi.fn(),
+ closeTab,
closeUnifiedTab,
setActiveTab,
setActiveWorktree: vi.fn()
@@ -294,7 +300,8 @@ describe('closeTerminalTab', () => {
closeTerminalTab('terminal-entity-1')
expect(setActiveTab).toHaveBeenCalledWith('terminal-entity-2')
- expect(closeUnifiedTab).toHaveBeenCalledWith('unified-tab-1')
+ expect(closeTab).toHaveBeenCalledWith('terminal-entity-1', { reason: undefined })
+ expect(closeUnifiedTab).not.toHaveBeenCalled()
})
it('routes closes on a remote worktree to the host even when the local→host map has no entry', () => {
@@ -319,7 +326,10 @@ describe('closeTerminalTab', () => {
closeTerminalTab('plain-uuid-tab')
- expect(closeTab).toHaveBeenCalledWith('plain-uuid-tab')
+ expect(closeTab).toHaveBeenCalledWith('plain-uuid-tab', {
+ reason: undefined,
+ remoteCloseOwnedByHost: true
+ })
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'plain-uuid-tab',
@@ -388,11 +398,13 @@ describe('closeTerminalTab', () => {
it('closes the pinned tab when the confirmation callback runs', () => {
const requestPinnedTabCloseConfirm = vi.fn()
+ const closeTab = vi.fn()
const closeUnifiedTab = vi.fn()
getStateMock.mockReturnValue(
makePinnedTabState({
confirmClosePinnedTab: true,
requestPinnedTabCloseConfirm,
+ closeTab,
closeUnifiedTab
})
)
@@ -401,7 +413,8 @@ describe('closeTerminalTab', () => {
const { onConfirm } = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onConfirm: () => void }
onConfirm()
- expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
+ expect(closeTab).toHaveBeenCalledWith('pinned-entity-1', { reason: undefined })
+ expect(closeUnifiedTab).not.toHaveBeenCalled()
})
it('guards a pinned tab closed by its unified id (workspace overlay path)', () => {
@@ -425,11 +438,13 @@ describe('closeTerminalTab', () => {
it('closes a pinned tab immediately when the confirmation setting is off', () => {
const requestPinnedTabCloseConfirm = vi.fn()
+ const closeTab = vi.fn()
const closeUnifiedTab = vi.fn()
getStateMock.mockReturnValue(
makePinnedTabState({
confirmClosePinnedTab: false,
requestPinnedTabCloseConfirm,
+ closeTab,
closeUnifiedTab
})
)
@@ -437,7 +452,8 @@ describe('closeTerminalTab', () => {
closeTerminalTab('pinned-entity-1')
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
- expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
+ expect(closeTab).toHaveBeenCalledWith('pinned-entity-1', { reason: undefined })
+ expect(closeUnifiedTab).not.toHaveBeenCalled()
})
})
diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts
index 9991b7648ea..288c536dcf6 100644
--- a/src/renderer/src/components/terminal/terminal-tab-actions.ts
+++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts
@@ -12,6 +12,18 @@ import {
import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard'
+import type {
+ TerminalTabCloseReason,
+ TerminalTabRetirementPlan
+} from '@/store/slices/terminal-tab-retirement'
+import { closeLocalTerminalTabState } from './close-local-terminal-tab-state'
+import {
+ getWorktreeTerminalTabIds,
+ resolveTerminalCloseTarget,
+ validatePrecomputedTerminalCloseState,
+ type PrecomputedTerminalCloseState
+} from './terminal-close-target'
+export type { PrecomputedTerminalCloseState } from './terminal-close-target'
const EDITOR_TAB_CONTENT_TYPES = new Set([
'editor',
@@ -22,71 +34,6 @@ const EDITOR_TAB_CONTENT_TYPES = new Set([
type TerminalTabActionState = ReturnType
-type CloseTerminalTabTarget = {
- worktreeId: string
- terminalTabId: string
-}
-
-function resolveCloseTerminalTabTarget(
- state: TerminalTabActionState,
- tabId: string
-): CloseTerminalTabTarget | null {
- for (const [worktreeId, worktreeTabs] of Object.entries(state.tabsByWorktree)) {
- if (worktreeTabs.some((tab) => tab.id === tabId)) {
- return { worktreeId, terminalTabId: tabId }
- }
- }
-
- for (const [worktreeId, unifiedTabs] of Object.entries(state.unifiedTabsByWorktree ?? {})) {
- const unified = unifiedTabs.find(
- (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId)
- )
- if (unified) {
- return { worktreeId, terminalTabId: unified.entityId }
- }
- }
-
- return null
-}
-
-// Why: host-backed terminals may only exist in unifiedTabsByWorktree as
-// terminal entities, so close/sibling selection must merge tabsByWorktree and
-// unified terminal entityIds into one deduped list per worktree.
-function getWorktreeTerminalTabIds(state: TerminalTabActionState, worktreeId: string): string[] {
- const ids = new Set()
- for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
- ids.add(tab.id)
- }
- for (const tab of state.unifiedTabsByWorktree?.[worktreeId] ?? []) {
- if (tab.contentType === 'terminal') {
- ids.add(tab.entityId)
- }
- }
- return [...ids]
-}
-
-function closeLocalTerminalTabState(terminalTabId: string): void {
- const state = useAppStore.getState()
- if (
- Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === terminalTabId))
- ) {
- state.closeTab(terminalTabId)
- return
- }
-
- for (const tabs of Object.values(state.unifiedTabsByWorktree ?? {})) {
- const unified = tabs.find(
- (tab) =>
- tab.contentType === 'terminal' &&
- (tab.entityId === terminalTabId || tab.id === terminalTabId)
- )
- if (unified) {
- state.closeUnifiedTab(unified.id)
- return
- }
- }
-}
-
function isPinnedVisibleTab(
state: TerminalTabActionState,
worktreeId: string,
@@ -149,9 +96,23 @@ export function createNewTerminalTab(
state.setTabBarOrder(activeWorktreeId, order)
}
-export function closeTerminalTab(tabId: string, options?: { force?: boolean }): void {
+export function closeTerminalTab(
+ tabId: string,
+ options?: {
+ force?: boolean
+ reason?: TerminalTabCloseReason
+ localPtyTeardownOwnedExternally?: boolean
+ precomputedRetirementPlan?: TerminalTabRetirementPlan
+ precomputedCloseState?: PrecomputedTerminalCloseState
+ }
+): void {
const state = useAppStore.getState()
- const target = resolveCloseTerminalTabTarget(state, tabId)
+ const precomputedCloseState = validatePrecomputedTerminalCloseState(
+ tabId,
+ options?.precomputedRetirementPlan,
+ options?.precomputedCloseState
+ )
+ const target = resolveTerminalCloseTarget(state, tabId, precomputedCloseState)
if (!target) {
return
}
@@ -159,7 +120,11 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
// Why: a pinned tab routes through the confirmation guard instead of closing
// outright. `force` is the post-confirmation re-entry, which skips the guard.
- if (!options?.force && isPinnedVisibleTab(state, owningWorktreeId, terminalTabId)) {
+ if (
+ options?.reason !== 'pty-exit' &&
+ !options?.force &&
+ isPinnedVisibleTab(state, owningWorktreeId, terminalTabId)
+ ) {
guardPinnedTabClose({
isPinned: true,
tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId),
@@ -187,7 +152,16 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
}) ?? toHostSessionTabId(terminalTabId)
// Why: prune local mirrors immediately so close feels responsive while the
// host session snapshot catches up.
- closeLocalTerminalTabState(terminalTabId)
+ closeLocalTerminalTabState(terminalTabId, {
+ reason: options?.reason,
+ remoteCloseOwnedByHost: true,
+ ...(options?.localPtyTeardownOwnedExternally
+ ? { localPtyTeardownOwnedExternally: true }
+ : {}),
+ ...(options?.precomputedRetirementPlan
+ ? { precomputedRetirementPlan: options.precomputedRetirementPlan }
+ : {})
+ })
void closeWebRuntimeSessionTab({
worktreeId: owningWorktreeId,
tabId: hostBackedTabId,
@@ -196,9 +170,21 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
return
}
- const currentTerminalTabIds = getWorktreeTerminalTabIds(state, owningWorktreeId)
- if (currentTerminalTabIds.length <= 1) {
- closeLocalTerminalTabState(terminalTabId)
+ const currentTerminalTabIds = precomputedCloseState
+ ? null
+ : getWorktreeTerminalTabIds(state, owningWorktreeId)
+ const terminalCountBeforeClose =
+ precomputedCloseState?.terminalCountBeforeClose ?? currentTerminalTabIds!.length
+ if (terminalCountBeforeClose <= 1) {
+ closeLocalTerminalTabState(terminalTabId, {
+ reason: options?.reason,
+ ...(options?.localPtyTeardownOwnedExternally
+ ? { localPtyTeardownOwnedExternally: true }
+ : {}),
+ ...(options?.precomputedRetirementPlan
+ ? { precomputedRetirementPlan: options.precomputedRetirementPlan }
+ : {})
+ })
if (state.activeWorktreeId === owningWorktreeId) {
// Why: only deactivate the worktree when no tabs of any kind remain.
// Editor files are a separate tab type; closing the last terminal tab
@@ -221,15 +207,22 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
}
if (state.activeWorktreeId === owningWorktreeId && terminalTabId === state.activeTabId) {
- const currentIndex = currentTerminalTabIds.indexOf(terminalTabId)
- const nextTabId =
- currentTerminalTabIds[currentIndex + 1] ?? currentTerminalTabIds[currentIndex - 1]
+ const currentIndex = currentTerminalTabIds?.indexOf(terminalTabId) ?? -1
+ const nextTabId = precomputedCloseState
+ ? precomputedCloseState.nextTerminalTabId
+ : (currentTerminalTabIds![currentIndex + 1] ?? currentTerminalTabIds![currentIndex - 1])
if (nextTabId) {
state.setActiveTab(nextTabId)
}
}
- closeLocalTerminalTabState(terminalTabId)
+ closeLocalTerminalTabState(terminalTabId, {
+ reason: options?.reason,
+ ...(options?.localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}),
+ ...(options?.precomputedRetirementPlan
+ ? { precomputedRetirementPlan: options.precomputedRetirementPlan }
+ : {})
+ })
}
export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | null): void {
diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts
index ec7764ec287..bc0ca7fec92 100644
--- a/src/renderer/src/hooks/useIpcEvents.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents.test.ts
@@ -4873,6 +4873,104 @@ describe('useIpcEvents agent status snapshot integration', () => {
)
})
+ it('does not recurse when flushing a pending status re-enters via the store subscriber', async () => {
+ // Repro for crash 9fc89529 (RangeError: Maximum call stack size exceeded):
+ // the store subscriber calls flushPendingAgentStatuses() on every update.
+ // flush -> applyAgentStatus -> store.setAgentStatus notifies subscribers
+ // synchronously (like Zustand) -> subscriber -> flush again while the same
+ // event is still queued -> infinite recursion. Model setAgentStatus with a
+ // real synchronous notify so the re-entrancy is exercised end to end.
+ const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
+ const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
+ current: null
+ }
+ let setAgentStatusCalls = 0
+ const notify = (): void => {
+ const listener = subscribeListenerRef.current
+ if (listener) {
+ listener(storeState, storeState)
+ }
+ }
+ const storeState: StoreLike = buildStoreState({
+ // Why: mirror Zustand — a state mutation notifies subscribers synchronously.
+ setAgentStatus: (paneKey: string, entry: unknown) => {
+ setAgentStatusCalls += 1
+ storeState.agentStatusByPaneKey = {
+ ...(storeState.agentStatusByPaneKey as Record),
+ [paneKey]: entry
+ }
+ notify()
+ },
+ workspaceSessionReady: true,
+ settings: { terminalFontSize: 13, notifications: { enabled: false } },
+ // Pane does not exist yet -> the incoming event is buffered as pending.
+ tabsByWorktree: {},
+ terminalLayoutsByTabId: {}
+ })
+
+ stubReactSyncEffect()
+ vi.doMock('../store', () => ({
+ useAppStore: {
+ subscribe: vi.fn((listener: StoreSubscribeListener) => {
+ subscribeListenerRef.current = listener
+ return () => {
+ subscribeListenerRef.current = null
+ }
+ }),
+ getState: () => storeState
+ }
+ }))
+ stubAuxiliaryModules()
+ vi.stubGlobal(
+ 'window',
+ buildWindowApi({
+ onSet: (cb) => {
+ onSetListenerRef.current = cb
+ return () => {}
+ }
+ })
+ )
+
+ const { useIpcEvents } = await import('./useIpcEvents')
+ useIpcEvents()
+ await Promise.resolve()
+
+ if (typeof onSetListenerRef.current !== 'function') {
+ throw new Error('Expected agentStatus.onSet listener to be registered')
+ }
+ if (typeof subscribeListenerRef.current !== 'function') {
+ throw new Error('Expected useAppStore.subscribe listener to be registered')
+ }
+
+ // Event lands before the tab exists -> buffered as a pending retry.
+ onSetListenerRef.current({
+ paneKey: FUTURE_PANE_KEY,
+ state: 'working',
+ prompt: 'p',
+ agentType: 'claude',
+ receivedAt: 1_700_000_000_100,
+ stateStartedAt: 1_699_999_999_100
+ })
+ expect(setAgentStatusCalls).toBe(0)
+
+ // Tab hydrates; the next store update flushes the pending event. Without the
+ // re-entrancy guard this overflows the stack instead of applying once.
+ storeState.tabsByWorktree = {
+ 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }]
+ }
+ storeState.terminalLayoutsByTabId = {
+ 'tab-future': {
+ root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
+ activeLeafId: FUTURE_LEAF_ID,
+ expandedLeafId: null
+ }
+ }
+
+ expect(() => notify()).not.toThrow()
+ // Applied exactly once — the re-entrant flush is a no-op, not a loop.
+ expect(setAgentStatusCalls).toBe(1)
+ })
+
it('applies ready push events for an unmounted inactive terminal tab', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts
index e341ff8a185..efec4bae01c 100644
--- a/src/renderer/src/hooks/useIpcEvents.ts
+++ b/src/renderer/src/hooks/useIpcEvents.ts
@@ -124,6 +124,7 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
import { titleHasAgentName } from '../../../shared/agent-detection'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
+import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority'
import { translate } from '@/i18n/i18n'
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode'
@@ -251,10 +252,14 @@ let remoteWorkspaceSnapshotWriteSuppressUntil = 0
const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000
function isAgentStatusForRecentlyClosedTab(
- store: Pick,
+ store: Pick,
paneKey: string
): boolean {
- const tabId = parsePaneKey(paneKey)?.tabId
+ const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
+ if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) {
+ return true
+ }
+ const tabId = parsePaneKey(ownerPaneKey)?.tabId
if (!tabId) {
return false
}
@@ -842,6 +847,11 @@ export function useIpcEvents(): void {
type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped'
const pendingAgentStatusEvents: PendingAgentStatusEvent[] = []
let pendingAgentStatusRetryTimer: ReturnType | null = null
+ // Why: applyAgentStatus -> store.setAgentStatus notifies the store
+ // subscriber synchronously, which re-enters flushPendingAgentStatuses while
+ // the queue is still mid-drain. Guard against that re-entrancy so the same
+ // event is not reprocessed forever (crash 9fc89529: stack overflow).
+ let isFlushingAgentStatuses = false
unsubs.push(attachMobileMarkdownBridge())
@@ -2917,25 +2927,37 @@ export function useIpcEvents(): void {
}
function flushPendingAgentStatuses(): void {
+ // Why: a re-entrant call (store subscriber firing during a setAgentStatus
+ // inside the loop below) must not reprocess the still-queued events — the
+ // outer flush already owns them. Bailing here breaks the infinite
+ // recursion without dropping work; the outer loop finishes the drain.
+ if (isFlushingAgentStatuses) {
+ return
+ }
if (pendingAgentStatusEvents.length === 0) {
return
}
- const now = Date.now()
- const remaining: PendingAgentStatusEvent[] = []
- for (const event of pendingAgentStatusEvents) {
- if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) {
- continue
+ isFlushingAgentStatuses = true
+ try {
+ const now = Date.now()
+ const remaining: PendingAgentStatusEvent[] = []
+ for (const event of pendingAgentStatusEvents) {
+ if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) {
+ continue
+ }
+ const result = applyAgentStatus(event.data, { retry: true })
+ if (result === 'pending') {
+ remaining.push(event)
+ }
}
- const result = applyAgentStatus(event.data, { retry: true })
- if (result === 'pending') {
- remaining.push(event)
+ pendingAgentStatusEvents.length = 0
+ pendingAgentStatusEvents.push(...remaining)
+ if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) {
+ globalThis.clearTimeout(pendingAgentStatusRetryTimer)
+ pendingAgentStatusRetryTimer = null
}
- }
- pendingAgentStatusEvents.length = 0
- pendingAgentStatusEvents.push(...remaining)
- if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) {
- globalThis.clearTimeout(pendingAgentStatusRetryTimer)
- pendingAgentStatusRetryTimer = null
+ } finally {
+ isFlushingAgentStatuses = false
}
schedulePendingAgentStatusFlush()
}
@@ -2951,6 +2973,8 @@ export function useIpcEvents(): void {
if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) {
return 'dropped'
}
+ const paneKey = resolveAgentPaneAuthorityKey(data.paneKey)
+ const ownerTabId = parsePaneKey(paneKey)?.tabId ?? data.tabId
const payload = normalizeAgentStatusPayload({
state: data.state,
prompt: data.prompt,
@@ -2976,7 +3000,7 @@ export function useIpcEvents(): void {
repoConnectionId,
repoConnectionResolved,
owningWorktreeId
- } = resolvePaneKey(store, data.paneKey)
+ } = resolvePaneKey(store, paneKey)
if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) {
// Why: orchestration worker hooks can carry main-side worktree
// attribution before this renderer has a terminal tab for the pane.
@@ -3058,7 +3082,7 @@ export function useIpcEvents(): void {
const statusPayloadWithTurnBoundary = data.promptInteractionKey
? { ...statusPayload, promptInteractionKey: data.promptInteractionKey }
: statusPayload
- const existingStatus = store.agentStatusByPaneKey[data.paneKey]
+ const existingStatus = store.agentStatusByPaneKey[paneKey]
if (existingStatus && data.receivedAt < existingStatus.updatedAt) {
// Why: the store rejects out-of-order status rows; keep notification and
// terminal lifecycle effects on the same accepted event boundary.
@@ -3088,8 +3112,8 @@ export function useIpcEvents(): void {
}
if (
shouldSuppressCodexAutoApprovalStatus(statusPayload, {
- paneKey: data.paneKey,
- tabId: data.tabId,
+ paneKey,
+ tabId: ownerTabId,
terminalHandle: data.terminalHandle,
launchToken: data.launchToken,
providerSession: data.providerSession,
@@ -3103,7 +3127,7 @@ export function useIpcEvents(): void {
const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
store.setAgentStatus(
- data.paneKey,
+ paneKey,
statusPayloadWithTurnBoundary,
terminalTitle,
{
@@ -3111,7 +3135,7 @@ export function useIpcEvents(): void {
stateStartedAt: data.stateStartedAt
},
{
- tabId: data.tabId,
+ tabId: ownerTabId,
worktreeId: statusWorktreeId,
terminalHandle: data.terminalHandle
},
@@ -3122,7 +3146,7 @@ export function useIpcEvents(): void {
}
: undefined
)
- applyResolvedAgentTerminalTitleToTab(store, data.paneKey, title, terminalTitle)
+ applyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle)
if (options?.replay !== true && statusWorktreeId) {
// Why: local Codex/Claude hooks arrive through this main-process IPC
// path, not the PTY OSC fallback, so task-complete notifications must
@@ -3132,7 +3156,7 @@ export function useIpcEvents(): void {
? { ...resolvedPayload, stateStartedAt: data.stateStartedAt }
: resolvedPayload
observeAgentHookCompletionForNotification({
- paneKey: data.paneKey,
+ paneKey,
worktreeId: statusWorktreeId,
payload: notificationPayload
})
diff --git a/src/renderer/src/lib/agent-background-draft-delivery.ts b/src/renderer/src/lib/agent-background-draft-delivery.ts
new file mode 100644
index 00000000000..745248f7239
--- /dev/null
+++ b/src/renderer/src/lib/agent-background-draft-delivery.ts
@@ -0,0 +1,17 @@
+import type { TuiAgent } from '../../../shared/types'
+import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
+import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast'
+
+export function scheduleAgentBackgroundDraft(
+ tabId: string,
+ content: string,
+ agent: TuiAgent
+): void {
+ void pasteDraftWhenAgentReady({
+ tabId,
+ content,
+ agent,
+ submit: true,
+ onTimeout: () => showAutomationPromptNotSentToast(agent)
+ })
+}
diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts
index 652fddb9bcf..1c24ae7683a 100644
--- a/src/renderer/src/lib/launch-agent-background-session.test.ts
+++ b/src/renderer/src/lib/launch-agent-background-session.test.ts
@@ -68,6 +68,7 @@ const state = {
}
]
},
+ tabsByWorktree: { 'wt-1': [] as { id: string; title: string }[] },
allWorktrees: vi.fn(() => state.worktreesByRepo['repo-1']),
createTab: mockCreateTab,
setTabCustomTitle: mockSetTabCustomTitle,
@@ -143,7 +144,15 @@ describe('launchAgentBackgroundSession', () => {
}
]
}
- mockCreateTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' })
+ state.tabsByWorktree = { 'wt-1': [] }
+ mockCreateTab.mockImplementation(() => {
+ const tab = { id: 'tab-1', title: 'Terminal 1' }
+ state.tabsByWorktree['wt-1'].push(tab)
+ return tab
+ })
+ mockCloseTab.mockImplementation((tabId: string) => {
+ state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].filter((tab) => tab.id !== tabId)
+ })
mockSpawn.mockResolvedValue({ id: 'pty-1' })
mockRuntimeEnvironmentCall.mockResolvedValue({
ok: true,
@@ -273,6 +282,84 @@ describe('launchAgentBackgroundSession', () => {
)
})
+ it('kills a local PTY when its tab closes before spawn resolves', async () => {
+ let resolveSpawn!: (result: { id: string }) => void
+ mockSpawn.mockReturnValueOnce(
+ new Promise<{ id: string }>((resolve) => {
+ resolveSpawn = resolve
+ })
+ )
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ const launch = launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run slowly'
+ })
+ await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce())
+ state.tabsByWorktree['wt-1'] = []
+ resolveSpawn({ id: 'pty-after-close' })
+
+ await expect(launch).resolves.toBeNull()
+ expect(mockKill).toHaveBeenCalledWith('pty-after-close')
+ expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
+ expect(mockSubscribeToPtyData).not.toHaveBeenCalled()
+ expect(mockDispatchEvent).not.toHaveBeenCalled()
+ })
+
+ it('closes a runtime terminal when its tab closes before creation resolves', async () => {
+ state.settings = {
+ agentCmdOverrides: {},
+ activeRuntimeEnvironmentId: 'env-1',
+ terminalMainSideEffectAuthority: undefined
+ }
+ let resolveCreate!: (result: {
+ ok: true
+ result: { terminal: { handle: string; worktreeId: string; title: null } }
+ }) => void
+ const createResult = new Promise<{
+ ok: true
+ result: { terminal: { handle: string; worktreeId: string; title: null } }
+ }>((resolve) => {
+ resolveCreate = resolve
+ })
+ mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => {
+ if (args.method === 'terminal.create') {
+ return createResult
+ }
+ return Promise.resolve({ ok: true, result: {} })
+ })
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ const launch = launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run remotely'
+ })
+ await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce())
+ await vi.waitFor(() =>
+ expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
+ expect.objectContaining({ method: 'terminal.create' })
+ )
+ )
+ state.tabsByWorktree['wt-1'] = []
+ resolveCreate({
+ ok: true,
+ result: { terminal: { handle: 'terminal-after-close', worktreeId: 'wt-1', title: null } }
+ })
+
+ await expect(launch).resolves.toBeNull()
+ expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'terminal.close',
+ params: { terminal: 'terminal-after-close' }
+ })
+ )
+ expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
+ expect(mockRuntimeEnvironmentSubscribe).not.toHaveBeenCalled()
+ expect(mockDispatchEvent).not.toHaveBeenCalled()
+ })
+
it('records effective launch config returned by local PTY spawn', async () => {
const effectiveLaunchConfig = {
agentCommand: "claude '--dangerously-skip-permissions'",
diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts
index edb4c6d9f68..bb2f102f681 100644
--- a/src/renderer/src/lib/launch-agent-background-session.ts
+++ b/src/renderer/src/lib/launch-agent-background-session.ts
@@ -7,8 +7,7 @@ import type {
import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
-import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
-import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast'
+import { scheduleAgentBackgroundDraft } from '@/lib/agent-background-draft-delivery'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
import {
@@ -28,6 +27,7 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
+import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
import { createBrowserUuid } from '@/lib/browser-uuid'
import {
subscribeToRuntimeTerminalData,
@@ -115,12 +115,13 @@ export async function launchAgentBackgroundSession(
const leafId = createBrowserUuid()
const paneKey = makePaneKey(tab.id, leafId)
const launchToken = createBrowserUuid()
- store.registerAgentLaunchConfig(paneKey, startupPlan.launchConfig, {
+ const launchRegistration = {
agentType: agent,
launchToken,
tabId: tab.id,
leafId
- })
+ }
+ store.registerAgentLaunchConfig(paneKey, startupPlan.launchConfig, launchRegistration)
// Why: `title` labels the tab/worktree entry. Pane titles render as an
// in-terminal title row, so background sessions must not persist it there.
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId))
@@ -148,6 +149,7 @@ export async function launchAgentBackgroundSession(
)
let ptyId = ''
let runtimeTerminalHandle: string | null = null
+ let returnedLaunchConfig: typeof startupPlan.launchConfig | undefined
let exitHandled = false
let eagerPtyBuffer: EagerPtyHandle | null = null
let unsubscribeExit = (): void => {},
@@ -236,14 +238,25 @@ export async function launchAgentBackgroundSession(
}
})
ptyId = result.id
- if (result.launchConfig) {
- store.registerAgentLaunchConfig(paneKey, result.launchConfig, {
- agentType: agent,
- launchToken,
- tabId: tab.id,
- leafId
- })
- }
+ returnedLaunchConfig = result.launchConfig
+ }
+ if (
+ await retireUnownedTerminal({
+ tabId: tab.id,
+ ptyId,
+ runtimeTarget,
+ runtimeTerminalHandle,
+ onRetire: () => {
+ exitHandled = true
+ sshStartupDelivery.clear()
+ store.clearAgentLaunchConfig(paneKey)
+ }
+ })
+ ) {
+ return null
+ }
+ if (returnedLaunchConfig) {
+ store.registerAgentLaunchConfig(paneKey, returnedLaunchConfig, launchRegistration)
}
store.updateTabPtyId(tab.id, ptyId)
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId))
@@ -297,13 +310,7 @@ export async function launchAgentBackgroundSession(
requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] })
if (pasteDraftAfterLaunch !== null) {
- void pasteDraftWhenAgentReady({
- tabId: tab.id,
- content: pasteDraftAfterLaunch,
- agent,
- submit: true,
- onTimeout: () => showAutomationPromptNotSentToast(agent)
- })
+ scheduleAgentBackgroundDraft(tab.id, pasteDraftAfterLaunch, agent)
}
return { tabId: tab.id, paneKey, ptyId, startupPlan }
@@ -317,17 +324,7 @@ export async function launchAgentBackgroundSession(
runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(tab.id, ptyId))
runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey))
if (ptyId) {
- try {
- if (runtimeTarget.kind === 'environment' && runtimeTerminalHandle) {
- await callRuntimeRpc(runtimeTarget, 'terminal.close', {
- terminal: runtimeTerminalHandle
- })
- } else if (runtimeTarget.kind === 'local') {
- await window.api.pty.kill(ptyId)
- }
- } catch {
- // Best-effort close; retiring the invalid hidden tab must still proceed.
- }
+ await retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle })
}
runBestEffortAgentBackgroundCleanups(() => store.closeTab(tab.id, { recordInteraction: false }))
throw error
diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts
index a33671bf9df..10dce9d658a 100644
--- a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts
+++ b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockSpawn = vi.fn()
+const mockKill = vi.fn()
const mockCreateTab = vi.fn()
const mockSetTabCustomTitle = vi.fn()
const mockSetTabColor = vi.fn()
@@ -31,6 +32,7 @@ const state = {
}
]
},
+ tabsByWorktree: { 'wt-1': [] as { id: string }[] },
allWorktrees: vi.fn(() => state.worktreesByRepo['repo-1'] ?? []),
createTab: mockCreateTab,
setTabCustomTitle: mockSetTabCustomTitle,
@@ -78,15 +80,24 @@ describe('launchWorktreeBackgroundTerminals', () => {
displayName: 'Worktree'
}
]
+ state.tabsByWorktree = { 'wt-1': [] }
let tabIndex = 0
- mockCreateTab.mockImplementation(() => ({ id: `tab-${++tabIndex}` }))
+ mockCreateTab.mockImplementation(() => {
+ const tab = { id: `tab-${++tabIndex}` }
+ state.tabsByWorktree['wt-1'].push(tab)
+ return tab
+ })
+ mockCloseTab.mockImplementation((tabId: string) => {
+ state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].filter((tab) => tab.id !== tabId)
+ })
let ptyIndex = 0
mockSpawn.mockImplementation(async () => ({ id: `pty-${++ptyIndex}` }))
mockGetActiveRuntimeTarget.mockReturnValue({ kind: 'local' })
vi.stubGlobal('window', {
api: {
pty: {
- spawn: mockSpawn
+ spawn: mockSpawn,
+ kill: mockKill
}
}
})
@@ -288,4 +299,63 @@ describe('launchWorktreeBackgroundTerminals', () => {
expect(mockCreateTab).not.toHaveBeenCalled()
expect(mockSpawn).not.toHaveBeenCalled()
})
+
+ it('kills a PTY whose tab is closed before the spawn resolves', async () => {
+ let resolveSpawn!: (result: { id: string }) => void
+ mockSpawn.mockReturnValueOnce(
+ new Promise<{ id: string }>((resolve) => {
+ resolveSpawn = resolve
+ })
+ )
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const { launchWorktreeBackgroundTerminals } =
+ await import('./launch-worktree-background-terminals')
+
+ const launch = launchWorktreeBackgroundTerminals({
+ worktreeId: 'wt-1',
+ defaultTabs: { runCommands: true, tabs: [{ command: 'pnpm dev' }] }
+ })
+ await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce())
+ state.tabsByWorktree['wt-1'] = []
+ resolveSpawn({ id: 'pty-after-close' })
+ await launch
+
+ expect(mockKill).toHaveBeenCalledWith('pty-after-close')
+ expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
+ expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled()
+ warn.mockRestore()
+ })
+
+ it('kills a late setup split PTY when its parent tab closes', async () => {
+ state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'split-horizontal' }
+ let resolveSetupSpawn!: (result: { id: string }) => void
+ mockSpawn.mockResolvedValueOnce({ id: 'pty-primary' }).mockReturnValueOnce(
+ new Promise<{ id: string }>((resolve) => {
+ resolveSetupSpawn = resolve
+ })
+ )
+ const { launchWorktreeBackgroundTerminals } =
+ await import('./launch-worktree-background-terminals')
+
+ const launch = launchWorktreeBackgroundTerminals({
+ worktreeId: 'wt-1',
+ setup: setupLaunch
+ })
+ await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2))
+ state.closeTab('tab-1')
+ resolveSetupSpawn({ id: 'pty-setup-after-close' })
+ await launch
+
+ expect(mockKill).toHaveBeenCalledWith('pty-setup-after-close')
+ expect(mockUpdateTabPtyId).toHaveBeenCalledTimes(1)
+ expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-primary')
+ expect(mockSetTabLayout).not.toHaveBeenCalledWith(
+ 'tab-1',
+ expect.objectContaining({
+ ptyIdsByLeafId: expect.objectContaining({
+ '00000000-0000-4000-8000-000000000002': 'pty-setup-after-close'
+ })
+ })
+ )
+ })
})
diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts
index 896da11f8e5..568f68e6f22 100644
--- a/src/renderer/src/lib/launch-worktree-background-terminals.ts
+++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts
@@ -6,6 +6,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid'
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
+import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
@@ -180,6 +181,15 @@ async function createBackgroundTab(args: {
store.closeTab(tab.id, { recordInteraction: false })
throw error
}
+ if (
+ await retireUnownedTerminal({
+ tabId: tab.id,
+ ptyId,
+ runtimeTarget: { kind: 'local' }
+ })
+ ) {
+ throw new Error('The terminal tab was closed before its session finished starting.')
+ }
store.updateTabPtyId(tab.id, ptyId)
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId))
registerBackgroundPaneBuffer(tab.id, leafId, ptyId)
@@ -203,6 +213,15 @@ async function addSetupSplit(args: {
command: buildSetupCommand(args.setup),
env: args.setup.envVars
})
+ if (
+ await retireUnownedTerminal({
+ tabId: args.tab.tabId,
+ ptyId: setupPtyId,
+ runtimeTarget: { kind: 'local' }
+ })
+ ) {
+ return
+ }
store.updateTabPtyId(args.tab.tabId, setupPtyId)
store.setTabLayout(
args.tab.tabId,
diff --git a/src/renderer/src/lib/retire-unowned-background-terminal.ts b/src/renderer/src/lib/retire-unowned-background-terminal.ts
new file mode 100644
index 00000000000..7a7f128125a
--- /dev/null
+++ b/src/renderer/src/lib/retire-unowned-background-terminal.ts
@@ -0,0 +1,38 @@
+import { useAppStore } from '@/store'
+import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
+import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement'
+
+export async function retireUnownedTerminal(args: {
+ tabId: string
+ ptyId: string
+ runtimeTarget: RuntimeClientTarget
+ runtimeTerminalHandle?: string | null
+ onRetire?: () => void
+}): Promise {
+ if (isTerminalTabPresent(useAppStore.getState(), args.tabId)) {
+ return false
+ }
+ // Why: close can win while provider creation is in flight, before the
+ // returned handle is bindable to store state or visible to tab retirement.
+ args.onRetire?.()
+ await retireProvider(args)
+ return true
+}
+
+export async function retireProvider(args: {
+ ptyId: string
+ runtimeTarget: RuntimeClientTarget
+ runtimeTerminalHandle?: string | null
+}): Promise {
+ try {
+ if (args.runtimeTarget.kind === 'environment' && args.runtimeTerminalHandle) {
+ await callRuntimeRpc(args.runtimeTarget, 'terminal.close', {
+ terminal: args.runtimeTerminalHandle
+ })
+ } else if (args.runtimeTarget.kind === 'local') {
+ await window.api.pty.kill(args.ptyId)
+ }
+ } catch {
+ // Best-effort provider teardown; the retired tab must not be recreated.
+ }
+}
diff --git a/src/renderer/src/store/slices/agent-pane-authority.test.ts b/src/renderer/src/store/slices/agent-pane-authority.test.ts
new file mode 100644
index 00000000000..a37f3057625
--- /dev/null
+++ b/src/renderer/src/store/slices/agent-pane-authority.test.ts
@@ -0,0 +1,125 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+import {
+ resetAgentPaneAuthorityAliasesForTests,
+ resolveAgentPaneAuthorityKey
+} from './agent-pane-authority'
+import { createTestStore } from './store-test-helpers'
+
+const SOURCE = makePaneKey('tab-source', '11111111-1111-4111-8111-111111111111')
+const TARGET = makePaneKey('tab-target', '22222222-2222-4222-8222-222222222222')
+const FINAL = makePaneKey('tab-final', '33333333-3333-4333-8333-333333333333')
+const SIBLING = makePaneKey('tab-target', '44444444-4444-4444-8444-444444444444')
+
+const retirePaneAuthority = vi.fn()
+const transferPaneAuthority = vi.fn()
+const dropByTabPrefix = vi.fn()
+
+beforeEach(() => {
+ resetAgentPaneAuthorityAliasesForTests()
+ vi.clearAllMocks()
+ vi.stubGlobal('window', {
+ api: {
+ agentStatus: {
+ retirePaneAuthority,
+ transferPaneAuthority,
+ dropByTabPrefix,
+ drop: vi.fn()
+ }
+ }
+ })
+})
+
+afterEach(() => {
+ resetAgentPaneAuthorityAliasesForTests()
+ vi.unstubAllGlobals()
+})
+
+describe('agent pane authority', () => {
+ it('retires one pane, clears resume authority, and rejects late status without harming siblings', () => {
+ const store = createTestStore()
+ store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'target' })
+ store.getState().setAgentStatus(SIBLING, { state: 'working', prompt: 'sibling' })
+ store.getState().registerAgentLaunchConfig(TARGET, { agentArgs: '', agentEnv: {} })
+ store.setState({
+ sleepingAgentSessionsByPaneKey: {
+ [TARGET]: {
+ paneKey: TARGET,
+ tabId: 'tab-target',
+ worktreeId: 'wt-1',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'session-1' },
+ prompt: 'continue',
+ state: 'working',
+ capturedAt: 1,
+ updatedAt: 1
+ }
+ }
+ })
+
+ store.getState().retireAgentPaneAuthority(TARGET)
+ store.getState().setAgentStatus(TARGET, { state: 'done', prompt: 'late' })
+
+ const state = store.getState()
+ expect(state.agentStatusByPaneKey[TARGET]).toBeUndefined()
+ expect(state.agentLaunchConfigByPaneKey[TARGET]).toBeUndefined()
+ expect(state.sleepingAgentSessionsByPaneKey[TARGET]).toBeUndefined()
+ expect(state.agentStatusByPaneKey[SIBLING]).toBeDefined()
+ expect(state.recentlyRetiredAgentStatusPaneKeys[TARGET]).toBe(true)
+ expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET)
+ })
+
+ it('keeps a physical pane routed through chained detaches until its current owner closes', () => {
+ const store = createTestStore()
+ store.getState().setAgentStatus(SOURCE, { state: 'working', prompt: 'source' })
+ store.setState({
+ sleepingAgentSessionsByPaneKey: {
+ [SOURCE]: {
+ paneKey: SOURCE,
+ tabId: 'tab-source',
+ worktreeId: 'wt-1',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'session-1' },
+ prompt: 'continue',
+ state: 'working',
+ capturedAt: 1,
+ updatedAt: 1
+ }
+ }
+ })
+
+ store
+ .getState()
+ .transferAgentPaneAuthority({ fromPaneKey: SOURCE, toPaneKey: TARGET, ptyId: 'pty-1' })
+ store
+ .getState()
+ .transferAgentPaneAuthority({ fromPaneKey: TARGET, toPaneKey: FINAL, ptyId: 'pty-1' })
+ store.getState().dropAgentStatusByTabPrefix('tab-source')
+
+ expect(resolveAgentPaneAuthorityKey(SOURCE)).toBe(FINAL)
+ expect(store.getState().sleepingAgentSessionsByPaneKey[FINAL]).toMatchObject({
+ paneKey: FINAL,
+ tabId: 'tab-final',
+ providerSession: { key: 'session_id', id: 'session-1' }
+ })
+ store.getState().setAgentStatus(SOURCE, { state: 'working', prompt: 'after source close' })
+ expect(store.getState().agentStatusByPaneKey[FINAL]?.prompt).toBe('after source close')
+ expect(transferPaneAuthority).toHaveBeenNthCalledWith(1, {
+ fromPaneKey: SOURCE,
+ toPaneKey: TARGET,
+ ptyId: 'pty-1'
+ })
+ expect(transferPaneAuthority).toHaveBeenNthCalledWith(2, {
+ fromPaneKey: TARGET,
+ toPaneKey: FINAL,
+ ptyId: 'pty-1'
+ })
+
+ store.getState().dropAgentStatusByTabPrefix('tab-final')
+ store.getState().setAgentStatus(SOURCE, { state: 'done', prompt: 'too late' })
+
+ expect(store.getState().agentStatusByPaneKey[SOURCE]).toBeUndefined()
+ expect(store.getState().agentStatusByPaneKey[FINAL]).toBeUndefined()
+ expect(store.getState().recentlyRetiredAgentStatusPaneKeys[SOURCE]).toBe(true)
+ })
+})
diff --git a/src/renderer/src/store/slices/agent-pane-authority.ts b/src/renderer/src/store/slices/agent-pane-authority.ts
new file mode 100644
index 00000000000..c5f25d0ec36
--- /dev/null
+++ b/src/renderer/src/store/slices/agent-pane-authority.ts
@@ -0,0 +1,86 @@
+import { parsePaneKey } from '../../../../shared/stable-pane-id'
+
+type AgentPaneAuthorityAlias = {
+ ownerPaneKey: string
+ ptyId: string | null
+}
+
+const aliasesByPhysicalPaneKey = new Map()
+
+export type AgentPaneAuthorityTransfer = {
+ physicalPaneKey: string
+ previousOwnerPaneKey: string
+ ownerPaneKey: string
+ ptyId: string | null
+}
+
+export function resolveAgentPaneAuthorityKey(paneKey: string): string {
+ return aliasesByPhysicalPaneKey.get(paneKey)?.ownerPaneKey ?? paneKey
+}
+
+export function transferAgentPaneAuthorityAlias(args: {
+ fromPaneKey: string
+ toPaneKey: string
+ ptyId?: string | null
+}): AgentPaneAuthorityTransfer | null {
+ if (!parsePaneKey(args.fromPaneKey) || !parsePaneKey(args.toPaneKey)) {
+ return null
+ }
+ const previousOwnerPaneKey = resolveAgentPaneAuthorityKey(args.fromPaneKey)
+ let physicalPaneKey = args.fromPaneKey
+ for (const [candidatePhysicalPaneKey, alias] of aliasesByPhysicalPaneKey) {
+ if (
+ alias.ownerPaneKey === previousOwnerPaneKey &&
+ (!args.ptyId || !alias.ptyId || alias.ptyId === args.ptyId)
+ ) {
+ physicalPaneKey = candidatePhysicalPaneKey
+ break
+ }
+ }
+ const ptyId = args.ptyId?.trim() || aliasesByPhysicalPaneKey.get(physicalPaneKey)?.ptyId || null
+ if (physicalPaneKey !== args.toPaneKey) {
+ // Why: the process keeps posting its original ORCA_PANE_KEY after detach;
+ // one physical-to-owner alias keeps chained moves on the current surface.
+ aliasesByPhysicalPaneKey.set(physicalPaneKey, {
+ ownerPaneKey: args.toPaneKey,
+ ptyId
+ })
+ }
+ return {
+ physicalPaneKey,
+ previousOwnerPaneKey,
+ ownerPaneKey: args.toPaneKey,
+ ptyId
+ }
+}
+
+export function retireAgentPaneAuthorityAliases(paneKey: string): string[] {
+ const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
+ const retiredPaneKeys = new Set([paneKey, ownerPaneKey])
+ for (const [physicalPaneKey, alias] of aliasesByPhysicalPaneKey) {
+ if (physicalPaneKey === paneKey || alias.ownerPaneKey === ownerPaneKey) {
+ aliasesByPhysicalPaneKey.delete(physicalPaneKey)
+ retiredPaneKeys.add(physicalPaneKey)
+ retiredPaneKeys.add(alias.ownerPaneKey)
+ }
+ }
+ return [...retiredPaneKeys]
+}
+
+export function retireAgentPaneAuthorityAliasesByOwnerTab(tabId: string): string[] {
+ const ownerPrefix = `${tabId}:`
+ const retiredPaneKeys = new Set()
+ for (const [physicalPaneKey, alias] of aliasesByPhysicalPaneKey) {
+ if (!alias.ownerPaneKey.startsWith(ownerPrefix)) {
+ continue
+ }
+ aliasesByPhysicalPaneKey.delete(physicalPaneKey)
+ retiredPaneKeys.add(physicalPaneKey)
+ retiredPaneKeys.add(alias.ownerPaneKey)
+ }
+ return [...retiredPaneKeys]
+}
+
+export function resetAgentPaneAuthorityAliasesForTests(): void {
+ aliasesByPhysicalPaneKey.clear()
+}
diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts
index 10ab468ab38..f6a902d2e49 100644
--- a/src/renderer/src/store/slices/agent-status.ts
+++ b/src/renderer/src/store/slices/agent-status.ts
@@ -32,6 +32,12 @@ import {
isOrcaDispatchPrompt,
orchestrationLabelsMatchLiveDispatch
} from '@/lib/agent-row-primary-text'
+import {
+ resolveAgentPaneAuthorityKey,
+ retireAgentPaneAuthorityAliases,
+ retireAgentPaneAuthorityAliasesByOwnerTab,
+ transferAgentPaneAuthorityAlias
+} from './agent-pane-authority'
import { createFreshnessScheduler } from './agent-status-freshness-scheduler'
/** Snapshot of a finished (or vanished) agent status entry, kept around so
@@ -133,6 +139,16 @@ export type AgentStatusSlice = {
* drop late in-flight IPC statuses and stale main-cache replays. */
recentlyClosedAgentStatusTabIds: Record
+ /** Exact pane authorities retired while sibling panes in the tab stay live. */
+ recentlyRetiredAgentStatusPaneKeys: Record
+
+ retireAgentPaneAuthority: (paneKey: string) => void
+ transferAgentPaneAuthority: (args: {
+ fromPaneKey: string
+ toPaneKey: string
+ ptyId?: string | null
+ }) => void
+
/** Update or insert an agent status entry from a status payload. */
setAgentStatus: (
paneKey: string,
@@ -838,6 +854,7 @@ function getLaunchConfigForEntry(
// so it must outlive the tab briefly — but tabId is ephemeral and it was only
// ever added to, growing one entry per tab-close for the renderer's whole life.
export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
+export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024
// delete-then-set for LRU recency, then evict the oldest keys past the cap (Record
// key order is insertion order for non-integer string keys). A status event for a
@@ -862,6 +879,58 @@ function boundRecentlyClosedAgentStatusTabIds(
return next
}
+function boundRecentlyRetiredAgentStatusPaneKeys(
+ existing: Record,
+ paneKeys: readonly string[]
+): Record {
+ const additions = new Set(paneKeys)
+ const next: Record = {}
+ for (const key of Object.keys(existing)) {
+ if (!additions.has(key)) {
+ next[key] = true
+ }
+ }
+ for (const paneKey of additions) {
+ next[paneKey] = true
+ }
+ const keys = Object.keys(next)
+ for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) {
+ delete next[stale]
+ }
+ return next
+}
+
+function movePaneKeyedRecord(
+ record: Record,
+ fromPaneKey: string,
+ toPaneKey: string,
+ transform: (value: T) => T = (value) => value
+): Record {
+ const value = record[fromPaneKey]
+ if (value === undefined || fromPaneKey === toPaneKey) {
+ return record
+ }
+ const next = { ...record }
+ delete next[fromPaneKey]
+ next[toPaneKey] = transform(value)
+ return next
+}
+
+function removePaneKeys(
+ record: Record,
+ paneKeys: ReadonlySet
+): Record {
+ const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key))
+ if (matchingKeys.length === 0) {
+ return record
+ }
+ const next = { ...record }
+ for (const key of matchingKeys) {
+ delete next[key]
+ }
+ return next
+}
+
function getLaunchConfigForStatusMetadata(
state: AppState,
metadata: AgentLaunchConfigStatusMetadata
@@ -1011,8 +1080,142 @@ export const createAgentStatusSlice: StateCreator freshness.schedule(),
+ retireAgentPaneAuthority: (paneKey) => {
+ const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
+ const retiredPaneKeys = retireAgentPaneAuthorityAliases(paneKey)
+ const retiredPaneKeySet = new Set(retiredPaneKeys)
+ let hadLive = false
+ set((s) => {
+ const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey)
+ hadLive = retiredLivePaneKeys.length > 0
+ let nextRetentionSuppressedPaneKeys = removePaneKeys(
+ s.retentionSuppressedPaneKeys,
+ retiredPaneKeySet
+ )
+ if (
+ retiredLivePaneKeys.length > 0 &&
+ nextRetentionSuppressedPaneKeys === s.retentionSuppressedPaneKeys
+ ) {
+ nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys }
+ }
+ for (const key of retiredLivePaneKeys) {
+ nextRetentionSuppressedPaneKeys[key] = true
+ }
+ return {
+ agentStatusByPaneKey: removePaneKeys(s.agentStatusByPaneKey, retiredPaneKeySet),
+ runtimeAgentOrchestrationByPaneKey: removePaneKeys(
+ s.runtimeAgentOrchestrationByPaneKey,
+ retiredPaneKeySet
+ ),
+ retainedAgentsByPaneKey: removePaneKeys(s.retainedAgentsByPaneKey, retiredPaneKeySet),
+ sleepingAgentSessionsByPaneKey: removePaneKeys(
+ s.sleepingAgentSessionsByPaneKey,
+ retiredPaneKeySet
+ ),
+ agentLaunchConfigByPaneKey: removePaneKeys(
+ s.agentLaunchConfigByPaneKey,
+ retiredPaneKeySet
+ ),
+ acknowledgedAgentsByPaneKey: removePaneKeys(
+ s.acknowledgedAgentsByPaneKey,
+ retiredPaneKeySet
+ ),
+ paneForegroundAgentByPaneKey: removePaneKeys(
+ s.paneForegroundAgentByPaneKey,
+ retiredPaneKeySet
+ ),
+ unreadTerminalPanes: removePaneKeys(s.unreadTerminalPanes, retiredPaneKeySet),
+ unreadAgentCompletionPanes: removePaneKeys(
+ s.unreadAgentCompletionPanes,
+ retiredPaneKeySet
+ ),
+ lastTerminalInputAtByPaneKey: removePaneKeys(
+ s.lastTerminalInputAtByPaneKey,
+ retiredPaneKeySet
+ ),
+ cacheTimerByKey: removePaneKeys(s.cacheTimerByKey, retiredPaneKeySet),
+ retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
+ recentlyRetiredAgentStatusPaneKeys: boundRecentlyRetiredAgentStatusPaneKeys(
+ s.recentlyRetiredAgentStatusPaneKeys,
+ retiredPaneKeys
+ ),
+ agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
+ sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch
+ }
+ })
+ if (hadLive) {
+ queueMicrotask(() => freshness.schedule())
+ }
+ if (typeof window !== 'undefined') {
+ window.api?.agentStatus?.retirePaneAuthority?.(ownerPaneKey)
+ }
+ },
+
+ transferAgentPaneAuthority: ({ fromPaneKey, toPaneKey, ptyId }) => {
+ const transfer = transferAgentPaneAuthorityAlias({ fromPaneKey, toPaneKey, ptyId })
+ if (!transfer || transfer.previousOwnerPaneKey === transfer.ownerPaneKey) {
+ return
+ }
+ const from = transfer.previousOwnerPaneKey
+ const to = transfer.ownerPaneKey
+ const targetTabId = getTabIdFromPaneKey(to) ?? undefined
+ const targetLeafId = getLeafIdFromPaneKey(to) ?? undefined
+ set((s) => ({
+ agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({
+ ...entry,
+ paneKey: to,
+ tabId: targetTabId
+ })),
+ runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord(
+ s.runtimeAgentOrchestrationByPaneKey,
+ from,
+ to
+ ),
+ retainedAgentsByPaneKey: movePaneKeyedRecord(
+ s.retainedAgentsByPaneKey,
+ from,
+ to,
+ (retained) => ({
+ ...retained,
+ entry: { ...retained.entry, paneKey: to, tabId: targetTabId },
+ tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab
+ })
+ ),
+ sleepingAgentSessionsByPaneKey: movePaneKeyedRecord(
+ s.sleepingAgentSessionsByPaneKey,
+ from,
+ to,
+ (record) => ({ ...record, paneKey: to, tabId: targetTabId })
+ ),
+ agentLaunchConfigByPaneKey: movePaneKeyedRecord(
+ s.agentLaunchConfigByPaneKey,
+ from,
+ to,
+ (entry) => ({
+ ...entry,
+ identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId }
+ })
+ ),
+ acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to),
+ paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to),
+ unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to),
+ unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to),
+ lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to),
+ cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to),
+ retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to)
+ }))
+ if (typeof window !== 'undefined') {
+ window.api?.agentStatus?.transferPaneAuthority?.({
+ fromPaneKey: from,
+ toPaneKey: to,
+ ...(transfer.ptyId ? { ptyId: transfer.ptyId } : {})
+ })
+ }
+ },
+
setRuntimeAgentOrchestrationByPaneKey: (entries) => {
const generatedTitleUpdates: AgentStatusEntry[] = []
set((s) => {
@@ -1180,8 +1383,10 @@ export const createAgentStatusSlice: StateCreator {
+ paneKey = resolveAgentPaneAuthorityKey(paneKey)
const updatedAt = timing?.updatedAt ?? Date.now()
if (
+ paneKey in get().recentlyRetiredAgentStatusPaneKeys ||
// Why: a closed terminal tab is no longer a valid destination for hook
// replays or late status events, even if main still receives them.
isRecentlyClosedAgentStatusTab(
@@ -1893,6 +2098,7 @@ export const createAgentStatusSlice: StateCreator {
const prefix = `${tabIdPrefix}:`
+ const retiredAliasPaneKeys = retireAgentPaneAuthorityAliasesByOwnerTab(tabIdPrefix)
let hadLive = false
set((s) => {
const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose(
@@ -1932,6 +2138,10 @@ export const createAgentStatusSlice: StateCreator 0
@@ -2004,6 +2218,7 @@ export const createAgentStatusSlice: StateCreator void
closeUnifiedTab: (
tabId: string,
- opts?: { recordInteraction?: boolean }
+ opts?: { recordInteraction?: boolean; terminalRetirementHandled?: boolean }
) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null
reorderUnifiedTabs: (
groupId: string,
@@ -899,6 +899,15 @@ export const createTabsSlice: StateCreator = (set,
return null
}
+ if (tab.contentType === 'terminal' && !opts?.terminalRetirementHandled) {
+ const dedupedGroupOrder = dedupeTabOrder(group.tabOrder)
+ const wasLastTab = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)).length === 0
+ // Why: unified-only hydrated tabs still own provider sessions even when
+ // their legacy terminal row is missing, so every terminal close retires by entity id.
+ get().closeTab(tab.entityId, { recordInteraction: opts?.recordInteraction })
+ return { closedTabId: tabId, wasLastTab, worktreeId }
+ }
+
const dedupedGroupOrder = dedupeTabOrder(group.tabOrder)
const remainingOrder = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId))
const wasLastTab = remainingOrder.length === 0
diff --git a/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts b/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts
index 5afdcb6fe43..6d12b987b30 100644
--- a/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts
+++ b/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts
@@ -3,7 +3,7 @@ import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-p
import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers'
describe('syncPaneDetachPtyOwnership agent identity', () => {
- it('moves process and launch identity to the detached leaf without forging hook identity', () => {
+ it('moves process, hook, launch, and resume authority to the detached leaf', () => {
const store = createTestStore()
const worktreeId = 'repo::/repo/worktree'
const sourceTabId = 'tab-source'
@@ -49,6 +49,21 @@ describe('syncPaneDetachPtyOwnership agent identity', () => {
prompt: '',
agentType: 'droid'
})
+ store.setState({
+ sleepingAgentSessionsByPaneKey: {
+ [sourcePaneKey]: {
+ paneKey: sourcePaneKey,
+ tabId: sourceTabId,
+ worktreeId,
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'session-1' },
+ prompt: 'continue',
+ state: 'working',
+ capturedAt: 1,
+ updatedAt: 1
+ }
+ }
+ })
store.getState().syncPaneDetachPtyOwnership({
detachedLeafId,
@@ -76,11 +91,19 @@ describe('syncPaneDetachPtyOwnership agent identity', () => {
tabId: targetTabId,
leafId: detachedLeafId
})
- // Why: the PTY keeps emitting its immutable source ORCA_PANE_KEY. A copied
- // target hook row would look live but could never receive later updates.
expect(state.agentStatusByPaneKey[sourcePaneKey]).toBeUndefined()
- expect(state.agentStatusByPaneKey[targetPaneKey]).toBeUndefined()
- expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBe(true)
+ expect(state.agentStatusByPaneKey[targetPaneKey]).toMatchObject({
+ paneKey: targetPaneKey,
+ tabId: targetTabId,
+ state: 'working'
+ })
+ expect(state.sleepingAgentSessionsByPaneKey[sourcePaneKey]).toBeUndefined()
+ expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
+ paneKey: targetPaneKey,
+ tabId: targetTabId,
+ providerSession: { key: 'session_id', id: 'session-1' }
+ })
+ expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBeUndefined()
expect(state.paneForegroundAgentByPaneKey[siblingPaneKey]).toEqual({
agent: 'antigravity',
shellForeground: false
diff --git a/src/renderer/src/store/slices/terminal-retirement-teardown-reservation.ts b/src/renderer/src/store/slices/terminal-retirement-teardown-reservation.ts
new file mode 100644
index 00000000000..79a9e3b3161
--- /dev/null
+++ b/src/renderer/src/store/slices/terminal-retirement-teardown-reservation.ts
@@ -0,0 +1,33 @@
+import {
+ getTerminalPtyOwnershipIdentity,
+ type TerminalTabRetirementPlan,
+ type TerminalTabRetirementState
+} from './terminal-tab-retirement'
+
+export function reserveTerminalRetirementTeardowns(
+ state: TerminalTabRetirementState,
+ plan: TerminalTabRetirementPlan,
+ scheduledPtyOwners: Set
+): { plan: TerminalTabRetirementPlan; newlyScheduledPtyOwners: string[] } {
+ const cleanupOnlyPtyIds = new Set(plan.cleanupOnlyPtyIds)
+ const newlyScheduledPtyOwners: string[] = []
+ const reserve = (ptyId: string): boolean => {
+ const owner = getTerminalPtyOwnershipIdentity(state, ptyId, plan.worktreeId)
+ if (scheduledPtyOwners.has(owner)) {
+ cleanupOnlyPtyIds.add(ptyId)
+ return false
+ }
+ scheduledPtyOwners.add(owner)
+ newlyScheduledPtyOwners.push(owner)
+ return true
+ }
+ return {
+ plan: {
+ ...plan,
+ localOrSshPtyIds: plan.localOrSshPtyIds.filter(reserve),
+ runtimeTerminals: plan.runtimeTerminals.filter((terminal) => reserve(terminal.ptyId)),
+ cleanupOnlyPtyIds: [...cleanupOnlyPtyIds]
+ },
+ newlyScheduledPtyOwners
+ }
+}
diff --git a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts
new file mode 100644
index 00000000000..13df54d1d9f
--- /dev/null
+++ b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts
@@ -0,0 +1,259 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
+
+const mockKill = vi.fn().mockResolvedValue(undefined)
+const mockRuntimeCall = vi.fn().mockResolvedValue({
+ id: 'rpc-1',
+ ok: true,
+ result: {},
+ _meta: { runtimeId: 'local-runtime' }
+})
+
+vi.stubGlobal('window', {
+ api: {
+ pty: { kill: mockKill },
+ runtime: { call: mockRuntimeCall },
+ runtimeEnvironments: { call: vi.fn() }
+ }
+})
+
+import {
+ capturedPanesByTabId,
+ parkedWatchersByTabId
+} from '@/components/terminal-pane/terminal-parked-watcher-registry'
+import {
+ createTestStore,
+ makeTab,
+ makeTabGroup,
+ makeUnifiedTab,
+ seedStore
+} from './store-test-helpers'
+
+function sleepingRecord(paneKey: string, tabId: string): SleepingAgentSessionRecord {
+ return {
+ paneKey,
+ tabId,
+ worktreeId: 'wt-1',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: paneKey },
+ prompt: 'continue',
+ state: 'working',
+ capturedAt: 1,
+ updatedAt: 1
+ }
+}
+
+describe('terminal tab retirement store boundary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockKill.mockResolvedValue(undefined)
+ mockRuntimeCall.mockResolvedValue({
+ id: 'rpc-1',
+ ok: true,
+ result: {},
+ _meta: { runtimeId: 'local-runtime' }
+ })
+ parkedWatchersByTabId.clear()
+ capturedPanesByTabId.clear()
+ })
+
+ it('retires split, relay, deferred, and pending sessions for a parked tab', async () => {
+ const store = createTestStore()
+ const dispose = vi.fn()
+ const siblingRecord = sleepingRecord('tab-2:leaf-2', 'tab-2')
+ seedStore(store, {
+ tabsByWorktree: {
+ 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-primary' })]
+ },
+ ptyIdsByTabId: { 'tab-1': ['pty-primary', 'pty-split'] },
+ terminalLayoutsByTabId: {
+ 'tab-1': {
+ root: null,
+ activeLeafId: null,
+ expandedLeafId: null,
+ ptyIdsByLeafId: { leaf1: 'pty-primary', leaf2: 'pty-split' }
+ }
+ },
+ lastKnownRelayPtyIdByTabId: { 'tab-1': 'ssh:ssh-1@@relay' },
+ deferredSshSessionIdsByTabId: { 'tab-1': 'pty-deferred' },
+ pendingReconnectPtyIdByTabId: { 'tab-1': 'pty-pending' },
+ sleepingAgentSessionsByPaneKey: {
+ 'tab-1:leaf-1': sleepingRecord('tab-1:leaf-1', 'tab-1'),
+ 'legacy-key': sleepingRecord('legacy-key', 'tab-1'),
+ 'tab-2:leaf-2': siblingRecord
+ }
+ })
+ parkedWatchersByTabId.set('tab-1', {
+ worktreeId: 'wt-1',
+ tabPtyId: 'pty-primary',
+ paneIdByPtyId: new Map([['pty-primary', 1]]),
+ disposersByPtyId: new Map([['pty-primary', dispose]])
+ })
+ capturedPanesByTabId.set('tab-1', { worktreeId: 'wt-1', panes: [] })
+
+ store.getState().closeTab('tab-1')
+ await vi.waitFor(() => expect(mockKill).toHaveBeenCalledTimes(5))
+
+ expect(new Set(mockKill.mock.calls.map(([ptyId]) => ptyId))).toEqual(
+ new Set(['pty-primary', 'pty-split', 'ssh:ssh-1@@relay', 'pty-deferred', 'pty-pending'])
+ )
+ expect(store.getState().tabsByWorktree['wt-1']).toEqual([])
+ expect(store.getState().deferredSshSessionIdsByTabId['tab-1']).toBeUndefined()
+ expect(store.getState().pendingReconnectPtyIdByTabId['tab-1']).toBeUndefined()
+ expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({
+ 'tab-2:leaf-2': siblingRecord
+ })
+ expect(store.getState().sleepingAgentSessionsByPaneKey['tab-2:leaf-2']).toBe(siblingRecord)
+ expect(dispose).toHaveBeenCalledOnce()
+ expect(parkedWatchersByTabId.has('tab-1')).toBe(false)
+ expect(capturedPanesByTabId.has('tab-1')).toBe(false)
+ })
+
+ it('routes runtime handles to runtime close and preserves shared PTYs', async () => {
+ const store = createTestStore()
+ seedStore(store, {
+ tabsByWorktree: {
+ 'wt-1': [
+ makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'remote:terminal-1' }),
+ makeTab({ id: 'tab-2', worktreeId: 'wt-1', ptyId: 'pty-shared' })
+ ]
+ },
+ ptyIdsByTabId: {
+ 'tab-1': ['remote:terminal-1', 'pty-shared'],
+ 'tab-2': ['pty-shared']
+ }
+ })
+
+ store.getState().closeTab('tab-1')
+ await vi.waitFor(() => expect(mockRuntimeCall).toHaveBeenCalled())
+
+ expect(mockRuntimeCall).toHaveBeenCalledWith({
+ method: 'terminal.close',
+ params: { terminal: 'terminal-1' }
+ })
+ expect(mockKill).not.toHaveBeenCalled()
+ })
+
+ it('preserves shared-owner snapshots while closing the source tab', async () => {
+ const store = createTestStore()
+ const snapshot = { snapshot: 'shared snapshot' }
+ const coldRestore = { scrollback: 'shared scrollback', cwd: 'C:\\workspace' }
+ seedStore(store, {
+ tabsByWorktree: {
+ 'wt-1': [
+ makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-shared' }),
+ makeTab({ id: 'tab-2', worktreeId: 'wt-1', ptyId: 'pty-shared' })
+ ]
+ },
+ ptyIdsByTabId: { 'tab-1': ['pty-shared'], 'tab-2': ['pty-shared'] },
+ pendingSnapshotByPtyId: { 'pty-shared': snapshot },
+ pendingColdRestoreByPtyId: { 'pty-shared': coldRestore }
+ })
+
+ store.getState().closeTab('tab-1')
+ await Promise.resolve()
+
+ expect(mockKill).not.toHaveBeenCalled()
+ expect(store.getState().pendingSnapshotByPtyId['pty-shared']).toBe(snapshot)
+ expect(store.getState().pendingColdRestoreByPtyId['pty-shared']).toBe(coldRestore)
+ })
+
+ it('reconciles natural exit without issuing teardown or revoking resume authority', async () => {
+ const store = createTestStore()
+ const record = sleepingRecord('tab-1:leaf-1', 'tab-1')
+ seedStore(store, {
+ tabsByWorktree: {
+ 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-dead' })]
+ },
+ ptyIdsByTabId: { 'tab-1': ['pty-dead'] },
+ sleepingAgentSessionsByPaneKey: { 'tab-1:leaf-1': record }
+ })
+
+ store.getState().closeTab('tab-1', { reason: 'pty-exit' })
+ await Promise.resolve()
+
+ expect(mockKill).not.toHaveBeenCalled()
+ expect(mockRuntimeCall).not.toHaveBeenCalled()
+ expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(record)
+ })
+
+ it('does not recreate PTY indexes for a tab that no longer exists', () => {
+ const store = createTestStore()
+
+ store.getState().updateTabPtyId('closed-tab', 'pty-after-close')
+
+ expect(store.getState().ptyIdsByTabId['closed-tab']).toBeUndefined()
+ expect(store.getState().lastKnownRelayPtyIdByTabId['closed-tab']).toBeUndefined()
+ })
+
+ it('retires a unified-only terminal instead of removing only its wrapper', async () => {
+ const store = createTestStore()
+ const unified = makeUnifiedTab({
+ id: 'unified-tab-1',
+ entityId: 'terminal-tab-1',
+ worktreeId: 'wt-1',
+ groupId: 'group-1'
+ })
+ seedStore(store, {
+ tabsByWorktree: { 'wt-1': [] },
+ unifiedTabsByWorktree: { 'wt-1': [unified] },
+ groupsByWorktree: {
+ 'wt-1': [
+ makeTabGroup({
+ id: 'group-1',
+ worktreeId: 'wt-1',
+ activeTabId: unified.id,
+ tabOrder: [unified.id]
+ })
+ ]
+ },
+ ptyIdsByTabId: { 'terminal-tab-1': ['pty-unified-only'] }
+ })
+
+ store.getState().closeUnifiedTab(unified.id)
+ await vi.waitFor(() => expect(mockKill).toHaveBeenCalledWith('pty-unified-only'))
+
+ expect(store.getState().unifiedTabsByWorktree['wt-1']).toEqual([])
+ expect(store.getState().ptyIdsByTabId['terminal-tab-1']).toBeUndefined()
+ })
+
+ it('lets a paired host own runtime teardown while pruning local state', async () => {
+ const store = createTestStore()
+ seedStore(store, {
+ tabsByWorktree: {
+ 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'remote:terminal-1' })]
+ },
+ ptyIdsByTabId: { 'tab-1': ['remote:terminal-1'] }
+ })
+
+ store.getState().closeTab('tab-1', { remoteCloseOwnedByHost: true })
+ await Promise.resolve()
+
+ expect(mockRuntimeCall).not.toHaveBeenCalled()
+ expect(store.getState().tabsByWorktree['wt-1']).toEqual([])
+ })
+
+ it('keeps the tab retired and reports provider rejection without an unhandled promise', async () => {
+ const store = createTestStore()
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ mockKill.mockRejectedValueOnce(new Error('provider unavailable'))
+ seedStore(store, {
+ tabsByWorktree: {
+ 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-1' })]
+ },
+ ptyIdsByTabId: { 'tab-1': ['pty-1'] }
+ })
+
+ store.getState().closeTab('tab-1')
+ await vi.waitFor(() =>
+ expect(warn).toHaveBeenCalledWith('[terminal-retirement] provider teardown failed', {
+ tabId: 'tab-1',
+ localOrSshFailures: 1,
+ runtimeFailures: 0
+ })
+ )
+
+ expect(store.getState().tabsByWorktree['wt-1']).toEqual([])
+ warn.mockRestore()
+ })
+})
diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement.test.ts
new file mode 100644
index 00000000000..1203a3937a2
--- /dev/null
+++ b/src/renderer/src/store/slices/terminal-tab-retirement.test.ts
@@ -0,0 +1,270 @@
+import { describe, expect, it } from 'vitest'
+import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
+import type { TerminalTab } from '../../../../shared/types'
+import {
+ buildTerminalTabRetirementPlan,
+ buildTerminalTabRetirementPlans,
+ isTerminalTabPresent,
+ removeSleepingAgentSessionsForTab
+} from './terminal-tab-retirement'
+
+type RetirementState = Parameters[0]
+
+function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab {
+ return {
+ id,
+ worktreeId,
+ ptyId,
+ title: id,
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 0
+ }
+}
+
+function makeState(overrides: Partial = {}): RetirementState {
+ return {
+ tabsByWorktree: {},
+ unifiedTabsByWorktree: {},
+ ptyIdsByTabId: {},
+ terminalLayoutsByTabId: {},
+ lastKnownRelayPtyIdByTabId: {},
+ deferredSshSessionIdsByTabId: {},
+ pendingReconnectPtyIdByTabId: {},
+ ...overrides
+ }
+}
+
+function makeSleepingRecord(paneKey: string, tabId?: string): SleepingAgentSessionRecord {
+ return {
+ paneKey,
+ tabId,
+ worktreeId: 'wt-1',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: paneKey },
+ prompt: 'continue',
+ state: 'working',
+ capturedAt: 1,
+ updatedAt: 1
+ }
+}
+
+describe('terminal tab retirement planning', () => {
+ it('collects and deduplicates every ownership source before routing providers', () => {
+ const state = makeState({
+ tabsByWorktree: {
+ 'wt-1': [makeTab('tab-1', 'wt-1', 'pty-row')]
+ },
+ ptyIdsByTabId: { 'tab-1': ['pty-index', 'pty-row'] },
+ terminalLayoutsByTabId: {
+ 'tab-1': {
+ root: null,
+ activeLeafId: null,
+ expandedLeafId: null,
+ ptyIdsByLeafId: {
+ leaf1: 'pty-layout',
+ leaf2: 'remote:env-1@@terminal-1'
+ }
+ }
+ },
+ lastKnownRelayPtyIdByTabId: { 'tab-1': 'ssh:ssh-1@@relay-pty' },
+ deferredSshSessionIdsByTabId: { 'tab-1': 'pty-deferred' },
+ pendingReconnectPtyIdByTabId: { 'tab-1': 'pty-pending' }
+ })
+
+ expect(buildTerminalTabRetirementPlan(state, 'tab-1')).toEqual({
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ ptyIds: [
+ 'pty-index',
+ 'pty-row',
+ 'pty-layout',
+ 'remote:env-1@@terminal-1',
+ 'ssh:ssh-1@@relay-pty',
+ 'pty-deferred',
+ 'pty-pending'
+ ],
+ localOrSshPtyIds: [
+ 'pty-index',
+ 'pty-row',
+ 'pty-layout',
+ 'ssh:ssh-1@@relay-pty',
+ 'pty-deferred',
+ 'pty-pending'
+ ],
+ runtimeTerminals: [
+ {
+ ptyId: 'remote:env-1@@terminal-1',
+ environmentId: 'env-1',
+ handle: 'terminal-1'
+ }
+ ],
+ cleanupOnlyPtyIds: [],
+ sharedPtyIds: [],
+ unroutablePtyIds: []
+ })
+ expect(isTerminalTabPresent(state, 'tab-1')).toBe(true)
+ })
+
+ it('does not retire a PTY still referenced by another live surface', () => {
+ const shared = 'pty-in-transfer'
+ const state = makeState({
+ tabsByWorktree: {
+ 'wt-1': [makeTab('tab-1', 'wt-1', shared), makeTab('tab-2', 'wt-1', null)]
+ },
+ ptyIdsByTabId: { 'tab-1': [shared], 'tab-2': [shared] },
+ terminalLayoutsByTabId: {
+ 'tab-2': {
+ root: null,
+ activeLeafId: null,
+ expandedLeafId: null,
+ ptyIdsByLeafId: { leaf2: shared }
+ }
+ }
+ })
+
+ const plan = buildTerminalTabRetirementPlan(state, 'tab-1')
+ expect(plan.sharedPtyIds).toEqual([shared])
+ expect(plan.localOrSshPtyIds).toEqual([])
+ expect(plan.runtimeTerminals).toEqual([])
+ })
+
+ it('protects a scoped runtime terminal referenced through its legacy alias', () => {
+ const scoped = 'remote:env-1@@terminal-1'
+ const legacy = 'remote:terminal-1'
+ const state = makeState({
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ tabsByWorktree: {
+ 'wt-1': [makeTab('tab-1', 'wt-1', legacy)],
+ 'wt-2': [makeTab('tab-2', 'wt-2', scoped)]
+ },
+ ptyIdsByTabId: { 'tab-1': [legacy], 'tab-2': [scoped] }
+ })
+
+ const plan = buildTerminalTabRetirementPlan(state, 'tab-1')
+ expect(plan.sharedPtyIds).toEqual([legacy])
+ expect(plan.runtimeTerminals).toEqual([])
+ })
+
+ it('deduplicates legacy and scoped aliases owned by the closing tab', () => {
+ const state = makeState({
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ tabsByWorktree: {
+ 'wt-1': [makeTab('tab-1', 'wt-1', 'remote:terminal-1')]
+ },
+ ptyIdsByTabId: {
+ 'tab-1': ['remote:terminal-1', 'remote:env-1@@terminal-1']
+ }
+ })
+
+ expect(buildTerminalTabRetirementPlan(state, 'tab-1').runtimeTerminals).toEqual([
+ {
+ ptyId: 'remote:terminal-1',
+ environmentId: null,
+ handle: 'terminal-1'
+ }
+ ])
+ })
+
+ it('ignores stale ownership maps and never routes malformed remote ids locally', () => {
+ const malformedRemote = 'remote:'
+ const state = makeState({
+ tabsByWorktree: {
+ 'wt-1': [makeTab('tab-1', 'wt-1', malformedRemote)]
+ },
+ ptyIdsByTabId: {
+ 'tab-1': [malformedRemote, 'pty-live'],
+ 'stale-tab': ['pty-live']
+ }
+ })
+
+ const plan = buildTerminalTabRetirementPlan(state, 'tab-1')
+ expect(plan.unroutablePtyIds).toEqual([malformedRemote])
+ expect(plan.localOrSshPtyIds).toEqual(['pty-live'])
+ expect(plan.sharedPtyIds).toEqual([])
+ })
+
+ it('deduplicates batch-owned PTYs while protecting owners outside the close set', () => {
+ const state = makeState({
+ tabsByWorktree: {
+ 'wt-1': [
+ makeTab('tab-1', 'wt-1', 'pty-batch'),
+ makeTab('tab-2', 'wt-1', 'pty-batch'),
+ makeTab('later-tab', 'wt-1', 'pty-external')
+ ]
+ },
+ ptyIdsByTabId: {
+ 'tab-1': ['pty-batch', 'pty-external'],
+ 'tab-2': ['pty-batch'],
+ 'later-tab': ['pty-external']
+ }
+ })
+
+ const plans = buildTerminalTabRetirementPlans(state, ['tab-1', 'tab-2'])
+
+ expect(plans.get('tab-1')).toMatchObject({
+ localOrSshPtyIds: ['pty-batch'],
+ sharedPtyIds: ['pty-external']
+ })
+ expect(plans.get('tab-2')).toMatchObject({
+ localOrSshPtyIds: [],
+ cleanupOnlyPtyIds: ['pty-batch'],
+ sharedPtyIds: []
+ })
+ })
+
+ it('indexes live owners once for a 100-tab batch', () => {
+ const tabs = Array.from({ length: 100 }, (_, index) =>
+ makeTab(`tab-${index}`, 'wt-1', `pty-${index}`)
+ )
+ let terminalStoreScans = 0
+ let unifiedStoreScans = 0
+ const state = makeState({
+ tabsByWorktree: new Proxy(
+ { 'wt-1': tabs },
+ {
+ ownKeys(target) {
+ terminalStoreScans += 1
+ return Reflect.ownKeys(target)
+ }
+ }
+ ),
+ unifiedTabsByWorktree: new Proxy(
+ {},
+ {
+ ownKeys(target) {
+ unifiedStoreScans += 1
+ return Reflect.ownKeys(target)
+ }
+ }
+ ),
+ ptyIdsByTabId: Object.fromEntries(tabs.map((tab, index) => [tab.id, [`pty-${index}`]]))
+ })
+
+ const plans = buildTerminalTabRetirementPlans(
+ state,
+ tabs.map((tab) => tab.id)
+ )
+
+ expect(plans).toHaveLength(100)
+ expect(terminalStoreScans).toBe(1)
+ expect(unifiedStoreScans).toBe(1)
+ })
+})
+
+describe('sleeping agent retirement', () => {
+ it('removes key- and metadata-owned records while preserving siblings by reference', () => {
+ const sibling = makeSleepingRecord('tab-2:leaf-2', 'tab-2')
+ const records = {
+ 'tab-1:leaf-1': makeSleepingRecord('tab-1:leaf-1'),
+ 'legacy-pane-key': makeSleepingRecord('legacy-pane-key', 'tab-1'),
+ 'tab-2:leaf-2': sibling
+ }
+
+ const next = removeSleepingAgentSessionsForTab(records, 'tab-1')
+ expect(next).toEqual({ 'tab-2:leaf-2': sibling })
+ expect(next['tab-2:leaf-2']).toBe(sibling)
+ expect(removeSleepingAgentSessionsForTab(next, 'missing-tab')).toBe(next)
+ })
+})
diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.ts b/src/renderer/src/store/slices/terminal-tab-retirement.ts
new file mode 100644
index 00000000000..e7b26582e7b
--- /dev/null
+++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts
@@ -0,0 +1,221 @@
+import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
+import type { AppState } from '../types'
+import {
+ getRuntimeEnvironmentIdForWorktree,
+ type WorktreeRuntimeOwnerState
+} from '@/lib/worktree-runtime-owner'
+import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
+
+export type TerminalTabCloseReason = 'user' | 'cleanup' | 'pty-exit'
+
+export type TerminalTabRetirementState = WorktreeRuntimeOwnerState &
+ Pick<
+ AppState,
+ | 'tabsByWorktree'
+ | 'unifiedTabsByWorktree'
+ | 'ptyIdsByTabId'
+ | 'terminalLayoutsByTabId'
+ | 'lastKnownRelayPtyIdByTabId'
+ | 'deferredSshSessionIdsByTabId'
+ | 'pendingReconnectPtyIdByTabId'
+ >
+
+export type TerminalTabRetirementPlan = {
+ tabId: string
+ worktreeId: string | null
+ ptyIds: string[]
+ localOrSshPtyIds: string[]
+ runtimeTerminals: {
+ ptyId: string
+ environmentId: string | null
+ handle: string
+ }[]
+ cleanupOnlyPtyIds: string[]
+ sharedPtyIds: string[]
+ unroutablePtyIds: string[]
+}
+
+function appendPtyId(ids: Set, ptyId: string | null | undefined): void {
+ if (ptyId) {
+ ids.add(ptyId)
+ }
+}
+
+export function getTerminalPtyOwnershipIdentity(
+ state: TerminalTabRetirementState,
+ ptyId: string,
+ worktreeId: string | null
+): string {
+ const remote = parseRemoteRuntimePtyId(ptyId)
+ if (!remote?.handle) {
+ return `pty:${ptyId}`
+ }
+ // Why: hydrated legacy runtime IDs omit their owner, but still refer to the
+ // same provider terminal as a scoped ID in the owning worktree.
+ const environmentId =
+ remote.environmentId?.trim() || getRuntimeEnvironmentIdForWorktree(state, worktreeId) || ''
+ return JSON.stringify(['runtime', environmentId, remote.handle])
+}
+
+function collectPtyIdsForTab(
+ state: TerminalTabRetirementState,
+ tabId: string,
+ rowPtyId: string | null | undefined
+): string[] {
+ const ids = new Set()
+ for (const ptyId of state.ptyIdsByTabId[tabId] ?? []) {
+ appendPtyId(ids, ptyId)
+ }
+ appendPtyId(ids, rowPtyId)
+ for (const ptyId of Object.values(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {})) {
+ appendPtyId(ids, ptyId)
+ }
+ appendPtyId(ids, state.lastKnownRelayPtyIdByTabId[tabId])
+ appendPtyId(ids, state.deferredSshSessionIdsByTabId[tabId])
+ appendPtyId(ids, state.pendingReconnectPtyIdByTabId[tabId])
+ return [...ids]
+}
+
+function collectLiveTerminalTabs(
+ state: TerminalTabRetirementState
+): Map {
+ const liveTabs = new Map()
+ for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
+ for (const tab of tabs) {
+ liveTabs.set(tab.id, { worktreeId, rowPtyId: tab.ptyId })
+ }
+ }
+ for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) {
+ for (const tab of tabs) {
+ if (tab.contentType === 'terminal' && !liveTabs.has(tab.entityId)) {
+ liveTabs.set(tab.entityId, { worktreeId, rowPtyId: null })
+ }
+ }
+ }
+ return liveTabs
+}
+
+function hasOwnerOutsideTargets(
+ ownerTabIds: ReadonlySet | undefined,
+ targetIds: ReadonlySet
+): boolean {
+ for (const ownerTabId of ownerTabIds ?? []) {
+ if (!targetIds.has(ownerTabId)) {
+ return true
+ }
+ }
+ return false
+}
+
+export function isTerminalTabPresent(
+ state: Pick,
+ tabId: string
+): boolean {
+ return Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === tabId))
+}
+
+export function buildTerminalTabRetirementPlan(
+ state: TerminalTabRetirementState,
+ tabId: string
+): TerminalTabRetirementPlan {
+ return buildTerminalTabRetirementPlans(state, [tabId]).get(tabId)!
+}
+
+export function buildTerminalTabRetirementPlans(
+ state: TerminalTabRetirementState,
+ tabIds: readonly string[]
+): Map {
+ const targetIds = [...new Set(tabIds)]
+ const targetIdSet = new Set(targetIds)
+ const liveTabs = collectLiveTerminalTabs(state)
+ const ptyIdsByLiveTab = new Map()
+ const ownerTabIdsByIdentity = new Map>()
+
+ // Why: bulk close must not rebuild the live-owner index after every tab;
+ // one snapshot keeps planning linear while preserving shared-surface safety.
+ for (const [tabId, owner] of liveTabs) {
+ const ptyIds = collectPtyIdsForTab(state, tabId, owner.rowPtyId)
+ ptyIdsByLiveTab.set(tabId, ptyIds)
+ for (const ptyId of ptyIds) {
+ const identity = getTerminalPtyOwnershipIdentity(state, ptyId, owner.worktreeId)
+ const owners = ownerTabIdsByIdentity.get(identity) ?? new Set()
+ owners.add(tabId)
+ ownerTabIdsByIdentity.set(identity, owners)
+ }
+ }
+
+ const plans = new Map()
+ const scheduledPtyOwners = new Set()
+ for (const tabId of targetIds) {
+ const owner = liveTabs.get(tabId)
+ const worktreeId = owner?.worktreeId ?? null
+ const ptyIds =
+ ptyIdsByLiveTab.get(tabId) ?? collectPtyIdsForTab(state, tabId, owner?.rowPtyId ?? null)
+ const sharedPtyIds: string[] = []
+ const localOrSshPtyIds: string[] = []
+ const runtimeTerminals: TerminalTabRetirementPlan['runtimeTerminals'] = []
+ const cleanupOnlyPtyIds: string[] = []
+ const unroutablePtyIds: string[] = []
+
+ for (const ptyId of ptyIds) {
+ const ownerIdentity = getTerminalPtyOwnershipIdentity(state, ptyId, worktreeId)
+ const ownerTabIds = ownerTabIdsByIdentity.get(ownerIdentity)
+ if (hasOwnerOutsideTargets(ownerTabIds, targetIdSet)) {
+ sharedPtyIds.push(ptyId)
+ continue
+ }
+ if (scheduledPtyOwners.has(ownerIdentity)) {
+ // Why: another closing tab already owns provider teardown, but this
+ // tab can still hold alias-keyed snapshots that must be discarded.
+ cleanupOnlyPtyIds.push(ptyId)
+ continue
+ }
+ scheduledPtyOwners.add(ownerIdentity)
+ const remote = parseRemoteRuntimePtyId(ptyId)
+ if (remote) {
+ if (!remote.handle) {
+ unroutablePtyIds.push(ptyId)
+ continue
+ }
+ runtimeTerminals.push({
+ ptyId,
+ environmentId: remote.environmentId?.trim() || null,
+ handle: remote.handle
+ })
+ } else if (ptyId.startsWith('remote:')) {
+ unroutablePtyIds.push(ptyId)
+ } else {
+ localOrSshPtyIds.push(ptyId)
+ }
+ }
+
+ plans.set(tabId, {
+ tabId,
+ worktreeId,
+ ptyIds,
+ localOrSshPtyIds,
+ runtimeTerminals,
+ cleanupOnlyPtyIds,
+ sharedPtyIds,
+ unroutablePtyIds
+ })
+ }
+ return plans
+}
+
+export function removeSleepingAgentSessionsForTab(
+ records: Record,
+ tabId: string
+): Record {
+ let next = records
+ for (const [paneKey, record] of Object.entries(records)) {
+ if (!paneKey.startsWith(`${tabId}:`) && record.tabId !== tabId) {
+ continue
+ }
+ if (next === records) {
+ next = { ...records }
+ }
+ delete next[paneKey]
+ }
+ return next
+}
diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts
index be20477bc78..e127c1973a8 100644
--- a/src/renderer/src/store/slices/terminals.ts
+++ b/src/renderer/src/store/slices/terminals.ts
@@ -67,7 +67,10 @@ import {
// Why: import the store-free registry, not terminal-parked-tab-watchers —
// that module imports @/store, and a slice importing it would re-enter store
// creation before this slice finishes evaluating.
-import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry'
+import {
+ disposeParkedTerminalWatchersForPtyIds,
+ retireParkedTerminalTab
+} from '@/components/terminal-pane/terminal-parked-watcher-registry'
import {
normalizeTerminalLayoutSnapshot,
resolvePtyBoundActiveLeafId
@@ -94,6 +97,13 @@ import {
removeSleepingRecordsReplacedByManualWorktreeSleep,
type AgentStatusWorktreeShutdownReason
} from './agent-status'
+import {
+ buildTerminalTabRetirementPlan,
+ isTerminalTabPresent,
+ removeSleepingAgentSessionsForTab,
+ type TerminalTabCloseReason,
+ type TerminalTabRetirementPlan
+} from './terminal-tab-retirement'
function getNextTerminalOrdinal(tabs: TerminalTab[]): number {
const usedOrdinals = new Set()
@@ -593,7 +603,16 @@ export type TerminalSlice = {
}
) => TerminalTab
openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise
- closeTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void
+ closeTab: (
+ tabId: string,
+ opts?: {
+ recordInteraction?: boolean
+ reason?: TerminalTabCloseReason
+ remoteCloseOwnedByHost?: boolean
+ localPtyTeardownOwnedExternally?: boolean
+ precomputedRetirementPlan?: TerminalTabRetirementPlan
+ }
+ ) => void
reorderTabs: (worktreeId: string, tabIds: string[]) => void
setTabBarOrder: (worktreeId: string, order: string[]) => void
setActiveTab: (tabId: string) => void
@@ -1197,18 +1216,69 @@ export const createTerminalSlice: StateCreator
},
closeTab: (tabId, opts) => {
+ const closeReason = opts?.reason ?? 'user'
+ const retiresSession = closeReason === 'user' || closeReason === 'cleanup'
+ const retirementPlan =
+ opts?.precomputedRetirementPlan?.tabId === tabId
+ ? opts.precomputedRetirementPlan
+ : buildTerminalTabRetirementPlan(get(), tabId)
let closingWorktreeId: string | null = null
+
+ // Why: a parked tab has no mounted TerminalPane cleanup. Retirement must
+ // synchronously revoke its observer/candidate state before provider exit races.
+ retireParkedTerminalTab(tabId)
+ if (retiresSession) {
+ const fallbackRuntimeEnvironmentId = retirementPlan.worktreeId
+ ? getRuntimeEnvironmentIdForWorktree(get(), retirementPlan.worktreeId)
+ : null
+ const retirementTasks: Promise[] = opts?.localPtyTeardownOwnedExternally
+ ? []
+ : retirementPlan.localOrSshPtyIds.map(async (ptyId) => window.api.pty.kill(ptyId))
+ const localOrSshTaskCount = retirementTasks.length
+ if (!opts?.remoteCloseOwnedByHost) {
+ for (const terminal of retirementPlan.runtimeTerminals) {
+ const environmentId = terminal.environmentId ?? fallbackRuntimeEnvironmentId
+ retirementTasks.push(
+ callRuntimeRpc(
+ environmentId ? { kind: 'environment', environmentId } : { kind: 'local' },
+ 'terminal.close',
+ { terminal: terminal.handle }
+ )
+ )
+ }
+ }
+ if (retirementPlan.unroutablePtyIds.length > 0) {
+ console.warn('[terminal-retirement] skipped unroutable runtime handles', {
+ tabId,
+ count: retirementPlan.unroutablePtyIds.length
+ })
+ }
+ // Why: close remains synchronous and idempotent; provider failures must
+ // not reject into the UI or prevent renderer ownership from being revoked.
+ void Promise.allSettled(retirementTasks).then((results) => {
+ const localOrSshFailures = results
+ .slice(0, localOrSshTaskCount)
+ .filter((result) => result.status === 'rejected').length
+ const runtimeFailures = results
+ .slice(localOrSshTaskCount)
+ .filter((result) => result.status === 'rejected').length
+ if (localOrSshFailures > 0 || runtimeFailures > 0) {
+ console.warn('[terminal-retirement] provider teardown failed', {
+ tabId,
+ localOrSshFailures,
+ runtimeFailures
+ })
+ }
+ })
+ }
+
set((s) => {
const next = { ...s.tabsByWorktree }
- let closingPtyId: string | null = null
for (const wId of Object.keys(next)) {
const before = next[wId]
const closingTab = before.find((t) => t.id === tabId)
if (closingTab) {
closingWorktreeId = wId
- if (!closingPtyId) {
- closingPtyId = closingTab.ptyId ?? null
- }
}
const after = before.filter((t) => t.id !== tabId)
if (after.length !== before.length) {
@@ -1225,6 +1295,10 @@ export const createTerminalSlice: StateCreator
delete nextPtyIdsByTabId[tabId]
const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId }
delete nextLastKnownRelay[tabId]
+ const nextDeferredSshSessionIdsByTabId = { ...s.deferredSshSessionIdsByTabId }
+ delete nextDeferredSshSessionIdsByTabId[tabId]
+ const nextPendingReconnectPtyIdByTabId = { ...s.pendingReconnectPtyIdByTabId }
+ delete nextPendingReconnectPtyIdByTabId[tabId]
const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId }
delete nextRuntimePaneTitlesByTabId[tabId]
// Why: preserve the unreadTerminalTabs reference when the closing tab had
@@ -1260,6 +1334,9 @@ export const createTerminalSlice: StateCreator
delete nextLastTerminalInputAtByPaneKey[paneKey]
}
}
+ const nextSleepingAgentSessionsByPaneKey = retiresSession
+ ? removeSleepingAgentSessionsForTab(s.sleepingAgentSessionsByPaneKey, tabId)
+ : s.sleepingAgentSessionsByPaneKey
const nextPendingStartupByTabId = { ...s.pendingStartupByTabId }
delete nextPendingStartupByTabId[tabId]
const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId }
@@ -1307,14 +1384,20 @@ export const createTerminalSlice: StateCreator
// prevent unbounded store growth across restarts.
let nextSnapshots = s.pendingSnapshotByPtyId
let nextColdRestores = s.pendingColdRestoreByPtyId
- if (closingPtyId) {
- if (closingPtyId in nextSnapshots) {
+ const closingPtyIds = new Set([
+ ...retirementPlan.localOrSshPtyIds,
+ ...retirementPlan.runtimeTerminals.map((terminal) => terminal.ptyId),
+ ...retirementPlan.cleanupOnlyPtyIds,
+ ...retirementPlan.unroutablePtyIds
+ ])
+ for (const closingId of closingPtyIds) {
+ if (closingId in nextSnapshots) {
nextSnapshots = { ...nextSnapshots }
- delete nextSnapshots[closingPtyId]
+ delete nextSnapshots[closingId]
}
- if (closingPtyId in nextColdRestores) {
+ if (closingId in nextColdRestores) {
nextColdRestores = { ...nextColdRestores }
- delete nextColdRestores[closingPtyId]
+ delete nextColdRestores[closingId]
}
}
@@ -1324,7 +1407,12 @@ export const createTerminalSlice: StateCreator
activeTabIdByWorktree: nextActiveTabIdByWorktree,
ptyIdsByTabId: nextPtyIdsByTabId,
lastKnownRelayPtyIdByTabId: nextLastKnownRelay,
+ deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId,
+ pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId,
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
+ ...(nextSleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey
+ ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessionsByPaneKey }
+ : {}),
// Why: skip writing unreadTerminalTabs when the reference is unchanged —
// avoids a no-op top-level state allocation that would force re-evaluation
// of full-state selectors. Mirrors the sibling pattern in tabs.ts.
@@ -1382,7 +1470,10 @@ export const createTerminalSlice: StateCreator
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
)
if (workspaceItem) {
- get().closeUnifiedTab(workspaceItem.id, opts)
+ get().closeUnifiedTab(workspaceItem.id, {
+ recordInteraction: opts?.recordInteraction,
+ terminalRetirementHandled: true
+ })
}
}
},
@@ -1872,6 +1963,11 @@ export const createTerminalSlice: StateCreator
},
updateTabPtyId: (tabId, ptyId) => {
+ // Why: async spawn owners must perform provider teardown themselves, but
+ // this final guard prevents any late caller from recreating retired tab maps.
+ if (!isTerminalTabPresent(get(), tabId)) {
+ return
+ }
let worktreeId: string | null = null
let wasActivationSpawn = false
const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId)
@@ -2800,6 +2896,8 @@ export const createTerminalSlice: StateCreator
sourceTabId,
targetTabId
}) => {
+ const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId)
+ const targetPaneKey = makePaneKey(targetTabId, detachedLeafId)
set((s) => {
const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {}))
const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter(
@@ -2839,62 +2937,19 @@ export const createTerminalSlice: StateCreator
? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId)
: sourceTabsByWorktree
- const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId)
- const targetPaneKey = makePaneKey(targetTabId, detachedLeafId)
- const sourceForeground = s.paneForegroundAgentByPaneKey[sourcePaneKey]
- const sourceLaunchConfig = s.agentLaunchConfigByPaneKey[sourcePaneKey]
- const hadSourceHookStatus = sourcePaneKey in s.agentStatusByPaneKey
- let nextPaneForegroundAgentByPaneKey = s.paneForegroundAgentByPaneKey
- let nextAgentLaunchConfigByPaneKey = s.agentLaunchConfigByPaneKey
- let nextAgentStatusByPaneKey = s.agentStatusByPaneKey
- let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys
- if (sourceForeground) {
- nextPaneForegroundAgentByPaneKey = { ...s.paneForegroundAgentByPaneKey }
- delete nextPaneForegroundAgentByPaneKey[sourcePaneKey]
- nextPaneForegroundAgentByPaneKey[targetPaneKey] = sourceForeground
- }
- if (sourceLaunchConfig) {
- nextAgentLaunchConfigByPaneKey = { ...s.agentLaunchConfigByPaneKey }
- delete nextAgentLaunchConfigByPaneKey[sourcePaneKey]
- nextAgentLaunchConfigByPaneKey[targetPaneKey] = {
- ...sourceLaunchConfig,
- identity: {
- ...sourceLaunchConfig.identity,
- tabId: targetTabId,
- leafId: detachedLeafId
- }
- }
- }
- if (hadSourceHookStatus) {
- nextAgentStatusByPaneKey = { ...s.agentStatusByPaneKey }
- delete nextAgentStatusByPaneKey[sourcePaneKey]
- nextRetentionSuppressedPaneKeys = {
- ...s.retentionSuppressedPaneKeys,
- [sourcePaneKey]: true
- }
- }
-
return {
ptyIdsByTabId: nextPtyIdsByTabId,
lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId,
- ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}),
- ...(nextPaneForegroundAgentByPaneKey !== s.paneForegroundAgentByPaneKey
- ? { paneForegroundAgentByPaneKey: nextPaneForegroundAgentByPaneKey }
- : {}),
- ...(nextAgentLaunchConfigByPaneKey !== s.agentLaunchConfigByPaneKey
- ? { agentLaunchConfigByPaneKey: nextAgentLaunchConfigByPaneKey }
- : {}),
- ...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey
- ? {
- // Why: the PTY keeps its immutable source ORCA_PANE_KEY; retire
- // rather than re-key a hook row that future events cannot update.
- agentStatusByPaneKey: nextAgentStatusByPaneKey,
- agentStatusEpoch: s.agentStatusEpoch + 1,
- retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys
- }
- : {})
+ ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {})
}
})
+ // Why: detach keeps the process and its immutable physical pane key alive;
+ // move resume/status authority to the new surface before the source can close.
+ get().transferAgentPaneAuthority({
+ fromPaneKey: sourcePaneKey,
+ toPaneKey: targetPaneKey,
+ ptyId: detachedPtyId
+ })
},
queueTabStartupCommand: (tabId, startup) => {
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 0d104751b17..1af722e3899 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -745,7 +745,9 @@ function createWebPreloadApi(): Partial {
onMigrationUnsupportedClear: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
drop: () => {},
- dropByTabPrefix: () => {}
+ dropByTabPrefix: () => {},
+ retirePaneAuthority: () => {},
+ transferPaneAuthority: () => {}
},
mobile: {
listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }),
@@ -2804,7 +2806,7 @@ function createPtyApi(): NonNullable['pty']> {
clearPendingPaneSerializer: () => Promise.resolve(),
management: {
listSessions: () => Promise.resolve({ sessions: [], degraded: false }),
- killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0 }),
+ killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }),
killOne: () => Promise.resolve({ success: false }),
restart: () => Promise.resolve({ success: false })
}
diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts
index 1e3728eaef7..5870fd5f43b 100644
--- a/src/shared/agent-hook-listener.ts
+++ b/src/shared/agent-hook-listener.ts
@@ -155,6 +155,47 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
state.claudeLeadStateByPaneKey.delete(paneKey)
}
+function movePaneScopedMapEntries(
+ map: Map,
+ fromPaneKey: string,
+ toPaneKey: string
+): void {
+ for (const [key, value] of Array.from(map.entries())) {
+ if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) {
+ continue
+ }
+ map.delete(key)
+ map.set(`${toPaneKey}${key.slice(fromPaneKey.length)}`, value)
+ }
+}
+
+function movePaneScopedSetEntries(set: Set, fromPaneKey: string, toPaneKey: string): void {
+ for (const key of Array.from(set)) {
+ if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) {
+ continue
+ }
+ set.delete(key)
+ set.add(`${toPaneKey}${key.slice(fromPaneKey.length)}`)
+ }
+}
+
+export function movePaneCacheState(
+ state: HookListenerState,
+ fromPaneKey: string,
+ toPaneKey: string
+): void {
+ if (fromPaneKey === toPaneKey) {
+ return
+ }
+ movePaneScopedMapEntries(state.lastPromptByPaneKey, fromPaneKey, toPaneKey)
+ movePaneScopedMapEntries(state.lastToolByPaneKey, fromPaneKey, toPaneKey)
+ movePaneScopedMapEntries(state.lastStatusByPaneKey, fromPaneKey, toPaneKey)
+ movePaneScopedMapEntries(state.antigravityCompletedTranscriptByPaneKey, fromPaneKey, toPaneKey)
+ movePaneScopedSetEntries(state.ampCompletedCacheKeys, fromPaneKey, toPaneKey)
+ movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey)
+ movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey)
+}
+
function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void {
state.lastPromptByPaneKey.delete(paneKey)
state.lastToolByPaneKey.delete(paneKey)
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 0766ace56f4..6c1ebaf8cc8 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -3569,7 +3569,10 @@ export type PersistedTrustedOrcaHooks = Record({
// Why: calling window.api.repos.add() goes through the same code path as
// the "Add Project" UI flow, ensuring worktrees are fetched and the session
// initializes properly.
- await page.evaluate(async (repoPath) => {
- await window.api.repos.add({ path: repoPath })
+ const seededRepoId = await page.evaluate(async (repoPath) => {
+ const result = await window.api.repos.add({ path: repoPath })
+ if ('error' in result) {
+ throw new Error(result.error)
+ }
+ return result.repo.id
}, repoPath)
// Fetch repos in the renderer store so it picks up the new repo, then opt
@@ -300,13 +304,13 @@ export const test = base.extend({
await playwrightExpect
.poll(
() =>
- page.evaluate(async (repoPath) => {
+ page.evaluate(async (repoId) => {
const store = window.__store
if (!store) {
return false
}
await store.getState().fetchRepos()
- const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
+ const repo = store.getState().repos.find((candidate) => candidate.id === repoId)
if (!repo) {
return false
}
@@ -314,7 +318,7 @@ export const test = base.extend({
// repos hide those by default after the visibility rollout.
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
return true
- }, repoPath),
+ }, seededRepoId),
{
timeout: 30_000,
message: `Expected e2e repo to be loaded: ${repoPath}`
@@ -322,21 +326,18 @@ export const test = base.extend({
)
.toBe(true)
- // Best-effort fetch of every repo's worktrees. Why: the renderer can still
+ // Best-effort fetch of the seeded repo's worktrees. Why: the renderer can still
// re-navigate during initial hydration and destroy the execution context
// mid-evaluate; the authoritative seeded-worktree poll below is the real wait,
// so swallow a hydration-reload failure here instead of failing setup.
await page
- .evaluate(async () => {
+ .evaluate(async (repoId) => {
const store = window.__store
if (!store) {
return
}
- const repos = store.getState().repos
- for (const repo of repos) {
- await store.getState().fetchWorktrees(repo.id)
- }
- })
+ await store.getState().fetchWorktrees(repoId)
+ }, seededRepoId)
.catch(() => false)
// Why: parallel specs mutate real git worktrees in the shared fixture repo.
@@ -345,18 +346,14 @@ export const test = base.extend({
await playwrightExpect
.poll(
() =>
- page.evaluate(async (repoPath) => {
+ page.evaluate(async (repoId) => {
const store = window.__store
if (!store) {
return 0
}
- const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
- if (!repo) {
- return 0
- }
- await store.getState().fetchWorktrees(repo.id)
- return store.getState().worktreesByRepo[repo.id]?.length ?? 0
- }, repoPath),
+ await store.getState().fetchWorktrees(repoId)
+ return store.getState().worktreesByRepo[repoId]?.length ?? 0
+ }, seededRepoId),
{
timeout: 30_000,
message: 'seeded e2e worktrees did not load'
@@ -378,21 +375,22 @@ export const test = base.extend({
// Why: workspaceSessionReady restoration can overwrite activeWorktreeId
// after earlier setup calls. Selecting it here ensures every test starts on
// the seeded repo instead of the "Select a worktree" empty state.
- await page.evaluate((repoPath: string) => {
+ await page.evaluate((repoId: string) => {
const store = window.__store
if (!store) {
return
}
const state = store.getState()
- const allWorktrees = Object.values(state.worktreesByRepo).flat()
- const testWorktree = allWorktrees.find(
- (worktree) => worktree.path === repoPath || worktree.path.startsWith(repoPath)
+ // Why: provider-returned identity is stable across Windows path casing
+ // and separator normalization, unlike comparing renderer path strings.
+ const testWorktree = state.worktreesByRepo[repoId]?.find(
+ (worktree) => worktree.isMainWorktree
)
if (testWorktree) {
state.setActiveWorktree(testWorktree.id)
}
- }, repoPath)
+ }, seededRepoId)
// Best-effort seed of a baseline terminal tab when a fresh isolated
// profile has none yet.
diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts
index 3d7af0b1d44..742d34ffdda 100644
--- a/tests/e2e/helpers/orca-restart.ts
+++ b/tests/e2e/helpers/orca-restart.ts
@@ -130,36 +130,40 @@ export async function attachRepoAndOpenTerminal(page: Page, repoPath: string): P
throw new Error(`attachRepoAndOpenTerminal: ${repoPath} is not a git repo`)
}
- await page.evaluate(async (repoPath) => {
- await window.api.repos.add({ path: repoPath })
- }, repoPath)
-
- await page.evaluate(async () => {
- const store = window.__store
- if (!store) {
- return
- }
- await store.getState().fetchRepos()
- })
-
const repoId = await page.evaluate(async (repoPath) => {
- const store = window.__store
- if (!store) {
- return null
+ const result = await window.api.repos.add({ path: repoPath })
+ if ('error' in result) {
+ throw new Error(result.error)
}
- const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
- if (!repo) {
- return null
- }
- // Why: this restart fixture uses the global e2e repo, whose seeded Git
- // worktree is external to Orca's workspace root after the visibility rollout.
- await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
- return repo.id
+ return result.repo.id
}, repoPath)
- if (!repoId) {
- throw new Error(`attachRepoAndOpenTerminal: expected e2e repo to be loaded: ${repoPath}`)
- }
+ await expect
+ .poll(
+ () =>
+ page.evaluate(async (repoId) => {
+ const store = window.__store
+ if (!store) {
+ return false
+ }
+ // Why: repos.add emits a concurrent refresh whose generation can
+ // supersede this fetch; poll until either refresh publishes the repo.
+ await store.getState().fetchRepos()
+ const repo = store.getState().repos.find((candidate) => candidate.id === repoId)
+ if (!repo) {
+ return false
+ }
+ // Why: this restart fixture uses the global e2e repo, whose seeded Git
+ // worktree is external to Orca's workspace root after the visibility rollout.
+ await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
+ return true
+ }, repoId),
+ {
+ timeout: 30_000,
+ message: `attachRepoAndOpenTerminal: expected e2e repo to be loaded: ${repoPath}`
+ }
+ )
+ .toBe(true)
await page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
@@ -170,10 +174,7 @@ export async function attachRepoAndOpenTerminal(page: Page, repoPath: string): P
// Why: fetchWorktrees() is async. Awaiting the outer page.evaluate returns
// before the Zustand worktree slice has observed the hydrated state, so a
// single evaluate() that reads worktreesByRepo can see an empty map. Poll
- // the store until *any* worktree shows up, then pick the one that matches
- // our seeded repo. Matching by basename is sufficient because each test
- // run uses a unique tmp-dir suffix, and it avoids symlink-canonicalization
- // mismatches between the path we pass in and the one the store records.
+ // the store until the seeded repo's worktree shows up.
await expect
.poll(
async () =>
@@ -192,28 +193,22 @@ export async function attachRepoAndOpenTerminal(page: Page, repoPath: string): P
)
.toBe(true)
- const repoBasename = path.basename(repoPath)
- const worktreeId = await page.evaluate((repoBasename: string) => {
+ const worktreeId = await page.evaluate((repoId: string) => {
const store = window.__store
if (!store) {
return null
}
const state = store.getState()
- const allWorktrees = Object.values(state.worktreesByRepo).flat()
- // Why: the primary worktree lives at the repo root, so its path ends with
- // the repo directory name. The secondary worktree is a sibling directory
- // and will not match this suffix. This gives us the primary deterministically
- // without depending on boolean fields on the worktree record.
- const primary =
- allWorktrees.find(
- (worktree) => worktree.path.split(/[\\/]+/).findLast(Boolean) === repoBasename
- ) ?? allWorktrees[0]
+ // Why: repo identity remains stable when Windows canonicalizes path casing
+ // or separators between the IPC and renderer layers.
+ const repoWorktrees = state.worktreesByRepo[repoId] ?? []
+ const primary = repoWorktrees.find((worktree) => worktree.isMainWorktree) ?? repoWorktrees[0]
if (!primary) {
return null
}
state.setActiveWorktree(primary.id)
return primary.id
- }, repoBasename)
+ }, repoId)
if (!worktreeId) {
throw new Error('attachRepoAndOpenTerminal: test repo did not surface in the store')
diff --git a/tests/e2e/terminal-parked-close-retirement.spec.ts b/tests/e2e/terminal-parked-close-retirement.spec.ts
new file mode 100644
index 00000000000..acd6a42b708
--- /dev/null
+++ b/tests/e2e/terminal-parked-close-retirement.spec.ts
@@ -0,0 +1,96 @@
+import type { Page } from '@stablyai/playwright-test'
+import { randomUUID } from 'node:crypto'
+import { test, expect } from './helpers/orca-app'
+import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking'
+import {
+ execInTerminal,
+ getTerminalContent,
+ waitForActiveTerminalManager,
+ waitForPaneIdentitySnapshot
+} from './helpers/terminal'
+import { nodeTerminalCommand } from './terminal-node-command'
+
+const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
+
+test.use({
+ orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
+})
+
+async function hasPtySession(page: Page, ptyId: string): Promise {
+ return page.evaluate(async (id) => {
+ const sessions = await window.api.pty.listSessions()
+ return sessions.some((session) => session.id === id)
+ }, ptyId)
+}
+
+async function createActiveTerminalTab(page: Page, worktreeId: string): Promise {
+ const tabId = await page.evaluate((id) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is unavailable')
+ }
+ const state = store.getState()
+ const tab = state.createTab(id, undefined, undefined, { activate: true })
+ state.setActiveTab(tab.id)
+ state.setActiveTabType('terminal')
+ return tab.id
+ }, worktreeId)
+
+ await waitForActiveTerminalManager(page, 30_000)
+ expect((await waitForPaneIdentitySnapshot(page, 1)).tabId).toBe(tabId)
+}
+
+test('closing a parked terminal tab retires its exact PTY session', async ({ orcaPage }) => {
+ await waitForSessionReady(orcaPage)
+ const worktreeId = await waitForActiveWorktree(orcaPage)
+ await ensureTerminalVisible(orcaPage)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+
+ const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
+ const tabId = snapshot.tabId
+ const ptyId = snapshot.panes[0]?.ptyId
+ if (!ptyId) {
+ throw new Error('active terminal pane did not bind a PTY')
+ }
+
+ const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`)
+ await expect(tab).toBeVisible()
+
+ const marker = `PARKED_CLOSE_READY_${randomUUID()}`
+ const keepAliveScript = `process.stdout.write(${JSON.stringify(`${marker}\n`)}); setInterval(() => {}, 1000)`
+ await execInTerminal(orcaPage, ptyId, nodeTerminalCommand(['-e', keepAliveScript]))
+ await expect
+ .poll(() => getTerminalContent(orcaPage), {
+ timeout: 10_000,
+ message: 'long-lived terminal child did not print its ready marker'
+ })
+ .toContain(marker)
+ await expect.poll(() => hasPtySession(orcaPage, ptyId), { timeout: 10_000 }).toBe(true)
+
+ // Why: the most recently hidden tab stays warm, so tab B must take that
+ // exemption before the helper opens decoy tab C and makes tab A parkable.
+ await createActiveTerminalTab(orcaPage, worktreeId)
+ await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, {
+ parkDelayMs: PARKING_DELAY_MS
+ })
+ // Why: parking must remove only the renderer view; otherwise the retirement
+ // assertion could pass because the PTY died before the close action ran.
+ await expect.poll(() => hasPtySession(orcaPage, ptyId), { timeout: 10_000 }).toBe(true)
+
+ await orcaPage.evaluate((id) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is unavailable')
+ }
+ store.getState().closeTab(id)
+ }, tabId)
+
+ await expect
+ .poll(() => hasPtySession(orcaPage, ptyId), {
+ timeout: 15_000,
+ message: `parked tab close did not retire PTY ${ptyId}`
+ })
+ .toBe(false)
+ await expect(tab).toHaveCount(0)
+})