Merge remote-tracking branch 'origin/main' into brennanb2025/cleanup-browse-hydration-guard

This commit is contained in:
Brennan Benson
2026-08-24 11:43:16 -07:00
92 changed files with 3244 additions and 240 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 27m">
<title>downloads: 27m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 28m">
<title>downloads: 28m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">27m</text>
<text x="90" y="14">27m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">28m</text>
<text x="90" y="14">28m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

@@ -0,0 +1,179 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PANE = makePaneKey('tab-1', LEAF)
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-ended-process-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
async function startServer(): Promise<AgentHookServer> {
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: dir })
return server
}
function claudeRow(server: AgentHookServer, state: 'working' | 'waiting' | 'done'): void {
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
payload: { state, prompt: 'review the PR', agentType: 'claude' }
})
}
/** A row carrying a resumable provider session — the only shape `dropStatusEntry` retains. */
function resumableClaudeRow(server: AgentHookServer): void {
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
source: 'claude',
hookEventName: 'UserPromptSubmit',
launchToken: 'dead-launch-token',
providerSession: { key: 'session_id', id: 'resume-me' },
payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' }
},
'conn-a'
)
}
function paneState(server: AgentHookServer): string {
return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing'
}
// The teardown that normally clears a dead pane depends on the spawn-time ptyPaneKey mapping, which
// a restored PTY may never rebuild — leaving the row and its latches with nothing left to retire
// them. See PLAN-STA-4612 §4.2.
describe('reconcileEndedProcessForPaneKeys', () => {
it('retires a working row whose process is certifiably dead', async () => {
const server = await startServer()
try {
claudeRow(server, 'working')
expect(paneState(server)).toBe('working')
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1)
expect(paneState(server)).toBe('missing')
} finally {
server.stop()
}
})
it('retires a waiting row — a wait whose owner is dead is not a wait', async () => {
const server = await startServer()
try {
claudeRow(server, 'waiting')
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1)
expect(paneState(server)).toBe('missing')
} finally {
server.stop()
}
})
it('clears Claude latches even when the stored row already reads done', async () => {
// An interrupted lead suppresses the gate while leaving the latch set, so a row can read `done`
// with a latch that would re-gate `working` on the pane's very next event.
const server = await startServer()
try {
claudeRow(server, 'done')
server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.add(PANE)
server._getStateForTests().claudeActiveSessionCronPaneKeys.add(PANE)
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1)
expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(false)
expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(false)
} finally {
server.stop()
}
})
it('is a no-op for a pane with nothing to retire', async () => {
const server = await startServer()
try {
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(0)
} finally {
server.stop()
}
})
it('keeps the resume identity a paired dismissal minted when the shell outlived the agent', async () => {
// The renderer's confirmed-shell route calls agentStatus:drop FIRST, which deliberately mints a
// providerSessionOnly remnant, then this. That remnant carries no state claim — it cannot gate a
// pane `working` — and the pane's PTY is still there to resume into, so retiring the pane's live
// claims must not take it. Without the option it goes with everything else.
const server = await startServer()
try {
resumableClaudeRow(server)
server.dropStatusEntry(PANE)
const afterDrop = server.getStatusSnapshotForPane(PANE)[0]
expect(afterDrop?.providerSessionOnly).toBe(true)
expect(afterDrop?.providerSession?.id).toBe('resume-me')
expect(
server.reconcileEndedProcessForPaneKeys([PANE], { preserveResumeIdentity: true })
).toBe(1)
const kept = server.getStatusSnapshotForPane(PANE)[0]
expect(kept?.providerSessionOnly).toBe(true)
expect(kept?.providerSession?.id).toBe('resume-me')
expect(kept?.launchToken).toBeUndefined()
// The live claims still went: a latch left behind would re-gate the pane on its next event.
expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(false)
// The retained identity must not recreate dead launch authority after restart.
server.flushStatusPersistSync()
server.stop()
const restarted = await startServer()
expect(restarted.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined()
expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0)
restarted.stop()
} finally {
server.stop()
}
})
it('takes the resume identity too on a certified PTY exit, where no pane is left to resume into', async () => {
const server = await startServer()
try {
resumableClaudeRow(server)
server.dropStatusEntry(PANE)
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1)
expect(paneState(server)).toBe('missing')
} finally {
server.stop()
}
})
it('drops the session owner so a rebound pane is not read as a session replacement', async () => {
const server = await startServer()
try {
claudeRow(server, 'working')
server._getStateForTests().claudeSessionOwnerByPaneKey.set(PANE, 'session-a')
server.reconcileEndedProcessForPaneKeys([PANE])
expect(server._getStateForTests().claudeSessionOwnerByPaneKey.has(PANE)).toBe(false)
} finally {
server.stop()
}
})
})
@@ -211,7 +211,10 @@ describe('restored subagent liveness sweep', () => {
}
})
it('does not probe a hydrated Claude pane without restored rows', async () => {
it('reaps a hydrated non-terminal row with no roster at all (STA-4612)', async () => {
// A restored roster is only one shape of stranded claim. A lead row left `working` with no
// children has nothing left to retire it — no child event, no inventory, no hook — so it holds
// the pane working for good and locks its agent out of hibernation.
const server = await restartWithInFlightSubagent({ subagents: [] })
const probeLiveLocalPty = vi.fn(() => false)
try {
@@ -220,9 +223,42 @@ describe('restored subagent liveness sweep', () => {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(1)
expect(probeLiveLocalPty).toHaveBeenCalled()
expect(paneStatus(server).state).toBe('missing')
} finally {
server.stop()
}
})
it('keeps a hydrated non-terminal row with no roster when its PTY is still live', async () => {
const server = await restartWithInFlightSubagent({ subagents: [] })
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: () => true,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a hydrated non-terminal row with no roster when the probe cannot prove either state', async () => {
const server = await restartWithInFlightSubagent({ subagents: [] })
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: () => null,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
+93 -12
View File
@@ -12,6 +12,7 @@ import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
import {
clearAllListenerCaches,
clearPaneCacheState,
paneHasStateClaims,
clearClaudeAnsweredQuestionWait,
createHookListenerState,
getEndpointFileName,
@@ -2652,28 +2653,87 @@ export class AgentHookServer {
this.notifyStatusChangeListeners()
}
/** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state
* claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live
* claims. Returns null when the row has no resumable session to keep. */
private toRetainedProviderSessionRow(
entry: EnrichedAgentHookEventPayload | null | undefined
): EnrichedAgentHookEventPayload | null {
if (
!entry?.providerSession ||
!entry.payload.agentType ||
entry.payload.agentType === 'unknown'
) {
return null
}
const { launchToken: _launchToken, ...resumeIdentity } = entry
return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true }
}
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
dropStatusEntry(paneKey: string): void {
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
if (!deleted) {
return
}
if (
deleted.providerSession &&
deleted.payload.agentType &&
deleted.payload.agentType !== 'unknown'
) {
const retained: EnrichedAgentHookEventPayload = {
...deleted,
providerSessionOnly: true,
retainedForLiveness: true
}
const retained = this.toRetainedProviderSessionRow(deleted)
if (retained) {
this.state.lastStatusByPaneKey.set(deleted.paneKey, retained)
}
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
/** Retire panes whose owning process is certifiably dead.
*
* The ordinary teardown already does this: every attributable PTY exit reaches
* `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that
* resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may
* never rebuild — so those panes keep a `working` row and its latches for good, with no hook left
* to retire them. This is the same operation reached from the runtime's own pane-key knowledge,
* so a dead pane is cleaned up identically however its keys were resolved. */
reconcileEndedProcessForPaneKeys(
paneKeys: Iterable<string>,
options?: {
/** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still
* be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop`
* minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to
* resume into, and dropping it matches what `clearProviderPtyState` already does. */
preserveResumeIdentity?: boolean
}
): number {
let cleared = 0
for (const paneKey of paneKeys) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) {
continue
}
const retained = options?.preserveResumeIdentity
? this.toRetainedProviderSessionRow(
this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
| EnrichedAgentHookEventPayload
| undefined
)
: null
this.clearPaneState(resolvedPaneKey)
if (retained) {
this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
cleared += 1
}
return cleared
}
/** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through
* `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list
* itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a
* different file. */
private hasLiveClaimsForPaneKey(paneKey: string): boolean {
return paneHasStateClaims(this.state, paneKey)
}
/** Clear statuses proven to belong to one lost SSH transport. */
clearStatusEntriesForConnection(connectionId: string): void {
const normalizedConnectionId = connectionId.trim()
@@ -2704,6 +2764,7 @@ export class AgentHookServer {
this.state.claudeLeadStateByPaneKey.delete(paneKey)
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
this.state.claudeActiveSessionCronPaneKeys.delete(paneKey)
this.state.claudeSessionOwnerByPaneKey.delete(paneKey)
}
}
}
@@ -2894,9 +2955,15 @@ export class AgentHookServer {
enriched.payload.agentType === 'claude' &&
enriched.connectionId === null &&
isLocalExecutionHost(enriched.worktreeId) &&
claudeRosterHasRestoredSnapshotSubagent(
// Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal,
// or a background-task/cron latch nothing will refresh, strands the pane just as
// permanently — and unlike the roster case there is no child event left to reap it.
(claudeRosterHasRestoredSnapshotSubagent(
this.state.claudeSubagentRosterByPaneKey.get(paneKey)
) &&
) ||
enriched.payload.state !== 'done' ||
this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) &&
!this.runtimeObservedStatusPaneKeys.has(paneKey)
) {
candidates.push({ paneKey, entry: enriched })
@@ -2924,6 +2991,20 @@ export class AgentHookServer {
continue
}
if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) {
// Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably
// gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the
// pane the same way an observed exit would — otherwise the widened candidate set is inert.
//
// Why delete rather than downgrade to `done` like the reap branch below: that branch has a
// real turn to describe — a parent whose children it just reaped — while these panes' only
// claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would
// invent a completion that never happened, and leaving it non-terminal keeps the bug. This
// sweep stands in for the exit Orca never observed, so it does what that exit does:
// `clearProviderPtyState` -> `clearPaneState`.
if (this.hasLiveClaimsForPaneKey(paneKey)) {
this.clearPaneState(paneKey)
changedPanes += 1
}
continue
}
changedPanes += 1
@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFs from 'node:fs'
import type * as NodeOs from 'node:os'
import type * as CodexManagedTrustReconciliation from './codex-managed-trust-reconciliation'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// Follow-up to STA-4823: two more reads on the Codex hook paths acted on
// "absent" when the real answer was "could not read". Both were found while
// reviewing #15417 and are not covered by it.
//
// The denial shape here is the MEASURED one: chmod 000 / icacls deny leave
// existsSync true and stat succeeding, and fail only the content read
// (EACCES -13 on macOS, EPERM -4048 on Windows). No mode fakes existsSync,
// because no platform does.
const denials = vi.hoisted(() => {
const state = {
paths: new Set<string>(),
deny(path: string): void {
state.paths.add(path)
},
reset(): void {
state.paths.clear()
},
check(target: unknown, syscall: string): void {
if (typeof target !== 'string' || !state.paths.has(target)) {
return
}
const error: NodeJS.ErrnoException = new Error(
`EACCES: permission denied, ${syscall} '${target}'`
)
error.code = 'EACCES'
error.errno = -13
error.syscall = syscall
error.path = target
throw error
}
}
return state
})
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
const guardRead = (fn: unknown, syscall: string): unknown => {
const original = fn as (...args: unknown[]) => unknown
const wrapped = (...args: unknown[]): unknown => {
denials.check(args[0], syscall)
return original(...args)
}
return Object.assign(wrapped, original)
}
const patched: Record<string, unknown> = {
...actual,
readFileSync: guardRead(actual.readFileSync, 'read'),
// Why: existsSync and stat stay HEALTHY. That is the whole point — a
// permission denial does not hide the file, it only refuses its contents.
openSync: Object.assign((...args: unknown[]): unknown => {
const flags = args[1]
if (flags === undefined || (typeof flags === 'string' && flags.startsWith('r'))) {
denials.check(args[0], 'open')
}
return (actual.openSync as (...a: unknown[]) => unknown)(...args)
}, actual.openSync)
}
return { ...patched, default: patched }
})
const { getPathMock, homedirMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
homedirMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({ app: { getPath: getPathMock } }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof NodeOs>('node:os')
return { ...actual, homedir: homedirMock }
})
const realFs = await vi.importActual<typeof NodeFs>('node:fs')
let fakeHomeDir: string
let userDataDir: string
let previousUserDataPath: string | undefined
beforeEach(() => {
denials.reset()
fakeHomeDir = realFs.mkdtempSync(join(tmpdir(), 'orca-hooksread-home-'))
userDataDir = realFs.mkdtempSync(join(tmpdir(), 'orca-hooksread-data-'))
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(fakeHomeDir)
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return userDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
})
realFs.mkdirSync(join(fakeHomeDir, '.codex'), { recursive: true })
})
afterEach(() => {
denials.reset()
realFs.rmSync(fakeHomeDir, { recursive: true, force: true })
realFs.rmSync(userDataDir, { recursive: true, force: true })
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
vi.clearAllMocks()
})
describe('an unreadable legacy hooks.json must not clear managed trust or its ledger', () => {
it('returns before the removal branch instead of discarding approvals', async () => {
const legacyHooksPath = join(fakeHomeDir, '.codex', 'hooks.json')
realFs.writeFileSync(legacyHooksPath, JSON.stringify({ hooks: { Stop: [] } }), 'utf-8')
const removeSpy = vi.fn()
vi.resetModules()
vi.doMock('./codex-managed-trust-reconciliation', async (importOriginal) => {
const actual = await importOriginal<typeof CodexManagedTrustReconciliation>()
return { ...actual, removeCodexManagedHookTrustEntries: removeSpy }
})
try {
// The removal only fires when a real-home grant is on record — without
// this the branch is inert and the test proves nothing either way.
const ledger = await import('./codex-trust-grant-ledger')
ledger.writeCodexTrustGrantLedgerHome(
join(fakeHomeDir, '.codex'),
{ entries: { 'seed:1': { trustedHash: 'sha256:seed' } } } as never,
ledger.getCodexTrustGrantLedgerPath()
)
const hookService = await import('./hook-service')
denials.deny(legacyHooksPath)
hookService._internals.cleanupLegacySystemManagedHooks()
// `readHooksJsonWithRaw` catches the denial and reports {raw:null,
// config:null} — "could not read". Before the fix that fell into the same
// branch as "no hooks configured", which removes the managed trust
// entries AND their grant-ledger record. A read that failed is not
// permission to discard approvals the user already gave.
expect(removeSpy).not.toHaveBeenCalled()
} finally {
vi.doUnmock('./codex-managed-trust-reconciliation')
vi.resetModules()
}
})
})
+8
View File
@@ -601,6 +601,13 @@ function cleanupLegacySystemManagedHooks(): void {
// Why: the pre-write guard below compares against these bytes; a separate
// later read would let a concurrent save land between parse and snapshot.
const { raw: previousRaw, config } = readHooksJsonWithRaw(legacyConfigPath)
// Why: `config === null` with no raw is the "could not read" answer, not the
// "no hooks here" one — the branch below removes managed trust entries AND
// their grant-ledger record, so acting on it would discard approvals over a
// read that merely failed. A genuine absence still returns `config: {}`.
if (config === null && previousRaw === null) {
return
}
if (!config?.hooks || previousRaw === null) {
if (hasRecordedRealHomeGrant) {
removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath)
@@ -1637,6 +1644,7 @@ export class CodexHookService {
export const codexHookService = new CodexHookService()
export const _internals = {
cleanupLegacySystemManagedHooks,
getManagedScript,
installManagedHooksIntoWslRuntime,
refreshWslRuntimeUserHooks,
+3
View File
@@ -2719,6 +2719,9 @@ void app.whenReady().then(async () => {
agentHookServer.attestCompatibilityAuthority(candidate),
retireAgentHookCompatibilityAuthority: (paneKey) =>
agentHookServer.retirePaneAuthority(paneKey),
reconcileAgentStatusForEndedProcess: (paneKeys) => {
agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys)
},
canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null,
// Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only.
getAdditionalAiVaultCodexHomePaths: () =>
+4 -39
View File
@@ -6,13 +6,9 @@ import type {
} from '../../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import { agentHookServer } from '../agent-hooks/server'
import { ampHookService } from '../amp/hook-service'
import {
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey,
getMigrationUnsupportedPtySnapshot
} from '../agent-hooks/migration-unsupported-pty-state'
import { getMigrationUnsupportedPtySnapshot } from '../agent-hooks/migration-unsupported-pty-state'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { geminiHookService } from '../gemini/hook-service'
@@ -27,10 +23,10 @@ import { devinHookService } from '../devin/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc'
import { registerAgentStatusRowTeardownIpcHandlers } from './agent-status-row-teardown-ipc'
import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership'
import {
enrichAgentStatusIpcPayload,
isValidAgentStatusDropTabId,
type AgentStatusRuntimeEnrichment
} from './agent-status-ipc-boundary'
@@ -70,38 +66,7 @@ export function registerAgentHookHandlers(
ipcMain.removeHandler('agentStatus:inferInterrupt')
ipcMain.removeHandler('agentStatus:inferQuestionAnswered')
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
// Why: agentStatus:drop is sent fire-and-forget from the renderer via
// ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't
// round-trip a response. Removing first keeps re-registration safe even
// though the module-level registered guard already prevents re-entry today.
ipcMain.removeAllListeners('agentStatus:drop')
ipcMain.removeAllListeners('agentStatus:dropByTabPrefix')
ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
}
try {
// Why: dropStatusEntry (not clearPaneState) is correct here — the user is
// dismissing a status row, not tearing down a PTY. clearPaneState would also
// wipe the per-pane prompt/tool caches, which the next hook event for that
// (still-alive) pane needs to render a coherent row.
agentHookServer.dropStatusEntry(paneKey)
clearMigrationUnsupportedPtysForPaneKey(paneKey)
} catch (err) {
console.warn('[agent-hooks] dropStatusEntry failed:', err)
}
})
ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => {
if (!isValidAgentStatusDropTabId(tabId)) {
return
}
try {
agentHookServer.dropStatusEntriesByTabPrefix(tabId)
clearMigrationUnsupportedPtysByTabPrefix(tabId)
} catch (err) {
console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err)
}
})
registerAgentStatusRowTeardownIpcHandlers()
registerAgentPaneAuthorityIpcHandlers({
ownsPty: createAgentPaneAuthorityOwnership({
getPtyIdForPaneKey: dependencies.getPtyIdForPaneKey,
@@ -0,0 +1,69 @@
import { ipcMain } from 'electron'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import {
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey
} from '../agent-hooks/migration-unsupported-pty-state'
import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary'
/**
* The three renderer-initiated ways a status row goes away. All fire-and-forget
* (`ipcRenderer.send` → `ipcMain.on`), so none round-trips a response; removing the
* listeners first keeps re-registration safe.
*
* They are NOT interchangeable. A dismissal keeps the pane's per-pane caches because its agent may
* still be alive; a confirmed process exit must take them too, or a surviving Claude latch resolves
* the pane's next event straight back to `working`.
*/
export function registerAgentStatusRowTeardownIpcHandlers(): void {
ipcMain.removeAllListeners('agentStatus:drop')
ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess')
ipcMain.removeAllListeners('agentStatus:dropByTabPrefix')
ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
}
try {
// Why: dropStatusEntry (not clearPaneState) is correct here — the user is
// dismissing a status row, not tearing down a PTY. clearPaneState would also
// wipe the per-pane prompt/tool caches, which the next hook event for that
// (still-alive) pane needs to render a coherent row.
agentHookServer.dropStatusEntry(paneKey)
clearMigrationUnsupportedPtysForPaneKey(paneKey)
} catch (err) {
console.warn('[agent-hooks] dropStatusEntry failed:', err)
}
})
ipcMain.on('agentStatus:reconcileEndedProcess', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
}
try {
// Why: a process-table-confirmed agent exit is exactly the case the dismissal above excludes
// — the pane's agent is NOT still alive — so its latches must go with the row (STA-4612).
agentHookServer.reconcileEndedProcessForPaneKeys([paneKey], {
// Why: this route only fires on a confirmed shell foreground, so the PTY outlived the
// agent. The row's resume identity is still usable in that very pane — only its live
// claims are dead.
preserveResumeIdentity: true
})
clearMigrationUnsupportedPtysForPaneKey(paneKey)
} catch (err) {
console.warn('[agent-hooks] reconcileEndedProcessForPaneKeys failed:', err)
}
})
ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => {
if (!isValidAgentStatusDropTabId(tabId)) {
return
}
try {
agentHookServer.dropStatusEntriesByTabPrefix(tabId)
clearMigrationUnsupportedPtysByTabPrefix(tabId)
} catch (err) {
console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err)
}
})
}
@@ -289,7 +289,9 @@ describe('registerPtyHandlers', () => {
await Promise.resolve()
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, {
providerExitObserved: true
})
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
@@ -342,7 +344,9 @@ describe('registerPtyHandlers', () => {
await expect(stopPromise).resolves.toBe(true)
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, {
providerExitObserved: true
})
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
@@ -440,6 +440,7 @@ describe('registerPtyHandlers', () => {
expect(killSpy).toHaveBeenCalled()
expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId, {
providerExitObserved: true,
cause: { kind: 'unknown', reason: 'stop_unverified' }
})
const listed = await getLocalPtyProvider().listProcesses()
@@ -287,7 +287,9 @@ describe('registerPtyHandlers', () => {
await handlers.get('pty:kill')!(null, { id: 'local-pty' })
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, {
providerExitObserved: true
})
expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual(
[['pty:exit', { id: 'local-pty', code: 0 }]]
)
@@ -529,7 +531,9 @@ describe('registerPtyHandlers', () => {
seq: 13,
rawLength: 'daemon output'.length
})
expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined, {
providerExitObserved: true
})
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
id: result.id,
code: 0
+12 -7
View File
@@ -2575,7 +2575,12 @@ export function registerPtyHandlers(
clearProviderPtyState(id)
ptyOwnership.delete(id)
markClaudePtyExited(id)
runtime?.onPtyExit(id, code, incarnationId, cause ? { cause } : undefined)
// Why: the provider's physical exit callback is a death certificate even when node-pty
// reports the code as -1; only the synthesized failed/unverified stop calls omit this.
runtime?.onPtyExit(id, code, incarnationId, {
providerExitObserved: true,
...(cause ? { cause } : {})
})
},
onData: (id, data, timestamp, sequenceChars, transformed) =>
runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed)
@@ -4116,12 +4121,12 @@ export function registerPtyHandlers(
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
runtime?.onPtyExit(
payload.id,
payload.code,
payload.incarnationId,
payload.cause ? { cause: payload.cause } : undefined
)
// Why: same physical-exit certificate as the LocalPtyProvider branch above — this is the
// daemon/WSL callback, and on macOS it is the branch that actually runs.
runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, {
providerExitObserved: true,
...(payload.cause ? { cause: payload.cause } : {})
})
}
// Why not the whole payload: the exit cause is a main-process fact for the
// runtime's records; the renderer's pty:exit contract stays as it was.
+12 -2
View File
@@ -9,6 +9,7 @@ import type {
} from '../../shared/remote-workspace-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { parseExecutionHostId } from '../../shared/execution-host'
import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace'
import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events'
import { CLIENT_ID } from './remote-workspace-client-identity'
@@ -55,7 +56,15 @@ function getExplicitHydratedTargetIds(value: unknown): Set<string> | null {
return new Set(value)
}
function targetForWorktree(store: Store, worktreeId: string): string | null {
function targetForWorktree(
store: Store,
worktreeId: string,
executionHostId?: string
): string | null {
const parsedHostId = parseExecutionHostId(executionHostId)
if (parsedHostId?.kind === 'ssh') {
return parsedHostId.targetId
}
const repoId = getRepoIdFromWorktreeId(worktreeId)
return store.getRepo(repoId)?.connectionId ?? null
}
@@ -66,7 +75,8 @@ function exportSessionForTarget(
session: WorkspaceSessionState
): RemoteWorkspaceSession {
return exportRemoteWorkspaceSession(session, {
isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId) === targetId
isTargetWorktree: (worktreeId, executionHostId) =>
targetForWorktree(store, worktreeId, executionHostId) === targetId
})
}
@@ -39,6 +39,7 @@ function hasTransferredSessionState(session: WorkspaceSessionState): boolean {
Object.keys(session.browserTabsByWorktree ?? {}).length > 0 ||
Object.keys(session.unifiedTabs ?? {}).length > 0 ||
Object.keys(session.tabGroups ?? {}).length > 0 ||
Object.keys(session.lastVisitedAtByWorktreeId ?? {}).length > 0 ||
Object.keys(session.terminalTopologyRevisionByRepoId ?? {}).length > 0
)
}
@@ -2,6 +2,10 @@ import type { PersistedState } from '../../shared/persisted-state-types'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import type { TransferProfileState } from './profile-project-state-file'
import { isRepoWorktreeId } from './profile-project-worktree-identity'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../shared/worktree/host-qualified-identity'
export function collectTransferWorktreeIds(
state: TransferProfileState,
@@ -51,8 +55,9 @@ function collectSessionWorktreeIds(
}
const addOwnerKeys = (record: Record<string, unknown> | undefined): void => {
for (const key of Object.keys(record ?? {})) {
if (isRepoWorktreeId(repoId, key)) {
ids.add(key)
const rawKey = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
if (isRepoWorktreeId(repoId, rawKey)) {
ids.add(rawKey)
}
const parsed = parseWorkspaceKey(key)
if (parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)) {
@@ -4,6 +4,10 @@ import type { WorkspaceKey } from '../../shared/folder-workspace-types'
import type { Repo } from '../../shared/repo-types'
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../shared/worktree/host-qualified-identity'
export function repoPhysicalKey(
repo: Pick<Repo, 'path' | 'connectionId' | 'executionHostId'>
@@ -46,8 +50,14 @@ export function rekeyOwnerKey(
newRepoId: string,
ownerKey: string
): string | null {
if (isRepoWorktreeId(oldRepoId, ownerKey)) {
return rekeyWorktreeId(oldRepoId, newRepoId, ownerKey)
const rawOwnerKey = isWorktreeHostIdentity(ownerKey)
? getWorktreeIdFromHostIdentity(ownerKey)
: ownerKey
if (isRepoWorktreeId(oldRepoId, rawOwnerKey)) {
const rekeyed = rekeyWorktreeId(oldRepoId, newRepoId, rawOwnerKey)
return isWorktreeHostIdentity(ownerKey)
? `${ownerKey.slice(0, ownerKey.length - rawOwnerKey.length)}${rekeyed}`
: rekeyed
}
const parsed = parseWorkspaceKey(ownerKey)
if (parsed?.type === 'worktree' && isRepoWorktreeId(oldRepoId, parsed.worktreeId)) {
@@ -57,7 +67,10 @@ export function rekeyOwnerKey(
}
export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean {
if (isRepoWorktreeId(repoId, ownerKey)) {
const rawOwnerKey = isWorktreeHostIdentity(ownerKey)
? getWorktreeIdFromHostIdentity(ownerKey)
: ownerKey
if (isRepoWorktreeId(repoId, rawOwnerKey)) {
return true
}
const parsed = parseWorkspaceKey(ownerKey)
@@ -387,6 +387,19 @@ describe('Store.migrateWorktreeIdentity', () => {
expect(store.getWorktreeMeta(NEWER)?.priorWorktreeIds).toEqual([OLD, NEW])
})
it('keeps the newest visit timestamp when a partial migration left both identities', async () => {
const store = await createStore()
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
lastVisitedAtByWorktreeId: { [OLD]: 100, [NEW]: 900 }
})
store.migrateWorktreeIdentity(OLD, NEW)
expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.[OLD]).toBeUndefined()
expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.[NEW]).toBe(900)
})
it('is a no-op when the ids match', async () => {
const store = await createStore()
store.setWorktreeMeta(OLD, { displayName: 'Cunner' })
@@ -467,6 +467,32 @@ describe('Store', () => {
).toBe(222)
})
it('removeProjectForHost on local prunes the unqualified host-identity visit key', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'shared', path: '/repo' }))
store.addRepo(
makeRepo({
id: 'shared',
path: '/repo',
connectionId: 'ssh-a',
executionHostId: 'ssh:ssh-a'
})
)
// No meta for this worktree, so only the unqualified host-identity key names it. The host split
// parks that canonical unknown-host form in the local partition; leaving it behind after a local
// removal re-materializes an orphaned workspace next launch.
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
lastVisitedAtByWorktreeId: { '|shared::/repo/wt-orphan': 111 }
})
store.removeProjectForHost('shared', 'local')
expect(
store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['|shared::/repo/wt-orphan']
).toBeUndefined()
})
it('removeProjectForHost prunes only the removed host when a third host also shares the owner key', async () => {
const store = await createStore()
// Same repo id + path on local and two SSH hosts, so the owner key
@@ -1,6 +1,10 @@
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
export function createMinimalPersistedTerminalTab(args: {
worktreeId: string
@@ -93,7 +97,12 @@ export function deleteOwnerKeyedSessionFields(
delete next.activeGroupIdByWorktree[ownerKey]
}
if (next.lastVisitedAtByWorktreeId) {
delete next.lastVisitedAtByWorktreeId[ownerKey]
for (const key of Object.keys(next.lastVisitedAtByWorktreeId)) {
const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
if (key === ownerKey || rawId === ownerKey) {
delete next.lastVisitedAtByWorktreeId[key]
}
}
}
if (next.defaultTerminalTabsAppliedByWorktreeId) {
delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey]
@@ -3,6 +3,10 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/e
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
export function pruneWorktreeStateForRepo(
state: StoreOwnedPersistedState,
@@ -36,7 +40,18 @@ export function pruneWorktreeStateForRepo(
const ownerKeysToPrune = new Set<string>()
const collectPrefixedKeys = (keys: Iterable<string>): void => {
for (const key of keys) {
if (key.startsWith(prefix)) {
const rawKey = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
const keyHost = isWorktreeHostIdentity(key) ? key.slice(0, key.indexOf('|')) : null
// Why: bare keys are scoped by the per-partition gating below; only host-qualified
// keys can sit in another host's partition and need host-matching at collection time.
// Why the empty-host arm: the host split parks the canonical unknown-host form (`|<id>`) in
// the local partition, so a local prune must claim it or the orphan restores next launch.
const belongsToPrunedHost =
hostId === null ||
keyHost === null ||
keyHost === hostId ||
(keyHost === '' && hostId === LOCAL_EXECUTION_HOST_ID)
if (belongsToPrunedHost && rawKey.startsWith(prefix)) {
ownerKeysToPrune.add(key)
}
}
@@ -1,6 +1,10 @@
import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
/**
@@ -94,7 +98,29 @@ export function migrateWorktreeIdentity(
moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || sessionChanged
sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged
sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged
sessionChanged = moveSessionKey(session.lastVisitedAtByWorktreeId) || sessionChanged
if (session.lastVisitedAtByWorktreeId) {
const nextRecency = { ...session.lastVisitedAtByWorktreeId }
let recencyChanged = false
for (const [key, value] of Object.entries(session.lastVisitedAtByWorktreeId)) {
const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
if (rawId !== oldWorktreeId) {
continue
}
const nextKey = isWorktreeHostIdentity(key)
? `${key.slice(0, key.length - rawId.length)}${newWorktreeId}`
: newWorktreeId
// Why max: a partial migration leaves both identities behind; taking the older one would
// regress Cmd+J recency after restart.
const existing = nextRecency[nextKey]
nextRecency[nextKey] = existing === undefined ? value : Math.max(existing, value)
delete nextRecency[key]
recencyChanged = true
}
if (recencyChanged) {
session.lastVisitedAtByWorktreeId = nextRecency
sessionChanged = true
}
}
sessionChanged =
moveSessionKey(session.defaultTerminalTabsAppliedByWorktreeId) || sessionChanged
if (session.activeWorktreeIdsOnShutdown?.includes(oldWorktreeId)) {
+47 -13
View File
@@ -3632,6 +3632,9 @@ export class OrcaRuntimeService {
}) => AgentHookAuthorityAttestation | null)
| null
private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null
private readonly reconcileAgentStatusForEndedProcessFn:
| ((paneKeys: Iterable<string>) => void)
| null
private readonly canRecoverPersistentLocalPtysFn: () => boolean
private readonly buildAgentHookPtyEnv: (() => Record<string, string>) | null
private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus
@@ -3713,6 +3716,7 @@ export class OrcaRuntimeService {
terminalProvenance: 'current_runtime' | 'restored'
}) => AgentHookAuthorityAttestation | null
retireAgentHookCompatibilityAuthority?: (paneKey: string) => void
reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable<string>) => void
canRecoverPersistentLocalPtys?: () => boolean
// Why: codex-home paths for the Agent Session History scan must be sourced
// here, not via the window-only registerCoreHandlers path — that path never
@@ -3755,6 +3759,7 @@ export class OrcaRuntimeService {
deps?.attestAgentHookCompatibilityAuthority ?? null
this.retireAgentHookCompatibilityAuthorityFn =
deps?.retireAgentHookCompatibilityAuthority ?? null
this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null
this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true)
// Why: configure the shared AiVault scan cache from a serve-mode-reachable
// seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions
@@ -13962,6 +13967,27 @@ export class OrcaRuntimeService {
}
}
/** Every pane key this PTY could be addressed by. Independent of launch authority: an ordinary
* restored PTY has neither a launch token nor a receipt, and those are exactly the panes whose
* spawn-time `ptyPaneKey` mapping teardown could not resolve. */
private collectPaneKeysForPty(ptyId: string): Set<string> {
const paneKeys = new Set<string>()
const pty = this.ptysById.get(ptyId)
if (pty?.paneKey && parsePaneKey(pty.paneKey)) {
paneKeys.add(pty.paneKey)
}
const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId)
if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) {
paneKeys.add(receipt.paneKey)
}
for (const leaf of this.getLeavesForPty(ptyId)) {
if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) {
paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId))
}
}
return paneKeys
}
private retirePtyAgentLaunchAuthority(ptyId: string): void {
const pty = this.ptysById.get(ptyId)
if (!pty) {
@@ -13971,21 +13997,10 @@ export class OrcaRuntimeService {
if (!pty.launchToken && !receipt) {
return
}
const paneKeys = this.collectPaneKeysForPty(ptyId)
this.restoredOrchestrationAuthorityByPtyId.delete(ptyId)
pty.launchToken = null
pty.launchIncarnationId = null
const paneKeys = new Set<string>()
if (pty.paneKey && parsePaneKey(pty.paneKey)) {
paneKeys.add(pty.paneKey)
}
if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) {
paneKeys.add(receipt.paneKey)
}
for (const leaf of this.getLeavesForPty(ptyId)) {
if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) {
paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId))
}
}
for (const paneKey of paneKeys) {
this.retireAgentHookCompatibilityAuthorityFn?.(paneKey)
}
@@ -15201,7 +15216,14 @@ export class OrcaRuntimeService {
ptyId: string,
exitCode: number,
exitIncarnationId?: PtyIncarnationId,
options?: { hostExitConfirmed?: boolean; cause?: TerminalExitCause }
options?: {
hostExitConfirmed?: boolean
cause?: TerminalExitCause
/** The provider's own physical-exit callback fired. Separate from `hostExitConfirmed`, which
* also drives the SSH surface decision and the liveness verdict: node-pty reports real exits
* as -1, so the numeric code alone cannot tell a dead process from a failed stop. */
providerExitObserved?: boolean
}
): void {
const pty = this.ptysById.get(ptyId)
if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) {
@@ -15227,6 +15249,9 @@ export class OrcaRuntimeService {
pty?.connectionId != null &&
exitCode < 0 &&
options?.hostExitConfirmed !== true
// Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority
// receipt a receipt-only pane's key comes from.
const exitPaneKeys = this.collectPaneKeysForPty(ptyId)
if (preservesAbnormalSshSurface) {
if (this.getPtyLivenessVerdict(ptyId)?.status !== 'unverifiable') {
this.markPtyLivenessUnverifiable(ptyId, SSH_EXIT_UNCONFIRMED_REASON)
@@ -15235,6 +15260,15 @@ export class OrcaRuntimeService {
} else {
this.retirePtyAgentLaunchAuthority(ptyId)
}
// Why: a synthetic -1 from a failed or unverified stop is not a death certificate (the PTY can
// have survived it), while a real exit can also report -1 — so neither the numeric code nor the
// SSH surface predicate is sufficient on its own. Decided separately from
// preservesAbnormalSshSurface, which a host-confirmed negative SSH exit would otherwise skip.
const processDeathCertified =
exitCode >= 0 || options?.hostExitConfirmed === true || options?.providerExitObserved === true
if (processDeathCertified && exitPaneKeys.size > 0) {
this.reconcileAgentStatusForEndedProcessFn?.(exitPaneKeys)
}
const incarnationId =
exitIncarnationId ??
pty?.incarnationId ??
@@ -0,0 +1,91 @@
/**
* A PTY that exits without a resolvable spawn-time pane key never reaches
* `agentHookServer.clearPaneState`, so its row and every Claude latch outlive the process with
* nothing left to retire them (STA-4612). `onPtyExit` knows the pane keys that teardown could not
* resolve, so it reconciles them — but only on a real death certificate.
*/
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { makePaneKey } from '../../shared/stable-pane-id'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PANE = makePaneKey('tab-1', LEAF)
const PTY = 'wt-1__pty-1'
type RuntimeInternals = {
ptysById: Map<string, { ptyId: string; paneKey: string; worktreeId: string; tabId: string }>
}
function runtimeWithBoundPane(
reconcile: (paneKeys: Iterable<string>) => void,
options: { connectionId?: string } = {}
): OrcaRuntimeService {
const runtime = new OrcaRuntimeService(null, undefined, {
reconcileAgentStatusForEndedProcess: reconcile
})
const ptys = (runtime as unknown as RuntimeInternals).ptysById
ptys.set(PTY, {
ptyId: PTY,
paneKey: PANE,
worktreeId: 'wt-1',
tabId: 'tab-1',
...(options.connectionId ? { connectionId: options.connectionId } : {})
} as never)
return runtime
}
function reconciledPaneKeys(calls: Iterable<string>[]): string[] {
return calls.flatMap((keys) => [...keys])
}
describe('onPtyExit agent-status reconciliation', () => {
it('reconciles on a normal zero exit code', () => {
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile).onPtyExit(PTY, 0)
expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE)
})
it('reconciles a physical -1 exit reported by the provider callback', () => {
// node-pty forwards real exits as -1 (local-pty-provider-shutdown.test.ts pins this), so the
// numeric code alone cannot separate a dead process from a failed stop.
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1, undefined, { providerExitObserved: true })
expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE)
})
it('reconciles a host-confirmed negative exit', () => {
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1, undefined, { hostExitConfirmed: true })
expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE)
})
it('reconciles a provider-observed -1 on a pane bound to a transport, where the SSH surface is preserved', () => {
// A WSL pane carries a `wsl:*` connectionId, so a negative exit keeps the abnormal-SSH surface
// (liveness unverifiable, launch authority not retired) — but the daemon provider's own exit
// callback still witnessed the process die. The two decisions are deliberately independent;
// without this the connectionId arm of the certificate is never exercised.
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile, { connectionId: 'wsl:Ubuntu' }).onPtyExit(PTY, -1, undefined, {
providerExitObserved: true
})
expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE)
})
it('does NOT reconcile a transport-bound synthetic -1, whose remote PTY is designed to survive it', () => {
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile, { connectionId: 'ssh-conn-1' }).onPtyExit(PTY, -1)
expect(reconcile).not.toHaveBeenCalled()
})
it('does NOT reconcile a synthetic -1 from a failed stop, whose PTY may have survived', () => {
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1)
expect(reconcile).not.toHaveBeenCalled()
})
})
+3
View File
@@ -30,6 +30,9 @@ export type AgentStatusApi = {
getMigrationUnsupportedSnapshot: () => Promise<MigrationUnsupportedPtyEntry[]>
/** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */
drop: (paneKey: string) => void
/** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a
* dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */
reconcileEndedProcess: (paneKey: string) => void
/** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */
dropByTabPrefix: (tabId: string) => void
/** Permanently retire one pane's hook authority while siblings stay live. */
+3
View File
@@ -5032,6 +5032,9 @@ const api = {
drop: (paneKey: string): void => {
ipcRenderer.send('agentStatus:drop', paneKey)
},
reconcileEndedProcess: (paneKey: string): void => {
ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey)
},
/** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */
dropByTabPrefix: (tabId: string): void => {
ipcRenderer.send('agentStatus:dropByTabPrefix', tabId)
@@ -12,6 +12,7 @@ import { makePaneKey } from '../../../shared/stable-pane-id'
import {
LEAF_ID,
makeAgentEntry,
makeDuplicateRecentTabState,
makeGroup,
makeManyTabState,
makeRecentTabState,
@@ -229,6 +230,25 @@ describe('WorktreeJumpPalette recent chats & terminals', () => {
expect(testContainer.textContent).toContain('Recent Worktrees')
})
it('keeps duplicate persisted tab ids as separate recent rows and digit targets', async () => {
await renderPalette(makeDuplicateRecentTabState())
expect(
getRenderedRowIds().filter(
(id) => id === 'workspace-tab:tab-duplicate' || id.includes(':workspace-tab:tab-duplicate')
)
).toEqual(['workspace-tab:tab-duplicate', 'palette-dup:1:workspace-tab:tab-duplicate'])
await act(async () => {
emitCmdJRowIndexJump(1)
})
await flushEffects()
expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith(
expect.objectContaining({ tabId: 'tab-duplicate', worktreeId: 'wt-beta' })
)
})
it('caps the recent section so the worktree header stays above the fold', async () => {
await renderPalette(makeManyTabState(12))
@@ -350,9 +350,56 @@ function isCurrentOpenTabItem(item: OpenTabPaletteItem): boolean {
return item.type === 'browser-page' ? item.result.isCurrentPage : item.result.isCurrentTab
}
/** Not the command id: two hosts — or a duplicate snapshot — can publish the same tab id. */
function getRecentTabOccurrenceBase(item: OpenTabPaletteItem): string {
if (item.type === 'browser-page') {
const result = item.result
return JSON.stringify([
item.type,
item.id,
result.executionHostId ?? '',
result.worktreeId,
result.workspaceId,
result.pageId
])
}
if (item.type === 'simulator-tab') {
const result = item.result
// Why no groupId: it changes when a tab is regrouped mid-open, and the frozen
// order must keep resolving the row; tabId already identifies it within a host.
return JSON.stringify([
item.type,
item.id,
result.executionHostId ?? '',
result.worktreeId,
result.tabId
])
}
const result = item.result
return JSON.stringify([
item.type,
item.id,
result.executionHostId ?? '',
result.worktreeId,
result.tabId,
result.entityId
])
}
function buildRecentTabOccurrenceIds(items: readonly OpenTabPaletteItem[]): string[] {
const nextOrdinalByBase = new Map<string, number>()
return items.map((item) => {
const base = getRecentTabOccurrenceBase(item)
const ordinal = nextOrdinalByBase.get(base) ?? 0
nextOrdinalByBase.set(base, ordinal + 1)
return `recent-tab:${base}:${ordinal}`
})
}
/** An open tab's recent-section row plus the inputs inclusion needs. */
type OpenTabRecentRow = {
item: OpenTabPaletteItem
occurrenceId: string
worktree: Worktree
row: RecentWorkspaceTabRow
}
@@ -1419,17 +1466,22 @@ function WorktreeJumpPaletteContent({
// once inclusion would drop it (a current tab that quiets down), or the pip blanks mid-open.
const openTabRecentRows = useMemo<OpenTabRecentRow[]>(() => {
const entries: OpenTabRecentRow[] = []
for (const item of openTabItems) {
const occurrenceIds = buildRecentTabOccurrenceIds(openTabItems)
for (const [index, item] of openTabItems.entries()) {
const worktree = resolveWorktree(item.result.worktreeId, item.result.executionHostId)
if (!worktree) {
continue
}
const occurrenceId = occurrenceIds[index]
entries.push({
item,
occurrenceId,
worktree,
row: {
id: item.id,
occurrenceId,
worktreeId: worktree.id,
worktreeHostId: worktree.hostId,
unifiedTabId: item.type === 'browser-page' ? null : item.result.tabId,
terminalTab:
item.type === 'workspace-tab' && item.result.contentType === 'terminal'
@@ -1442,8 +1494,8 @@ function WorktreeJumpPaletteContent({
return entries
}, [openTabItems, resolveWorktree, terminalTabsById])
const recentTabRowById = useMemo(
() => new Map(openTabRecentRows.map(({ row }) => [row.id, row])),
const recentTabRowByItem = useMemo(
() => new Map(openTabRecentRows.map(({ item, row }) => [item, row])),
[openTabRecentRows]
)
@@ -1558,15 +1610,16 @@ function WorktreeJumpPaletteContent({
visible
])
// Why: walk the frozen order rather than re-sorting the tab list — the frozen ids are the ranking,
// so agent churn never reshuffles rows under the cursor. Cap after resolving, not before: a chip
// applied mid-open narrows `openTabItems`, and capping first would leave the section empty.
// Why walk the frozen order, and cap after resolving: agent churn must not reshuffle rows, and a
// mid-open chip narrows `openTabItems` — capping first would leave the section empty.
const recentTabItems = useMemo<PaletteItem[]>(() => {
const itemById = new Map(openTabItems.map((item) => [item.id, item]))
const itemByOccurrenceId = new Map(
openTabRecentRows.map(({ occurrenceId, item }) => [occurrenceId, item])
)
return recentTabOrder
.flatMap((id) => itemById.get(id) ?? [])
.flatMap((occurrenceId) => itemByOccurrenceId.get(occurrenceId) ?? [])
.slice(0, EMPTY_QUERY_RECENT_TAB_CAP)
}, [openTabItems, recentTabOrder])
}, [openTabRecentRows, recentTabOrder])
const settingsResults = useMemo(
() => buildCmdJSettingsResults(settingsSections),
@@ -1873,10 +1926,10 @@ function WorktreeJumpPaletteContent({
])
// Why: badges number the snapshotted recent rows only — ⌘N is meaningless on a typed query.
const recentTabShortcutIndexById = useMemo(
const recentTabShortcutIndexByItem = useMemo(
() =>
new Map(
hasQuery ? [] : paletteSections.visibleOpenTabItems.map((item, index) => [item.id, index])
hasQuery ? [] : paletteSections.visibleOpenTabItems.map((item, index) => [item, index])
),
[hasQuery, paletteSections]
)
@@ -3450,7 +3503,7 @@ function WorktreeJumpPaletteContent({
)
// Why regardless of query: a searched-for tab is exactly when you need to know it's
// still working — the map covers every open tab, not just the recent section.
const recentRow = recentTabRowById.get(entry.id) ?? null
const recentRow = recentTabRowByItem.get(entry) ?? null
return (
<CommandItem
@@ -3512,7 +3565,7 @@ function WorktreeJumpPaletteContent({
</span>
)}
<PaletteRowShortcutBadge
index={recentTabShortcutIndexById.get(entry.id)}
index={recentTabShortcutIndexByItem.get(entry)}
modifierKeys={digitShortcutModifiers}
/>
</div>
@@ -3594,7 +3647,7 @@ function WorktreeJumpPaletteContent({
</span>
)}
<PaletteRowShortcutBadge
index={recentTabShortcutIndexById.get(entry.id)}
index={recentTabShortcutIndexByItem.get(entry)}
modifierKeys={digitShortcutModifiers}
/>
</div>
@@ -3672,7 +3725,7 @@ function WorktreeJumpPaletteContent({
</span>
)}
<PaletteRowShortcutBadge
index={recentTabShortcutIndexById.get(entry.id)}
index={recentTabShortcutIndexByItem.get(entry)}
modifierKeys={digitShortcutModifiers}
/>
</div>
@@ -4,6 +4,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match'
type AppStoreState = ReturnType<typeof useAppStore.getState>
@@ -56,7 +57,9 @@ function pickNextWorktreeIdAfterDelete(
if (others.length > 0) {
const lastVisited = state.lastVisitedAtByWorktreeId
const [mostRecent] = [...others].sort(
(a, b) => (lastVisited[b.id] ?? 0) - (lastVisited[a.id] ?? 0)
(a, b) =>
(getWorktreeVisitTimestamp(lastVisited, b) ?? 0) -
(getWorktreeVisitTimestamp(lastVisited, a) ?? 0)
)
return mostRecent.id
}
@@ -328,6 +328,106 @@ describe('connectPanePty', () => {
shellForeground: true
})
expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter')
// Why: `agentStatus:drop` deliberately preserves a live pane's per-pane caches, so a
// process-confirmed agent exit must go through the reconcile route instead — otherwise a
// surviving Claude latch resolves the pane's next event straight back to 'working' (STA-4612).
expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey)
})
it('reconciles a pane that HAS a status row, whose own drop removes it first', async () => {
// Why this shape: the sibling test above covers a pane with no row, where the deferred drop
// early-returns and never touches the pane's accepted-status bookkeeping. That is not the
// production case. A pane worth reconciling always HAS a row — and settling runs the drop
// BEFORE the reconcile, so the reconcile must survive its own drop having already fired.
vi.useFakeTimers()
const { connectPanePty } = await import('./pty-connection')
// Both imported after vi.resetModules() so they share pty-connection's module registry; a
// static top-level import here is a DIFFERENT instance and the assertion goes vacuous.
const { createTestStore } = await import('@/store/slices/store-test-helpers')
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe')
vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear()
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
const ptyId = 'pty-claude-row-confirmed-shell'
const transport = createMockTransport(ptyId)
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
dataCallbackRef.current = callbacks.onData ?? null
return { id: ptyId }
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({ isVisibleRef: { current: false } }) as never
)
await vi.advanceTimersByTimeAsync(20)
await flushAsyncTicks()
// A real accepted status write, through the real slice, so the row carries whatever
// bookkeeping the store stamps on an accepted write. `working` is the shape STA-4612 is about:
// a latch is holding the pane open and the agent is about to exit out from under it.
const realStore = createTestStore()
realStore
.getState()
.setAgentStatus(paneKey, { state: 'working', prompt: 'ship it', agentType: 'claude' })
const row = realStore.getState().agentStatusByPaneKey[paneKey]
expect(row).toBeDefined()
mockStoreState.agentStatusByPaneKey[paneKey] = row
// The real dismissal removes the row and runs the slice's own teardown; mirror both, or the
// ordering this test exists to pin is never exercised.
mockStoreState.dropAgentStatus.mockImplementation((key: string) => {
delete mockStoreState.agentStatusByPaneKey[key]
realStore.getState().dropAgentStatus(key)
})
dataCallbackRef.current?.('\x1b]133;D;0\x07')
await vi.advanceTimersByTimeAsync(350)
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey)
expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey)
})
it('does NOT reconcile when the process check could not confirm a shell', async () => {
vi.useFakeTimers()
const { connectPanePty } = await import('./pty-connection')
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null)
vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear()
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
const ptyId = 'pty-unconfirmed-shell'
const transport = createMockTransport(ptyId)
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
dataCallbackRef.current = callbacks.onData ?? null
return { id: ptyId }
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({ isVisibleRef: { current: false } }) as never
)
await vi.advanceTimersByTimeAsync(20)
await flushAsyncTicks()
mockStoreState.agentLaunchConfigByPaneKey[paneKey] = {
launchConfig: { agentArgs: '', agentEnv: {} },
identity: { agentType: 'droid' }
}
dataCallbackRef.current?.('\x1b]133;D;0\x07')
// Run the whole confirm ladder. At 350ms the unavailable branch has not settled yet, so
// asserting there proves nothing — it would pass just as happily if this path DID tear the
// pane down a moment later.
await vi.advanceTimersByTimeAsync(350 + 1200 + 6000)
await flushAsyncTicks()
// The unavailable branch really ran: it publishes an unproven foreground before settling.
expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({
agent: null,
shellForeground: false
})
expect(window.api.agentStatus.reconcileEndedProcess).not.toHaveBeenCalled()
})
it('disarms stale TUI modes in the emulator after a confirmed return to shell', async () => {
@@ -72,7 +72,8 @@ export function installTerminalTestGlobals(): void {
restoreTerminalFit: vi.fn().mockResolvedValue({ restored: true })
},
agentStatus: {
inferInterrupt: vi.fn().mockResolvedValue(false)
inferInterrupt: vi.fn().mockResolvedValue(false),
reconcileEndedProcess: vi.fn()
}
},
dispatchEvent: vi.fn(),
@@ -88,12 +88,45 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
}
}
session.deferredCommandFinishedStatusDrop = null
session.deferredConfirmedShellReconcile = null
/** The pane's foreground was proven to be a shell, so its agent exited. Unlike a user dismissal,
* this must also retire the main-side per-pane caches — a surviving Claude latch resolves the
* next event straight back to `working`.
*
* Ordered by the row's `acceptedStatusSeq`, not by row identity or `updatedAt`: the confirming
* process read can take seconds, an unrelated field moving in that window is not evidence the
* agent is alive, and a genuinely newer row can share the anchor's millisecond. Reading the token
* off the row is what makes the ordering safe — the paired drop runs FIRST and removes the row,
* and a removed row means nothing reported, not "the anchor is gone".
*
* Residual: a row deleted by some OTHER path mid-window and then rebuilt restarts its count, so a
* pane armed at 1 that reports exactly once more compares equal. It needs a foreign drop inside
* the confirm window; the relaunch case that would otherwise hit it is already discarded by
* onCommandStarted, and the cost is retiring a pane the process table just proved is a shell. */
const reconcileEndedProcessIfPaneQuiet = (armedAcceptedStatusSeq: number | undefined): void => {
const current = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
if (current && current.acceptedStatusSeq !== armedAcceptedStatusSeq) {
return
}
// Why: main-side only. The renderer row and launch config are already owned by the deferred
// drop above; what that path cannot reach is the hook server's per-pane Claude latches, which
// `agentStatus:drop` deliberately preserves for a still-live pane. Main echoes its own clear
// back through the pane-status-cleared channel, so both sides stay consistent.
window.api?.agentStatus?.reconcileEndedProcess?.(session.cacheKey)
}
session.visibleForegroundSamplePending = false
session.visibleForegroundSampleSettled = false
session.settleDeferredCommandFinishedStatusDrop = (): void => {
session.settleDeferredCommandFinishedStatusDrop = (
options: { confirmedShell?: boolean } = {}
): void => {
const dropStatus = session.deferredCommandFinishedStatusDrop
const reconcile = session.deferredConfirmedShellReconcile
session.deferredCommandFinishedStatusDrop = null
session.deferredConfirmedShellReconcile = null
dropStatus?.()
if (options.confirmedShell) {
reconcile?.()
}
}
session.isForegroundTrackingAllowed = (id: string): boolean => {
if (isRemoteRuntimePtyId(id) || parseAppSshPtyId(id) !== null) {
@@ -143,9 +176,11 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
useAppStore.getState().clearAgentLaunchConfig(session.cacheKey)
return
}
session.settleDeferredCommandFinishedStatusDrop()
session.settleDeferredCommandFinishedStatusDrop({ confirmedShell: true })
},
onCommandFinishedUnavailable: session.settleDeferredCommandFinishedStatusDrop,
// Why wrapped: passed bare, a caller-supplied argument would be read as `options` and could
// reconcile on the unavailable path, which has no proof the agent exited.
onCommandFinishedUnavailable: () => session.settleDeferredCommandFinishedStatusDrop(),
onVisibleForegroundSettled: (outcome) => {
session.visibleForegroundSamplePending = false
session.visibleForegroundSampleSettled = outcome !== 'inconclusive'
@@ -195,10 +230,16 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
if (shouldDeferStatusDrop) {
// Why: keep the concrete pane identity routable while the local process
// check distinguishes a leaked nested-shell D from a genuine agent exit.
// Anchor the accepted-status ordinal once, here — not per rung of the confirm ladder, which
// can span seconds — so the gate tolerates churn across the whole window.
const armedAcceptedStatusSeq = entry?.acceptedStatusSeq
session.deferredCommandFinishedStatusDrop = dropStatus
session.deferredConfirmedShellReconcile = () =>
reconcileEndedProcessIfPaneQuiet(armedAcceptedStatusSeq)
return
}
session.deferredCommandFinishedStatusDrop = null
session.deferredConfirmedShellReconcile = null
dropStatus()
}
session.sampleVisiblePaneForegroundAgent = (forceRoutingConfirmation = false): void => {
@@ -9,6 +9,7 @@ import {
resetWorkspaceCleanupBrowsePersistTimer
} from '@/store/slices/workspace-cleanup-browse'
import { createDefaultWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state'
import { listAppliedWorkspaceCleanupFilters } from '../../../../shared/workspace-cleanup-applied-filters'
import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state'
import type { WorkspaceCleanupBrowseController } from './use-workspace-cleanup-browse-state'
@@ -104,5 +105,27 @@ describe('useWorkspaceCleanupBrowseState', () => {
field: 'size',
direction: 'desc'
})
})
it('keeps a cleared chip cleared when a facet patch lands in the same tick', () => {
const controller = mountController()
act(() => controller.current!.patchFilters('activity', { idleMinDays: 20 }))
const format = new Proxy({}, { get: () => () => 'chip' }) as Parameters<
typeof listAppliedWorkspaceCleanupFilters
>[1]
const chip = listAppliedWorkspaceCleanupFilters(
store.getState().workspaceCleanupBrowse.filters,
format
).find((a) => a.id === 'activity.idleMinDays')!
act(() => {
controller.current!.replaceFilters(chip.clear)
controller.current!.patchFilters('git', { states: ['dirty'] })
})
const filters = store.getState().workspaceCleanupBrowse.filters
expect(filters.activity.idleMinDays).toBeNull()
expect(filters.git.states).toEqual(['dirty'])
})
})
@@ -16,6 +16,10 @@ export type WorkspaceCleanupBrowseController = {
) => void
toggleSortField: (field: WorkspaceCleanupSortField) => void
clearFilters: () => void
/** Applies a whole-state transform, for callers that clear one named constraint. */
replaceFilters: (
transform: (filters: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState
) => void
}
/**
@@ -56,6 +60,15 @@ export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseControll
[updateBrowse]
)
// Same updater form as patchFilters: a chip clear and a facet patch can land in one
// tick, and whichever read the render snapshot would undo the other.
const replaceFilters = useCallback<WorkspaceCleanupBrowseController['replaceFilters']>(
(transform) => {
updateBrowse((current) => ({ ...current, filters: transform(current.filters) }))
},
[updateBrowse]
)
const clearFilters = useCallback(() => {
updateBrowse((current) => ({
...current,
@@ -68,6 +81,7 @@ export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseControll
sort: browse.sort,
patchFilters,
toggleSortField,
clearFilters
clearFilters,
replaceFilters
}
}
@@ -0,0 +1,180 @@
import React from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { toMegabytes } from './workspace-cleanup-facet-panel-model'
import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters'
import { listAppliedWorkspaceCleanupFilters } from '../../../../shared/workspace-cleanup-applied-filters'
import type { WorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
/** Why in the bar, not the panel: a filter narrowing the list must be visible without opening anything. */
// Why not memoized: the labels are translated at build time, so caching them on
// `filters` alone reuses the previous language's strings after a language change.
// Derivation is constant-size (one pass over the filter fields, not per row).
export function useAppliedWorkspaceCleanupFilters(
filters: WorkspaceCleanupFilterState
): WorkspaceCleanupAppliedFilter[] {
return listAppliedWorkspaceCleanupFilters(filters, buildAppliedFilterFormatters())
}
export function WorkspaceCleanupAppliedFilterChips({
applied,
onClear
}: {
applied: readonly WorkspaceCleanupAppliedFilter[]
onClear: (filter: WorkspaceCleanupAppliedFilter) => void
}): React.JSX.Element | null {
if (applied.length === 0) {
return null
}
return (
<div
className="flex w-full flex-wrap items-center gap-1"
role="list"
aria-label={translate(
'components.workspace.cleanup.browse.appliedFilters',
'Applied filters'
)}
>
{applied.map((filter) => (
<span
key={filter.id}
role="listitem"
className="inline-flex items-center gap-1 rounded-full border border-border bg-background py-0.5 pl-2 pr-1 text-[11px] text-muted-foreground"
>
{filter.label}
<Button
variant="ghost"
size="icon-xs"
className="rounded-full"
aria-label={translate(
'components.workspace.cleanup.browse.removeFilter',
'Remove filter {{value0}}',
{ value0: filter.label }
)}
onClick={() => onClear(filter)}
>
<X className="size-3" />
</Button>
</span>
))}
</div>
)
}
function buildAppliedFilterFormatters(): Parameters<typeof listAppliedWorkspaceCleanupFilters>[1] {
const mb = (bytes: number): number => toMegabytes(bytes) ?? 0
return {
idleDays: (days) =>
translate('components.workspace.cleanup.browse.chip.idleDays', 'Idle {{value0}}d+', {
value0: days
}),
neverVisited: () =>
translate('components.workspace.cleanup.browse.chip.neverVisited', 'Never visited'),
minSize: (bytes) =>
translate('components.workspace.cleanup.browse.chip.minSize', 'At least {{value0}} MB', {
value0: mb(bytes)
}),
maxSize: (bytes) =>
translate('components.workspace.cleanup.browse.chip.maxSize', 'At most {{value0}} MB', {
value0: mb(bytes)
}),
excludesUnsized: () =>
translate('components.workspace.cleanup.browse.chip.excludesUnsized', 'Measured only'),
excludesStatusless: () =>
translate('components.workspace.cleanup.browse.chip.excludesStatusless', 'Has a status'),
list: (kind, count) =>
translate('components.workspace.cleanup.browse.chip.list', '{{value0}}: {{value1}}', {
value0: getChipKindLabel(kind),
value1: count
}),
triState: (kind, mode) =>
translate('components.workspace.cleanup.browse.chip.triState', '{{value0}}: {{value1}}', {
value0: getChipKindLabel(kind),
value1:
mode === 'only'
? translate('components.workspace.cleanup.browse.chip.only', 'Only')
: translate('components.workspace.cleanup.browse.chip.exclude', 'Excluded')
}),
minAhead: (count) =>
translate('components.workspace.cleanup.browse.chip.minAhead', 'Ahead {{value0}}+', {
value0: count
}),
minBehind: (count) =>
translate('components.workspace.cleanup.browse.chip.minBehind', 'Behind {{value0}}+', {
value0: count
}),
branchQuery: (value) =>
translate('components.workspace.cleanup.browse.chip.branchQuery', 'Branch: {{value0}}', {
value0: value
}),
pathPrefix: (value) =>
translate('components.workspace.cleanup.browse.chip.pathPrefix', 'Path: {{value0}}', {
value0: value
}),
presence: (kind, mode) =>
translate('components.workspace.cleanup.browse.chip.presence', '{{value0}}: {{value1}}', {
value0: getChipKindLabel(kind),
value1:
mode === 'some'
? translate('components.workspace.cleanup.browse.chip.has', 'Has')
: translate('components.workspace.cleanup.browse.chip.none', 'None')
}),
completelyEmpty: () =>
translate('components.workspace.cleanup.browse.chip.completelyEmpty', 'Nothing to lose'),
selectableOnly: () =>
translate('components.workspace.cleanup.browse.chip.selectableOnly', 'Deletable only')
}
}
function getChipKindLabel(kind: string): string {
switch (kind) {
case 'status':
return translate('components.workspace.cleanup.browse.chip.kind.status', 'Status')
case 'agent':
return translate('components.workspace.cleanup.browse.chip.kind.agent', 'Agent')
case 'git':
return translate('components.workspace.cleanup.browse.chip.kind.git', 'Git')
case 'review':
return translate('components.workspace.cleanup.browse.chip.kind.review', 'Review')
case 'reviewState':
return translate('components.workspace.cleanup.browse.chip.kind.reviewState', 'Review state')
case 'reviewProvider':
return translate('components.workspace.cleanup.browse.chip.kind.reviewProvider', 'Provider')
case 'ticket':
return translate('components.workspace.cleanup.browse.chip.kind.ticket', 'Ticket')
case 'ticketSource':
return translate('components.workspace.cleanup.browse.chip.kind.ticketSource', 'Ticket source')
case 'context':
return translate('components.workspace.cleanup.browse.chip.kind.context', 'Context')
case 'host':
return translate('components.workspace.cleanup.browse.chip.kind.host', 'Host')
case 'repo':
return translate('components.workspace.cleanup.browse.chip.kind.repo', 'Repo')
case 'blocker':
return translate('components.workspace.cleanup.browse.chip.kind.blocker', 'Blocker')
case 'tier':
return translate('components.workspace.cleanup.browse.chip.kind.tier', 'Safety')
case 'dismissed':
return translate('components.workspace.cleanup.browse.chip.kind.dismissed', 'Ignored')
case 'archived':
return translate('components.workspace.cleanup.browse.chip.kind.archived', 'Archived')
case 'pinned':
return translate('components.workspace.cleanup.browse.chip.kind.pinned', 'Pinned')
case 'unread':
return translate('components.workspace.cleanup.browse.chip.kind.unread', 'Unread')
case 'comment':
return translate('components.workspace.cleanup.browse.chip.kind.comment', 'Comment')
case 'prunable':
return translate('components.workspace.cleanup.browse.chip.kind.prunable', 'Prunable')
case 'locked':
return translate('components.workspace.cleanup.browse.chip.kind.locked', 'Locked')
case 'retainedAgents':
return translate(
'components.workspace.cleanup.browse.chip.kind.retainedAgents',
'Finished agents'
)
default:
return kind
}
}
@@ -71,6 +71,7 @@ export function WorkspaceCleanupBrowseToolbar({
gitEvidence={{ pendingCount: gitPendingCount, totalCount: gitCheckedTotal }}
onQueryChange={(query) => browse.patchFilters('query', query)}
onClearFilters={browse.clearFilters}
onClearAppliedFilter={(filter) => browse.replaceFilters(filter.clear)}
/>
<WorkspaceCleanupSortHeader
sort={browse.sort}
@@ -17,6 +17,7 @@ import {
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupHostIdentity
} from './workspace-cleanup-host-identity'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
/**
* Streaming ticks replace only the candidate objects they touched, so all
@@ -105,7 +106,10 @@ export function computeWorkspaceCleanupFacetList(args: {
const sizeBytes =
sources.sizeBytesByWorktreeId.get(hostIdentity) ??
sources.sizeBytesByWorktreeId.get(candidate.worktreeId)
const lastVisitedAt = sources.lastVisitedAtByWorktreeId[candidate.worktreeId]
const lastVisitedAt = getWorktreeVisitTimestamp(sources.lastVisitedAtByWorktreeId, {
id: candidate.worktreeId,
hostId: getWorkspaceCleanupCandidateHostId(candidate)
})
const agentState = sources.liveAgentStatusByWorktreeId.get(candidate.worktreeId)
const review =
sources.reviewInfoByWorktreeId.get(hostIdentity) ??
@@ -22,6 +22,7 @@ import {
getWorkspaceCleanupHostIdentity,
type WorkspaceCleanupWorktreeFacts
} from './workspace-cleanup-host-identity'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
export type { WorkspaceCleanupWorktreeFacts } from './workspace-cleanup-host-identity'
export type WorkspaceCleanupFacetSources = {
@@ -128,7 +129,12 @@ export function buildWorkspaceCleanupFacets(
isSelectable: canSelectWorkspaceCleanupCandidate(candidate),
lastActivityAt: candidate.lastActivityAt,
createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt),
lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]),
lastVisitedAt: toFiniteOrNull(
getWorktreeVisitTimestamp(sources.lastVisitedAtByWorktreeId, {
id: candidate.worktreeId,
hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate)
})
),
sizeBytes: toFiniteOrNull(
sources.sizeBytesByWorktreeId?.get(hostIdentity) ??
sources.sizeBytesByWorktreeId?.get(candidate.worktreeId)
@@ -2,15 +2,24 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters'
import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
afterEach(cleanup)
function renderFilterBar(facetPanelOpen = false): void {
function renderFilterBar(
facetPanelOpen = false,
overrides: {
filters?: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>
hasActiveFilters?: boolean
onClearAppliedFilter?: (filter: WorkspaceCleanupAppliedFilter) => void
onClearFilters?: () => void
} = {}
): void {
render(
<WorkspaceCleanupFilterBar
facetProps={{
filters: createDefaultWorkspaceCleanupFilterState(),
filters: overrides.filters ?? createDefaultWorkspaceCleanupFilterState(),
counts: {
activity: 0,
size: 0,
@@ -31,10 +40,11 @@ function renderFilterBar(facetPanelOpen = false): void {
onFacetPanelOpenChange={vi.fn()}
activeFacetGroupCount={0}
matchedCount={100}
hasActiveFilters={false}
hasActiveFilters={overrides.hasActiveFilters ?? false}
gitEvidence={{ pendingCount: 0, totalCount: 0 }}
onQueryChange={vi.fn()}
onClearFilters={vi.fn()}
onClearFilters={overrides.onClearFilters ?? vi.fn()}
onClearAppliedFilter={overrides.onClearAppliedFilter ?? vi.fn()}
/>
)
}
@@ -46,6 +56,41 @@ describe('WorkspaceCleanupFilterBar', () => {
expect(screen.queryByRole('button', { name: 'Scan' })).toBeNull()
})
it('names an applied filter in the bar without opening the panel', () => {
// The reported defect: a persisted idleMinDays the user never set. The bar already
// read "Showing N of M", so only the cause was hidden.
const filters = createDefaultWorkspaceCleanupFilterState()
filters.activity.idleMinDays = 20
renderFilterBar(false, { filters, hasActiveFilters: true })
expect(screen.getByRole('listitem').textContent).toContain('Idle 20d+')
})
it('clears one named filter from its chip', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.activity.idleMinDays = 20
const onClearAppliedFilter = vi.fn()
renderFilterBar(false, { filters, hasActiveFilters: true, onClearAppliedFilter })
screen.getByRole('button', { name: 'Remove filter Idle 20d+' }).click()
expect(onClearAppliedFilter).toHaveBeenCalledTimes(1)
expect(onClearAppliedFilter.mock.calls[0][0].id).toBe('activity.idleMinDays')
})
it('offers Clear filters in the bar, not only inside the panel', () => {
renderFilterBar(false, { hasActiveFilters: true })
expect(screen.getAllByRole('button', { name: /Clear filters/ }).length).toBeGreaterThan(0)
})
it('shows no chips and no Clear for a default profile', () => {
renderFilterBar()
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
expect(screen.queryByRole('button', { name: /Clear filters/ })).toBeNull()
})
it('keeps the footer visible while the facet panel scrolls', () => {
renderFilterBar(true)
@@ -5,6 +5,11 @@ import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { translate } from '@/i18n/i18n'
import {
useAppliedWorkspaceCleanupFilters,
WorkspaceCleanupAppliedFilterChips
} from './workspace-cleanup-applied-filter-chips'
import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters'
import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets'
import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets'
import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model'
@@ -23,7 +28,8 @@ export function WorkspaceCleanupFilterBar({
hasActiveFilters,
gitEvidence,
onQueryChange,
onClearFilters
onClearFilters,
onClearAppliedFilter
}: {
facetProps: WorkspaceCleanupFacetGroupProps
facetPanelOpen: boolean
@@ -34,8 +40,10 @@ export function WorkspaceCleanupFilterBar({
gitEvidence: WorkspaceCleanupGitEvidenceProgress
onQueryChange: (query: string) => void
onClearFilters: () => void
onClearAppliedFilter: (filter: WorkspaceCleanupAppliedFilter) => void
}): React.JSX.Element {
const { filters, totalCount } = facetProps
const applied = useAppliedWorkspaceCleanupFilters(filters)
return (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/15 px-3 py-2">
<div className="relative min-w-[180px] flex-1">
@@ -101,6 +109,15 @@ export function WorkspaceCleanupFilterBar({
)}
</span>
{/* Why out here and not only in the popover: a filter narrowing the list must be
clearable without first discovering which panel it lives in. */}
{hasActiveFilters ? (
<Button variant="ghost" size="sm" className="shrink-0" onClick={onClearFilters}>
<RotateCcw className="size-3.5" />
{translate('components.workspace.cleanup.browse.clearFilters', 'Clear filters')}
</Button>
) : null}
{gitEvidence.pendingCount > 0 ? (
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
@@ -111,6 +128,8 @@ export function WorkspaceCleanupFilterBar({
)}
</span>
) : null}
<WorkspaceCleanupAppliedFilterChips applied={applied} onClear={onClearAppliedFilter} />
</div>
)
}
@@ -136,6 +136,36 @@ export function makeRecentTabState(overrides: Partial<AppState> = {}): Partial<A
}
}
/** Two host-qualified worktrees intentionally publish the same unified tab id. */
export function makeDuplicateRecentTabState(): Partial<AppState> {
const alpha = makeWorktree('wt-alpha', 'Alpha workspace', { hostId: 'ssh:alpha' })
const beta = makeWorktree('wt-beta', 'Beta workspace', { hostId: 'ssh:beta' })
return {
worktreesByRepo: { 'repo-1': [alpha, beta] },
showSleepingWorkspaces: true,
ptyIdsByTabId: {
'term-alpha': ['pty-term-alpha'],
'term-beta': ['pty-term-beta']
},
tabsByWorktree: {
'wt-alpha': [makeTerminalTab('term-alpha', 'wt-alpha', 'Alpha duplicate')],
'wt-beta': [makeTerminalTab('term-beta', 'wt-beta', 'Beta duplicate')]
},
unifiedTabsByWorktree: {
'wt-alpha': [makeUnifiedTab('tab-duplicate', 'wt-alpha', 'term-alpha', 'Alpha duplicate')],
'wt-beta': [makeUnifiedTab('tab-duplicate', 'wt-beta', 'term-beta', 'Beta duplicate')]
},
groupsByWorktree: {
'wt-alpha': [makeGroup('wt-alpha', ['tab-duplicate'])],
'wt-beta': [makeGroup('wt-beta', ['tab-duplicate'])]
},
activeGroupIdByWorktree: {
'wt-alpha': 'group-wt-alpha',
'wt-beta': 'group-wt-beta'
}
}
}
/** One tab-heavy worktree plus `count` bare ones, so both sections overflow their caps. */
export function makeManyTabState(count: number): Partial<AppState> {
const ids = Array.from({ length: count }, (_, index) => `${index}`)
@@ -1,7 +1,12 @@
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import { splitWorktreeId } from '../../../shared/worktree/id'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import type { AppState } from '../store/types'
function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTab): TerminalTab {
@@ -20,7 +25,8 @@ export function mergeDirectSshRemoteWorkspaceSession(
remote: WorkspaceSessionState,
replaceWorktreeIds: ReadonlySet<string>,
liveTabsByWorktree: AppState['tabsByWorktree'],
preserveLocalTerminalTabIds: ReadonlySet<string>
preserveLocalTerminalTabIds: ReadonlySet<string>,
replaceExecutionHostId?: ExecutionHostId
): WorkspaceSessionState {
const currentTabsById = new Map(
[...replaceWorktreeIds]
@@ -146,6 +152,25 @@ export function mergeDirectSshRemoteWorkspaceSession(
Object.fromEntries(
Object.entries(record ?? {}).filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId))
)
const omitTargetVisitRecency = (
record: Record<string, number> | undefined
): Record<string, number> =>
Object.fromEntries(
Object.entries(record ?? {}).filter(([key]) => {
const worktreeId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
if (!replaceWorktreeIds.has(worktreeId)) {
return true
}
// A direct SSH snapshot replaces one host's row. Legacy bare keys
// remain because they may be the only recency evidence for a sibling
// host; a qualified key is removed only for the target host.
return Boolean(
replaceExecutionHostId &&
(!isWorktreeHostIdentity(key) ||
key.slice(0, key.indexOf('|')) !== replaceExecutionHostId)
)
})
)
const terminalLayoutsByTabId = {
...Object.fromEntries(
Object.entries(current.terminalLayoutsByTabId).filter(
@@ -226,7 +251,7 @@ export function mergeDirectSshRemoteWorkspaceSession(
)
},
lastVisitedAtByWorktreeId: {
...omitTargetWorktrees(current.lastVisitedAtByWorktreeId),
...omitTargetVisitRecency(current.lastVisitedAtByWorktreeId),
...remote.lastVisitedAtByWorktreeId
},
defaultTerminalTabsAppliedByWorktreeId: {
@@ -2,6 +2,7 @@ import type { StoreApi } from 'zustand'
import type { RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types'
import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection'
import type { DirectSshAuthority } from '../../../shared/ssh-types'
import { toSshExecutionHostId } from '../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
import { buildWorkspaceSessionPayload } from '../lib/workspace-session'
import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope'
@@ -109,14 +110,16 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({
const state = store.getState()
const worktreeIds = exactTargetWorktreeIds(state, authority)
const remoteSession = importRemoteWorkspaceSession(snapshot.session, {
resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds)
resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds),
executionHostId: toSshExecutionHostId(authority.targetId)
})
const merged = mergeDirectSshRemoteWorkspaceSession(
buildWorkspaceSessionPayload(state),
remoteSession,
worktreeIds,
state.tabsByWorktree,
currentRecoveryTabIds(state, authority, worktreeIds)
currentRecoveryTabIds(state, authority, worktreeIds),
toSshExecutionHostId(authority.targetId)
)
if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) {
return
+6 -1
View File
@@ -924,7 +924,12 @@ export function useIpcEvents(): void {
`[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`,
removed
)
afterState.purgeWorktreeTerminalState(removed)
const purgeHostId =
options?.executionHostId ??
(options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined)
afterState.purgeWorktreeTerminalState(
purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed
)
afterState.removeWorkspaceSpaceWorktrees(removed)
}
}
+47 -1
View File
@@ -16234,7 +16234,53 @@
"selectionWithheldOne": "1 selected workspace is hidden by the current filters and was deselected.",
"selectionWithheld": "{{value0}} selected workspaces are hidden by the current filters and were deselected.",
"selectAllCountOne": "Select 1 safety-checked workspace",
"selectAllCount": "Select all {{value0}} safety-checked workspaces"
"selectAllCount": "Select all {{value0}} safety-checked workspaces",
"appliedFilters": "Applied filters",
"removeFilter": "Remove filter {{value0}}",
"chip": {
"idleDays": "Idle {{value0}}d+",
"neverVisited": "Never visited",
"minSize": "At least {{value0}} MB",
"maxSize": "At most {{value0}} MB",
"excludesUnsized": "Measured only",
"excludesStatusless": "Has a status",
"list": "{{value0}}: {{value1}}",
"triState": "{{value0}}: {{value1}}",
"only": "Only",
"exclude": "Excluded",
"minAhead": "Ahead {{value0}}+",
"minBehind": "Behind {{value0}}+",
"branchQuery": "Branch: {{value0}}",
"pathPrefix": "Path: {{value0}}",
"presence": "{{value0}}: {{value1}}",
"has": "Has",
"none": "None",
"completelyEmpty": "Nothing to lose",
"selectableOnly": "Deletable only",
"kind": {
"status": "Status",
"agent": "Agent",
"git": "Git",
"review": "Review",
"reviewState": "Review state",
"reviewProvider": "Provider",
"ticket": "Ticket",
"ticketSource": "Ticket source",
"context": "Context",
"host": "Host",
"repo": "Repo",
"blocker": "Blocker",
"tier": "Safety",
"dismissed": "Ignored",
"archived": "Archived",
"pinned": "Pinned",
"unread": "Unread",
"comment": "Comment",
"prunable": "Prunable",
"locked": "Locked",
"retainedAgents": "Finished agents"
}
}
},
"scan": {
"progress": "Scanning workspaces ({{value0}}/{{value1}})",
+48
View File
@@ -14566,6 +14566,54 @@
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
},
"browse": {
"appliedFilters": "적용된 필터",
"removeFilter": "{{value0}} 필터 제거",
"chip": {
"idleDays": "{{value0}}일 이상 유휴",
"neverVisited": "방문한 적 없음",
"minSize": "{{value0}} MB 이상",
"maxSize": "{{value0}} MB 이하",
"excludesUnsized": "측정된 항목만",
"excludesStatusless": "상태 있음",
"list": "{{value0}}: {{value1}}",
"triState": "{{value0}}: {{value1}}",
"only": "만",
"exclude": "제외",
"minAhead": "{{value0}}개 이상 앞섬",
"minBehind": "{{value0}}개 이상 뒤처짐",
"branchQuery": "브랜치: {{value0}}",
"pathPrefix": "경로: {{value0}}",
"presence": "{{value0}}: {{value1}}",
"has": "있음",
"none": "없음",
"completelyEmpty": "잃을 것 없음",
"selectableOnly": "삭제 가능한 항목만",
"kind": {
"status": "상태",
"agent": "에이전트",
"git": "Git",
"review": "리뷰",
"reviewState": "리뷰 상태",
"reviewProvider": "제공자",
"ticket": "티켓",
"ticketSource": "티켓 소스",
"context": "컨텍스트",
"host": "호스트",
"repo": "저장소",
"blocker": "차단 요인",
"tier": "안전",
"dismissed": "무시됨",
"archived": "보관됨",
"pinned": "고정됨",
"unread": "읽지 않음",
"comment": "댓글",
"prunable": "정리 가능",
"locked": "잠김",
"retainedAgents": "완료된 에이전트"
}
}
}
}
},
+48
View File
@@ -14557,6 +14557,54 @@
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
},
"browse": {
"appliedFilters": "已应用的筛选",
"removeFilter": "移除筛选 {{value0}}",
"chip": {
"idleDays": "闲置 {{value0}} 天以上",
"neverVisited": "从未访问",
"minSize": "至少 {{value0}} MB",
"maxSize": "最多 {{value0}} MB",
"excludesUnsized": "仅已测量",
"excludesStatusless": "有状态",
"list": "{{value0}}:{{value1}}",
"triState": "{{value0}}:{{value1}}",
"only": "仅",
"exclude": "排除",
"minAhead": "领先 {{value0}} 个以上",
"minBehind": "落后 {{value0}} 个以上",
"branchQuery": "分支:{{value0}}",
"pathPrefix": "路径:{{value0}}",
"presence": "{{value0}}:{{value1}}",
"has": "有",
"none": "无",
"completelyEmpty": "没有可丢失的内容",
"selectableOnly": "仅可删除",
"kind": {
"status": "状态",
"agent": "代理",
"git": "Git",
"review": "审查",
"reviewState": "审查状态",
"reviewProvider": "提供方",
"ticket": "工单",
"ticketSource": "工单来源",
"context": "上下文",
"host": "主机",
"repo": "仓库",
"blocker": "阻塞项",
"tier": "安全",
"dismissed": "已忽略",
"archived": "已归档",
"pinned": "已固定",
"unread": "未读",
"comment": "评论",
"prunable": "可修剪",
"locked": "已锁定",
"retainedAgents": "已完成的代理"
}
}
}
}
},
@@ -12,7 +12,7 @@ import type { PaletteDocumentRank } from './palette-match/palette-document'
function rank(overrides: Partial<PaletteDocumentRank> = {}): PaletteDocumentRank {
return {
exactIntent: 1,
matchedDirectField: 0,
containerOnlyTokenCount: 0,
wholeQuery: 3,
worstQuality: 5,
usesSupportingEvidence: 0,
@@ -2,6 +2,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host'
import type { Worktree } from '../../../shared/worktree/types'
import { isPaletteCurrentWorktree } from './palette-repo-resolution'
import { compareWorktreeDisplayName } from './worktree-display-name-order'
import { getWorktreeVisitTimestamp } from './worktree-visit-recency'
export type OrderEmptyQueryInputs = {
visibleWorktrees: readonly Worktree[]
@@ -51,8 +52,8 @@ export function orderEmptyQueryWorktrees(inputs: OrderEmptyQueryInputs): OrderEm
// visited SSH worktree below the fold — the exact bug the feature
// fixes. Compare presence first, then value within each tier.
const sorted = [...switchable].sort((a, b) => {
const aVisited = lastVisitedAtByWorktreeId[a.id]
const bVisited = lastVisitedAtByWorktreeId[b.id]
const aVisited = getWorktreeVisitTimestamp(lastVisitedAtByWorktreeId, a)
const bVisited = getWorktreeVisitTimestamp(lastVisitedAtByWorktreeId, b)
if (aVisited != null && bVisited != null) {
if (bVisited !== aVisited) {
return bVisited - aVisited
@@ -163,8 +163,10 @@ function rankAssignments(args: {
let worstQuality: PaletteMatchQuality = 'field-exact'
let fuzzyTokenCount = 0
const fields = new Set<string>()
// Derive from field metadata only: an evidence field can itself be a container.
let hasDirectHit = false
let containerOnlyTokenCount = 0
let tokenIndex = -1
let tokenHasDirectField = false
let matchedTokenCount = 0
for (const assignment of args.assignments) {
if (paletteMatchQualityRank(assignment.quality) > paletteMatchQualityRank(worstQuality)) {
@@ -174,20 +176,32 @@ function rankAssignments(args: {
fuzzyTokenCount += 1
}
fields.add(assignment.fieldId)
if (assignment.tokenIndex !== tokenIndex) {
if (tokenIndex !== -1 && !tokenHasDirectField) {
containerOnlyTokenCount += 1
}
tokenIndex = assignment.tokenIndex
tokenHasDirectField = false
matchedTokenCount += 1
}
const field = args.document.fieldById.get(assignment.fieldId)
if (field && !field.isContainer) {
hasDirectHit = true
tokenHasDirectField = true
}
}
const isContainerOnly = !hasDirectHit
if (tokenIndex !== -1 && !tokenHasDirectField) {
containerOnlyTokenCount += 1
}
const isContainerOnly =
containerOnlyTokenCount > 0 && containerOnlyTokenCount === matchedTokenCount
return {
worstQuality,
isContainerOnly,
rank: {
exactIntent: args.exactIntent ? 0 : 1,
matchedDirectField: isContainerOnly ? 1 : 0,
containerOnlyTokenCount,
wholeQuery: args.wholeQuery,
worstQuality: paletteMatchQualityRank(worstQuality),
usesSupportingEvidence: args.usesEvidence ? 1 : 0,
@@ -129,8 +129,8 @@ export type PaletteSupportingEvidence = {
export type PaletteDocumentRank = {
/** 0 when a recognized exact intent (such as a task URL) produced this row. */
exactIntent: number
/** 0 when query matched at least one direct field; 1 when all matched tokens are container-only. */
matchedDirectField: number
/** Tokens whose chosen assignment only matched container fields. */
containerOnlyTokenCount: number
/** 0 equality, 1 prefix, 2 word boundary, 3 none — whole query in visible text. */
wholeQuery: number
worstQuality: number
@@ -150,7 +150,7 @@ export type PaletteDocumentMatch = {
const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [
'exactIntent',
'matchedDirectField',
'containerOnlyTokenCount',
'wholeQuery',
'worstQuality',
'usesSupportingEvidence',
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
import { matchPaletteDocument } from './match-document'
import { mapNormalizedRange, normalizePaletteText } from './normalized-text'
import { preparePaletteQuery, PALETTE_QUERY_MAX_TOKENS } from './palette-query'
import { buildPaletteDocument, type PaletteDocumentInput } from './palette-document'
import {
buildPaletteDocument,
comparePaletteDocumentRank,
type PaletteDocumentInput
} from './palette-document'
import { segmentPaletteText } from './text-segments'
import { isWithinOnePaletteEdit } from './typo-distance'
@@ -383,7 +387,7 @@ describe('typo distance', () => {
})
describe('container field matching', () => {
it('marks matchedDirectField as 1 and demotes quality class when all tokens land on container fields', () => {
it('counts a container-only token and demotes quality class when every token lands on containers', () => {
const tabDoc: PaletteDocumentInput = {
id: 'tab-1',
visibleFields: [
@@ -394,11 +398,11 @@ describe('container field matching', () => {
}
const match = run(tabDoc, '4360')
expect(match).not.toBeNull()
expect(match?.rank.matchedDirectField).toBe(1)
expect(match?.rank.containerOnlyTokenCount).toBe(1)
expect(match?.qualityClass).toBe('exact-evidence')
})
it('marks matchedDirectField as 0 when at least one token lands on a direct field', () => {
it('does not count a token that lands on a direct field', () => {
const tabDoc: PaletteDocumentInput = {
id: 'tab-1',
visibleFields: [
@@ -409,7 +413,40 @@ describe('container field matching', () => {
}
const match = run(tabDoc, '4360')
expect(match).not.toBeNull()
expect(match?.rank.matchedDirectField).toBe(0)
expect(match?.rank.containerOnlyTokenCount).toBe(0)
expect(match?.qualityClass).toBe('exact-visible')
})
it('ranks an all-direct multi-token match ahead of a mixed direct and container match', () => {
const direct = run(
{
id: 'direct',
visibleFields: [
{ id: 'title', profile: 'structured-label', text: 'alpha' },
{ id: 'path', profile: 'structured-label', text: 'beta' }
],
evidence: []
},
'alpha beta'
)
const mixed = run(
{
id: 'mixed',
visibleFields: [
{ id: 'title', profile: 'structured-label', text: 'alpha' },
{ id: 'worktree', profile: 'structured-label', text: 'beta', isContainer: true }
],
evidence: []
},
'alpha beta'
)
expect(direct).not.toBeNull()
expect(mixed).not.toBeNull()
expect(direct?.rank.containerOnlyTokenCount).toBe(0)
expect(mixed?.rank.containerOnlyTokenCount).toBe(1)
if (direct && mixed) {
expect(comparePaletteDocumentRank(direct.rank, mixed.rank)).toBeLessThan(0)
}
})
})
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
buildFocusedGroupTabRecency,
focusedGroupTabKey,
orderRecentWorkspaceTabs,
resolveRecentWorkspaceTabStatus,
type RecentWorkspaceTabRow
@@ -130,6 +131,22 @@ describe('orderRecentWorkspaceTabs', () => {
).toEqual(['warm', 'cold'])
})
it('uses host-qualified recency for same-id worktree rows', () => {
const rows = [
row('local', { worktreeId: 'repo::/app', worktreeHostId: 'local' }),
row('ssh', { worktreeId: 'repo::/app', worktreeHostId: 'ssh:builder' })
]
expect(
order(rows, sources([]), {
lastVisitedAtByWorktreeId: {
'local|repo::/app': NOW - 1_000,
'ssh:builder|repo::/app': NOW
}
})
).toEqual(['ssh', 'local'])
})
it('prefers any visited worktree over a never-visited one', () => {
const rows = [row('never'), row('ancient')]
@@ -151,9 +168,9 @@ describe('orderRecentWorkspaceTabs', () => {
order(rows, sources([]), {
lastVisitedAtByWorktreeId: { 'wt-1': NOW },
focusedGroupTabRecency: new Map([
['unified-first', 0],
['unified-third', 1],
['unified-second', 2]
[focusedGroupTabKey('wt-1', 'unified-first'), 0],
[focusedGroupTabKey('wt-1', 'unified-third'), 1],
[focusedGroupTabKey('wt-1', 'unified-second'), 2]
])
})
).toEqual(['second', 'third', 'first'])
@@ -171,14 +188,70 @@ describe('orderRecentWorkspaceTabs', () => {
expect(
order(rows, sources([]), {
focusedGroupTabRecency: new Map([
['unified-alpha-1', 0],
['unified-alpha-2', 1],
['unified-beta-1', 5]
[focusedGroupTabKey('wt-alpha', 'unified-alpha-1'), 0],
[focusedGroupTabKey('wt-alpha', 'unified-alpha-2'), 1],
[focusedGroupTabKey('wt-beta', 'unified-beta-1'), 5]
])
})
).toEqual(['alpha-2', 'alpha-1', 'beta-1'])
})
it('keeps an interleaved worktree block together before applying its focused-group MRU', () => {
const rows = [
row('alpha-old', { worktreeId: 'wt-alpha', unifiedTabId: 'unified-alpha-old' }),
row('beta', { worktreeId: 'wt-beta', unifiedTabId: 'unified-beta' }),
row('alpha-new', { worktreeId: 'wt-alpha', unifiedTabId: 'unified-alpha-new' })
]
expect(
order(rows, sources([]), {
focusedGroupTabRecency: new Map([
[focusedGroupTabKey('wt-alpha', 'unified-alpha-old'), 0],
[focusedGroupTabKey('wt-alpha', 'unified-alpha-new'), 1],
[focusedGroupTabKey('wt-beta', 'unified-beta'), 0]
])
})
).toEqual(['alpha-new', 'alpha-old', 'beta'])
})
it('keeps duplicate tab ids in separate worktrees on their own MRU ordinals', () => {
const rows = [
row('alpha-1', { worktreeId: 'wt-alpha', unifiedTabId: 'shared-tab' }),
row('alpha-2', { worktreeId: 'wt-alpha', unifiedTabId: 'alpha-only' }),
row('beta', { worktreeId: 'wt-beta', unifiedTabId: 'shared-tab' })
]
expect(
order(rows, sources([]), {
lastVisitedAtByWorktreeId: { 'wt-alpha': NOW, 'wt-beta': NOW - 1 },
focusedGroupTabRecency: new Map([
[focusedGroupTabKey('wt-alpha', 'shared-tab'), 0],
[focusedGroupTabKey('wt-alpha', 'alpha-only'), 1],
// Beta's ordinal for the same tab id must not hoist alpha's occurrence.
[focusedGroupTabKey('wt-beta', 'shared-tab'), 9]
])
})
).toEqual(['alpha-2', 'alpha-1', 'beta'])
})
it('keeps same-id worktrees on two hosts in separate order blocks', () => {
const rows = [
row('local-old', { worktreeId: 'wt-1', worktreeHostId: 'local', unifiedTabId: 'local-old' }),
row('ssh', { worktreeId: 'wt-1', worktreeHostId: 'ssh:box', unifiedTabId: 'ssh' }),
row('local-new', { worktreeId: 'wt-1', worktreeHostId: 'local', unifiedTabId: 'local-new' })
]
expect(
order(rows, sources([]), {
focusedGroupTabRecency: new Map([
[focusedGroupTabKey('wt-1', 'local-old'), 0],
[focusedGroupTabKey('wt-1', 'local-new'), 1],
[focusedGroupTabKey('wt-1', 'ssh'), 5]
])
})
).toEqual(['local-new', 'local-old', 'ssh'])
})
it('keeps input (positional) order when nothing else separates two rows', () => {
const rows = [row('a', { worktreeId: 'wt-1' }), row('b', { worktreeId: 'wt-1' })]
@@ -188,6 +261,21 @@ describe('orderRecentWorkspaceTabs', () => {
])
})
it('returns each occurrence identity when palette ids collide', () => {
const rows = [
row('workspace-tab:duplicate', {
occurrenceId: 'recent-tab:alpha',
worktreeId: 'wt-alpha'
}),
row('workspace-tab:duplicate', {
occurrenceId: 'recent-tab:beta',
worktreeId: 'wt-beta'
})
]
expect(order(rows, sources([]))).toEqual(['recent-tab:alpha', 'recent-tab:beta'])
})
it('treats rows without a terminal tab as idle', () => {
const rows = [row('browser', { terminalTab: null, unifiedTabId: null }), row('blocked')]
@@ -272,8 +360,8 @@ describe('buildFocusedGroupTabRecency', () => {
)
expect([...recency]).toEqual([
['t1', 0],
['t2', 1]
[focusedGroupTabKey('wt-1', 't1'), 0],
[focusedGroupTabKey('wt-1', 't2'), 1]
])
})
@@ -10,6 +10,9 @@ import { tabHasLivePty } from './tab-has-live-pty'
import type { WorktreeStatus } from './worktree-status'
import type { TabGroup } from '../../../shared/tab-types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { getWorktreeVisitTimestamp } from './worktree-visit-recency'
import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
/**
* Row model for Cmd+J's empty-query "Recent chats & terminals" section.
@@ -20,7 +23,15 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types'
export type RecentWorkspaceTabRow = {
/** Palette item id. */
id: string
/**
* Per-occurrence identity used while the palette is open. Persisted tab ids can collide across
* hosts (or in a transient duplicate snapshot), so ordering must not use `id` as its key.
* Callers that know their ids are unique may omit this and fall back to `id`.
*/
occurrenceId?: string
worktreeId: string
/** Host owner used to distinguish same-id rows published by two hosts. */
worktreeHostId?: ExecutionHostId
/** Unified tab id — the key `TabGroup.recentTabIds` uses. Null for rows outside a tab group. */
unifiedTabId: string | null
/** Terminal tab whose panes carry agent state. Null for editor, browser and simulator rows. */
@@ -33,18 +44,19 @@ export type RecentWorkspaceTabOrderInputs = {
paneSources: TabPaneInputSources
now: number
lastVisitedAtByWorktreeId: Record<string, number>
/** Unified tab id → ordinal in its worktree's focused group; higher is more recent. */
/** `focusedGroupTabKey` → ordinal in that worktree's focused group; higher is more recent. */
focusedGroupTabRecency: ReadonlyMap<string, number>
}
type RankedRow = {
id: string
occurrenceId: string
needsAttention: boolean
attentionClass: SmartClass
attentionTimestamp: number
visitedAt: number | undefined
focusOrdinal: number
worktreeId: string
worktreeOrder: number
}
/** Classes 1 (blocked/waiting) and 2 (freshly done) are the rows that want the user. */
@@ -89,6 +101,15 @@ export function resolveRecentWorkspaceTabStatus(
: 'inactive'
}
/**
* Ordinals are per-worktree, so the key must be too: two worktrees can publish the same tab id and
* a bare key would let one overwrite the other's MRU position.
*/
export function focusedGroupTabKey(worktreeId: string, unifiedTabId: string): string {
// NUL separator: a worktree id embeds a filesystem path, so a printable one would be ambiguous.
return `${worktreeId}\u0000${unifiedTabId}`
}
/** `TabGroup.recentTabIds` keeps most-recent at the tail, so the index is the ordinal. */
export function buildFocusedGroupTabRecency(
activeGroupIdByWorktree: Record<string, string | undefined>,
@@ -102,7 +123,9 @@ export function buildFocusedGroupTabRecency(
}
// Why: MRU only means something inside the focused group; other groups keep positional order.
const focusedGroup = groups?.find((group) => group.id === activeGroupId)
focusedGroup?.recentTabIds?.forEach((tabId, index) => recency.set(tabId, index))
focusedGroup?.recentTabIds?.forEach((tabId, index) =>
recency.set(focusedGroupTabKey(worktreeId, tabId), index)
)
}
return recency
}
@@ -127,10 +150,8 @@ function compareRankedRows(a: RankedRow, b: RankedRow): number {
}
return b.visitedAt - a.visitedAt
}
// Why: focusOrdinal is a per-worktree sequence, so comparing it across worktrees interleaves
// them arbitrarily. Keep input (positional) order instead.
if (a.worktreeId !== b.worktreeId) {
return 0
if (a.worktreeOrder !== b.worktreeOrder) {
return a.worktreeOrder - b.worktreeOrder
}
return b.focusOrdinal - a.focusOrdinal
}
@@ -139,26 +160,41 @@ function compareRankedRows(a: RankedRow, b: RankedRow): number {
* Rank rows into ids, most-wanted first:
* tier 1 — needs attention: class 1 (blocked/waiting) then 2 (fresh done), newest first
* tier 2 — everything else: worktree focus recency, then focused-group MRU
* Full ties keep input order, which callers pass in positional (worktree/group/tab) order.
* Equal worktree tiers preserve first-seen worktree order, then use that worktree's MRU.
*/
export function orderRecentWorkspaceTabs(inputs: RecentWorkspaceTabOrderInputs): string[] {
const { rows, paneSources, now, lastVisitedAtByWorktreeId, focusedGroupTabRecency } = inputs
// Host-qualified: the same worktree id on two hosts is two workspaces and must not share a block.
const worktreeOrder = new Map<string, number>()
for (const row of rows) {
const identity = composeWorktreeHostIdentity(row.worktreeHostId, row.worktreeId)
if (!worktreeOrder.has(identity)) {
worktreeOrder.set(identity, worktreeOrder.size)
}
}
return rows
.map((row): RankedRow => {
const attention = resolveRecentWorkspaceTabAttention(row, paneSources, now)
return {
id: row.id,
occurrenceId: row.occurrenceId ?? row.id,
needsAttention: attention.cls <= NEEDS_ATTENTION_MAX_CLASS,
attentionClass: attention.cls,
attentionTimestamp: attention.attentionTimestamp,
visitedAt: lastVisitedAtByWorktreeId[row.worktreeId],
visitedAt: getWorktreeVisitTimestamp(lastVisitedAtByWorktreeId, {
id: row.worktreeId,
hostId: row.worktreeHostId
}),
worktreeId: row.worktreeId,
worktreeOrder:
worktreeOrder.get(composeWorktreeHostIdentity(row.worktreeHostId, row.worktreeId)) ??
Number.MAX_SAFE_INTEGER,
focusOrdinal:
row.unifiedTabId === null
? NO_FOCUS_ORDINAL
: (focusedGroupTabRecency.get(row.unifiedTabId) ?? NO_FOCUS_ORDINAL)
: (focusedGroupTabRecency.get(focusedGroupTabKey(row.worktreeId, row.unifiedTabId)) ??
NO_FOCUS_ORDINAL)
}
})
.sort(compareRankedRows)
.map((row) => row.id)
.map((row) => row.occurrenceId)
}
@@ -11,6 +11,10 @@ import {
} from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import {
mergeWorkspaceSessionsFromHosts,
splitWorkspaceSessionByHost,
@@ -76,6 +80,9 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
}
function normalizeWorkspaceSessionKeyForOwnerMap(value: string): string {
if (isWorktreeHostIdentity(value)) {
return getWorktreeIdFromHostIdentity(value)
}
const scope = parseWorkspaceKey(value)
return scope?.type === 'worktree' ? scope.worktreeId : value
}
@@ -113,6 +113,32 @@ describe('splitWorkspaceSessionByHost', () => {
expect(Object.keys(slices[RUNTIME_B]?.tabsByWorktree ?? {})).toEqual(['b-wt'])
})
it('keeps ssh-qualified visit recency in the local slice and routes runtime-qualified keys to their partition', () => {
const state: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
lastVisitedAtByWorktreeId: {
'local-wt': 1,
'a-wt': 2,
'ssh:builder|ssh-wt': 3,
'runtime:env-a|a-wt': 4
}
}
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix())
// Why local for ssh: boot hydration reads only local + runtime:* partitions,
// so an ssh partition would strand the recency across restarts.
expect(slices[LOCAL_EXECUTION_HOST_ID]?.lastVisitedAtByWorktreeId).toEqual({
'local-wt': 1,
'ssh:builder|ssh-wt': 3
})
expect(slices[RUNTIME_A]?.lastVisitedAtByWorktreeId).toEqual({
'a-wt': 2,
'runtime:env-a|a-wt': 4
})
expect(slices['ssh:builder' as ExecutionHostId]).toBeUndefined()
})
it('routes tab-keyed maps via the owning tab worktree (legacy + unified)', () => {
const state: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
@@ -1,5 +1,10 @@
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
import {
GLOBAL_WORKSPACE_SESSION_FIELDS,
WORKSPACE_SESSION_FIELD_OWNERSHIP
@@ -84,6 +89,33 @@ function assignWorktreeKeyed(
}
}
function assignVisitRecencyByHost(
slices: HostSessionSlices,
template: WorkspaceSessionState,
value: unknown,
ctx: SplitContext
): void {
if (!isWorkspaceSessionRecord(value)) {
return
}
for (const [key, entry] of Object.entries(value)) {
// Why: boot hydration reads only local + runtime:* partitions, and SSH worktree
// session state deliberately stays in the local partition (see buildHostIdByWorktreeId);
// routing ssh-qualified keys to an ssh partition would strand them across restarts.
const qualifiedHost = isWorktreeHostIdentity(key)
? parseExecutionHostId(key.slice(0, key.indexOf('|')))
: null
const host = isWorktreeHostIdentity(key)
? qualifiedHost?.kind === 'runtime'
? qualifiedHost.id
: LOCAL_EXECUTION_HOST_ID
: ctx.hostIdByWorktreeId(key)
const slice = ensureSlice(slices, host, template) as WorkspaceSessionRecord
const target = (slice.lastVisitedAtByWorktreeId ??= {}) as WorkspaceSessionRecord
target[key] = entry
}
}
function assignKeyedByResolvedWorktree(
slices: HostSessionSlices,
template: WorkspaceSessionState,
@@ -157,7 +189,11 @@ export function splitWorkspaceSessionByHost(
// Main preserves this field while rebasing renderer writes onto host authority.
break
case 'worktreeKeyed':
assignWorktreeKeyed(slices, template, field, value, ctx)
if (field === 'lastVisitedAtByWorktreeId') {
assignVisitRecencyByHost(slices, template, value, ctx)
} else {
assignWorktreeKeyed(slices, template, field, value, ctx)
}
break
case 'worktreeArray': {
if (!Array.isArray(value)) {
@@ -21,7 +21,7 @@ import type { AgentMetadata } from './workspace-tab-agent-metadata'
*/
const AGENT_SNIPPET_RANK: PaletteDocumentRank = {
exactIntent: 1,
matchedDirectField: 0,
containerOnlyTokenCount: Number.MAX_SAFE_INTEGER,
wholeQuery: 3,
worstQuality: paletteMatchQualityRank(PALETTE_MATCH_QUALITIES.at(-1) as PaletteMatchQuality) + 1,
usesSupportingEvidence: 1,
@@ -99,13 +99,14 @@ function makeEntry({
describe('searchWorkspaceTabs lastActiveAt', () => {
it('is null when neither agent activity nor worktree activity is known', () => {
const [result] = searchWorkspaceTabs([makeEntry()], '')
const [result] = searchWorkspaceTabs([makeEntry({ createdAt: 4000 })], '')
expect(result.lastActiveAt).toBeNull()
})
it('falls back to worktree PTY activity for editor tabs with no agent metadata', () => {
const entry = makeEntry({
contentType: 'editor',
createdAt: 1000,
worktree: makeWorktree({ lastActivityAt: 5000 })
})
const [result] = searchWorkspaceTabs([entry], '')
@@ -148,9 +149,61 @@ describe('searchWorkspaceTabs lastActiveAt', () => {
const [result] = searchWorkspaceTabs([entry], '')
expect(result.lastActiveAt).toBe(6000)
})
it('uses a newer tab focus instead of stale agent activity', () => {
const entry = makeEntry({
id: 'tab-refocused',
createdAt: 1000,
agentLastActivityAt: 3000
})
entry.tab.lastFocusedAt = 8000
const [result] = searchWorkspaceTabs([entry], '')
expect(result.lastActiveAt).toBe(8000)
})
it('uses newer agent activity instead of an older tab focus', () => {
const entry = makeEntry({
id: 'tab-agent-active',
createdAt: 1000,
agentLastActivityAt: 8000
})
entry.tab.lastFocusedAt = 3000
const [result] = searchWorkspaceTabs([entry], '')
expect(result.lastActiveAt).toBe(8000)
})
})
describe('searchWorkspaceTabs ranking', () => {
it('ranks a multi-token direct-plus-container hit above a container-only whole-query hit', () => {
const directEntry = makeEntry({ id: 'direct-tab' })
const containerEntry = makeEntry({ id: 'container-tab' })
directEntry.document = buildPaletteTabDocument({
id: 'direct',
title: 'Auth notes',
secondaryTexts: [],
worktreeName: 'Aurora migration',
branch: 'main',
repoName: 'repo'
})
containerEntry.document = buildPaletteTabDocument({
id: 'container',
title: 'Unrelated notes',
secondaryTexts: [],
worktreeName: 'Auth Aurora',
branch: 'main',
repoName: 'repo'
})
const results = searchWorkspaceTabs([containerEntry, directEntry], 'auth aurora')
expect(results.map((result) => result.tabId)).toEqual(['direct-tab', 'container-tab'])
expect(results.map((result) => result.rank?.containerOnlyTokenCount)).toEqual([1, 2])
})
it('ranks direct tab title matches ahead of container-only worktree matches', () => {
const directEntry = makeEntry({ id: 'README-4360' })
const containerEntry = makeEntry({ id: 'unrelated-file' })
@@ -175,9 +228,9 @@ describe('searchWorkspaceTabs ranking', () => {
const results = searchWorkspaceTabs([containerEntry, directEntry], '4360')
expect(results).toHaveLength(2)
expect(results[0].tabId).toBe('README-4360')
expect(results[0].rank?.matchedDirectField).toBe(0)
expect(results[0].rank?.containerOnlyTokenCount).toBe(0)
expect(results[1].tabId).toBe('unrelated-file')
expect(results[1].rank?.matchedDirectField).toBe(1)
expect(results[1].rank?.containerOnlyTokenCount).toBe(1)
})
it('breaks tie between two container-matching tabs using lastActiveAt recency', () => {
@@ -87,15 +87,13 @@ function positionScore(entry: SearchableWorkspaceTab): number {
}
function resolveWorkspaceTabLastActiveAt(entry: SearchableWorkspaceTab): number | null {
// Agent activity wins when present; then explicit tab focus; otherwise fall back to worktree.
const candidate =
maxAgentActivityAt(entry.agentMetadata) ??
entry.tab.lastFocusedAt ??
(entry.worktree.lastActivityAt || null)
// Why: explicit tab activity outranks the worktree fallback; creation only clamps stale signals.
const tabLocalActivity =
Math.max(maxAgentActivityAt(entry.agentMetadata) ?? 0, entry.tab.lastFocusedAt ?? 0) || null
const candidate = tabLocalActivity || entry.worktree.lastActivityAt || null
if (candidate == null) {
return null
}
// Never report activity older than the tab itself (e.g. a stale worktree signal).
return Math.max(candidate, entry.tab.createdAt)
}
@@ -38,7 +38,7 @@ export function buildWorktreePaletteTaskUrlResult(args: {
qualityClass: 'exact-intent',
rank: {
exactIntent: 0,
matchedDirectField: 0,
containerOnlyTokenCount: 0,
wholeQuery: 0,
worstQuality: 0,
usesSupportingEvidence: 1,
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
getWorktreeVisitKey,
getWorktreeVisitTimestamp,
removeWorktreeVisitEntries
} from './worktree-visit-recency'
describe('worktree visit recency identities', () => {
it('keeps same-id host twins independent when reading and writing keys', () => {
const id = 'repo::/srv/app'
const localKey = getWorktreeVisitKey(id, 'local')
const sshKey = getWorktreeVisitKey(id, 'ssh:builder')
const timestamps = { [localKey]: 100, [sshKey]: 200 }
expect(localKey).toBe(`local|${id}`)
expect(sshKey).toBe(`ssh:builder|${id}`)
expect(getWorktreeVisitTimestamp(timestamps, { id, hostId: 'local' })).toBe(100)
expect(getWorktreeVisitTimestamp(timestamps, { id, hostId: 'ssh:builder' })).toBe(200)
})
it('falls back to a legacy bare key only when the host key is absent', () => {
const id = 'repo::/srv/app'
expect(getWorktreeVisitTimestamp({ [id]: 100 }, { id, hostId: 'ssh:builder' })).toBe(100)
expect(
getWorktreeVisitTimestamp(
{ [id]: 100, [getWorktreeVisitKey(id, 'ssh:builder')]: 200 },
{ id, hostId: 'ssh:builder' }
)
).toBe(200)
})
it('removes one host key without deleting the other host twin or legacy fallback', () => {
const id = 'repo::/srv/app'
const timestamps = {
[id]: 50,
[getWorktreeVisitKey(id, 'local')]: 100,
[getWorktreeVisitKey(id, 'ssh:builder')]: 200
}
expect(removeWorktreeVisitEntries(timestamps, new Set([id]), 'local')).toEqual({
[id]: 50,
[getWorktreeVisitKey(id, 'ssh:builder')]: 200
})
})
})
@@ -0,0 +1,103 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
import {
composeWorktreeHostIdentity,
getWorktreeHostIdentity,
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import type { Worktree } from '../../../shared/worktree/types'
/** The persisted focus-recency map is host-qualified for new writes. */
export type WorktreeVisitOwner = Pick<Worktree, 'id' | 'hostId'>
/**
* Read recency for one visible row.
*
* The bare lookup is deliberately second: it keeps sessions written before
* host-qualified recency useful without letting a legacy value override a
* newer host-specific visit.
*/
export function getWorktreeVisitTimestamp(
timestamps: Readonly<Record<string, number>> | undefined,
worktree: WorktreeVisitOwner
): number | undefined {
if (!timestamps) {
return undefined
}
const qualified = timestamps[getWorktreeHostIdentity(worktree)]
return qualified ?? timestamps[worktree.id]
}
/**
* Key used for a newly stamped visit. Unknown-host rows keep the legacy bare
* key because there is no safe host qualification to persist; once a row has
* host evidence, all new writes use the canonical `${host}|${id}` form.
*/
export function getWorktreeVisitKey(worktreeId: string, hostId?: ExecutionHostId): string {
return hostId ? composeWorktreeHostIdentity(hostId, worktreeId) : worktreeId
}
/**
* Decode only keys that use the canonical host-qualified shape. Bare legacy
* ids are returned unchanged, including ids that happen to contain `|`.
*/
export function getWorktreeIdFromVisitKey(key: string): string {
const separator = key.indexOf('|')
if (separator === -1) {
return key
}
// Empty host is the canonical unknown-host bucket. Known host ids are the
// other valid prefixes; a legacy worktree id may itself contain `|`.
if (isWorktreeHostIdentity(key)) {
return getWorktreeIdFromHostIdentity(key)
}
return key
}
export function isHostQualifiedVisitKey(key: string): boolean {
return getWorktreeIdFromVisitKey(key) !== key
}
/** Remove all recency entries belonging to one or more raw worktree ids. */
export function removeWorktreeVisitEntries(
timestamps: Readonly<Record<string, number>>,
worktreeIds: ReadonlySet<string>,
hostId?: ExecutionHostId
): Record<string, number> {
return removeWorktreeVisitEntriesForTargets(
timestamps,
[...worktreeIds].map((id) => ({ id, hostId }))
)
}
/**
* Remove recency for bulk purge targets. A host-qualified target only removes
* that host's key; an unknown-host target deliberately retains the legacy
* raw-id behavior because no owner can safely be inferred.
*/
export function removeWorktreeVisitEntriesForTargets(
timestamps: Readonly<Record<string, number>>,
targets: readonly WorktreeVisitOwner[]
): Record<string, number> {
let changed = false
const next: Record<string, number> = {}
const qualified = new Set<string>()
const legacyIds = new Set<string>()
for (const target of targets) {
if (target.hostId) {
qualified.add(getWorktreeVisitKey(target.id, target.hostId))
} else {
legacyIds.add(target.id)
}
}
for (const [key, timestamp] of Object.entries(timestamps)) {
const rawId = getWorktreeIdFromVisitKey(key)
const remove = qualified.has(key) || legacyIds.has(rawId)
if (remove) {
changed = true
continue
}
next[key] = timestamp
}
return changed ? next : (timestamps as Record<string, number>)
}
@@ -2343,6 +2343,15 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
// Why: `updatedAt` cannot order two writes inside one millisecond — and the accept check
// above admits equal timestamps — so a deferred process-exit drop needs a token ordered by
// construction to tell "the pane reported again" from "an unrelated field moved". Every
// field-level rewrite of a row spreads it forward, so only a real report re-stamps it.
//
// Derived from the row it replaces, not from a module counter: there is then nothing for a
// sibling teardown path to reset (the bug this replaced), and a batched burst lands the
// same ordinals as the equivalent sequential writes.
acceptedStatusSeq: (existing?.acceptedStatusSeq ?? 0) + 1,
// Why: never inherited from `existing` — an unstamped write is an unstamped
// observation, not the previous one repeated.
...(payload.observation ? { observation: payload.observation } : {}),
@@ -257,6 +257,11 @@ describe('purgeStaleRuntimeHostState', () => {
]
},
tabsByWorktree: { [worktreeId]: tabs },
lastVisitedAtByWorktreeId: {
[worktreeId]: 50,
[`${RUNTIME_A}|${worktreeId}`]: 100,
[`${RUNTIME_B}|${worktreeId}`]: 200
},
restoredRuntimeHostIdByWorkspaceSessionKey: { [worktreeId]: RUNTIME_B },
activeWorktreeId: worktreeId,
activeWorkspaceKey: worktreeWorkspaceKey(worktreeId)
@@ -269,6 +274,10 @@ describe('purgeStaleRuntimeHostState', () => {
expect(s.worktreesByRepo.shared).toHaveLength(1)
expect(s.worktreesByRepo.shared[0]?.hostId).toBe(RUNTIME_B)
expect(s.tabsByWorktree[worktreeId]).toBe(tabs)
expect(s.lastVisitedAtByWorktreeId).toEqual({
[worktreeId]: 50,
[`${RUNTIME_B}|${worktreeId}`]: 200
})
expect(s.restoredRuntimeHostIdByWorkspaceSessionKey).toBe(restoredOwners)
expect(s.restoredRuntimeHostIdByWorkspaceSessionKey[worktreeId]).toBe(RUNTIME_B)
expect(s.activeWorktreeId).toBe(worktreeId)
@@ -366,6 +366,31 @@ describe('repo slice host identity routing', () => {
expect(ptyKill).not.toHaveBeenCalledWith('remote-pty')
})
it('preserves qualified recency for an exact-id sibling host', async () => {
const worktreeId = 'same-repo::/shared/wt'
const localWorktree = makeWorktree({ id: worktreeId, repoId: 'same-repo' })
const remoteWorktree = makeWorktree({
id: worktreeId,
repoId: 'same-repo',
hostId: 'runtime:env-1'
})
const store = createTestStore()
store.setState({
repos: [localDuplicate, remoteDuplicate],
worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] },
lastVisitedAtByWorktreeId: {
[`local|${worktreeId}`]: 10,
[`runtime:env-1|${worktreeId}`]: 20
}
})
await store.getState().removeProject('same-repo', { hostId: 'local' })
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({
[`runtime:env-1|${worktreeId}`]: 20
})
})
it('removeProject with an explicit hostId routes to that host, not the focused one', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-explicit-host',
+23 -3
View File
@@ -50,6 +50,7 @@ import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/man
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import { getWorktreeIdFromVisitKey, getWorktreeVisitKey } from '@/lib/worktree-visit-recency'
import { structuralValuesEqual } from '../../../../shared/structural-value-equality'
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
import {
@@ -3700,6 +3701,20 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// Kill PTYs for all worktrees belonging to this repo
const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId)
// A raw id can be published by two hosts. Keep the purge host-scoped for
// those twins so the sibling's qualified visit recency survives.
const knownRepoWorktrees = [
...(get().worktreesByRepo[projectId] ?? []),
...(get().detectedWorktreesByRepo[projectId]?.worktrees ?? [])
]
const exactSiblingIds = new Set(
knownRepoWorktrees
.filter((worktree) => !worktreeBelongsToHost(worktree, ownerHostId))
.map((worktree) => worktree.id)
)
const purgeTargets = worktreeIds.map((id) =>
exactSiblingIds.has(id) ? { id, hostId: ownerHostId } : id
)
const localAgentContextProjectIds =
ownerHostId === LOCAL_EXECUTION_HOST_ID
? [
@@ -3735,7 +3750,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
// Why: use the canonical per-worktree purge to evict all worktree-scoped maps (hand-deletion leaked most); runs before the set() below so it still sees tabsByWorktree.
get().purgeWorktreeTerminalState(worktreeIds)
get().purgeWorktreeTerminalState(purgeTargets)
get().clearLocalDetectedAgentContextsForProjects(localAgentContextProjectIds)
set((s) => {
@@ -3774,6 +3789,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
// Why: editor state is worktree-scoped; clear the repo's open files + active-file tracking so orphans don't linger in the session save.
const worktreeIdSet = new Set(worktreeIds)
const removedVisitKeys = new Set(
worktreeIds.map((worktreeId) => getWorktreeVisitKey(worktreeId, ownerHostId))
)
const nextOpenFiles = s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId))
const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
@@ -3789,9 +3807,11 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const repoIdFullyRemoved = !nextRepos.some((r) => r.id === projectId)
let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId
for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) {
const rawId = getWorktreeIdFromVisitKey(id)
if (
worktreeIdSet.has(id) ||
(repoIdFullyRemoved && getRepoIdFromWorktreeId(id) === projectId)
(ownerHostId && removedVisitKeys.has(id)) ||
(!ownerHostId && worktreeIdSet.has(rawId)) ||
(repoIdFullyRemoved && getRepoIdFromWorktreeId(rawId) === projectId)
) {
if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) {
nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId }
@@ -43,6 +43,7 @@ import {
throwIfWorkspaceCleanupScanSuperseded
} from './workspace-cleanup-broad-scan-registry'
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup'
export type WorkspaceCleanupFailure = {
@@ -821,7 +822,12 @@ function getInitialWorkspaceCleanupGitDeferrals(state: AppState): string[] {
const hasVisibleContext =
openEditorWorktreeIds.has(worktreeId) ||
(state.browserTabsByWorktree[worktreeId]?.length ?? 0) > 0
const lastVisitedAt = state.lastVisitedAtByWorktreeId[worktreeId] ?? 0
// Why: enrichment state may be a plain snapshot without slice methods.
const lastVisitedAt =
getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, {
id: worktreeId,
hostId: state.getKnownWorktreeById?.(worktreeId)?.hostId
}) ?? 0
if (
hasVisibleContext &&
lastVisitedAt > 0 &&
@@ -991,7 +997,11 @@ function getWorkspaceCleanupLocalStateSignature(
browserTabCount: (state.browserTabsByWorktree[worktreeId] ?? []).length,
retainedDoneAgentPaneKeys,
agentStatuses,
lastVisitedAt: state.lastVisitedAtByWorktreeId[worktreeId] ?? 0,
lastVisitedAt:
getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, {
id: worktreeId,
hostId: getWorkspaceCleanupCandidateHostId(candidate)
}) ?? 0,
viewed: state.workspaceCleanupViewedCandidates[worktreeId] ?? null,
dismissal
})
@@ -1036,7 +1046,11 @@ async function enrichWorkspaceCleanupCandidate(
blockers.push('terminal-liveness-unknown')
}
const lastVisitedAt = state.lastVisitedAtByWorktreeId[candidate.worktreeId] ?? 0
const lastVisitedAt =
getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, {
id: candidate.worktreeId,
hostId: getWorkspaceCleanupCandidateHostId(candidate)
}) ?? 0
const hasVisibleContext = cleanEditorTabCount > 0 || browserTabCount > 0
if (
hasVisibleContext &&
@@ -0,0 +1,15 @@
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { WorktreeForceDeleteReason } from '../../../../shared/worktree/removal'
import type { Worktree } from '../../../../shared/worktree/types'
export type WorktreeDeleteState = {
isDeleting: boolean
phase?: 'deleting' | 'queued'
executionHostId?: ExecutionHostId | null
error: string | null
canForceDelete: boolean
forceDeleteReason: WorktreeForceDeleteReason | null
lockReason?: string | null
}
export type WorktreeDeleteStateTarget = Pick<Worktree, 'id' | 'hostId'>
@@ -24,10 +24,7 @@ import type {
Worktree
} from '../../../../shared/worktree/types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type {
WorktreeForceDeleteReason,
WorktreeRemovalTarget
} from '../../../../shared/worktree/removal'
import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { RemoveWorktreeOptions } from './worktree-removal-options'
@@ -43,20 +40,12 @@ import type {
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type { AppState } from '../types'
import type { WorktreeRefreshAllOptions } from './worktree-refresh-options'
export type { WorktreePurgeTarget, WorktreePurgeTargets } from './worktree-purge-target'
import type { WorktreePurgeTargets } from './worktree-purge-target'
export type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree-delete-state-types'
import type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree-delete-state-types'
export { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
export type WorktreeDeleteState = {
isDeleting: boolean
phase?: 'deleting' | 'queued'
executionHostId?: ExecutionHostId | null
error: string | null
canForceDelete: boolean
forceDeleteReason: WorktreeForceDeleteReason | null
lockReason?: string | null
}
export type WorktreeDeleteStateTarget = Pick<Worktree, 'id' | 'hostId'>
import type { RendererRemoveWorktreeResult } from './renderer-remove-worktree-result'
export type WorktreeFetchOptions = {
@@ -147,6 +136,7 @@ export type WorktreeSlice = {
* (activateAndRevealWorktree), NOT from background activity events or raw
* `setActiveWorktree` calls. See docs/cmd-j-empty-query-ordering.md.
*/
/** New host-qualified rows use `${host}|${worktreeId}`; legacy bare ids remain readable. */
lastVisitedAtByWorktreeId: Record<string, number>
/**
* Guards the one-shot hydration-time purge in `fetchAllWorktrees`. Set to
@@ -296,7 +286,11 @@ export type WorktreeSlice = {
* stored value. Called from user-initiated activations only. See
* docs/cmd-j-empty-query-ordering.md.
*/
markWorktreeVisited: (worktreeId: string, visitedAt?: number) => void
markWorktreeVisited: (
worktreeId: string,
visitedAt?: number,
executionHostId?: ExecutionHostId
) => void
/**
* Drop `lastVisitedAtByWorktreeId` entries whose worktree IDs no longer
* exist. Must be called AFTER worktree hydration completes — repos load
@@ -337,7 +331,7 @@ export type WorktreeSlice = {
* Called by the `worktrees:changed` listener on server-side deletions and
* one-shot at hydration time. See design §4.4.
*/
purgeWorktreeTerminalState: (worktreeIds: string[]) => void
purgeWorktreeTerminalState: (worktreeTargets: WorktreePurgeTargets) => void
/**
* Retires every client-store row (repos, project host setups, worktree +
* detected-worktree rows, and their tab/PTY/browser/editor cascade) owned by a
@@ -0,0 +1,7 @@
import type { Worktree } from '../../../../shared/worktree/types'
/** A purge target with optional host ownership for host-scoped state. */
export type WorktreePurgeTarget = Pick<Worktree, 'id' | 'hostId'>
/** Strings remain supported for callers that only have a raw worktree id. */
export type WorktreePurgeTargets = readonly (string | WorktreePurgeTarget)[]
@@ -115,6 +115,18 @@ describe('setActiveWorktree focus handling', () => {
})
describe('markWorktreeVisited', () => {
it('stamps a host-qualified key when the worktree owner is known', () => {
const store = createTestStore()
const worktree = makeWorktree({ id: 'repo1::/hosted', repoId: 'repo1', hostId: 'ssh:builder' })
store.setState({ worktreesByRepo: { repo1: [worktree] } } as Partial<AppState>)
store.getState().markWorktreeVisited(worktree.id, 1000)
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({
[`ssh:builder|${worktree.id}`]: 1000
})
})
it('is monotonic: an older timestamp does not regress the stored value', () => {
const store = createTestStore()
store.getState().markWorktreeVisited('wt-1', 1000)
@@ -155,6 +167,28 @@ describe('markWorktreeVisited', () => {
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ 'repo1::/a': 100 })
})
it('prunes host-qualified recency by host while retaining a same-id twin', () => {
const store = createTestStore()
const id = 'repo1::/shared'
const local = makeWorktree({ id, repoId: 'repo1', path: '/shared', hostId: 'local' })
const ssh = makeWorktree({ id, repoId: 'repo1', path: '/shared', hostId: 'ssh:builder' })
store.setState({
worktreesByRepo: { repo1: [local, ssh] },
lastVisitedAtByWorktreeId: {
[`local|${id}`]: 100,
[`ssh:builder|${id}`]: 200,
'local|repo1::/gone': 300
}
} as Partial<AppState>)
store.getState().pruneLastVisitedTimestamps()
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({
[`local|${id}`]: 100,
[`ssh:builder|${id}`]: 200
})
})
it('pruneLastVisitedTimestamps preserves entries for not-yet-hydrated repos (e.g. SSH pre-connect)', () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a' })
@@ -162,7 +162,12 @@ export function mergeFetchedWorktrees(
}
}
: {}),
...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {})
...(removedIds.length > 0
? buildWorktreePurgeState(
s,
removedIds.map((id) => ({ id, hostId: args.hostId }))
)
: {})
}
})
if (admitted) {
@@ -1,6 +1,7 @@
import type { AppState } from '../../../types'
import { splitWorktreeIdForFilesystem } from '../../../../../../shared/worktree/id'
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { getWorktreeIdFromVisitKey } from '@/lib/worktree-visit-recency'
import {
remapClosedTerminalTabSnapshotCwds,
type ClosedTerminalTabSnapshot
@@ -41,7 +42,6 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [
'gitBranchCompareRequestStatusHeadByWorktree',
'showDotfilesByWorktree',
'expandedDirs',
'lastVisitedAtByWorktreeId',
'defaultTerminalTabsAppliedByWorktreeId',
'recentlyClosedTabKindsByWorktree'
] as const satisfies readonly (keyof AppState)[]
@@ -98,6 +98,24 @@ export function buildWorktreeRenameState(
for (const key of WORKTREE_ID_KEYED_MAP_KEYS) {
renameKey(key, renameValueByKey[key] as ((value: unknown) => unknown) | undefined)
}
// Recency keys may carry a host prefix. Preserve that prefix while moving
// the path-derived id so a rename cannot merge host twins.
const nextVisitRecency = { ...s.lastVisitedAtByWorktreeId }
let visitRecencyChanged = false
for (const [key, value] of Object.entries(s.lastVisitedAtByWorktreeId)) {
const rawId = getWorktreeIdFromVisitKey(key)
if (rawId !== oldWorktreeId) {
continue
}
const nextKey =
rawId === key ? newWorktreeId : `${key.slice(0, key.length - rawId.length)}${newWorktreeId}`
nextVisitRecency[nextKey] = value
delete nextVisitRecency[key]
visitRecencyChanged = true
}
if (visitRecencyChanged) {
renamed.lastVisitedAtByWorktreeId = nextVisitRecency
}
// Re-key on rename so a renamed worktree keeps its editor-undo + push/pull state.
renameKey('recentlyClosedEditorTabsByWorktree', (files: { worktreeId: string }[]) =>
files.map(withNewWorktreeId)
@@ -82,11 +82,14 @@ export function createPurgeWorktreeTerminalState(
set: WorktreeSliceSet,
_get: WorktreeSliceGet
): WorktreeSlice['purgeWorktreeTerminalState'] {
return (worktreeIds: string[]) => {
const purgeableWorktreeIds = worktreeIds.filter((id) => id !== FLOATING_TERMINAL_WORKTREE_ID)
if (purgeableWorktreeIds.length === 0) {
return (worktreeTargets) => {
const purgeableWorktreeTargets = worktreeTargets.filter((target) => {
const worktreeId = typeof target === 'string' ? target : target.id
return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID
})
if (purgeableWorktreeTargets.length === 0) {
return
}
set((s) => buildWorktreePurgeState(s, purgeableWorktreeIds))
set((s) => buildWorktreePurgeState(s, purgeableWorktreeTargets))
}
}
@@ -2,24 +2,39 @@ import type { WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import { getRepoIdFromWorktreeId } from '../../worktree-helpers'
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import {
getWorktreeIdFromVisitKey,
getWorktreeVisitKey,
getWorktreeVisitTimestamp
} from '@/lib/worktree-visit-recency'
export function createMarkWorktreeVisited(
set: WorktreeSliceSet,
_get: WorktreeSliceGet
): WorktreeSlice['markWorktreeVisited'] {
return (worktreeId, visitedAt) => {
return (worktreeId, visitedAt, executionHostId) => {
// Why: Cmd+J empty-query ordering needs a focus-recency signal distinct from lastActivityAt (background PTY/activity).
// Monotonic: CLI/IPC activations can race, so older timestamps must not regress. See docs/cmd-j-empty-query-ordering.md.
set((s) => {
const now = visitedAt ?? Date.now()
const prev = s.lastVisitedAtByWorktreeId[worktreeId] ?? 0
const ownerHostId =
executionHostId ??
(s.activeWorktreeId === worktreeId ? s.activeWorkspaceExecutionHostId : undefined) ??
s.getKnownWorktreeById(worktreeId, executionHostId)?.hostId
const visitKey = getWorktreeVisitKey(worktreeId, ownerHostId)
const prev =
getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, {
id: worktreeId,
hostId: ownerHostId
}) ?? 0
if (!(now > prev)) {
return {}
}
return {
lastVisitedAtByWorktreeId: {
...s.lastVisitedAtByWorktreeId,
[worktreeId]: now
[visitKey]: now
}
}
})
@@ -35,30 +50,41 @@ export function createPruneLastVisitedTimestamps(
// Why: prune per-repo, not globally — SSH repos aren't hydrated at startup, so a global prune would wipe SSH focus-recency.
// Only drop for repos with a populated/authoritative list; a missing repoId means not-yet-hydrated (defer).
const validIdsByRepo = new Map<string, Set<string>>()
const validWorktreeIds = new Set<string>()
const validVisitKeys = new Set<string>()
const addValidWorktree = (worktree: { id: string; hostId?: ExecutionHostId }): void => {
validWorktreeIds.add(worktree.id)
validVisitKeys.add(getWorktreeVisitKey(worktree.id, worktree.hostId))
}
for (const [repoId, list] of Object.entries(s.worktreesByRepo)) {
// An empty list is the not-yet-hydrated shape, not an authoritative "no worktrees".
if (s.detectedWorktreesByRepo[repoId] || list.length === 0) {
continue
}
validIdsByRepo.set(repoId, new Set(list.map((worktree) => worktree.id)))
list.forEach(addValidWorktree)
}
for (const [repoId, result] of Object.entries(s.detectedWorktreesByRepo)) {
if (result.authoritative) {
validIdsByRepo.set(repoId, new Set(result.worktrees.map((worktree) => worktree.id)))
result.worktrees.forEach(addValidWorktree)
}
}
let changed = false
const next: Record<string, number> = {}
for (const [id, ts] of Object.entries(s.lastVisitedAtByWorktreeId)) {
for (const [key, ts] of Object.entries(s.lastVisitedAtByWorktreeId)) {
const id = getWorktreeIdFromVisitKey(key)
const repoId = getRepoIdFromWorktreeId(id)
const repoIds = validIdsByRepo.get(repoId)
if (!repoIds) {
// Repo not yet hydrated (e.g. SSH not connected). Keep the entry.
next[id] = ts
next[key] = ts
continue
}
if (repoIds.has(id)) {
next[id] = ts
// Legacy bare keys remain valid when any host still publishes the id;
// qualified keys must match the exact host-qualified row.
if (getWorktreeIdFromVisitKey(key) === key ? repoIds.has(id) : validVisitKeys.has(key)) {
next[key] = ts
} else {
changed = true
}
@@ -113,13 +139,15 @@ export function createSeedActiveWorktreeLastVisitedIfMissing(
if (!id) {
return {}
}
if (s.lastVisitedAtByWorktreeId[id] != null) {
const hostId = s.activeWorkspaceExecutionHostId ?? s.getKnownWorktreeById(id)?.hostId
const key = getWorktreeVisitKey(id, hostId)
if (getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, { id, hostId }) != null) {
return {}
}
return {
lastVisitedAtByWorktreeId: {
...s.lastVisitedAtByWorktreeId,
[id]: Date.now()
[key]: Date.now()
}
}
})
@@ -1,4 +1,4 @@
import type { WorktreeSlice } from '../../worktree-helpers'
import type { WorktreePurgeTarget, WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import type { ProjectHostSetup } from '../../../../../../shared/project-types'
import type { Repo } from '../../../../../../shared/repo-types'
@@ -15,6 +15,7 @@ import {
isRemovedRuntimeHostId
} from '../../stale-runtime-host-rows'
import { buildWorktreePurgeState } from './worktree-purge-state'
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
export function createPurgeStaleRuntimeHostState(
set: WorktreeSliceSet,
@@ -86,6 +87,8 @@ export function createPurgeStaleRuntimeHostState(
recordWorktreeOwners(s.worktreesByRepo)
recordWorktreeOwners(detectedRows)
const removedWorktreeTargets: WorktreePurgeTarget[] = []
const seenRemovedWorktreeTargets = new Set<string>()
const sessionWorktreeIdsOwnedByRemovedHosts = new Set<string>()
let survivingRestoredSessionOwners = s.restoredRuntimeHostIdByWorkspaceSessionKey
for (const [workspaceKey, hostId] of Object.entries(
@@ -103,6 +106,12 @@ export function createPurgeStaleRuntimeHostState(
continue
}
sessionWorktreeIdsOwnedByRemovedHosts.add(worktreeId)
// Restored session ownership is authoritative even before catalog rows hydrate.
const identity = `${hostId}\u0000${worktreeId}`
if (!seenRemovedWorktreeTargets.has(identity)) {
seenRemovedWorktreeTargets.add(identity)
removedWorktreeTargets.push({ id: worktreeId, hostId })
}
repoIdsWithRemovedOwners.add(repoId)
if (survivingRestoredSessionOwners === s.restoredRuntimeHostIdByWorkspaceSessionKey) {
survivingRestoredSessionOwners = { ...survivingRestoredSessionOwners }
@@ -116,6 +125,55 @@ export function createPurgeStaleRuntimeHostState(
repoIdsWithoutSurvivingOwners.delete(repoId)
}
// Preserve host ownership for recency teardown. The other bulk maps are
// keyed by raw worktree id, but a host-qualified visit must not be
// removed when an id twin survives on another host.
const addRemovedWorktreeTarget = (
worktreeId: string,
hostId: ExecutionHostId | undefined
): void => {
const identity = `${hostId ?? ''}\u0000${worktreeId}`
if (seenRemovedWorktreeTargets.has(identity)) {
return
}
seenRemovedWorktreeTargets.add(identity)
removedWorktreeTargets.push(hostId ? { id: worktreeId, hostId } : { id: worktreeId })
}
const recordRemovedRowTargets = (
rowsByRepo: Record<
string,
readonly {
id: string
hostId?: ExecutionHostId
runtimeOwnerEnvironmentId?: string
}[]
>
): void => {
for (const [repoId, rows] of Object.entries(rowsByRepo)) {
for (const row of rows) {
const removedOwner =
(row.runtimeOwnerEnvironmentId !== undefined &&
removed.has(row.runtimeOwnerEnvironmentId)) ||
isRemovedRuntimeHostId(row.hostId, removed) ||
(row.hostId === undefined && repoIdsWithoutSurvivingOwners.has(repoId))
if (!removedOwner) {
continue
}
// A row's execution host is the recency owner. Runtime ownership
// is the only host evidence available for legacy rows without it.
addRemovedWorktreeTarget(
row.id,
row.hostId ??
(row.runtimeOwnerEnvironmentId
? toRuntimeExecutionHostId(row.runtimeOwnerEnvironmentId)
: undefined)
)
}
}
}
recordRemovedRowTargets(s.worktreesByRepo)
recordRemovedRowTargets(detectedRows)
const worktreeDrop = dropWorktreeRowsForRemovedRuntimeEnvironments(
s.worktreesByRepo,
removed,
@@ -163,8 +221,24 @@ export function createPurgeStaleRuntimeHostState(
}
}
}
const purgeState =
removedWorktreeIds.size > 0 ? buildWorktreePurgeState(s, [...removedWorktreeIds]) : {}
const purgeTargets = [...removedWorktreeIds].flatMap((worktreeId) => {
const targets = removedWorktreeTargets.filter((target) => target.id === worktreeId)
return targets.length > 0 ? targets : [{ id: worktreeId }]
})
const purgeState = purgeTargets.length > 0 ? buildWorktreePurgeState(s, purgeTargets) : {}
const visitPurgeTargets = [
...removedWorktreeTargets,
...purgeTargets.filter(
(target) => !removedWorktreeTargets.some((candidate) => candidate.id === target.id)
)
]
const nextLastVisitedAtByWorktreeId = removeWorktreeVisitEntriesForTargets(
s.lastVisitedAtByWorktreeId,
visitPurgeTargets
)
if (nextLastVisitedAtByWorktreeId !== s.lastVisitedAtByWorktreeId) {
purgeState.lastVisitedAtByWorktreeId = nextLastVisitedAtByWorktreeId
}
const restoredSessionOwnersChanged =
survivingRestoredSessionOwners !== s.restoredRuntimeHostIdByWorkspaceSessionKey
@@ -1,11 +1,14 @@
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { WorktreeSliceSet } from '../listing/worktree-slice-types'
import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
import { removeWorktreeVisitEntries } from '@/lib/worktree-visit-recency'
export function applyRemoveWorktreeSuccessState(
set: WorktreeSliceSet,
worktreeId: string,
tabIds: Set<string>
tabIds: Set<string>,
executionHostId?: ExecutionHostId
): void {
set((s) => {
const next = { ...s.worktreesByRepo }
@@ -147,14 +150,11 @@ export function applyRemoveWorktreeSuccessState(
const nextEverActivatedWorktreeIds = s.everActivatedWorktreeIds.has(worktreeId)
? new Set([...s.everActivatedWorktreeIds].filter((id) => id !== worktreeId))
: s.everActivatedWorktreeIds
const nextLastVisitedAtByWorktreeId =
worktreeId in s.lastVisitedAtByWorktreeId
? (() => {
const next = { ...s.lastVisitedAtByWorktreeId }
delete next[worktreeId]
return next
})()
: s.lastVisitedAtByWorktreeId
const nextLastVisitedAtByWorktreeId = removeWorktreeVisitEntries(
s.lastVisitedAtByWorktreeId,
new Set([worktreeId]),
executionHostId
)
return {
worktreesByRepo: next,
worktreeLineageById: nextLineage,
@@ -59,7 +59,7 @@ export async function tearDownRemovedWorktreeRendererState(args: {
// Why: dispose parked terminal watchers only on explicit deletion; identity migration/remounts must keep buffered PTY state.
disposeRemovedWorktreeParkedTerminalWatchers(worktreeId, terminalPtyIdsBeforeRemoval)
applyRemoveWorktreeSuccessState(set, worktreeId, tabIds)
applyRemoveWorktreeSuccessState(set, worktreeId, tabIds, requiredExecutionHostId ?? hostId)
get().removeWorkspaceSpaceWorktrees?.(
hostId ? [{ id: worktreeId, executionHostId: hostId }] : [worktreeId]
)
@@ -1,13 +1,21 @@
import type { AppState } from '../../../types'
import type { WorktreePurgeTarget, WorktreePurgeTargets } from '../../worktree-helpers'
import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals'
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation'
import { collectWorktreePurgeDoomedIds } from './worktree-purge-doomed-ids'
import { createWorktreePurgeOmitters } from './worktree-purge-omitters'
import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
export function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<AppState> {
const worktreeIdSet = new Set(worktreeIds)
export function buildWorktreePurgeState(
s: AppState,
worktreeTargets: WorktreePurgeTargets
): Partial<AppState> {
const normalizedTargets: WorktreePurgeTarget[] = worktreeTargets.map((target) =>
typeof target === 'string' ? { id: target } : target
)
const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id))
pruneHostedReviewLinkMutationGenerations(worktreeIdSet)
// Why: every authoritative and explicit purge converges here, so a deleted path can't inherit stale UI state.
forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet)
@@ -172,7 +180,10 @@ export function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Par
// Top-level actives
openFiles: nextOpenFiles,
everActivatedWorktreeIds: nextEverActivatedWorktreeIds,
lastVisitedAtByWorktreeId: omitByWorktree(s.lastVisitedAtByWorktreeId),
lastVisitedAtByWorktreeId: removeWorktreeVisitEntriesForTargets(
s.lastVisitedAtByWorktreeId,
normalizedTargets
),
// Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (write-once default-terminal guard).
defaultTerminalTabsAppliedByWorktreeId: omitByWorktree(
s.defaultTerminalTabsAppliedByWorktreeId
+1
View File
@@ -945,6 +945,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
onLegacyWorkerTerminalRecovery: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
drop: () => {},
reconcileEndedProcess: () => {},
dropByTabPrefix: () => {},
retirePaneAuthority: () => {},
restorePaneAuthority: () => {},
@@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest'
import {
createHookListenerState,
normalizeHookPayload,
type HookListenerState
} from './agent-hook-listener'
import { makePaneKey } from './stable-pane-id'
const LEAF_ID = '44444444-4444-4444-8444-444444444444'
const SESSION_A = 'session-a'
const SESSION_B = 'session-b'
function claudeEvent(
state: HookListenerState,
paneKey: string,
payload: Record<string, unknown>
): ReturnType<typeof normalizeHookPayload> {
return normalizeHookPayload(state, 'claude', { paneKey, payload }, 'production')
}
/** Close a turn for `sessionId`, carrying whatever inventory the caller wants folded. */
function stop(
state: HookListenerState,
paneKey: string,
sessionId: string,
extra: Record<string, unknown> = {}
): ReturnType<typeof normalizeHookPayload> {
return claudeEvent(state, paneKey, {
hook_event_name: 'Stop',
session_id: sessionId,
...extra
})
}
// A replacement session is the only evidence Orca gets for the exits Claude emits no terminating
// hook for (/clear, relaunch, resume). See PLAN-STA-4612 §4.1.
describe('Claude session replacement voids the replaced session claims', () => {
it('voids a session cron gate held by the previous session', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('cron-gate', LEAF_ID)
stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true)
const replaced = stop(state, paneKey, SESSION_B)
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(false)
expect(replaced?.payload.state).toBe('done')
})
it('voids a one-shot subagent the previous session was still running', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('roster-void', LEAF_ID)
claudeEvent(state, paneKey, {
hook_event_name: 'UserPromptSubmit',
session_id: SESSION_A,
prompt: 'go'
})
claudeEvent(state, paneKey, {
hook_event_name: 'SubagentStart',
session_id: SESSION_A,
agent_id: 'achild-0000000000000001'
})
expect(state.claudeSubagentRosterByPaneKey.get(paneKey)?.size).toBe(1)
const replaced = stop(state, paneKey, SESSION_B)
expect(state.claudeSubagentRosterByPaneKey.has(paneKey)).toBe(false)
expect(replaced?.payload.state).toBe('done')
expect(replaced?.payload.subagents ?? []).toEqual([])
})
it('keeps a confirmed teammate across the replacement — a lead swap cannot end an in-process teammate', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('teammate-kept', LEAF_ID)
// Anchor the owning session on a LEAD event first. Subagent/teammate events branch out of
// normalizeClaudeEvent before the void ever runs, so without this the replacement below finds
// no previous owner, returns early, and the assertion certifies a guard it never exercised.
claudeEvent(state, paneKey, {
hook_event_name: 'UserPromptSubmit',
session_id: SESSION_A,
prompt: 'spin up the team'
})
claudeEvent(state, paneKey, {
hook_event_name: 'SubagentStart',
session_id: SESSION_A,
agent_id: 'arev-0000000000000001',
agent_type: 'rev'
})
claudeEvent(state, paneKey, {
hook_event_name: 'TeammateIdle',
session_id: SESSION_A,
teammate_name: 'rev'
})
claudeEvent(state, paneKey, {
hook_event_name: 'SubagentStart',
session_id: SESSION_A,
agent_id: 'arev-0000000000000001',
agent_type: 'rev'
})
const tracked = state.claudeSubagentRosterByPaneKey.get(paneKey)?.get('arev-0000000000000001')
expect(tracked?.confirmedTeammate).toBe(true)
expect(tracked?.state).toBe('working')
expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_A)
stop(state, paneKey, SESSION_B)
// The replacement really happened, so the surviving row below is the guard's doing.
expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_B)
expect(
state.claudeSubagentRosterByPaneKey.get(paneKey)?.get('arev-0000000000000001')?.state
).toBe('working')
})
it('KEEPS a running background-shell gate across the replacement', () => {
// /clear keeps the Claude process alive, so a background shell can outlive the conversation and
// the previous inventory is positive evidence it was running. Retiring it here would decide the
// open live-shell product question inside this change. See PLAN-STA-4612 §6.3.
const state = createHookListenerState()
const paneKey = makePaneKey('bg-gate-kept', LEAF_ID)
stop(state, paneKey, SESSION_A, {
background_tasks: [{ type: 'bash', status: 'running', id: 'bash_1' }]
})
expect(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)).toBe(true)
const replaced = claudeEvent(state, paneKey, {
hook_event_name: 'PostToolUse',
session_id: SESSION_B
})
expect(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)).toBe(true)
expect(replaced?.payload.state).toBe('working')
})
it('voids nothing when the session id is unchanged', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('same-session', LEAF_ID)
stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
const same = stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true)
expect(same?.payload.state).toBe('working')
})
it('voids nothing when a CHILD event carries a foreign session id', () => {
// Claude has no equivalent of the Codex child-session guard, so a child payload's own
// session_id reaches the same extraction path as the lead's.
const state = createHookListenerState()
const paneKey = makePaneKey('child-session', LEAF_ID)
stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
claudeEvent(state, paneKey, {
hook_event_name: 'PostToolUse',
session_id: 'child-session',
agent_id: 'achild-0000000000000009'
})
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true)
expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_A)
})
it('voids nothing when the event carries no session id', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('no-session', LEAF_ID)
stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
stop(state, paneKey, '')
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true)
expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_A)
})
it('does not anchor an ignored compact SessionStart', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('compact-owner', LEAF_ID)
stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
expect(
claudeEvent(state, paneKey, {
hook_event_name: 'SessionStart',
session_id: SESSION_B,
source: 'compact'
})
).toBeNull()
stop(state, paneKey, SESSION_B)
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(false)
})
it('leaves the lead-state record to the incoming fold rather than deleting it', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('lead-untouched', LEAF_ID)
stop(state, paneKey, SESSION_A)
claudeEvent(state, paneKey, {
hook_event_name: 'UserPromptSubmit',
session_id: SESSION_B,
prompt: 'new conversation'
})
expect(state.claudeLeadStateByPaneKey.get(paneKey)?.state).toBe('working')
})
})
+110
View File
@@ -142,6 +142,10 @@ export type HookListenerState = {
claudeRunningNonAgentTaskPaneKeys: Set<string>
/** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */
claudeActiveSessionCronPaneKeys: Set<string>
/** Claude `session_id` that last reported on the pane from a LEAD event. A different id means the
* conversation was replaced (/clear, relaunch, resume), so claims the old session owned are void
* even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */
claudeSessionOwnerByPaneKey: Map<string, string>
/** Live thread-spawn children per Codex pane. */
codexSubagentRosterByPaneKey: Map<string, CodexSubagentRoster>
/** Incremental parent/child rollout cursors for Codex collaboration v2. */
@@ -182,6 +186,7 @@ export function createHookListenerState(): HookListenerState {
claudeUnconfirmedRestoredStatusPaneKeys: new Set(),
claudeRunningNonAgentTaskPaneKeys: new Set(),
claudeActiveSessionCronPaneKeys: new Set(),
claudeSessionOwnerByPaneKey: new Map(),
codexSubagentRosterByPaneKey: new Map(),
codexSubagentTranscriptByPaneKey: new Map(),
codexLeadStateByPaneKey: new Map()
@@ -199,11 +204,32 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey)
state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
state.claudeSessionOwnerByPaneKey.delete(paneKey)
state.codexSubagentRosterByPaneKey.delete(paneKey)
state.codexSubagentTranscriptByPaneKey.delete(paneKey)
state.codexLeadStateByPaneKey.delete(paneKey)
}
/** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that
* `resolveClaudePaneState` would re-gate `working` from on the pane's next event?
*
* Deliberately lives next to `clearPaneCacheState` above and enumerates the claim-bearing subset of
* what that function deletes: the two must be edited together, and keeping them three lines apart in
* one file is what makes that obvious. Prompt/tool/transcript caches are excluded — they render a
* row, they never create one. */
export function paneHasStateClaims(state: HookListenerState, paneKey: string): boolean {
return (
state.lastStatusByPaneKey.has(paneKey) ||
state.claudeSubagentRosterByPaneKey.has(paneKey) ||
state.claudeLeadStateByPaneKey.has(paneKey) ||
state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
state.claudeActiveSessionCronPaneKeys.has(paneKey) ||
state.claudeSessionOwnerByPaneKey.has(paneKey) ||
state.codexSubagentRosterByPaneKey.has(paneKey) ||
state.codexLeadStateByPaneKey.has(paneKey)
)
}
function movePaneScopedMapEntries<T>(
map: Map<string, T>,
fromPaneKey: string,
@@ -246,6 +272,7 @@ export function movePaneCacheState(
movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.claudeSessionOwnerByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey)
@@ -291,6 +318,7 @@ export function clearAllListenerCaches(state: HookListenerState): void {
state.claudeUnconfirmedRestoredStatusPaneKeys.clear()
state.claudeRunningNonAgentTaskPaneKeys.clear()
state.claudeActiveSessionCronPaneKeys.clear()
state.claudeSessionOwnerByPaneKey.clear()
state.codexSubagentRosterByPaneKey.clear()
state.codexSubagentTranscriptByPaneKey.clear()
state.codexLeadStateByPaneKey.clear()
@@ -2631,6 +2659,9 @@ function updateClaudeRunningNonAgentTask(
state: HookListenerState,
paneKey: string,
hasRunningNonAgentTask: boolean,
/** Lead-turn property. Pass `false` from any non-lead fold: an interrupt clears the gate even when
* the inventory positively reports a running shell, which is a live-shell judgement no new call
* site may inherit by copying this signature. */
interrupted: boolean
): void {
if (hasRunningNonAgentTask && !interrupted) {
@@ -2914,6 +2945,84 @@ function buildClaudeCachedLeadStatusPayload(
})
}
/** Lead events that may re-anchor a pane's owning session. Allow-list, not a deny-list: a payload we
* can't attribute (unknown name, child event missing its agent_id) must void nothing. */
const CLAUDE_SESSION_OWNER_EVENTS: ReadonlySet<string> = new Set([
'SessionStart',
'UserPromptSubmit',
'Stop',
'StopFailure',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionRequest'
])
/** A pane whose `session_id` changed is running a different conversation, so claims the previous one
* owned are void — the hook-independent backstop for /clear, relaunch and resume, which emit no
* terminating hook (SessionEnd covers about a third of exit paths).
*
* Voids only what the replaced session provably owned. Deliberately NOT voided:
* - `claudeRunningNonAgentTaskPaneKeys`: a background shell is an OS process that survives /clear,
* and the previous inventory is positive evidence it was running. Only a fresh inventory or a
* certified process death may retire it.
* - `confirmedTeammate` roster rows: persistent in-process teammates a lead replacement can't end.
* - `claudeLeadStateByPaneKey`: the caller's own fold overwrites it anyway. */
function voidClaimsOfReplacedClaudeSession(
state: HookListenerState,
eventName: unknown,
eventAgentId: string | undefined,
paneKey: string,
hookPayload: Record<string, unknown>
): void {
if (
eventAgentId !== undefined ||
typeof eventName !== 'string' ||
!CLAUDE_SESSION_OWNER_EVENTS.has(eventName)
) {
return
}
// Compact/unknown SessionStart events are intentionally ignored by the status fold; do not let
// them advance the owner anchor or the next real lead event will miss the replacement.
if (
eventName === 'SessionStart' &&
hookPayload['source'] !== 'startup' &&
hookPayload['source'] !== 'resume' &&
hookPayload['source'] !== 'clear'
) {
return
}
const sessionId = readString(hookPayload, 'session_id')
if (!sessionId) {
return
}
const previousOwner = state.claudeSessionOwnerByPaneKey.get(paneKey)
state.claudeSessionOwnerByPaneKey.set(paneKey, sessionId)
if (previousOwner === undefined || previousOwner === sessionId) {
return
}
// Why: a compact restart mints a SessionStart mid-turn under the same conversation; the existing
// handler already fails closed on non-idle sources, and this must not undercut it. No other
// allow-listed event carries a compact `trigger`, so SessionStart is the whole guard — if a
// compact event is ever added to CLAUDE_SESSION_OWNER_EVENTS, re-derive one for it deliberately.
if (eventName === 'SessionStart') {
return
}
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
if (!roster) {
return
}
for (const [id, tracked] of roster) {
if (tracked.confirmedTeammate !== true) {
roster.delete(id)
}
}
if (roster.size === 0) {
state.claudeSubagentRosterByPaneKey.delete(paneKey)
}
}
function normalizeClaudeEvent(
state: HookListenerState,
eventName: unknown,
@@ -2929,6 +3038,7 @@ function normalizeClaudeEvent(
) {
return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload)
}
voidClaimsOfReplacedClaudeSession(state, eventName, eventAgentId, paneKey, hookPayload)
if (eventName === 'SessionStart') {
// Why: SessionStart is the only signal a resumed session emits before its first prompt
// (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom
+11
View File
@@ -62,6 +62,17 @@ export type AgentStatusObservation = {
* body, so a hook or OSC writer cannot declare its own provenance. */
export type WithAgentStatusObservation = { observation?: AgentStatusObservation }
/** Renderer-local count of ACCEPTED status writes this pane's row has taken. Incremented only by
* the store's accept branch, off the row it replaces, and carried through by every field-level
* rewrite — so it answers "did the pane report again?", which `updatedAt` cannot, because the
* accept rule admits equal timestamps. Lives on the row rather than in a side table so no
* teardown path can reset it out from under a reader (STA-4612). Never sent over IPC or
* persisted to last-status.json.
*
* Declared here beside the observation facet because both are per-write facets mixed into
* `AgentStatusEntry` rather than fields a reporter supplies. */
export type AgentStatusRowFacets = WithAgentStatusObservation & { acceptedStatusSeq?: number }
// ─── THE ORDERING RULE ──────────────────────────────────────────────────────
// `(authorityId, incarnation, revision)` is a total order ONLY within one authorityId.
// A different authorityId means "incomparable", not "older" — the id is regenerated per
+2 -2
View File
@@ -3,7 +3,7 @@
// a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook.
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { WithAgentStatusObservation } from './agent-status-observation'
import type { AgentStatusRowFacets, WithAgentStatusObservation } from './agent-status-observation'
import {
normalizeInteractivePromptField,
normalizeOptionalField,
@@ -152,7 +152,7 @@ export type AgentStatusEntry = {
* the transition may have been missed while no receiver was up, so freshness gates
* treat the row as stale immediately. Cleared by any accepted live event. */
restoredUnconfirmed?: boolean
} & WithAgentStatusObservation
} & AgentStatusRowFacets
export type MigrationUnsupportedPtyEntry = {
ptyId: string
@@ -3,13 +3,21 @@ import type { RemoteWorkspaceSession, RemoteWorkspaceTerminalTab } from './remot
import type { TerminalTab } from './terminal-tab-types'
import type { WorkspaceSessionState } from './workspace-session-state-types'
import { splitWorktreeId } from './worktree/id'
import type { ExecutionHostId } from './execution-host'
import {
composeWorktreeHostIdentity,
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from './worktree/host-qualified-identity'
type ExportOptions = {
isTargetWorktree: (worktreeId: string) => boolean
isTargetWorktree: (worktreeId: string, executionHostId?: ExecutionHostId) => boolean
}
type ImportOptions = {
resolveWorktreeId: (worktreePath: string) => string | null
/** Host owning the projected snapshot; absent preserves the legacy bare key. */
executionHostId?: ExecutionHostId
}
function worktreePathFromId(worktreeId: string): string | null {
@@ -70,13 +78,23 @@ export function exportRemoteWorkspaceSession(
}
const lastVisitedAtByWorktreePath: Record<string, number> = {}
for (const [worktreeId, timestamp] of Object.entries(session.lastVisitedAtByWorktreeId ?? {})) {
if (!options.isTargetWorktree(worktreeId)) {
for (const [visitKey, timestamp] of Object.entries(session.lastVisitedAtByWorktreeId ?? {})) {
const worktreeId = isWorktreeHostIdentity(visitKey)
? getWorktreeIdFromHostIdentity(visitKey)
: visitKey
const executionHostId = isWorktreeHostIdentity(visitKey)
? (visitKey.slice(0, visitKey.indexOf('|')) as ExecutionHostId)
: undefined
if (!options.isTargetWorktree(worktreeId, executionHostId)) {
continue
}
const worktreePath = worktreePathFromId(worktreeId)
if (worktreePath) {
lastVisitedAtByWorktreePath[worktreePath] = timestamp
// Why max: a bare legacy key and its host-qualified twin collapse onto one path.
lastVisitedAtByWorktreePath[worktreePath] = Math.max(
lastVisitedAtByWorktreePath[worktreePath] ?? 0,
timestamp
)
}
}
@@ -101,7 +119,7 @@ export function exportRemoteWorkspaceSession(
)
),
activeWorktreePathsOnShutdown: session.activeWorktreeIdsOnShutdown
?.filter(options.isTargetWorktree)
?.filter((worktreeId) => options.isTargetWorktree(worktreeId))
.map(worktreePathFromId)
.filter((path): path is string => Boolean(path)),
activeTabIdByWorktreePath,
@@ -166,7 +184,10 @@ export function importRemoteWorkspaceSession(
)) {
const worktreeId = resolvePath(worktreePath)
if (worktreeId) {
lastVisitedAtByWorktreeId[worktreeId] = timestamp
const key = options.executionHostId
? composeWorktreeHostIdentity(options.executionHostId, worktreeId)
: worktreeId
lastVisitedAtByWorktreeId[key] = timestamp
}
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import { createDefaultWorkspaceCleanupFilterState } from './workspace-cleanup-filter-model'
import { listAppliedWorkspaceCleanupFilters } from './workspace-cleanup-applied-filters'
const FORMAT = {
idleDays: (d: number) => `Idle >= ${d}d`,
neverVisited: () => 'Never visited',
minSize: (b: number) => `At least ${b}B`,
maxSize: (b: number) => `At most ${b}B`,
excludesUnsized: () => 'Excludes unmeasured',
excludesStatusless: () => 'Excludes statusless',
list: (kind: string, count: number) => `${kind}:${count}`,
triState: (kind: string, mode: string) => `${kind}:${mode}`,
minAhead: (n: number) => `Ahead >= ${n}`,
minBehind: (n: number) => `Behind >= ${n}`,
branchQuery: (v: string) => `Branch ~ ${v}`,
pathPrefix: (v: string) => `Path ~ ${v}`,
presence: (kind: string, mode: string) => `${kind}:${mode}`,
completelyEmpty: () => 'Nothing to lose',
selectableOnly: () => 'Deletable only'
}
const list = (f: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>) =>
listAppliedWorkspaceCleanupFilters(f, FORMAT)
describe('listAppliedWorkspaceCleanupFilters', () => {
it('shows nothing for a default profile', () => {
expect(list(createDefaultWorkspaceCleanupFilterState())).toEqual([])
})
it('names the reported stuck filter rather than its group', () => {
// The bar always read "Showing 546 of 799"; what was missing was which filter.
const filters = createDefaultWorkspaceCleanupFilterState()
filters.activity.idleMinDays = 20
const applied = list(filters)
expect(applied).toHaveLength(1)
expect(applied[0].label).toBe('Idle >= 20d')
})
it('clears one constraint and leaves the rest of its own group alone', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.activity.idleMinDays = 20
filters.activity.neverVisited = true
const idle = list(filters).find((a) => a.id === 'activity.idleMinDays')
const next = idle!.clear(filters)
expect(next.activity.idleMinDays).toBeNull()
expect(next.activity.neverVisited).toBe(true)
})
it('never mutates the filters it is handed', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.location.pathPrefix = '/repos'
list(filters)[0].clear(filters)
expect(filters.location.pathPrefix).toBe('/repos')
})
describe('numeric zero', () => {
it('hides an inert zero minimum, which matches every row', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.activity.idleMinDays = 0
filters.size.minBytes = 0
filters.git.minAhead = 0
filters.git.minBehind = 0
expect(list(filters)).toEqual([])
})
it('shows a zero maximum, which hides every measured non-empty workspace', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.size.maxBytes = 0
expect(list(filters).map((a) => a.id)).toEqual(['size.maxBytes'])
})
})
it('ignores whitespace-only text', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.git.branchQuery = ' '
filters.location.pathPrefix = ' '
expect(list(filters)).toEqual([])
})
it('ignores parameter fields that constrain nothing on their own', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.activity.idleSignal = 'created'
filters.safety.blockerMode = 'any-of'
expect(list(filters)).toEqual([])
})
it('shows the permissive booleans only when switched off', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.size.includeUnsized = false
filters.status.matchStatusless = false
expect(list(filters).map((a) => a.id).sort()).toEqual([
'size.includeUnsized',
'status.matchStatusless'
])
})
it('gives every constraint a unique, stable id', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.activity.idleMinDays = 20
filters.size.maxBytes = 500
filters.status.archived = 'only'
filters.git.states = ['dirty']
filters.git.locked = 'exclude'
filters.location.repoIds = ['repo-1']
filters.safety.dismissed = 'only'
const ids = list(filters).map((a) => a.id)
expect(new Set(ids).size).toBe(ids.length)
expect(ids).toContain('status.archived')
expect(ids).toContain('git.locked')
})
it('clearing every chip in turn returns the profile to no constraints', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.activity.idleMinDays = 20
filters.size.maxBytes = 0
filters.size.includeUnsized = false
filters.status.matchStatusless = false
filters.git.branchQuery = 'release'
filters.location.pathPrefix = '/repos'
filters.safety.selectableOnly = true
let next = filters
for (const applied of list(filters)) {
next = applied.clear(next)
}
expect(list(next)).toEqual([])
})
it('leaves the search query alone, which chips do not own', () => {
const filters = createDefaultWorkspaceCleanupFilterState()
filters.query = 'release'
filters.activity.idleMinDays = 20
const next = list(filters)[0].clear(filters)
expect(next.query).toBe('release')
})
})
@@ -0,0 +1,255 @@
import {
createDefaultWorkspaceCleanupFilterState,
type WorkspaceCleanupFilterState
} from './workspace-cleanup-filter-model'
/**
* One constraint the user can see and remove from the filter bar.
*
* Deliberately per-field, not per-group: "Activity" tells a reader nothing, while
* "Idle >= 20d" names the thing that is hiding their workspaces. That naming *is* the
* fix — the reported defect was never that the effect was invisible (the bar always
* read "Showing 546 of 799"), it was that the cause was.
*/
export type WorkspaceCleanupAppliedFilter = {
/** Stable across renders and unique per constraint. */
id: string
/** Rendered on the chip. Already localized by the caller-supplied formatter. */
label: string
/** Returns `filters` with just this constraint reset to its default. */
clear: (filters: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState
}
type Formatters = {
idleDays: (days: number) => string
neverVisited: () => string
minSize: (bytes: number) => string
maxSize: (bytes: number) => string
excludesUnsized: () => string
excludesStatusless: () => string
list: (kind: string, count: number) => string
triState: (kind: string, mode: 'only' | 'exclude') => string
minAhead: (count: number) => string
minBehind: (count: number) => string
branchQuery: (value: string) => string
pathPrefix: (value: string) => string
presence: (kind: string, mode: string) => string
completelyEmpty: () => string
selectableOnly: () => string
}
/**
* Every constraint currently narrowing the fleet, in panel order.
*
* A field counts only when its matcher actually narrows. That is why minimums count
* above zero but `size.maxBytes` counts *at* zero: a `0` minimum matches every row,
* while "at most 0 MB" hides every measured non-empty workspace.
*/
export function listAppliedWorkspaceCleanupFilters(
filters: WorkspaceCleanupFilterState,
format: Formatters
): WorkspaceCleanupAppliedFilter[] {
const defaults = createDefaultWorkspaceCleanupFilterState()
const applied: WorkspaceCleanupAppliedFilter[] = []
const add = (
id: string,
label: string,
clear: (f: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState
): void => {
applied.push({ id, label, clear })
}
const { activity, size, status, agent, git, review, ticket, context, location, safety } = filters
if (activity.idleMinDays !== null && activity.idleMinDays > 0) {
add('activity.idleMinDays', format.idleDays(activity.idleMinDays), (f) => ({
...f,
activity: { ...f.activity, idleMinDays: null }
}))
}
if (activity.neverVisited) {
add('activity.neverVisited', format.neverVisited(), (f) => ({
...f,
activity: { ...f.activity, neverVisited: false }
}))
}
if (size.minBytes !== null && size.minBytes > 0) {
add('size.minBytes', format.minSize(size.minBytes), (f) => ({
...f,
size: { ...f.size, minBytes: null }
}))
}
if (size.maxBytes !== null) {
add('size.maxBytes', format.maxSize(size.maxBytes), (f) => ({
...f,
size: { ...f.size, maxBytes: null }
}))
}
if (!size.includeUnsized) {
add('size.includeUnsized', format.excludesUnsized(), (f) => ({
...f,
size: { ...f.size, includeUnsized: true }
}))
}
if (status.workspaceStatuses.length > 0) {
add('status.workspaceStatuses', format.list('status', status.workspaceStatuses.length), (f) => ({
...f,
status: { ...f.status, workspaceStatuses: [] }
}))
}
if (!status.matchStatusless) {
add('status.matchStatusless', format.excludesStatusless(), (f) => ({
...f,
status: { ...f.status, matchStatusless: true }
}))
}
for (const key of ['archived', 'pinned', 'unread', 'comment'] as const) {
const value = status[key]
if (value !== 'any') {
add(`status.${key}`, format.triState(key, value), (f) => ({
...f,
status: { ...f.status, [key]: 'any' }
}))
}
}
if (agent.states.length > 0) {
add('agent.states', format.list('agent', agent.states.length), (f) => ({
...f,
agent: { ...f.agent, states: [] }
}))
}
if (agent.retainedDoneAgents !== 'any') {
add('agent.retainedDoneAgents', format.triState('retainedAgents', agent.retainedDoneAgents), (f) => ({
...f,
agent: { ...f.agent, retainedDoneAgents: 'any' }
}))
}
if (git.states.length > 0) {
add('git.states', format.list('git', git.states.length), (f) => ({
...f,
git: { ...f.git, states: [] }
}))
}
if (git.minAhead !== null && git.minAhead > 0) {
add('git.minAhead', format.minAhead(git.minAhead), (f) => ({
...f,
git: { ...f.git, minAhead: null }
}))
}
if (git.minBehind !== null && git.minBehind > 0) {
add('git.minBehind', format.minBehind(git.minBehind), (f) => ({
...f,
git: { ...f.git, minBehind: null }
}))
}
if (git.branchQuery.trim().length > 0) {
add('git.branchQuery', format.branchQuery(git.branchQuery.trim()), (f) => ({
...f,
git: { ...f.git, branchQuery: '' }
}))
}
for (const key of ['prunable', 'locked'] as const) {
const value = git[key]
if (value !== 'any') {
add(`git.${key}`, format.triState(key, value), (f) => ({
...f,
git: { ...f.git, [key]: 'any' }
}))
}
}
if (review.presence !== 'any') {
add('review.presence', format.presence('review', review.presence), (f) => ({
...f,
review: { ...f.review, presence: defaults.review.presence }
}))
}
if (review.states.length > 0) {
add('review.states', format.list('reviewState', review.states.length), (f) => ({
...f,
review: { ...f.review, states: [] }
}))
}
if (review.providers.length > 0) {
add('review.providers', format.list('reviewProvider', review.providers.length), (f) => ({
...f,
review: { ...f.review, providers: [] }
}))
}
if (ticket.presence !== 'any') {
add('ticket.presence', format.presence('ticket', ticket.presence), (f) => ({
...f,
ticket: { ...f.ticket, presence: defaults.ticket.presence }
}))
}
if (ticket.sources.length > 0) {
add('ticket.sources', format.list('ticketSource', ticket.sources.length), (f) => ({
...f,
ticket: { ...f.ticket, sources: [] }
}))
}
if (context.presence !== 'any') {
add('context.presence', format.presence('context', context.presence), (f) => ({
...f,
context: { ...f.context, presence: defaults.context.presence }
}))
}
if (context.completelyEmpty) {
add('context.completelyEmpty', format.completelyEmpty(), (f) => ({
...f,
context: { ...f.context, completelyEmpty: false }
}))
}
if (location.hostIds.length > 0) {
add('location.hostIds', format.list('host', location.hostIds.length), (f) => ({
...f,
location: { ...f.location, hostIds: [] }
}))
}
if (location.repoIds.length > 0) {
add('location.repoIds', format.list('repo', location.repoIds.length), (f) => ({
...f,
location: { ...f.location, repoIds: [] }
}))
}
if (location.pathPrefix.trim().length > 0) {
add('location.pathPrefix', format.pathPrefix(location.pathPrefix.trim()), (f) => ({
...f,
location: { ...f.location, pathPrefix: '' }
}))
}
if (safety.blockers.length > 0) {
add('safety.blockers', format.list('blocker', safety.blockers.length), (f) => ({
...f,
safety: { ...f.safety, blockers: [] }
}))
}
if (safety.tiers.length > 0) {
add('safety.tiers', format.list('tier', safety.tiers.length), (f) => ({
...f,
safety: { ...f.safety, tiers: [] }
}))
}
if (safety.dismissed !== 'any') {
add('safety.dismissed', format.triState('dismissed', safety.dismissed), (f) => ({
...f,
safety: { ...f.safety, dismissed: 'any' }
}))
}
if (safety.selectableOnly) {
add('safety.selectableOnly', format.selectableOnly(), (f) => ({
...f,
safety: { ...f.safety, selectableOnly: false }
}))
}
return applied
}
+2
View File
@@ -147,6 +147,8 @@ const tabSchema = z.object({
color: z.string().nullable(),
sortOrder: z.number(),
createdAt: z.number(),
// Why: corrupt optional recency must not discard the whole persisted tab.
lastFocusedAt: z.number().finite().nonnegative().optional().catch(undefined),
isPreview: z.boolean().optional(),
isPinned: z.boolean().optional(),
// Why: persist the per-tab native-chat view mode so 'chat' survives reload /
+3 -1
View File
@@ -84,7 +84,9 @@ export type WorkspaceSessionState = {
* and worktreeNavHistory (Back/Forward stack). See
* docs/cmd-j-empty-query-ordering.md. Absent in sessions written by
* older builds — hydration tolerates missing/partial maps and the
* active worktree is seeded on first restore. */
* active worktree is seeded on first restore. New host-qualified keys use
* `${executionHostId}|${worktreeId}`; legacy bare keys remain readable
* during migration and remote snapshot projection. */
lastVisitedAtByWorktreeId?: Record<string, number>
/** Worktrees whose repo-defined default terminal tabs have already been
* considered. Persisted so closing all tabs and re-opening the workspace
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { parseWorkspaceSession } from './workspace-session-schema'
function sessionWithLastFocusedAt(lastFocusedAt: unknown): Record<string, unknown> {
return {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
unifiedTabs: {
wt: [
{
id: 'tab-1',
entityId: 'terminal-1',
groupId: 'group-1',
worktreeId: 'wt',
contentType: 'terminal',
label: 'Terminal',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1,
lastFocusedAt
}
]
}
}
}
describe('workspace session unified-tab focus timestamp', () => {
it('preserves a finite nonnegative lastFocusedAt', () => {
const result = parseWorkspaceSession(sessionWithLastFocusedAt(1_700_000_000_123))
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.unifiedTabs?.wt[0]?.lastFocusedAt).toBe(1_700_000_000_123)
}
})
it.each([-1, Number.NaN, Number.POSITIVE_INFINITY])(
'drops an invalid lastFocusedAt without dropping its tab (%s)',
(lastFocusedAt) => {
const result = parseWorkspaceSession(sessionWithLastFocusedAt(lastFocusedAt))
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.unifiedTabs?.wt).toHaveLength(1)
expect(result.value.unifiedTabs?.wt[0]?.lastFocusedAt).toBeUndefined()
}
}
)
})
+10 -2
View File
@@ -1,5 +1,4 @@
import type { ExecutionHostId } from '../execution-host'
import { parseExecutionHostId } from '../execution-host'
import { parseExecutionHostId, type ExecutionHostId } from '../execution-host'
import type { Worktree } from './types'
/**
@@ -67,3 +66,12 @@ export function getExecutionHostIdFromWorktreeHostIdentity(
export function getWorktreeIdFromHostIdentity(identity: string): string {
return identity.slice(identity.indexOf(HOST_SEPARATOR) + 1)
}
/** True only for the canonical host-qualified form, not a legacy id containing `|`. */
export function isWorktreeHostIdentity(identity: string): boolean {
const separator = identity.indexOf(HOST_SEPARATOR)
return (
separator === 0 ||
(separator > 0 && parseExecutionHostId(identity.slice(0, separator)) !== null)
)
}