mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/cleanup-browse-hydration-guard
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+100
@@ -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 => {
|
||||
|
||||
+23
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
+15
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+180
@@ -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)
|
||||
|
||||
+49
-4
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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": "완료된 에이전트"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 /
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user