Fix terminal create cleanup and targeted listing (#5454)

This commit is contained in:
Brennan Benson 2026-06-15 23:11:40 -07:00 committed by GitHub
parent da386fae8c
commit 94857be0d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 876 additions and 19 deletions

View File

@ -52,6 +52,7 @@ import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/s
import { DEFAULT_REPO_BADGE_COLOR, getDefaultWorkspaceSession } from '../../shared/constants'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { makePaneKey } from '../../shared/stable-pane-id'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest } from './rpc/core'
import { TERMINAL_METHODS } from './rpc/methods/terminal'
@ -1083,6 +1084,242 @@ describe('OrcaRuntimeService', () => {
expect(shown.ptyId).toBe('pty-1')
})
it('keeps targeted terminal lists from adopting controller PTYs for other worktrees', async () => {
vi.mocked(listWorktrees).mockResolvedValue([
...MOCK_GIT_WORKTREES,
{
path: '/tmp/worktree-b',
head: 'def',
branch: 'feature/bar',
isBare: false,
isMainWorktree: false
},
{
path: '/tmp/worktree-a/nested',
head: 'ghi',
branch: 'feature/nested',
isBare: false,
isMainWorktree: false
}
])
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'target-controller-pty', cwd: '/tmp/worktree-a/src', title: 'target' },
{ id: 'other-controller-pty', cwd: '/tmp/worktree-b/src', title: 'other' },
{
id: 'repo-1::/tmp/worktree-b@@other-controller-pty',
cwd: '/tmp/worktree-a/src',
title: 'prefixed other'
},
{ id: 'nested-controller-pty', cwd: '/tmp/worktree-a/nested/src', title: 'nested' }
]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
const terminals = await runtime.listTerminals(`path:${TEST_WORKTREE_PATH}`)
expect(terminals.terminals).toHaveLength(1)
expect(terminals.terminals[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
worktreePath: TEST_WORKTREE_PATH
})
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
expect(internals.ptysById.has('target-controller-pty')).toBe(true)
expect(internals.ptysById.has('other-controller-pty')).toBe(false)
expect(internals.ptysById.has('repo-1::/tmp/worktree-b@@other-controller-pty')).toBe(false)
expect(internals.ptysById.has('nested-controller-pty')).toBe(false)
})
it('keeps explicit-id terminal lists from resolving all worktrees', async () => {
vi.mocked(listWorktrees).mockClear()
vi.mocked(listWorktrees).mockRejectedValue(
new Error('all-worktree resolution should be skipped')
)
const runtime = createRuntime()
const ptyId = `${TEST_WORKTREE_ID}@@daemon-controller-pty`
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: ptyId, cwd: '/unresolved/cwd', title: 'daemon shell' },
{ id: 'cwd-only-pty', cwd: TEST_WORKTREE_PATH, title: 'cwd shell' }
]
})
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
expect(listWorktrees).not.toHaveBeenCalled()
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([
TEST_WORKTREE_ID,
TEST_WORKTREE_ID
])
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
expect(internals.ptysById.has(ptyId)).toBe(true)
expect(internals.ptysById.has('cwd-only-pty')).toBe(true)
})
it('matches explicit-id cwd PTYs when the resolved worktree cache is incomplete', async () => {
vi.mocked(listWorktrees).mockResolvedValueOnce([
{
path: '/tmp/worktree-a/nested',
head: 'ghi',
branch: 'feature/nested',
isBare: false,
isMainWorktree: false
}
])
const runtime = createRuntime()
await runtime.listTerminals()
vi.mocked(listWorktrees).mockClear()
vi.mocked(listWorktrees).mockRejectedValue(
new Error('explicit-id fallback should not rescan worktrees')
)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'cwd-only-pty', cwd: `${TEST_WORKTREE_PATH}/src`, title: 'cwd shell' },
{ id: 'nested-controller-pty', cwd: `${TEST_WORKTREE_PATH}/nested/src`, title: 'nested' }
]
})
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
expect(listWorktrees).not.toHaveBeenCalled()
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID])
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
expect(internals.ptysById.has('cwd-only-pty')).toBe(true)
expect(internals.ptysById.has('nested-controller-pty')).toBe(false)
})
it('keeps explicit-id cold-cache terminal lists from adopting nested worktree PTYs', async () => {
const nestedWorktreeId = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}/nested`
vi.mocked(listWorktrees).mockClear()
vi.mocked(listWorktrees).mockRejectedValue(
new Error('explicit-id fallback should not rescan worktrees')
)
const runtime = new OrcaRuntimeService({
...store,
getAllWorktreeMeta: () => ({
[TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
[nestedWorktreeId]: makeWorktreeMeta()
}),
getWorktreeMeta: (worktreeId: string) =>
({
[TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
[nestedWorktreeId]: makeWorktreeMeta()
})[worktreeId]
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'cwd-only-pty', cwd: `${TEST_WORKTREE_PATH}/src`, title: 'cwd shell' },
{ id: 'nested-controller-pty', cwd: `${TEST_WORKTREE_PATH}/nested/src`, title: 'nested' }
]
})
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
expect(listWorktrees).not.toHaveBeenCalled()
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID])
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
expect(internals.ptysById.has('cwd-only-pty')).toBe(true)
expect(internals.ptysById.has('nested-controller-pty')).toBe(false)
})
it('keeps explicit-id cold-cache terminal lists from classifying unrelated same-repo worktrees', async () => {
const siblingWorktreePath = '/tmp/worktree-sibling'
const siblingWorktreeId = `${TEST_REPO_ID}::${siblingWorktreePath}`
vi.mocked(listWorktrees).mockClear()
vi.mocked(listWorktrees).mockRejectedValue(
new Error('explicit-id fallback should not rescan worktrees')
)
const runtime = new OrcaRuntimeService({
...store,
getAllWorktreeMeta: () => ({
[TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
[siblingWorktreeId]: makeWorktreeMeta()
}),
getWorktreeMeta: (worktreeId: string) =>
({
[TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
[siblingWorktreeId]: makeWorktreeMeta()
})[worktreeId]
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'target-cwd-pty', cwd: `${TEST_WORKTREE_PATH}/src`, title: 'target' },
{ id: 'sibling-cwd-pty', cwd: `${siblingWorktreePath}/src`, title: 'sibling' }
]
})
const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
expect(listWorktrees).not.toHaveBeenCalled()
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID])
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
expect(internals.ptysById.has('target-cwd-pty')).toBe(true)
expect(internals.ptysById.has('sibling-cwd-pty')).toBe(false)
})
it('ignores cwd-only controller PTYs for malformed explicit worktree IDs', async () => {
vi.mocked(listWorktrees).mockClear()
vi.mocked(listWorktrees).mockRejectedValue(
new Error('malformed explicit-id fallback should not rescan worktrees')
)
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'cwd-only-pty', cwd: `${TEST_WORKTREE_PATH}/src`, title: 'cwd shell' }
]
})
const terminals = await runtime.listTerminals(`id:${TEST_REPO_ID}::`)
expect(listWorktrees).not.toHaveBeenCalled()
expect(terminals.terminals).toEqual([])
const internals = runtime as unknown as { ptysById: Map<string, unknown> }
expect(internals.ptysById.has('cwd-only-pty')).toBe(false)
})
it('matches explicit-id cwd PTYs for folder workspace instance IDs', async () => {
const folderWorktreeId = `${TEST_REPO_ID}::${TEST_FOLDER_WORKSPACE_PATH}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}11111111-1111-4111-8111-111111111111`
vi.mocked(listWorktrees).mockClear()
vi.mocked(listWorktrees).mockRejectedValue(
new Error('folder explicit-id fallback should not rescan worktrees')
)
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'folder-cwd-pty', cwd: `${TEST_FOLDER_WORKSPACE_PATH}/src`, title: 'folder shell' }
]
})
const terminals = await runtime.listTerminals(`id:${folderWorktreeId}`)
expect(listWorktrees).not.toHaveBeenCalled()
expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([folderWorktreeId])
expect(terminals.terminals[0]?.worktreePath).toBe(TEST_FOLDER_WORKSPACE_PATH)
})
it('routes PTY output through the PTY leaf index in large terminal graphs', () => {
const runtime = new OrcaRuntimeService(store)
const liveLeafCount = 2773

View File

@ -127,7 +127,11 @@ import {
import { isLinearUuid } from '../../shared/linear-uuid'
import type { FeatureInteractionId } from '../../shared/feature-interactions'
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
import {
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
splitWorktreeId,
splitWorktreeIdForFilesystem
} from '../../shared/worktree-id'
import {
getProjectHostSetupForRepo,
getProjectHostSetupWorktreeMeta
@ -6387,17 +6391,59 @@ export class OrcaRuntimeService {
throw new Error('invalid_limit')
}
const graphEpoch = this.graphStatus === 'ready' ? this.rendererGraphEpoch : null
const targetWorktreeId = worktreeSelector
? (getExplicitWorktreeIdSelector(worktreeSelector) ??
(await this.resolveWorktreeSelector(worktreeSelector)).id)
const explicitTargetWorktreeId = worktreeSelector
? getExplicitWorktreeIdSelector(worktreeSelector)
: null
const worktreesById = await this.getResolvedWorktreeMap()
const initialResolvedWorktreeCache = this.resolvedWorktreeCache
const cachedResolvedWorktrees =
initialResolvedWorktreeCache && initialResolvedWorktreeCache.expiresAt > Date.now()
? initialResolvedWorktreeCache.worktrees
: null
const cachedExplicitTargetWorktree =
explicitTargetWorktreeId && cachedResolvedWorktrees
? (cachedResolvedWorktrees.find((worktree) => worktree.id === explicitTargetWorktreeId) ??
null)
: null
const parsedExplicitTargetWorktree =
explicitTargetWorktreeId && !cachedExplicitTargetWorktree
? this.buildResolvedWorktreeFromId(explicitTargetWorktreeId)
: null
const targetWorktree =
worktreeSelector && !explicitTargetWorktreeId
? await this.resolveWorktreeSelector(worktreeSelector)
: (cachedExplicitTargetWorktree ?? parsedExplicitTargetWorktree)
const targetWorktreeId = explicitTargetWorktreeId ?? targetWorktree?.id ?? null
const classificationResolvedWorktreeCache = this.resolvedWorktreeCache
const classificationResolvedWorktrees =
targetWorktreeId &&
classificationResolvedWorktreeCache &&
classificationResolvedWorktreeCache.expiresAt > Date.now()
? includeTargetResolvedWorktree(
classificationResolvedWorktreeCache.worktrees,
targetWorktree
)
: targetWorktreeId && explicitTargetWorktreeId
? this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree)
: null
const worktreesById =
targetWorktreeId && targetWorktree
? new Map([[targetWorktree.id, targetWorktree]])
: targetWorktreeId
? new Map()
: await this.getResolvedWorktreeMap()
if (graphEpoch !== null) {
this.assertStableReadyGraph(graphEpoch)
}
const resolvedWorktrees = [...worktreesById.values()]
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
const resolvedWorktrees =
targetWorktreeId && classificationResolvedWorktrees
? classificationResolvedWorktrees
: targetWorktreeId && targetWorktree
? [targetWorktree]
: targetWorktreeId
? []
: [...worktreesById.values()]
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees, targetWorktreeId)
const livePtyWorktreeIds = new Set<string>()
for (const pty of this.ptysById.values()) {
@ -13666,6 +13712,70 @@ export class OrcaRuntimeService {
return this.store as unknown as Store
}
private buildResolvedWorktreeFromId(worktreeId: string): ResolvedWorktree | null {
const parsed = splitWorktreeIdForFilesystem(worktreeId)
if (!parsed?.repoId || !parsed.worktreePath) {
return null
}
const repo = this.store?.getRepos().find((entry) => entry.id === parsed.repoId)
const git = {
path: parsed.worktreePath,
head: '',
branch: '',
isBare: false,
isMainWorktree: repo ? areWorktreePathsEqual(parsed.worktreePath, repo.path) : false
}
const meta = this.store?.getWorktreeMeta(worktreeId)
const merged = mergeWorktree(parsed.repoId, git, meta, repo?.displayName)
return {
...merged,
id: worktreeId,
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null,
git,
displayName: merged.displayName,
comment: merged.comment
}
}
private listKnownResolvedWorktreesForExplicitTarget(
targetWorktreeId: string,
targetWorktree: ResolvedWorktree | null
): ResolvedWorktree[] {
if (!this.store || !targetWorktree) {
return []
}
const target = splitWorktreeIdForFilesystem(targetWorktreeId)
if (!target?.repoId || !target.worktreePath) {
return []
}
const worktreeIds = new Set(
Object.keys(this.store.getAllWorktreeMeta()).filter((worktreeId) => {
const parsed = splitWorktreeIdForFilesystem(worktreeId)
return (
parsed?.repoId === target.repoId &&
Boolean(parsed.worktreePath) &&
(isPathInsideOrEqual(target.worktreePath, parsed.worktreePath) ||
isPathInsideOrEqual(parsed.worktreePath, target.worktreePath))
)
})
)
worktreeIds.add(targetWorktreeId)
const resolved: ResolvedWorktree[] = []
for (const worktreeId of worktreeIds) {
const worktree =
worktreeId === targetWorktreeId
? targetWorktree
: this.buildResolvedWorktreeFromId(worktreeId)
if (worktree) {
resolved.push(worktree)
}
}
return resolved
}
private async listResolvedWorktrees(): Promise<ResolvedWorktree[]> {
if (!this.store) {
return []
@ -14029,7 +14139,8 @@ export class OrcaRuntimeService {
}
private async refreshPtyWorktreeRecordsFromController(
resolvedWorktrees: ResolvedWorktree[]
resolvedWorktrees: ResolvedWorktree[],
targetWorktreeId: string | null = null
): Promise<void> {
if (!this.ptyController?.listProcesses) {
return
@ -14048,6 +14159,9 @@ export class OrcaRuntimeService {
const worktreeId =
inferWorktreeIdFromPtyId(session.id) ??
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)
if (targetWorktreeId && worktreeId !== targetWorktreeId) {
continue
}
if (worktreeId) {
this.recordPtyWorktree(session.id, worktreeId, {
connected: true
@ -19117,6 +19231,16 @@ function parseRuntimeWorktreeId(
return parsed
}
function includeTargetResolvedWorktree(
resolvedWorktrees: ResolvedWorktree[],
targetWorktree: ResolvedWorktree | null
): ResolvedWorktree[] {
if (!targetWorktree || resolvedWorktrees.some((worktree) => worktree.id === targetWorktree.id)) {
return resolvedWorktrees
}
return [...resolvedWorktrees, targetWorktree]
}
function findResolvedWorktreeIdForPath(
resolvedWorktrees: ResolvedWorktree[],
cwd: string

View File

@ -71,6 +71,7 @@ import {
seedStore
} from './store-test-helpers'
import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures'
import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers'
// ─── Tests ────────────────────────────────────────────────────────────
@ -1569,6 +1570,446 @@ describe('setActiveWorktree', () => {
expect(replacement.title).toBe('Terminal 1')
})
it('preserves cleanup-owned references when there are no orphan terminals', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })]
},
unifiedTabsByWorktree: {
[wt]: [
makeUnifiedTab({
id: 'terminal-1',
entityId: 'terminal-1',
worktreeId: wt,
groupId: 'group-1'
})
]
},
ptyIdsByTabId: {
'terminal-1': []
},
activeTabId: 'terminal-1',
activeTabIdByWorktree: {
[wt]: 'terminal-1'
}
})
const state = store.getState()
const patch = buildOrphanTerminalCleanupPatch(state, wt, new Set())
const referenceKeys = [
'tabsByWorktree',
'ptyIdsByTabId',
'runtimePaneTitlesByTabId',
'expandedPaneByTabId',
'canExpandPaneByTabId',
'terminalLayoutsByTabId',
'pendingStartupByTabId',
'pendingSetupSplitByTabId',
'pendingIssueCommandSplitByTabId',
'tabBarOrderByWorktree',
'cacheTimerByKey',
'activeTabIdByWorktree'
] as const
for (const key of referenceKeys) {
expect(patch[key]).toBe(state[key])
}
expect(patch.activeTabId).toBe(state.activeTabId)
})
it('removes orphan terminal caches while creating a replacement tab', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const orphanId = 'orphan-terminal'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: orphanId, worktreeId: wt })]
},
unifiedTabsByWorktree: {
[wt]: []
},
ptyIdsByTabId: {
[orphanId]: []
},
runtimePaneTitlesByTabId: {
[orphanId]: { 1: 'stale' }
},
terminalLayoutsByTabId: {
[orphanId]: makeLayout()
},
pendingStartupByTabId: {
[orphanId]: { command: 'codex' }
},
tabBarOrderByWorktree: {
[wt]: [orphanId]
},
cacheTimerByKey: {
[`${orphanId}:seed`]: 123
},
activeTabId: orphanId,
activeTabIdByWorktree: {
[wt]: orphanId
}
})
const replacement = store.getState().createTab(wt)
const s = store.getState()
expect(s.tabsByWorktree[wt]?.map((tab) => tab.id)).toEqual([replacement.id])
expect(s.ptyIdsByTabId[orphanId]).toBeUndefined()
expect(s.runtimePaneTitlesByTabId[orphanId]).toBeUndefined()
expect(s.terminalLayoutsByTabId[orphanId]).toBeUndefined()
expect(s.pendingStartupByTabId[orphanId]).toBeUndefined()
expect(s.cacheTimerByKey[`${orphanId}:seed`]).toBeUndefined()
expect(s.terminalLayoutsByTabId[replacement.id]).toEqual(makeLayout())
})
it('clears orphan active terminal state while creating an inactive replacement tab', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const orphanId = 'orphan-terminal'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: orphanId, worktreeId: wt })]
},
unifiedTabsByWorktree: {
[wt]: []
},
groupsByWorktree: {
[wt]: [
makeTabGroup({
id: 'group-1',
worktreeId: wt,
activeTabId: orphanId,
tabOrder: [orphanId]
})
]
},
ptyIdsByTabId: {
[orphanId]: []
},
activeTabId: orphanId,
activeTabIdByWorktree: {
[wt]: orphanId
}
})
const replacement = store.getState().createTab(wt, undefined, undefined, { activate: false })
const s = store.getState()
expect(s.tabsByWorktree[wt]?.map((tab) => tab.id)).toEqual([replacement.id])
expect(s.activeTabId).toBeNull()
expect(s.activeTabIdByWorktree[wt]).toBe(replacement.id)
expect(s.groupsByWorktree[wt]?.[0]?.activeTabId).toBe(replacement.id)
expect(s.groupsByWorktree[wt]?.[0]?.tabOrder).toEqual([replacement.id])
})
it('uses cleanup active fallback when inactive creation removes an orphan active tab', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const orphanId = 'orphan-terminal'
const existingId = 'existing-terminal'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [
makeTab({ id: orphanId, worktreeId: wt }),
makeTab({ id: existingId, worktreeId: wt })
]
},
unifiedTabsByWorktree: {
[wt]: [
makeUnifiedTab({
id: existingId,
entityId: existingId,
worktreeId: wt,
groupId: 'group-a'
})
]
},
groupsByWorktree: {
[wt]: [
makeTabGroup({
id: 'group-a',
worktreeId: wt,
activeTabId: existingId,
tabOrder: [existingId]
}),
makeTabGroup({
id: 'group-b',
worktreeId: wt,
activeTabId: null,
tabOrder: []
})
]
},
ptyIdsByTabId: {
[orphanId]: [],
[existingId]: []
},
activeTabId: orphanId,
activeTabIdByWorktree: {
[wt]: orphanId
}
})
const created = store.getState().createTab(wt, 'group-b', undefined, { activate: false })
const s = store.getState()
expect(s.activeTabId).toBeNull()
expect(s.activeTabIdByWorktree[wt]).toBe(existingId)
expect(s.tabsByWorktree[wt]?.map((tab) => tab.id)).toEqual([existingId, created.id])
expect(s.groupsByWorktree[wt]?.find((group) => group.id === 'group-b')).toMatchObject({
activeTabId: created.id,
tabOrder: [created.id]
})
})
it('keeps surviving target-group tab active when inactive creation removes an orphan', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const orphanId = 'orphan-terminal'
const existingId = 'existing-terminal'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [
makeTab({ id: orphanId, worktreeId: wt }),
makeTab({ id: existingId, worktreeId: wt })
]
},
unifiedTabsByWorktree: {
[wt]: [
makeUnifiedTab({
id: existingId,
entityId: existingId,
worktreeId: wt,
groupId: 'group-1'
})
]
},
groupsByWorktree: {
[wt]: [
makeTabGroup({
id: 'group-1',
worktreeId: wt,
activeTabId: orphanId,
tabOrder: [orphanId, existingId],
recentTabIds: [orphanId]
})
]
},
ptyIdsByTabId: {
[orphanId]: [],
[existingId]: []
},
activeTabId: orphanId,
activeTabIdByWorktree: {
[wt]: orphanId
}
})
const created = store.getState().createTab(wt, 'group-1', undefined, { activate: false })
const s = store.getState()
expect(s.activeTabIdByWorktree[wt]).toBe(existingId)
expect(s.groupsByWorktree[wt]?.[0]).toMatchObject({
activeTabId: existingId,
tabOrder: [existingId, created.id],
recentTabIds: [existingId]
})
})
it('keeps inactive terminal creation active state scoped to the target group', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const existingId = 'existing-terminal'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: existingId, worktreeId: wt })]
},
unifiedTabsByWorktree: {
[wt]: [
makeUnifiedTab({
id: existingId,
entityId: existingId,
worktreeId: wt,
groupId: 'group-a'
})
]
},
groupsByWorktree: {
[wt]: [
makeTabGroup({
id: 'group-a',
worktreeId: wt,
activeTabId: existingId,
tabOrder: [existingId]
}),
makeTabGroup({
id: 'group-b',
worktreeId: wt,
activeTabId: null,
tabOrder: []
})
]
},
ptyIdsByTabId: {
[existingId]: []
},
activeTabId: existingId,
activeTabIdByWorktree: {
[wt]: existingId
}
})
const created = store.getState().createTab(wt, 'group-b', undefined, { activate: false })
const groups = store.getState().groupsByWorktree[wt] ?? []
expect(store.getState().activeTabIdByWorktree[wt]).toBe(existingId)
expect(groups.find((group) => group.id === 'group-a')?.activeTabId).toBe(existingId)
expect(groups.find((group) => group.id === 'group-b')?.activeTabId).toBe(created.id)
expect(groups.find((group) => group.id === 'group-b')?.tabOrder).toEqual([created.id])
})
it('clears orphan terminal state from non-target groups during tab creation', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const orphanId = 'orphan-terminal'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: orphanId, worktreeId: wt })]
},
unifiedTabsByWorktree: {
[wt]: []
},
groupsByWorktree: {
[wt]: [
makeTabGroup({
id: 'group-a',
worktreeId: wt,
activeTabId: orphanId,
tabOrder: [orphanId],
recentTabIds: [orphanId]
}),
makeTabGroup({
id: 'group-b',
worktreeId: wt,
activeTabId: null,
tabOrder: []
})
]
},
ptyIdsByTabId: {
[orphanId]: []
}
})
const created = store.getState().createTab(wt, 'group-b', undefined, { activate: false })
const groups = store.getState().groupsByWorktree[wt] ?? []
expect(groups.find((group) => group.id === 'group-a')).toMatchObject({
activeTabId: null,
tabOrder: [],
recentTabIds: []
})
expect(groups.find((group) => group.id === 'group-b')).toMatchObject({
activeTabId: created.id,
tabOrder: [created.id]
})
})
it('keeps surviving non-target group tab active when inactive creation removes an orphan', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const orphanId = 'orphan-terminal'
const existingId = 'existing-terminal'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [
makeTab({ id: orphanId, worktreeId: wt }),
makeTab({ id: existingId, worktreeId: wt })
]
},
unifiedTabsByWorktree: {
[wt]: [
makeUnifiedTab({
id: existingId,
entityId: existingId,
worktreeId: wt,
groupId: 'group-a'
})
]
},
groupsByWorktree: {
[wt]: [
makeTabGroup({
id: 'group-a',
worktreeId: wt,
activeTabId: orphanId,
tabOrder: [orphanId, existingId],
recentTabIds: [orphanId]
}),
makeTabGroup({
id: 'group-b',
worktreeId: wt,
activeTabId: null,
tabOrder: []
})
]
},
ptyIdsByTabId: {
[orphanId]: [],
[existingId]: []
}
})
const created = store.getState().createTab(wt, 'group-b', undefined, { activate: false })
const groups = store.getState().groupsByWorktree[wt] ?? []
expect(groups.find((group) => group.id === 'group-a')).toMatchObject({
activeTabId: existingId,
tabOrder: [existingId],
recentTabIds: [existingId]
})
expect(groups.find((group) => group.id === 'group-b')).toMatchObject({
activeTabId: created.id,
tabOrder: [created.id]
})
})
// Why: unread flags are ephemeral UI state — they must not linger past the
// lifetime of the tab/pane they point at. A stale flag on a closed tab
// would render a bell the user can never dismiss because the tab (and

View File

@ -66,6 +66,24 @@ export function buildOrphanTerminalCleanupPatch(
| 'activeTabIdByWorktree'
| 'activeTabId'
> {
if (orphanTerminalIds.size === 0) {
return {
tabsByWorktree: state.tabsByWorktree,
ptyIdsByTabId: state.ptyIdsByTabId,
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
expandedPaneByTabId: state.expandedPaneByTabId,
canExpandPaneByTabId: state.canExpandPaneByTabId,
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
pendingStartupByTabId: state.pendingStartupByTabId,
pendingSetupSplitByTabId: state.pendingSetupSplitByTabId,
pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId,
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
cacheTimerByKey: state.cacheTimerByKey,
activeTabIdByWorktree: state.activeTabIdByWorktree,
activeTabId: state.activeTabId
}
}
const nextTabs = (state.tabsByWorktree[worktreeId] ?? []).filter(
(tab) => !orphanTerminalIds.has(tab.id)
)

View File

@ -702,6 +702,36 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
id,
'terminal'
)
const groupsForWorktree = groupsByWorktree[worktreeId] ?? []
const cleanedGroups =
orphanTerminalIds.size === 0
? groupsForWorktree
: groupsForWorktree.map((entry) => {
// Why: orphan cleanup must repair every group before adding the
// new tab, or inactive/background creation can revive stale focus.
const tabOrder = dedupeTabOrder(entry.tabOrder).filter(
(tabId) => !orphanTerminalIds.has(tabId)
)
const recentTabIds = sanitizeRecentTabIds(entry.recentTabIds, tabOrder)
const replacedActiveTabId = Boolean(
entry.activeTabId && orphanTerminalIds.has(entry.activeTabId)
)
const fallbackActiveTabId = recentTabIds.at(-1) ?? tabOrder[0] ?? null
const activeTabId = replacedActiveTabId ? fallbackActiveTabId : entry.activeTabId
return {
...entry,
activeTabId,
tabOrder,
recentTabIds:
replacedActiveTabId && activeTabId
? pushRecentTabId(recentTabIds, activeTabId)
: recentTabIds
}
})
const cleanedTargetGroup = cleanedGroups.find((entry) => entry.id === group.id) ?? group
const cleanedGroupOrder = dedupeTabOrder(cleanedTargetGroup.tabOrder).filter(
(tabId) => !orphanTerminalIds.has(tabId)
)
const unifiedTab = existingTerminalTab ?? {
id,
entityId: id,
@ -714,16 +744,21 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
: {}),
customLabel: tab.customTitle,
color: tab.color,
sortOrder: dedupeTabOrder(group.tabOrder).length,
sortOrder: cleanedGroupOrder.length,
createdAt: tab.createdAt
}
const nextGroupOrder = dedupeTabOrder([...group.tabOrder, unifiedTab.id])
const nextGroupOrder = dedupeTabOrder([...cleanedGroupOrder, unifiedTab.id])
const nextRecent = shouldActivate
? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), unifiedTab.id)
: sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder)
: sanitizeRecentTabIds(cleanedTargetGroup.recentTabIds, nextGroupOrder)
const cleanedActiveTabIdForWorktree = orphanCleanupPatch.activeTabIdByWorktree[worktreeId]
const cleanedGroupActiveTabId =
cleanedTargetGroup.activeTabId && !orphanTerminalIds.has(cleanedTargetGroup.activeTabId)
? cleanedTargetGroup.activeTabId
: null
const nextActiveTabIdForWorktree = shouldActivate
? tab.id
: (s.activeTabIdByWorktree[worktreeId] ?? group.activeTabId ?? tab.id)
: (cleanedActiveTabIdForWorktree ?? cleanedGroupActiveTabId ?? tab.id)
return {
...orphanCleanupPatch,
tabsByWorktree: {
@ -741,9 +776,11 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
},
groupsByWorktree: {
...groupsByWorktree,
[worktreeId]: updateGroup(groupsByWorktree[worktreeId] ?? [], {
...group,
activeTabId: shouldActivate ? unifiedTab.id : (group.activeTabId ?? unifiedTab.id),
[worktreeId]: updateGroup(cleanedGroups, {
...cleanedTargetGroup,
activeTabId: shouldActivate
? unifiedTab.id
: (cleanedGroupActiveTabId ?? unifiedTab.id),
tabOrder: nextGroupOrder,
recentTabIds: nextRecent
})
@ -753,17 +790,17 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
...s.layoutByWorktree,
[worktreeId]: s.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id }
},
activeTabId: shouldActivate ? tab.id : s.activeTabId,
activeTabId: shouldActivate ? tab.id : orphanCleanupPatch.activeTabId,
activeTabIdByWorktree: {
...s.activeTabIdByWorktree,
...orphanCleanupPatch.activeTabIdByWorktree,
[worktreeId]: nextActiveTabIdForWorktree
},
ptyIdsByTabId: {
...s.ptyIdsByTabId,
...orphanCleanupPatch.ptyIdsByTabId,
[tab.id]: options?.initialPtyId ? [options.initialPtyId] : []
},
terminalLayoutsByTabId: {
...s.terminalLayoutsByTabId,
...orphanCleanupPatch.terminalLayoutsByTabId,
[tab.id]: emptyLayoutSnapshot()
}
}