mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Keep closed tab agents from reappearing (#6095)
This commit is contained in:
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types'
|
||||
import {
|
||||
clearMigrationUnsupportedPty,
|
||||
clearMigrationUnsupportedPtysByTabPrefix,
|
||||
clearMigrationUnsupportedPtysForPaneKey,
|
||||
getMigrationUnsupportedPtySnapshot,
|
||||
setMigrationUnsupportedPty,
|
||||
@@ -54,4 +55,30 @@ describe('migration unsupported PTY state', () => {
|
||||
expect(persist).toHaveBeenCalledTimes(1)
|
||||
expect(persist).toHaveBeenCalledWith([otherPane])
|
||||
})
|
||||
|
||||
it('persists once when clearing multiple entries under one tab prefix', () => {
|
||||
const listener = vi.fn()
|
||||
const persist = vi.fn()
|
||||
setMigrationUnsupportedPtyListener(listener)
|
||||
setMigrationUnsupportedPtyPersistenceListener(persist)
|
||||
const first = makeEntry('pty-1', 'tab-1:leaf-a')
|
||||
const second = makeEntry('pty-2', 'tab-1:leaf-b')
|
||||
const sibling = makeEntry('pty-3', 'tab-10:leaf-c')
|
||||
const otherTab = makeEntry('pty-4', 'tab-2:leaf-d')
|
||||
|
||||
setMigrationUnsupportedPty(first)
|
||||
setMigrationUnsupportedPty(second)
|
||||
setMigrationUnsupportedPty(sibling)
|
||||
setMigrationUnsupportedPty(otherTab)
|
||||
listener.mockClear()
|
||||
persist.mockClear()
|
||||
|
||||
clearMigrationUnsupportedPtysByTabPrefix('tab-1')
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(listener).toHaveBeenNthCalledWith(1, { type: 'clear', ptyId: 'pty-1' })
|
||||
expect(listener).toHaveBeenNthCalledWith(2, { type: 'clear', ptyId: 'pty-2' })
|
||||
expect(persist).toHaveBeenCalledTimes(1)
|
||||
expect(persist).toHaveBeenCalledWith([sibling, otherTab])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,3 +57,21 @@ export function clearMigrationUnsupportedPtysForPaneKey(paneKey: string): void {
|
||||
}
|
||||
persistenceListener?.(getMigrationUnsupportedPtySnapshot())
|
||||
}
|
||||
|
||||
export function clearMigrationUnsupportedPtysByTabPrefix(tabId: string): void {
|
||||
const prefix = `${tabId}:`
|
||||
const ptyIdsToClear: string[] = []
|
||||
for (const [ptyId, entry] of entriesByPtyId) {
|
||||
if (entry.paneKey?.startsWith(prefix)) {
|
||||
ptyIdsToClear.push(ptyId)
|
||||
}
|
||||
}
|
||||
if (ptyIdsToClear.length === 0) {
|
||||
return
|
||||
}
|
||||
for (const ptyId of ptyIdsToClear) {
|
||||
entriesByPtyId.delete(ptyId)
|
||||
listener?.({ type: 'clear', ptyId })
|
||||
}
|
||||
persistenceListener?.(getMigrationUnsupportedPtySnapshot())
|
||||
}
|
||||
|
||||
@@ -54,6 +54,13 @@ type Body = {
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
type AgentHookServerCacheInternals = {
|
||||
assistantMessageRetryTimers: Map<string, number | ReturnType<typeof globalThis.setTimeout>>
|
||||
promptSentDedupeByPaneKey: Map<string, unknown>
|
||||
runtimeObservedStatusPaneKeys: Set<string>
|
||||
scheduleStatusPersist: () => void
|
||||
}
|
||||
|
||||
function buildBody(payload: Record<string, unknown>, overrides: Partial<Body> = {}): Body {
|
||||
return {
|
||||
paneKey: PANE,
|
||||
@@ -1086,6 +1093,199 @@ describe('AgentHookServer listener replay', () => {
|
||||
expect(listener).toHaveBeenCalledWith(PANE)
|
||||
})
|
||||
|
||||
it('drops cached statuses and pane-scoped listener caches under one tab prefix', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
const internals = server as unknown as AgentHookServerCacheInternals
|
||||
const sameTabPane = makePaneKey('tab-1', LEAF_2)
|
||||
const siblingPrefixPane = makePaneKey('tab-10', LEAF_3)
|
||||
const statusListener = vi.fn()
|
||||
const aliasPersist = vi.fn()
|
||||
const sameTabRetry = vi.fn()
|
||||
const siblingRetry = vi.fn()
|
||||
server.subscribeStatusChanges(statusListener)
|
||||
server.setPaneKeyAliasPersistenceListener(aliasPersist)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'first', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: sameTabPane,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'done', prompt: 'second', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: siblingPrefixPane,
|
||||
tabId: 'tab-10',
|
||||
worktreeId: 'wt-2',
|
||||
payload: { state: 'working', prompt: 'sibling', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.registerPaneKeyAlias('tab-1:0', sameTabPane, 'pty-1')
|
||||
const state = server._getStateForTests()
|
||||
state.lastPromptByPaneKey.set(PANE, 'cached prompt')
|
||||
state.lastToolByPaneKey.set(`${sameTabPane}\0tool`, {} as never)
|
||||
state.antigravityCompletedTranscriptByPaneKey.set(`${sameTabPane}\0done`, 'cached')
|
||||
state.ampCompletedCacheKeys.add(`${sameTabPane}\0amp`)
|
||||
state.lastPromptByPaneKey.set(siblingPrefixPane, 'sibling prompt')
|
||||
internals.assistantMessageRetryTimers.set(PANE, setTimeout(sameTabRetry, 1_000))
|
||||
internals.assistantMessageRetryTimers.set(siblingPrefixPane, setTimeout(siblingRetry, 1_000))
|
||||
internals.promptSentDedupeByPaneKey.set(PANE, { promptHash: 'same-tab' })
|
||||
internals.promptSentDedupeByPaneKey.set(siblingPrefixPane, { promptHash: 'sibling' })
|
||||
const scheduleStatusPersist = vi.spyOn(internals, 'scheduleStatusPersist')
|
||||
statusListener.mockClear()
|
||||
aliasPersist.mockClear()
|
||||
scheduleStatusPersist.mockClear()
|
||||
|
||||
server.dropStatusEntriesByTabPrefix('tab-1')
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: siblingPrefixPane, prompt: 'sibling' })
|
||||
])
|
||||
expect(state.lastPromptByPaneKey.has(PANE)).toBe(false)
|
||||
expect(state.lastToolByPaneKey.has(`${sameTabPane}\0tool`)).toBe(false)
|
||||
expect(state.antigravityCompletedTranscriptByPaneKey.has(`${sameTabPane}\0done`)).toBe(false)
|
||||
expect(state.ampCompletedCacheKeys.has(`${sameTabPane}\0amp`)).toBe(false)
|
||||
expect(state.lastPromptByPaneKey.get(siblingPrefixPane)).toBe('sibling prompt')
|
||||
expect(internals.assistantMessageRetryTimers.has(PANE)).toBe(false)
|
||||
expect(internals.assistantMessageRetryTimers.has(siblingPrefixPane)).toBe(true)
|
||||
expect(internals.promptSentDedupeByPaneKey.has(PANE)).toBe(false)
|
||||
expect(internals.promptSentDedupeByPaneKey.get(siblingPrefixPane)).toEqual({
|
||||
promptHash: 'sibling'
|
||||
})
|
||||
expect(internals.runtimeObservedStatusPaneKeys.has(PANE)).toBe(false)
|
||||
expect(internals.runtimeObservedStatusPaneKeys.has(sameTabPane)).toBe(false)
|
||||
expect(internals.runtimeObservedStatusPaneKeys.has(siblingPrefixPane)).toBe(true)
|
||||
expect(statusListener).toHaveBeenCalledTimes(1)
|
||||
expect(statusListener).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ state: 'working', observedInCurrentRuntime: true })
|
||||
])
|
||||
expect(aliasPersist).toHaveBeenCalledTimes(1)
|
||||
expect(aliasPersist).toHaveBeenCalledWith([])
|
||||
expect(scheduleStatusPersist).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(1_000)
|
||||
expect(sameTabRetry).not.toHaveBeenCalled()
|
||||
expect(siblingRetry).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('suppresses late writes for a closed tab for the rest of the server session', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'before close', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
server.dropStatusEntriesByTabPrefix('tab-1')
|
||||
listener.mockClear()
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'done', prompt: 'late remote', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'done', prompt: 'late terminal', agentType: 'codex' }
|
||||
})
|
||||
|
||||
vi.setSystemTime(16_001)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'future reuse', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts statuses for unrelated tabs while another tab is recently closed', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.dropStatusEntriesByTabPrefix('tab-1')
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: GOOD_PANE,
|
||||
tabId: 'tab-good',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'unrelated', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: GOOD_PANE, state: 'working', prompt: 'unrelated' })
|
||||
])
|
||||
})
|
||||
|
||||
it('suppresses local HTTP hook writes for a recently closed tab', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (prompt: string): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody({ hook_event_name: 'UserPromptSubmit', prompt }))
|
||||
})
|
||||
|
||||
await expect(postHook('before close')).resolves.toMatchObject({ status: 204 })
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, prompt: 'before close' })
|
||||
])
|
||||
|
||||
server.dropStatusEntriesByTabPrefix('tab-1')
|
||||
await expect(postHook('late local')).resolves.toMatchObject({ status: 204 })
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('hydrates cached statuses as not observed in the current runtime', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-'))
|
||||
const firstServer = new AgentHookServer()
|
||||
|
||||
@@ -271,6 +271,15 @@ function trackEmptyPaneKeyHook(body: unknown): void {
|
||||
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
||||
}
|
||||
|
||||
function paneCacheKeyTabId(key: string): string | null {
|
||||
const paneKey = key.split('\0', 1)[0] ?? key
|
||||
return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null
|
||||
}
|
||||
|
||||
function paneCacheKeyMatchesTab(key: string, tabId: string): boolean {
|
||||
return paneCacheKeyTabId(key) === tabId
|
||||
}
|
||||
|
||||
function shouldKeepClaudePermissionVisible(
|
||||
previous: EnrichedAgentHookEventPayload | undefined,
|
||||
next: AgentHookEventPayload
|
||||
@@ -434,6 +443,7 @@ export class AgentHookServer {
|
||||
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
|
||||
private promptSentHashSalt = randomBytes(16).toString('hex')
|
||||
private closedAgentStatusTabIds = new Set<string>()
|
||||
// Why: identity check — skip writes when the JSON-stringified contents
|
||||
// exactly match the last successful disk write. Cheap protection against
|
||||
// re-firing trailing timers when nothing changed.
|
||||
@@ -571,6 +581,18 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
private markTabClosedForAgentStatus(tabId: string): void {
|
||||
this.closedAgentStatusTabIds.add(tabId)
|
||||
}
|
||||
|
||||
private shouldSuppressClosedTabStatus(paneKey: string): boolean {
|
||||
const tabId = parsePaneKey(paneKey)?.tabId
|
||||
if (!tabId) {
|
||||
return false
|
||||
}
|
||||
return this.closedAgentStatusTabIds.has(tabId)
|
||||
}
|
||||
|
||||
private attachStatusTiming(
|
||||
payload: AgentHookEventPayload,
|
||||
now = Date.now()
|
||||
@@ -936,6 +958,9 @@ export class AgentHookServer {
|
||||
if (tabId !== undefined && tabId !== parsedPaneKey.tabId) {
|
||||
return
|
||||
}
|
||||
if (this.shouldSuppressClosedTabStatus(paneKey)) {
|
||||
return
|
||||
}
|
||||
const worktreeId =
|
||||
event.worktreeId !== undefined && event.worktreeId.trim().length > 0
|
||||
? event.worktreeId.trim()
|
||||
@@ -1039,6 +1064,9 @@ export class AgentHookServer {
|
||||
if (tabId !== undefined && tabId !== parsedPaneKey.tabId) {
|
||||
return
|
||||
}
|
||||
if (this.shouldSuppressClosedTabStatus(paneKey)) {
|
||||
return
|
||||
}
|
||||
const worktreeId =
|
||||
envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
|
||||
? envelope.worktreeId.trim()
|
||||
@@ -1167,7 +1195,7 @@ export class AgentHookServer {
|
||||
trackEmptyPaneKeyHook(body)
|
||||
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
|
||||
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
|
||||
if (normalized) {
|
||||
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
|
||||
const enriched = this.applyNormalizedStatus(normalized)
|
||||
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
|
||||
}
|
||||
@@ -1235,6 +1263,7 @@ export class AgentHookServer {
|
||||
this.lastWrittenJson = null
|
||||
this.runtimeObservedStatusPaneKeys.clear()
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.legacyPaneKeyAliases.clear()
|
||||
clearAllListenerCaches(this.state)
|
||||
this.notifyStatusChangeListeners()
|
||||
@@ -1263,6 +1292,77 @@ export class AgentHookServer {
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
|
||||
dropStatusEntriesByTabPrefix(tabId: string): void {
|
||||
this.markTabClosedForAgentStatus(tabId)
|
||||
const paneKeysToClear = new Set<string>()
|
||||
for (const key of this.state.lastStatusByPaneKey.keys()) {
|
||||
if (paneCacheKeyMatchesTab(key, tabId)) {
|
||||
paneKeysToClear.add(key)
|
||||
}
|
||||
}
|
||||
for (const key of this.state.lastPromptByPaneKey.keys()) {
|
||||
if (paneCacheKeyMatchesTab(key, tabId)) {
|
||||
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
|
||||
}
|
||||
}
|
||||
for (const key of this.state.lastToolByPaneKey.keys()) {
|
||||
if (paneCacheKeyMatchesTab(key, tabId)) {
|
||||
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
|
||||
}
|
||||
}
|
||||
for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) {
|
||||
if (paneCacheKeyMatchesTab(key, tabId)) {
|
||||
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
|
||||
}
|
||||
}
|
||||
for (const key of this.state.ampCompletedCacheKeys) {
|
||||
if (paneCacheKeyMatchesTab(key, tabId)) {
|
||||
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
|
||||
}
|
||||
}
|
||||
for (const paneKey of this.runtimeObservedStatusPaneKeys) {
|
||||
if (paneCacheKeyMatchesTab(paneKey, tabId)) {
|
||||
paneKeysToClear.add(paneKey)
|
||||
}
|
||||
}
|
||||
for (const paneKey of this.promptSentDedupeByPaneKey.keys()) {
|
||||
if (paneCacheKeyMatchesTab(paneKey, tabId)) {
|
||||
paneKeysToClear.add(paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
let aliasChanged = false
|
||||
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
if (
|
||||
paneCacheKeyMatchesTab(legacyPaneKey, tabId) ||
|
||||
paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)
|
||||
) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
paneKeysToClear.add(legacyPaneKey)
|
||||
paneKeysToClear.add(entry.stablePaneKey)
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
let statusChanged = false
|
||||
for (const paneKey of paneKeysToClear) {
|
||||
if (this.state.lastStatusByPaneKey.has(paneKey)) {
|
||||
statusChanged = true
|
||||
}
|
||||
this.clearAssistantMessageRetry(paneKey)
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(paneKey)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (statusChanged) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
}
|
||||
|
||||
clearPaneState(paneKey: string): void {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
// Why: only schedule a write when we actually evicted a status entry —
|
||||
|
||||
@@ -8,8 +8,11 @@ import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
// evicts the entry.
|
||||
|
||||
const dropStatusEntry = vi.fn()
|
||||
const dropStatusEntriesByTabPrefix = vi.fn()
|
||||
const getStatusSnapshot = vi.fn()
|
||||
const inferInterrupt = vi.fn()
|
||||
const clearMigrationUnsupportedPtysByTabPrefix = vi.fn()
|
||||
const clearMigrationUnsupportedPtysForPaneKey = vi.fn()
|
||||
const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>()
|
||||
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
|
||||
const removeHandler = vi.fn()
|
||||
@@ -38,12 +41,19 @@ vi.mock('../agent-hooks/server', async () => {
|
||||
...actual,
|
||||
agentHookServer: {
|
||||
dropStatusEntry,
|
||||
dropStatusEntriesByTabPrefix,
|
||||
getStatusSnapshot,
|
||||
inferInterrupt
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
|
||||
clearMigrationUnsupportedPtysByTabPrefix,
|
||||
clearMigrationUnsupportedPtysForPaneKey,
|
||||
getMigrationUnsupportedPtySnapshot: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('../claude/hook-service', () => ({
|
||||
claudeHookService: { getStatus: vi.fn(() => ({ agent: 'claude', state: 'absent' })) }
|
||||
}))
|
||||
@@ -89,8 +99,11 @@ vi.mock('../kimi/hook-service', () => ({
|
||||
|
||||
beforeEach(() => {
|
||||
dropStatusEntry.mockReset()
|
||||
dropStatusEntriesByTabPrefix.mockReset()
|
||||
getStatusSnapshot.mockReset()
|
||||
inferInterrupt.mockReset()
|
||||
clearMigrationUnsupportedPtysByTabPrefix.mockReset()
|
||||
clearMigrationUnsupportedPtysForPaneKey.mockReset()
|
||||
onHandlers.clear()
|
||||
handleHandlers.clear()
|
||||
removeHandler.mockReset()
|
||||
@@ -294,6 +307,7 @@ describe('agentStatus:drop IPC', () => {
|
||||
expect(handler).toBeDefined()
|
||||
handler!({}, PANE_KEY)
|
||||
expect(dropStatusEntry).toHaveBeenCalledWith(PANE_KEY)
|
||||
expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY)
|
||||
})
|
||||
|
||||
it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => {
|
||||
@@ -320,3 +334,47 @@ describe('agentStatus:drop IPC', () => {
|
||||
expect(dropStatusEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentStatus:dropByTabPrefix IPC', () => {
|
||||
it('forwards valid tab ids to tab-prefix cache eviction', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
const handler = onHandlers.get('agentStatus:dropByTabPrefix')
|
||||
expect(handler).toBeDefined()
|
||||
handler!({}, 'tab-1')
|
||||
expect(dropStatusEntriesByTabPrefix).toHaveBeenCalledWith('tab-1')
|
||||
expect(clearMigrationUnsupportedPtysByTabPrefix).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
|
||||
it('rejects malformed tab ids', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
const handler = onHandlers.get('agentStatus:dropByTabPrefix')!
|
||||
const bad: unknown[] = [
|
||||
123,
|
||||
undefined,
|
||||
'',
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
'tab-1:leaf',
|
||||
' leading-space',
|
||||
'trailing-space ',
|
||||
'x'.repeat(161)
|
||||
]
|
||||
for (const value of bad) {
|
||||
expect(() => handler({}, value)).not.toThrow()
|
||||
}
|
||||
expect(dropStatusEntriesByTabPrefix).not.toHaveBeenCalled()
|
||||
expect(clearMigrationUnsupportedPtysByTabPrefix).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes any existing listener before registering the tab-prefix channel', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:dropByTabPrefix')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,8 +7,10 @@ import type {
|
||||
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import { ampHookService } from '../amp/hook-service'
|
||||
import {
|
||||
clearMigrationUnsupportedPtysByTabPrefix,
|
||||
clearMigrationUnsupportedPtysForPaneKey,
|
||||
getMigrationUnsupportedPtySnapshot
|
||||
} from '../agent-hooks/migration-unsupported-pty-state'
|
||||
@@ -31,6 +33,8 @@ type AgentStatusRuntimeEnrichment = Pick<
|
||||
'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey'
|
||||
>
|
||||
|
||||
const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160
|
||||
|
||||
function enrichAgentStatusIpcPayload(
|
||||
data: AgentStatusIpcPayload,
|
||||
runtime: AgentStatusRuntimeEnrichment | undefined
|
||||
@@ -47,6 +51,15 @@ function enrichAgentStatusIpcPayload(
|
||||
}
|
||||
}
|
||||
|
||||
function isValidAgentStatusDropTabId(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH &&
|
||||
value.trim() === value &&
|
||||
isValidTerminalTabId(value)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: install/remove are intentionally not exposed to the renderer. Orca
|
||||
// auto-installs managed hooks at app startup (see src/main/index.ts), so a
|
||||
// renderer-triggered remove would be silently reverted on the next launch
|
||||
@@ -80,6 +93,7 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment
|
||||
// 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
|
||||
@@ -95,6 +109,17 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment
|
||||
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)
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => {
|
||||
// Why: the renderer pulls this after workspace hydration, so startup cannot
|
||||
// lose replayed statuses while its local store is still empty. Match the
|
||||
|
||||
@@ -2690,6 +2690,9 @@ export type PreloadApi = {
|
||||
/** Drop a paneKey from the main-process hook cache and the on-disk
|
||||
* last-status file. Fire-and-forget. */
|
||||
drop: (paneKey: string) => void
|
||||
/** Drop every cached hook status under one terminal tab prefix.
|
||||
* Fire-and-forget. */
|
||||
dropByTabPrefix: (tabId: string) => void
|
||||
}
|
||||
mobile: {
|
||||
listNetworkInterfaces: () => Promise<{
|
||||
|
||||
@@ -3838,6 +3838,11 @@ const api = {
|
||||
* cannot resurrect it. Fire-and-forget; no response. */
|
||||
drop: (paneKey: string): void => {
|
||||
ipcRenderer.send('agentStatus:drop', paneKey)
|
||||
},
|
||||
/** Drop all cached hook statuses under one terminal tab prefix. Fired on
|
||||
* explicit tab close even when the renderer has no matching local row. */
|
||||
dropByTabPrefix: (tabId: string): void => {
|
||||
ipcRenderer.send('agentStatus:dropByTabPrefix', tabId)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -3522,6 +3522,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
|
||||
runtimePaneTitlesByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
agentStatusByPaneKey: {},
|
||||
recentlyClosedAgentStatusTabIds: {},
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
tabsByWorktree: {},
|
||||
@@ -4061,6 +4062,128 @@ describe('useIpcEvents agent status snapshot integration', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('drops late push events for a recently closed terminal tab', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
recentlyClosedAgentStatusTabIds: { 'tab-future': true },
|
||||
settings: { terminalFontSize: 13, notifications: { enabled: false } },
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
if (typeof onSetListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onSet listener to be registered')
|
||||
}
|
||||
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'done',
|
||||
prompt: 'late completion',
|
||||
agentType: 'codex',
|
||||
receivedAt: 1_700_000_000_200,
|
||||
stateStartedAt: 1_699_999_999_100
|
||||
})
|
||||
|
||||
expect(setAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps missing-tab runtime attribution for tabs that were never explicitly closed', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
setAgentStatus,
|
||||
workspaceSessionReady: true,
|
||||
recentlyClosedAgentStatusTabIds: {},
|
||||
settings: { terminalFontSize: 13, notifications: { enabled: false } },
|
||||
repos: [{ id: 'repo-1', connectionId: null }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
if (typeof onSetListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onSet listener to be registered')
|
||||
}
|
||||
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'runtime child',
|
||||
agentType: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
receivedAt: 1_700_000_000_200,
|
||||
stateStartedAt: 1_700_000_000_000,
|
||||
orchestration: {
|
||||
parentPaneKey: 'parent-tab:11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(setAgentStatus).toHaveBeenCalledWith(
|
||||
FUTURE_PANE_KEY,
|
||||
expect.objectContaining({ state: 'working', prompt: 'runtime child', agentType: 'codex' }),
|
||||
undefined,
|
||||
{ updatedAt: 1_700_000_000_200, stateStartedAt: 1_700_000_000_000 },
|
||||
expectWorktreeRouting('wt-1'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('clears a worktree-attributed live row when main reports pane teardown', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const removeAgentStatus = vi.fn()
|
||||
|
||||
@@ -228,6 +228,17 @@ let remoteWorkspaceSnapshotApplyDepth = 0
|
||||
let remoteWorkspaceSnapshotWriteSuppressUntil = 0
|
||||
const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000
|
||||
|
||||
function isAgentStatusForRecentlyClosedTab(
|
||||
store: Pick<AppState, 'recentlyClosedAgentStatusTabIds'>,
|
||||
paneKey: string
|
||||
): boolean {
|
||||
const tabId = parsePaneKey(paneKey)?.tabId
|
||||
if (!tabId) {
|
||||
return false
|
||||
}
|
||||
return store.recentlyClosedAgentStatusTabIds[tabId] === true
|
||||
}
|
||||
|
||||
function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set<string> | null {
|
||||
const detected = state.detectedWorktreesByRepo[repoId]
|
||||
if (detected?.authoritative !== true) {
|
||||
@@ -2644,6 +2655,9 @@ export function useIpcEvents(): void {
|
||||
if (!store.workspaceSessionReady) {
|
||||
return 'dropped'
|
||||
}
|
||||
if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) {
|
||||
return 'dropped'
|
||||
}
|
||||
const payload = normalizeAgentStatusPayload({
|
||||
state: data.state,
|
||||
prompt: data.prompt,
|
||||
|
||||
@@ -86,7 +86,8 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => {
|
||||
vi.setSystemTime(new Date('2026-04-29T12:00:00.000Z'))
|
||||
const store = createTestStore()
|
||||
|
||||
// Session 1: agent runs on tab-1:0, user acks it, tab closes.
|
||||
// Session 1: agent runs on tab-1:0, user acks it, pane state is torn down
|
||||
// without marking the whole tab closed.
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus('tab-1:0', { state: 'working', prompt: 'first', agentType: 'claude' })
|
||||
@@ -94,7 +95,7 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => {
|
||||
const firstAck = store.getState().acknowledgedAgentsByPaneKey['tab-1:0']
|
||||
expect(firstAck).toBeGreaterThan(0)
|
||||
|
||||
store.getState().dropAgentStatusByTabPrefix('tab-1')
|
||||
store.getState().removeAgentStatus('tab-1:0')
|
||||
expect(store.getState().acknowledgedAgentsByPaneKey['tab-1:0']).toBeUndefined()
|
||||
|
||||
// Session 2: a brand-new tab+pane happens to collide on the same paneKey,
|
||||
|
||||
@@ -24,12 +24,16 @@ afterEach(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function stubWindowApi(): { drop: ReturnType<typeof vi.fn> } {
|
||||
function stubWindowApi(): {
|
||||
drop: ReturnType<typeof vi.fn>
|
||||
dropByTabPrefix: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const drop = vi.fn()
|
||||
const dropByTabPrefix = vi.fn()
|
||||
;(globalThis as { window?: unknown }).window = {
|
||||
api: { agentStatus: { drop } }
|
||||
api: { agentStatus: { drop, dropByTabPrefix } }
|
||||
}
|
||||
return { drop }
|
||||
return { drop, dropByTabPrefix }
|
||||
}
|
||||
|
||||
describe('dropAgentStatus → IPC fan-out', () => {
|
||||
@@ -73,6 +77,50 @@ describe('dropAgentStatus → IPC fan-out', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropAgentStatusByTabPrefix -> IPC fan-out', () => {
|
||||
it('fires window.api.agentStatus.dropByTabPrefix after dropping local tab rows', () => {
|
||||
const { dropByTabPrefix } = stubWindowApi()
|
||||
const store = createTestStore()
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus('tab-1:0', { state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus('tab-2:0', { state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
|
||||
store.getState().dropAgentStatusByTabPrefix('tab-1')
|
||||
|
||||
expect(dropByTabPrefix).toHaveBeenCalledTimes(1)
|
||||
expect(dropByTabPrefix).toHaveBeenCalledWith('tab-1')
|
||||
expect(store.getState().agentStatusByPaneKey['tab-1:0']).toBeUndefined()
|
||||
expect(store.getState().agentStatusByPaneKey['tab-2:0']).toBeDefined()
|
||||
})
|
||||
|
||||
it('fires dropByTabPrefix when no local rows match so stale main cache can be evicted', () => {
|
||||
const { dropByTabPrefix } = stubWindowApi()
|
||||
const store = createTestStore()
|
||||
|
||||
store.getState().dropAgentStatusByTabPrefix('tab-missing')
|
||||
|
||||
expect(dropByTabPrefix).toHaveBeenCalledTimes(1)
|
||||
expect(dropByTabPrefix).toHaveBeenCalledWith('tab-missing')
|
||||
expect(store.getState().recentlyClosedAgentStatusTabIds['tab-missing']).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps closed tab markers for the renderer session', () => {
|
||||
stubWindowApi()
|
||||
const store = createTestStore()
|
||||
|
||||
store.getState().dropAgentStatusByTabPrefix('tab-old')
|
||||
store.getState().dropAgentStatusByTabPrefix('tab-new')
|
||||
|
||||
expect(store.getState().recentlyClosedAgentStatusTabIds).toEqual({
|
||||
'tab-old': true,
|
||||
'tab-new': true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissRetainedAgentsByWorktree → IPC fan-out', () => {
|
||||
it('fires drop once per dismissed paneKey under the worktree', () => {
|
||||
const { drop } = stubWindowApi()
|
||||
|
||||
@@ -104,6 +104,10 @@ export type AgentStatusSlice = {
|
||||
* disappearance. Consumed by the retention sync as a one-shot suppressor. */
|
||||
retentionSuppressedPaneKeys: Record<string, true>
|
||||
|
||||
/** Terminal tabs explicitly closed in this renderer session. Used only to
|
||||
* drop late in-flight IPC statuses and stale main-cache replays. */
|
||||
recentlyClosedAgentStatusTabIds: Record<string, true>
|
||||
|
||||
/** Update or insert an agent status entry from a status payload. */
|
||||
setAgentStatus: (
|
||||
paneKey: string,
|
||||
@@ -227,6 +231,16 @@ function getLeafIdFromPaneKey(paneKey: string): string | null {
|
||||
return leafId.length > 0 ? leafId : null
|
||||
}
|
||||
|
||||
function isRecentlyClosedAgentStatusTab(
|
||||
closedTabs: Record<string, true>,
|
||||
tabId: string | null
|
||||
): boolean {
|
||||
if (!tabId) {
|
||||
return false
|
||||
}
|
||||
return closedTabs[tabId] === true
|
||||
}
|
||||
|
||||
function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | null {
|
||||
const tabId = getTabIdFromPaneKey(paneKey)
|
||||
if (!tabId) {
|
||||
@@ -770,6 +784,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
agentLaunchConfigByPaneKey: {},
|
||||
retentionSuppressedPaneKeys: {},
|
||||
recentlyClosedAgentStatusTabIds: {},
|
||||
|
||||
setRuntimeAgentOrchestrationByPaneKey: (entries) => {
|
||||
set((s) => {
|
||||
@@ -915,6 +930,16 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
|
||||
setAgentStatus: (paneKey, payload, terminalTitle, timing, routing, metadata) => {
|
||||
const updatedAt = timing?.updatedAt ?? Date.now()
|
||||
if (
|
||||
// Why: a closed terminal tab is no longer a valid destination for hook
|
||||
// replays or late status events, even if main still receives them.
|
||||
isRecentlyClosedAgentStatusTab(
|
||||
get().recentlyClosedAgentStatusTabIds,
|
||||
getTabIdFromPaneKey(paneKey)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
let completionRefreshWorktreeId: string | null = null
|
||||
let suppressedInheritedTerminalStatus = false
|
||||
set((s) => {
|
||||
@@ -1528,6 +1553,11 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
delete nextAck[k]
|
||||
}
|
||||
}
|
||||
const nextClosedTabs: Record<string, true> = {
|
||||
...s.recentlyClosedAgentStatusTabIds,
|
||||
[tabIdPrefix]: true
|
||||
}
|
||||
|
||||
if (
|
||||
liveKeys.length === 0 &&
|
||||
launchConfigKeys.length === 0 &&
|
||||
@@ -1535,9 +1565,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
!migrationUnsupported.changed
|
||||
) {
|
||||
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
|
||||
return { acknowledgedAgentsByPaneKey: nextAck }
|
||||
return {
|
||||
acknowledgedAgentsByPaneKey: nextAck,
|
||||
recentlyClosedAgentStatusTabIds: nextClosedTabs
|
||||
}
|
||||
}
|
||||
return s
|
||||
return { recentlyClosedAgentStatusTabIds: nextClosedTabs }
|
||||
}
|
||||
hadLive = liveKeys.length > 0
|
||||
|
||||
@@ -1589,6 +1622,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
retainedAgentsByPaneKey: nextRetained,
|
||||
migrationUnsupportedByPtyId: migrationUnsupported.next,
|
||||
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
|
||||
recentlyClosedAgentStatusTabIds: nextClosedTabs,
|
||||
...(nextAck !== s.acknowledgedAgentsByPaneKey
|
||||
? { acknowledgedAgentsByPaneKey: nextAck }
|
||||
: {}),
|
||||
@@ -1603,6 +1637,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
if (hadLive) {
|
||||
queueMicrotask(() => freshness.schedule())
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.api?.agentStatus?.dropByTabPrefix?.(tabIdPrefix)
|
||||
}
|
||||
},
|
||||
|
||||
dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts) => {
|
||||
|
||||
@@ -655,7 +655,8 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
onMigrationUnsupported: () => noopUnsubscribe,
|
||||
onMigrationUnsupportedClear: () => noopUnsubscribe,
|
||||
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
|
||||
drop: () => {}
|
||||
drop: () => {},
|
||||
dropByTabPrefix: () => {}
|
||||
},
|
||||
mobile: {
|
||||
listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }),
|
||||
|
||||
Reference in New Issue
Block a user