test: remove 164 low-value renderer store tests (#37638)

This commit is contained in:
OrcaWin
2026-11-02 19:20:23 -08:00
committed by GitHub
parent 174fdd8ca5
commit ec7da75d4f
29 changed files with 25 additions and 5198 deletions
-3
View File
@@ -1078,9 +1078,6 @@
7 test-no-wall-clock:src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts
1 test-no-wall-clock:src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts
2 test-no-wall-clock:src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts
2 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-agent-stop-preflight.test.ts
4 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts
2 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts
1 test-no-wall-clock:src/renderer/src/store/slices/worktrees-always-force-delete.test.ts
6 test-no-wall-clock:src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts
1 test-no-wall-clock:src/renderer/src/store/slices/worktrees-terminal-reference-origins.test.ts
-14
View File
@@ -11421,33 +11421,19 @@
"src/renderer/src/store/slices/workspace-attention/workspace-unread-characterization.test.ts": 1974,
"src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts": 1914,
"src/renderer/src/store/slices/workspace-attention/workspace-unread-on-screen-characterization.test.ts": 1780,
"src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts": 2113,
"src/renderer/src/store/slices/workspace-attention/workspace-unread-screen-moves-characterization.test.ts": 1884,
"src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts": 1896,
"src/renderer/src/store/slices/workspace-cleanup-agent-stop-preflight.test.ts": 1752,
"src/renderer/src/store/slices/workspace-cleanup-browse.test.ts": 69,
"src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts": 815,
"src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts": 995,
"src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts": 1784,
"src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts": 681,
"src/renderer/src/store/slices/workspace-cleanup-plain-shell-visit.test.ts": 632,
"src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts": 750,
"src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts": 632,
"src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts": 1207,
"src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts": 639,
"src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts": 1800,
"src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts": 1909,
"src/renderer/src/store/slices/workspace-document-title-refresh.test.ts": 1630,
"src/renderer/src/store/slices/workspace-space.test.ts": 49,
"src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts": 39,
"src/renderer/src/store/slices/worktree-helpers.test.ts": 94,
"src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts": 48,
"src/renderer/src/store/slices/worktree-meta-update-application.test.ts": 96,
"src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts": 107,
"src/renderer/src/store/slices/worktree-nav-history.test.ts": 112,
"src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts": 1643,
"src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts": 1764,
"src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts": 1786,
"src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts": 323,
"src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts": 307,
"src/renderer/src/store/slices/worktrees-activity-persistence.test.ts": 1305,
@@ -1,25 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist'
import { makeFolderWorkspace, makeWorktree } from '../worktrees-slice-test-fixtures'
import { makeFolderWorkspace } from '../worktrees-slice-test-fixtures'
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants'
import '../../../../../shared/constants'
import {
addLiveTerminalTab,
createAttentionStore,
installAttentionMockApi,
LEAF_2,
observe,
WS_A,
WS_B
WS_A
} from './workspace-unread-characterization-fixture'
import {
autoAckViewedTab,
completeAgentTurn,
pointerDownInTab,
ringBell,
toggleUnreadByUser,
typeInPane
} from './workspace-unread-characterization-driver'
import { pointerDownInTab, ringBell, typeInPane } from './workspace-unread-characterization-driver'
// Pin marker/flag persistence, activity, Dock and floating-dot transitions using the production scan; default window is visible/focused.
@@ -43,599 +34,6 @@ afterEach(() => {
vi.useRealTimers()
})
describe('bells', () => {
it('a hidden waiting tab keeps the dot after switching in and while typing in another tab (STA-9273)', () => {
const { store, persists } = createAttentionStore()
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
observe(store, persists)
ringBell(store, first)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell",
"panes -",
"completions -",
"dock=1 floatingDot=false",
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
]
`)
vi.setSystemTime(2000)
store.getState().setActiveWorktree(WS_A)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell",
"panes -",
"completions -",
"dock=1 floatingDot=false",
]
`)
typeInPane(store, second)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell",
"panes -",
"completions -",
"dock=1 floatingDot=false",
]
`)
vi.setSystemTime(3000)
ringBell(store, first)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell",
"panes -",
"completions -",
"dock=1 floatingDot=false",
]
`)
})
it('a bell in the pane on screen in a focused window leaves no marker and no flag write', () => {
const { store, persists } = createAttentionStore({ experimental: true })
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
store.getState().setActiveWorktree(WS_A)
observe(store, persists)
ringBell(store, pane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
pointerDownInTab(store, { ...pane, paneKey: null })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
typeInPane(store, pane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
})
it('bell on a tab no workspace owns lights the workspace but no tab marker', () => {
const { store, persists } = createAttentionStore({ experimental: true })
observe(store, persists)
ringBell(store, { worktreeId: WS_A, tabId: 'orphan-tab', paneKey: 'orphan-tab:leaf' })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes orphan-tab:leaf=terminal-bell",
"completions -",
"dock=1 floatingDot=false",
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
]
`)
})
it('activating a tab in the active workspace reads every marker of it and settles the flag', () => {
const { store, persists } = createAttentionStore({ experimental: true })
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
store.getState().setActiveWorktree(WS_A)
ringBell(store, first)
observe(store, persists)
store.getState().setActiveTab(first.tabId)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
ringBell(store, second)
store.getState().activateTab(`unified-${second.tabId}`)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=2",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
"persist worktree a {"isUnread":false}",
]
`)
})
it('tab activation in a background workspace keeps the tab marker', () => {
const { store, persists } = createAttentionStore()
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
ringBell(store, pane)
observe(store, persists)
store.getState().setActiveTab(pane.tabId)
store.getState().activateTab(`unified-${pane.tabId}`)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell",
"panes -",
"completions -",
"dock=1 floatingDot=false",
]
`)
})
it('floating terminal bell', () => {
const { store, persists } = createAttentionStore()
store.getState().setFloatingWorkspacePath('/home/test/floating')
const pane = addLiveTerminalTab(store, FLOATING_TERMINAL_WORKTREE_ID, 'tab-float')
observe(store, persists)
const before = store.getState()
ringBell(store, pane)
expect(store.getState() === before).toBe(false)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs tab-float=terminal-bell",
"panes -",
"completions -",
"dock=0 floatingDot=true",
"persist worktree global-floating-terminal {"isUnread":true,"lastActivityAt":1000}",
]
`)
ringBell(store, pane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs tab-float=terminal-bell",
"panes -",
"completions -",
"dock=0 floatingDot=true",
"persist worktree global-floating-terminal {"isUnread":true,"lastActivityAt":1000}",
]
`)
typeInPane(store, pane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs tab-float=terminal-bell",
"panes -",
"completions -",
"dock=0 floatingDot=true",
]
`)
store.setState({
settings: { ...store.getState().settings!, floatingTerminalEnabled: true },
floatingWorkspacePanelOpen: true
})
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
})
})
describe('agent completions', () => {
function viewedThenHiddenCompletion(experimental: boolean) {
const { store, persists } = createAttentionStore({ experimental })
const viewed = addLiveTerminalTab(store, WS_A, 'tab-a1')
const hidden = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
store.getState().setActiveWorktree(WS_A)
store.getState().activateTab(`unified-${viewed.tabId}`)
observe(store, persists)
completeAgentTurn(store, viewed)
const afterViewed = observe(store, persists)
completeAgentTurn(store, hidden)
return { afterViewed, afterHidden: observe(store, persists) }
}
it('hidden vs viewed completion, experimental off', () => {
const { afterViewed, afterHidden } = viewedThenHiddenCompletion(false)
expect(afterViewed).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
expect(afterHidden).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
]
`)
})
it('hidden vs viewed completion, experimental on', () => {
const { afterViewed, afterHidden } = viewedThenHiddenCompletion(true)
expect(afterViewed).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
expect(afterHidden).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a2=agent-completion",
"panes tab-a2:L2=agent-completion",
"completions tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
]
`)
})
it('completion with no subject lights only the workspace of a background workspace', () => {
const { store, persists } = createAttentionStore({ experimental: true })
addLiveTerminalTab(store, WS_A, 'tab-a1')
observe(store, persists)
completeAgentTurn(store, { worktreeId: WS_A, paneKey: null })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=1 floatingDot=false",
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
]
`)
store.getState().setActiveWorktree(WS_A)
observe(store, persists)
completeAgentTurn(store, { worktreeId: WS_A, paneKey: null })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
})
it('the workspace clears only once no sibling marker remains', () => {
const { store, persists } = createAttentionStore()
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
store.getState().setActiveWorktree(WS_A)
const unifiedOf = (tabId: string) => `unified-${tabId}`
store.getState().activateTab(unifiedOf(second.tabId))
completeAgentTurn(store, first)
store.getState().activateTab(unifiedOf(first.tabId))
completeAgentTurn(store, second)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=2",
"tabs -",
"panes -",
"completions tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
"persist worktree a {"isUnread":false}",
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
]
`)
autoAckViewedTab(store, first)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=2",
"tabs -",
"panes -",
"completions tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
]
`)
store.getState().activateTab(unifiedOf(second.tabId))
autoAckViewedTab(store, second)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=2",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
})
})
describe('manual, inbound and restart', () => {
it('Mark as unread / read on host A of two same-id host rows', async () => {
const { store, persists } = createAttentionStore()
store.setState({
repos: [
{
id: 'repo1',
path: '/repo1',
displayName: 'Local',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'local'
},
{
id: 'repo1',
path: '/repo1',
displayName: 'SSH',
badgeColor: '#111',
addedAt: 1,
connectionId: 'ssh-1'
}
],
worktreesByRepo: {
repo1: [
makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', hostId: 'local' }),
makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', hostId: 'ssh:ssh-1' })
]
}
})
observe(store, persists)
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
await Promise.resolve()
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:0 a@ssh:ssh-1:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=1 floatingDot=false",
"persist worktree a {"isUnread":true} host=local",
]
`)
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
await Promise.resolve()
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 a@ssh:ssh-1:read:0 sortEpoch=2",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false} host=local",
]
`)
})
it('inbound refetch overwrites the flag and leaves markers', () => {
const { store, persists } = createAttentionStore({ experimental: true })
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
ringBell(store, pane)
observe(store, persists)
const rowsWith = (isUnread: boolean) => ({
worktreesByRepo: {
repo1: store
.getState()
.worktreesByRepo.repo1.map((row) => (row.id === WS_A ? { ...row, isUnread } : row))
}
})
store.setState(rowsWith(false))
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell",
"panes tab-a1:L1=terminal-bell",
"completions -",
"dock=0 floatingDot=false",
]
`)
ringBell(store, pane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=2",
"tabs tab-a1=terminal-bell",
"panes tab-a1:L1=terminal-bell",
"completions -",
"dock=1 floatingDot=false",
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
]
`)
typeInPane(store, pane)
store.setState(rowsWith(true))
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=2",
"tabs tab-a1=terminal-bell",
"panes tab-a1:L1=terminal-bell",
"completions -",
"dock=1 floatingDot=false",
]
`)
})
it('restart: persisted flags with no markers, worktree and folder Dock count', () => {
const { store, persists } = createAttentionStore()
store.setState({
worktreesByRepo: {
repo1: [makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', isUnread: true })]
},
projectGroups: [
{
id: 'group-1',
name: 'platform',
parentPath: '/work',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 0,
updatedAt: 0
}
],
folderWorkspaces: [makeFolderWorkspace({ isUnread: true })]
})
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:0 folder-1@local:UNREAD:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=1 floatingDot=false",
]
`)
const folderKey = folderWorkspaceKey('folder-1')
const folderPane = addLiveTerminalTab(store, folderKey, 'tab-f1')
ringBell(store, folderPane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:0 folder-1@local:UNREAD:0 sortEpoch=0",
"tabs tab-f1=terminal-bell",
"panes -",
"completions -",
"dock=2 floatingDot=false",
]
`)
vi.setSystemTime(2000)
store.getState().setActiveWorktree(folderKey, 'local')
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:0 folder-1@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=1 floatingDot=false",
"persist folder folder-1 {"isUnread":false} host=local",
]
`)
ringBell(store, folderPane)
typeInPane(store, folderPane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:0 folder-1@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=1 floatingDot=false",
]
`)
})
it('folder Dock counts flag AND live tab marker; a completion marker alone does not count', () => {
const { store, persists } = createAttentionStore()
store.setState({
projectGroups: [
{
id: 'group-1',
name: 'platform',
parentPath: '/work',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 0,
updatedAt: 0
}
],
folderWorkspaces: [makeFolderWorkspace()]
})
const folderKey = folderWorkspaceKey('folder-1')
const folderPane = addLiveTerminalTab(store, folderKey, 'tab-f1')
observe(store, persists)
completeAgentTurn(store, folderPane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 folder-1@local:UNREAD:1000 sortEpoch=1",
"tabs -",
"panes -",
"completions tab-f1:L1=agent-completion",
"dock=0 floatingDot=false",
"persist folder folder-1 {"isUnread":true,"lastActivityAt":1000} host=local",
]
`)
store.getState().setActiveWorktree(WS_B)
vi.setSystemTime(5000)
ringBell(store, folderPane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 folder-1@local:UNREAD:1000 sortEpoch=1",
"tabs tab-f1=terminal-bell",
"panes -",
"completions tab-f1:L1=agent-completion",
"dock=1 floatingDot=false",
]
`)
})
})
describe('hot-path no-ops keep the store reference', () => {
it('a keystroke on a read workspace and a repeat bell notify nobody', () => {
const { store, persists } = createAttentionStore({ experimental: true })
@@ -8,7 +8,7 @@ import { createTestStore, makeWorktree, TEST_REPO } from '../store-test-helpers'
import { makeFolderWorkspace } from '../worktrees-slice-test-fixtures'
import { markWorkspaceUnreadFlag } from './workspace-unread-flag'
import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client'
import '@/runtime/runtime-rpc-client'
// With the switch on, the window raises the flag exactly where the owning host does not.
@@ -90,17 +90,6 @@ beforeEach(() => {
})
describe('the window raises the unread flag only where its host does not', () => {
it('answers from the cached status without probing again', async () => {
const { mark, isUnread } = createStore()
cachedStatus.set('slow', [WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY])
await mark(SLOW_SERVER_WT)
expect(isUnread(SLOW_SERVER_WT)).toBe(false)
cachedStatus.set('slow', [])
await mark(SLOW_SERVER_WT)
expect(isUnread(SLOW_SERVER_WT)).toBe(true)
expect(runtimeEnvironmentSupportsCapability).not.toHaveBeenCalled()
})
it('waits for a server’s first status, and raises it when the status cannot be read', async () => {
const { mark, isUnread } = createStore()
let answer: (capabilities: readonly string[]) => void = () => {}
@@ -1,31 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist'
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
import {
registerAgentSubjectReadCapture,
subscribeAgentSubjectReads,
type AgentSubjectRead,
type AgentSubjectReadIntent,
type StructuredSubjectRead
} from '@/attention/agent-subject-read-actions'
import { makeWorktree } from '../worktrees-slice-test-fixtures'
import {
completeAgentTurn,
pointerDownInTab,
ringBell,
setWindowOnScreen,
toggleUnreadByUser,
typeInPane
} from './workspace-unread-characterization-driver'
import '../../../../../shared/agent-session/view/structured-agent-session-projection'
import '@/attention/agent-subject-read-actions'
import '../worktrees-slice-test-fixtures'
import { ringBell, setWindowOnScreen } from './workspace-unread-characterization-driver'
import {
addLiveTerminalTab,
createAttentionStore,
installAttentionMockApi,
LEAF_1,
LEAF_2,
observe,
WS_A,
type AttentionStore
WS_A
} from './workspace-unread-characterization-fixture'
// On screen means read, the flag follows its markers, and reads retire OS notifications.
@@ -52,26 +36,6 @@ afterEach(() => {
vi.useRealTimers()
})
/** A tab split into LEAF_1 | LEAF_2. */
function splitTab(store: AttentionStore, tabId: string): void {
const s = store.getState()
store.setState({
terminalLayoutsByTabId: {
...s.terminalLayoutsByTabId,
[tabId]: {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_1 },
second: { type: 'leaf', leafId: LEAF_2 }
},
activeLeafId: LEAF_1,
expandedLeafId: null
}
}
})
}
const dismissedPaneKeys = (): string[][] =>
dismiss.mock.calls.map((call) => [...(call[1] ?? [])].sort())
@@ -108,259 +72,4 @@ describe('on screen means read', () => {
`)
expect(dismissedPaneKeys()).toEqual([[pane.paneKey]])
})
it('a bell in the pane on screen of a focused window leaves no marker and writes no flag', () => {
const { store, persists } = createAttentionStore()
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
store.getState().setActiveWorktree(WS_A)
observe(store, persists)
ringBell(store, pane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
expect(dismiss).not.toHaveBeenCalled()
})
it('a completion in any split pane of the tab on screen leaves no marker and writes no flag', () => {
const { store, persists } = createAttentionStore({ experimental: true })
addLiveTerminalTab(store, WS_A, 'tab-a1')
splitTab(store, 'tab-a1')
store.getState().setActiveWorktree(WS_A)
observe(store, persists)
completeAgentTurn(store, { worktreeId: WS_A, paneKey: `tab-a1:${LEAF_2}` })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
expect(dismiss).not.toHaveBeenCalled()
})
it('a tab-level bell dismisses every pane of its split tab; a pane completion only its pane', () => {
const { store } = createAttentionStore()
addLiveTerminalTab(store, WS_A, 'tab-a1')
const hidden = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_1)
splitTab(store, 'tab-a2')
store.getState().setActiveWorktree(WS_A)
store.getState().activateTab('unified-tab-a1')
ringBell(store, hidden)
completeAgentTurn(store, { worktreeId: WS_A, paneKey: `tab-a2:${LEAF_2}` })
expect(dismiss).not.toHaveBeenCalled()
store.getState().activateTab('unified-tab-a2')
expect(dismissedPaneKeys()).toEqual([[`tab-a2:${LEAF_1}`, `tab-a2:${LEAF_2}`]])
})
it('no-op keystrokes and pointerdowns write nothing and send no dismiss', () => {
const { store } = createAttentionStore()
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
store.getState().setActiveWorktree(WS_A)
const before = store.getState()
for (let i = 0; i < 20; i += 1) {
typeInPane(store, pane)
pointerDownInTab(store, { ...pane, paneKey: null })
}
expect(store.getState()).toBe(before)
expect(dismiss).not.toHaveBeenCalled()
})
})
describe('the flag follows its markers', () => {
it('a restart flag with no marker is read when its workspace comes on screen', () => {
const { store, persists } = createAttentionStore({ windowOnScreen: false })
store.setState({
worktreesByRepo: {
repo1: [makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', isUnread: true })]
}
})
addLiveTerminalTab(store, WS_A, 'tab-a1')
store.getState().setActiveWorktree(WS_A)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=1 floatingDot=false",
]
`)
setWindowOnScreen(store, true)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
})
it('Mark as unread on the workspace on screen survives input until the screen moves', () => {
const { store, persists } = createAttentionStore()
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
store.getState().setActiveWorktree(WS_A)
observe(store, persists)
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
typeInPane(store, first)
pointerDownInTab(store, { ...first, paneKey: null })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:0 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=1 floatingDot=false",
"persist worktree a {"isUnread":true} host=local",
]
`)
store.getState().activateTab('unified-tab-a1')
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
})
it('a superseded turn removing the last completion marker clears the flag', () => {
const { store, persists } = createAttentionStore()
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
completeAgentTurn(store, pane)
observe(store, persists)
store
.getState()
.recordAgentProviderSession(
pane.paneKey,
'codex',
{ key: 'session_id', id: 'session-a1' },
{ updatedAt: 2000 },
{ tabId: pane.tabId, worktreeId: WS_A }
)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
expect(dismissedPaneKeys()).toEqual([[pane.paneKey]])
})
it('Mark as read removes every marker in the workspace and dismisses their panes', () => {
const { store, persists } = createAttentionStore({ experimental: true })
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
ringBell(store, first)
completeAgentTurn(store, second)
observe(store, persists)
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=2",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false} host=local",
]
`)
expect(dismissedPaneKeys()).toEqual([[first.paneKey, second.paneKey]])
})
it('Mark as read emits an explicit read for each cleared chat', () => {
const { store } = createAttentionStore()
const chat = store
.getState()
.createUnifiedTab(WS_A, 'agent-session', { id: 'chat-a1', entityId: 'session-1' })
const paneKey = structuredAgentSessionPaneKey(chat.id, chat.entityId)
store.getState().markAgentAttentionUnread({
workspaceId: WS_A,
subjectKey: paneKey,
groupId: null,
reason: 'agent-completion'
})
const structured: StructuredSubjectRead = {
sessionId: 'session-1',
target: { kind: 'local' },
observedCursor: { epoch: 'epoch-1', sequence: 10 },
observationKey: 'edge:epoch-1:10'
}
const intents: AgentSubjectReadIntent[] = []
const emitted: AgentSubjectRead[] = []
const stopCapture = registerAgentSubjectReadCapture(paneKey, (intent) => {
intents.push(intent)
return structured
})
const stopReads = subscribeAgentSubjectReads((reads) => emitted.push(...reads))
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
stopReads()
stopCapture()
expect(intents).toEqual(['explicit'])
expect(emitted).toEqual([{ subjectKey: paneKey, structured }])
})
it('closing an unmarked tab keeps a manual unread; closing a marked one dismisses its panes', () => {
const { store, persists } = createAttentionStore()
const marked = addLiveTerminalTab(store, WS_A, 'tab-a1')
const unmarked = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
observe(store, persists)
store.getState().closeTab(unmarked.tabId)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:0 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=1 floatingDot=false",
]
`)
expect(dismiss).not.toHaveBeenCalled()
ringBell(store, marked)
store.getState().closeTab(marked.tabId)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
expect(dismissedPaneKeys()).toEqual([[marked.paneKey]])
})
})
@@ -1,395 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist'
import type * as AgentStatusModule from '@/lib/agent-status'
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
import { makeFolderWorkspace } from '../worktrees-slice-test-fixtures'
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
import {
applySleepRuntimeRpcDefault,
createStoreCascadesMockApi
} from '../store-cascades-test-harness'
import {
addLiveTerminalTab,
createAttentionStore,
LEAF_2,
observe,
WS_A,
type AttentionStore,
type PaneTarget
} from './workspace-unread-characterization-fixture'
import { completeAgentTurn, ringBell } from './workspace-unread-characterization-driver'
import { drawTabsFromMain, type TabsDrawnFromMain } from '../tabs-drawn-from-main.test-fixture'
// Pins what removing a tab or pane does to unread: its markers go, and a workspace whose removed
// marker was its last live one is read.
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
unregisterPtyDataHandlers: vi.fn(() => [])
}))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
vi.mock('../worktrees/metadata/worktree-meta-persist', async (importOriginal) => {
const actual = await importOriginal<typeof WorktreeMetaPersistModule>()
return { ...actual, persistWorktreeMeta: vi.fn(async () => undefined) }
})
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeLayoutClientWithTestFrames(await importOriginal())
)
let main: TabsDrawnFromMain | undefined
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(1000)
const api = createStoreCascadesMockApi()
applySleepRuntimeRpcDefault(api)
api.pty.kill.mockResolvedValue(undefined)
Object.assign(api, { notifications: { dismiss: vi.fn().mockResolvedValue(undefined) } })
})
afterEach(() => {
main?.close()
main = undefined
vi.useRealTimers()
})
/** Two live tabs in background WS_A, each with every marker layer lit. */
function seedLitWorkspace(): {
store: AttentionStore
persists: ReturnType<typeof createAttentionStore>['persists']
first: PaneTarget
second: PaneTarget
} {
const { store, persists } = createAttentionStore({ experimental: true })
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
for (const pane of [first, second]) {
ringBell(store, pane)
completeAgentTurn(store, pane)
}
observe(store, persists)
return { store, persists, first, second }
}
describe('closing tabs', () => {
it('closing the only unread terminal tab removes its markers and the dot (STA-9043)', () => {
const { store, persists } = createAttentionStore({ experimental: true })
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
ringBell(store, pane)
completeAgentTurn(store, pane)
observe(store, persists)
store.getState().closeTab(pane.tabId)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
})
it('closing a terminal through its unified tab removes all of its markers', () => {
const { store, persists, first } = seedLitWorkspace()
store.getState().closeUnifiedTab(`unified-${first.tabId}`)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a2=terminal-bell",
"panes tab-a2:L2=terminal-bell",
"completions tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
]
`)
})
it('a unified close whose terminal retirement is handled elsewhere removes only the tab marker', () => {
const { store, persists, first } = seedLitWorkspace()
store.getState().closeUnifiedTab(`unified-${first.tabId}`, { terminalRetirementHandled: true })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a2=terminal-bell",
"panes tab-a1:L1=terminal-bell tab-a2:L2=terminal-bell",
"completions tab-a1:L1=agent-completion tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
]
`)
})
it('closing a structured chat tab removes its markers', async () => {
const { store, persists } = createAttentionStore({ experimental: true })
const chat = store.getState().createUnifiedTab(WS_A, 'agent-session', {
id: 'chat-1',
entityId: 'session-1'
})
const paneKey = structuredAgentSessionPaneKey(chat.id, chat.entityId)
store.setState({
unreadTerminalTabs: { [chat.id]: 'agent-completion' },
unreadTerminalPanes: { [paneKey]: 'agent-completion' },
unreadAgentCompletionPanes: { [paneKey]: 'agent-completion' }
})
main = await drawTabsFromMain(store)
observe(store, persists)
store.getState().closeUnifiedTab(chat.id)
await main.settle()
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
})
it('closing the only unread folder tab clears the folder flag', () => {
const { store, persists } = createAttentionStore()
store.setState({
projectGroups: [
{
id: 'group-1',
name: 'platform',
parentPath: '/work',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 0,
updatedAt: 0
}
],
folderWorkspaces: [makeFolderWorkspace()]
})
const pane = addLiveTerminalTab(store, folderWorkspaceKey('folder-1'), 'tab-f1')
ringBell(store, pane)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 folder-1@local:UNREAD:1000 sortEpoch=1",
"tabs tab-f1=terminal-bell",
"panes -",
"completions -",
"dock=1 floatingDot=false",
"persist folder folder-1 {"isUnread":true,"lastActivityAt":1000} host=local",
]
`)
store.getState().closeTab(pane.tabId)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 folder-1@local:read:1000 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist folder folder-1 {"isUnread":false} host=local",
]
`)
})
})
describe('sleep, hibernate, retire, rekey, reconcile, purge', () => {
it('sleeping the workspace removes every marker of its tabs and its flag', async () => {
const { store, persists } = seedLitWorkspace()
await store.getState().shutdownWorktreeTerminals(WS_A, { keepIdentifiers: true })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
})
it('hibernating a completed agent pane removes that pane’s markers only', async () => {
const { store, persists, first } = seedLitWorkspace()
store
.getState()
.setAgentStatus(
first.paneKey,
{ state: 'done', prompt: 'p', agentType: 'codex', lastAssistantMessage: 'done' },
'Codex',
{ updatedAt: 900, stateStartedAt: 800 },
{ tabId: first.tabId, worktreeId: WS_A },
{ providerSession: { key: 'session_id', id: 'session-a1' } }
)
observe(store, persists)
await store.getState().shutdownCompletedAgentPaneForHibernation(WS_A, {
paneKey: first.paneKey,
tabId: first.tabId,
leafId: first.paneKey.split(':')[1]!,
ptyId: `pty-${first.tabId}`
})
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=3",
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
"panes tab-a2:L2=terminal-bell",
"completions tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
]
`)
})
it('retiring a pane authority removes its pane and completion markers', () => {
const { store, persists, first } = seedLitWorkspace()
store.getState().retireAgentPaneAuthority(first.paneKey)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
"panes tab-a2:L2=terminal-bell",
"completions tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
]
`)
})
it('recording a provider session removes only the completion marker', () => {
const { store, persists, first } = seedLitWorkspace()
store
.getState()
.recordAgentProviderSession(
first.paneKey,
'codex',
{ key: 'session_id', id: 'session-a1' },
{ updatedAt: 2000 },
{ tabId: first.tabId, worktreeId: WS_A }
)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
"panes tab-a1:L1=terminal-bell tab-a2:L2=terminal-bell",
"completions tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
]
`)
})
it('transferring a pane authority moves its pane markers', () => {
const { store, persists, first } = seedLitWorkspace()
store.getState().transferAgentPaneAuthority({
fromPaneKey: first.paneKey,
toPaneKey: `${first.tabId}:33333333-3333-4333-8333-333333333333`
})
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
"panes tab-a2:L2=terminal-bell tab-a1:33333333-3333-4333-8333-333333333333=terminal-bell",
"completions tab-a2:L2=agent-completion tab-a1:33333333-3333-4333-8333-333333333333=agent-completion",
"dock=1 floatingDot=false",
]
`)
})
it('reconciling a dropped terminal on activation leaves no live marker, so the flag reads', () => {
const { store, persists, first } = seedLitWorkspace()
store.setState({
tabsByWorktree: {
[WS_A]: (store.getState().tabsByWorktree[WS_A] ?? []).filter(
(tab) => tab.id !== first.tabId
)
}
})
observe(store, persists)
store.getState().setActiveWorktree(WS_A)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes tab-a1:L1=terminal-bell",
"completions tab-a1:L1=agent-completion",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
})
it('purging the workspace removes every marker of its tabs and its flag', () => {
const { store, persists } = seedLitWorkspace()
store.getState().purgeWorktreeTerminalState([WS_A])
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
})
})
describe('engagement, read state and tab focus on other surfaces', () => {
it('focusing a group reads nothing beyond the active tab already on screen', () => {
const { store, persists } = seedLitWorkspace()
store.getState().setActiveWorktree(WS_A)
observe(store, persists)
const groupId = store.getState().groupsByWorktree[WS_A]?.[0]?.id
store.getState().focusGroup(WS_A, groupId!)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell",
"panes tab-a1:L1=terminal-bell",
"completions tab-a1:L1=agent-completion",
"dock=1 floatingDot=false",
]
`)
})
it('acknowledging agents drops their completion markers only', () => {
const { store, persists, first } = seedLitWorkspace()
store.getState().acknowledgeAgents([first.paneKey], [])
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
"panes tab-a1:L1=terminal-bell tab-a2:L2=terminal-bell",
"completions tab-a2:L2=agent-completion",
"dock=1 floatingDot=false",
]
`)
})
})
@@ -1,21 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist'
import { makeFolderWorkspace, makeWorktree } from '../worktrees-slice-test-fixtures'
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
import {
completeAgentTurn,
pointerDownInTab,
ringBell,
toggleUnreadByUser
} from './workspace-unread-characterization-driver'
import { makeWorktree } from '../worktrees-slice-test-fixtures'
import '../../../../../shared/workspace-scope'
import { pointerDownInTab, ringBell } from './workspace-unread-characterization-driver'
import {
addLiveTerminalTab,
createAttentionStore,
installAttentionMockApi,
LEAF_2,
observe,
WS_A,
type AttentionStore
WS_A
} from './workspace-unread-characterization-fixture'
// What counts as the screen moving, what is on it, and who is looking at it.
@@ -46,41 +39,6 @@ const flushPresenceCheck = async (): Promise<void> => {
await Promise.resolve()
}
function seedSameIdFolders(store: AttentionStore, unread: { local: boolean; ssh: boolean }) {
store.setState({
folderWorkspaces: [
makeFolderWorkspace({ executionHostId: 'local', isUnread: unread.local }),
makeFolderWorkspace({ executionHostId: 'ssh:remote', isUnread: unread.ssh })
]
})
}
describe('arrival without a terminal or chat on screen', () => {
it('reads a restart flag on a workspace with no tabs, but not from Settings', () => {
const { store, persists } = createAttentionStore()
store.setState({
activeView: 'settings',
worktreesByRepo: {
repo1: [makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', isUnread: true })]
}
})
store.getState().setActiveWorktree(WS_A)
expect(observe(store, persists)[0]).toContain('a@local:UNREAD')
store.setState({ activeView: 'terminal' })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
})
})
describe('presence', () => {
it('a bell on screen while the desktop is away marks, and returning input reads it', async () => {
const { store, persists } = createAttentionStore({ desktopAway: true })
@@ -147,160 +105,4 @@ describe('presence evidence', () => {
]
`)
})
it('a completion with no subject on the active workspace marks while Settings is showing', () => {
const { store, persists } = createAttentionStore()
addLiveTerminalTab(store, WS_A, 'tab-a1')
store.getState().setActiveWorktree(WS_A)
store.setState({ activeView: 'settings' })
observe(store, persists)
completeAgentTurn(store, { worktreeId: WS_A, paneKey: null })
expect(observe(store, persists)[0]).toContain('a@local:UNREAD')
})
})
describe('hosts and groups', () => {
it('switching between same-id folder rows on two hosts is an arrival on the new host only', () => {
const { store, persists } = createAttentionStore()
const key = folderWorkspaceKey('folder-1')
seedSameIdFolders(store, { local: false, ssh: true })
store.getState().setActiveWorktree(key, 'local')
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 folder-1@local:read:0 folder-1@ssh:remote:UNREAD:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
store.getState().setActiveWorktree(key, 'ssh:remote')
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 folder-1@local:read:0 folder-1@ssh:remote:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist folder folder-1 {"isUnread":false} host=ssh:remote",
]
`)
})
it('closing the last marked tab lowers every same-id folder row and persists each host', () => {
const { store, persists } = createAttentionStore()
const key = folderWorkspaceKey('folder-1')
const pane = addLiveTerminalTab(store, key, 'tab-f1')
seedSameIdFolders(store, { local: true, ssh: true })
ringBell(store, pane)
observe(store, persists)
store.getState().closeTab(pane.tabId)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 folder-1@local:read:0 folder-1@ssh:remote:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist folder folder-1 {"isUnread":false} host=local",
"persist folder folder-1 {"isUnread":false} host=ssh:remote",
]
`)
})
it('a folder row with no host of its own marks and persists under its project group’s host', () => {
const { store, persists } = createAttentionStore()
store.setState({
projectGroups: [
{
id: 'group-1',
name: 'platform',
parentPath: '/work',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 0,
updatedAt: 0,
connectionId: 'ssh-1'
}
],
folderWorkspaces: [makeFolderWorkspace()]
})
const pane = addLiveTerminalTab(store, folderWorkspaceKey('folder-1'), 'tab-f1')
observe(store, persists)
ringBell(store, pane)
expect(observe(store, persists).at(-1)).toMatchInlineSnapshot(
`"persist folder folder-1 {"isUnread":true,"lastActivityAt":1000} host=ssh:ssh-1"`
)
})
it('a runtime host purge keeps the surviving host row’s manual unread', () => {
const { store, persists } = createAttentionStore()
const key = folderWorkspaceKey('folder-1')
store.setState({
folderWorkspaces: [
makeFolderWorkspace({ executionHostId: 'local', isUnread: true }),
makeFolderWorkspace({ executionHostId: 'runtime:env-1', isUnread: true })
]
})
const pane = addLiveTerminalTab(store, key, 'tab-f1')
ringBell(store, pane)
observe(store, persists)
store.getState().purgeStaleRuntimeHostState(['env-1'])
expect(observe(store, persists)[0]).toContain('folder-1@local:UNREAD')
})
it('a bell in the unfocused side-by-side group is on screen and leaves no marker', () => {
const { store, persists } = createAttentionStore()
addLiveTerminalTab(store, WS_A, 'tab-a1')
const leftGroupId = store.getState().groupsByWorktree[WS_A]![0]!.id
const rightGroupId = store.getState().createEmptySplitGroup(WS_A, leftGroupId, 'right')!
const right = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2, rightGroupId)
store.getState().setActiveWorktree(WS_A)
store.getState().focusGroup(WS_A, leftGroupId)
observe(store, persists)
ringBell(store, right)
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
]
`)
})
it('closing the floating panel moves the screen and reads a manual unread behind it', () => {
const { store, persists } = createAttentionStore()
addLiveTerminalTab(store, WS_A, 'tab-a1')
store.setState({
settings: { ...store.getState().settings!, floatingTerminalEnabled: true },
floatingWorkspacePanelOpen: true
})
store.getState().setActiveWorktree(WS_A)
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
expect(observe(store, persists)[0]).toContain('a@local:UNREAD')
store.setState({ floatingWorkspacePanelOpen: false })
expect(observe(store, persists)).toMatchInlineSnapshot(`
[
"rows a@local:read:0 b@local:read:0 sortEpoch=1",
"tabs -",
"panes -",
"completions -",
"dock=0 floatingDot=false",
"persist worktree a {"isUnread":false}",
]
`)
})
})
@@ -2,8 +2,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import '../../../../shared/execution-host'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
vi.mock('sonner', () => ({
@@ -34,8 +34,7 @@ import {
makeWorktree,
makeTab,
makeTabGroup,
makeUnifiedTab,
TEST_REPO
makeUnifiedTab
} from './store-test-helpers'
import {
clearNativeChatComposerDraftsForTests,
@@ -220,83 +219,6 @@ describe('workspace chat drafts on removal', () => {
expect(draftTexts()).toEqual(['', '', 'other'])
})
it('a removal the host refuses keeps the drafts', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = createTestStore()
seedWorktree(store)
mockApi.worktrees.remove.mockRejectedValue(new Error('Permission denied'))
const result = await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true)
expect(result.ok).toBe(false)
expect(draftTexts()).toEqual(['chat', 'pane', 'other'])
})
it('a listing purge alone keeps the drafts', () => {
const store = createTestStore()
seedWorktree(store)
store.getState().purgeWorktreeTerminalState([WT1])
expect(store.getState().unifiedTabsByWorktree[WT1]).toBeUndefined()
expect(draftTexts()).toEqual(['chat', 'pane', 'other'])
})
it('re-pairing a server under the same id keeps the drafts of chats it restores', () => {
const runtime = toRuntimeExecutionHostId('env-a')
const environment = (pairingRevision: number): PublicKnownRuntimeEnvironment => ({
id: 'env-a',
name: 'Server',
createdAt: 1,
updatedAt: 1,
pairingRevision,
lastUsedAt: null,
runtimeId: null,
endpoints: [{ id: 'e', kind: 'websocket', label: 'Server', endpoint: 'ws://server' }],
preferredEndpointId: 'e'
})
const store = createTestStore()
seedStore(store, {
runtimeEnvironments: [environment(1)],
repos: [TEST_REPO, { ...TEST_REPO, id: 'repoA', path: '/repoA', executionHostId: runtime }],
worktreesByRepo: {
repoA: [makeWorktree({ id: WT1, repoId: 'repoA', path: '/path/wt1', hostId: runtime })]
}
})
seedWorkspace(store, WT1)
store.getState().setRuntimeEnvironments([environment(2)])
expect(store.getState().unifiedTabsByWorktree[WT1]).toBeUndefined()
expect(draftTexts()).toEqual(['chat', 'pane', 'other'])
})
it('removing a project deletes its worktrees’ drafts only', async () => {
const store = createTestStore()
seedStore(store, { repos: [{ ...TEST_REPO, id: 'repo1' }] })
seedWorktree(store)
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' })],
repo2: [makeWorktree({ id: WT2, repoId: 'repo2', path: '/path/wt2' })]
}
})
await store.getState().removeProject('repo1', { hostId: 'local' })
expect(draftTexts()).toEqual(['', '', 'other'])
})
it('deleting a folder workspace deletes its drafts', async () => {
mockApi.folderWorkspaces.delete.mockResolvedValue(true)
const store = createTestStore()
seedFolderWorkspace(store)
await expect(store.getState().deleteFolderWorkspace(FOLDER.id)).resolves.toBe(true)
expect(draftTexts()).toEqual(['', '', 'other'])
})
it('a paste still uploading when its folder workspace is deleted never writes a draft back', async () => {
mockApi.folderWorkspaces.delete.mockResolvedValue(true)
const store = createTestStore()
@@ -1,62 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { getWorkspaceCleanupHostIdentity as identity } from '../../../../shared/workspace-cleanup-host-identity'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { makeTab, makeLayout } from './store-test-helpers'
import {
makeAgentEntry,
makeUnifiedTab
} from '@/components/worktree-jump/worktree-jump-palette-test-fixtures'
import { enrichWorkspaceCleanupCandidates } from './workspace-cleanup-candidate-enrichment'
import { getWorkspaceCleanupAgentEvidence } from './workspace-cleanup-agent-evidence'
import { WORKTREE_ID, makeCandidate, makeState } from './workspace-cleanup-slice-test-harness'
function paneKey(tabId: string): string {
return makePaneKey(tabId, '11111111-1111-4111-8111-111111111111')
}
function agentStatus(paneKey: string, state: AgentStatusEntry['state']): AgentStatusEntry {
return makeAgentEntry('unused', state, Date.now(), { paneKey, connectionId: null })
}
// A structured chat publishes status under its own surface id; its tab is not in tabsByWorktree.
function structuredChatStatus(state: AgentStatusEntry['state']): AgentStatusEntry {
return { ...agentStatus(paneKey('chat-1'), state), worktreeId: WORKTREE_ID }
}
describe('workspace cleanup live-agent finder', () => {
it.each([
['working', true],
['done', false]
] as const)('qualifies %s structured evidence and live children: %s', async (state, live) => {
const entry = structuredChatStatus(state)
const appState = makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } })
const candidates = (['local', 'runtime:host', 'ssh:other'] as const).map((executionHostId) =>
makeCandidate({ executionHostId })
)
const evidence = () => getWorkspaceCleanupAgentEvidence(appState, candidates)
expect([...evidence().keys()]).toEqual(live ? [identity('local', WORKTREE_ID)] : [])
const [enriched] = await enrichWorkspaceCleanupCandidates([makeCandidate()], appState)
expect(enriched.blockers.includes('live-agent')).toBe(live)
appState.tabsByWorktree = {
[WORKTREE_ID]: [makeTab({ id: 'chat-1', worktreeId: WORKTREE_ID, title: '⠋ Codex' })]
}
expect([...evidence().keys()]).toEqual(live ? [identity('local', WORKTREE_ID)] : [])
entry.subagents = [{ id: 'child', state: 'working', startedAt: Date.now() }]
appState.tabsByWorktree = {}
if (!live) {
const tab = makeUnifiedTab('chat-1', WORKTREE_ID, 'session-1', 'Codex Chat')
appState.unifiedTabsByWorktree = { [WORKTREE_ID]: [{ ...tab, contentType: 'agent-session' }] }
}
expect([...evidence().keys()]).toEqual([identity('local', WORKTREE_ID)])
appState.ptyIdsByTabId = { 'chat-1': ['pty-other', 'remote:host@@pty'] }
const layout = makeLayout()
layout.ptyIdsByLeafId = { [entry.paneKey.split(':')[1]]: 'remote:host@@pty' }
appState.terminalLayoutsByTabId = { 'chat-1': layout }
expect([...evidence().keys()]).toEqual([identity('runtime:host', WORKTREE_ID)])
appState.terminalLayoutsByTabId = {}
appState.agentStatusByPaneKey = { [entry.paneKey]: { ...entry, connectionId: undefined } }
expect([...evidence().values()]).toEqual(['unknown', 'unknown', 'unknown'])
})
})
@@ -6,10 +6,7 @@ import {
resetWorkspaceCleanupBrowsePersistTimer,
WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS
} from './workspace-cleanup-browse'
import {
createDefaultWorkspaceCleanupBrowseState,
normalizeWorkspaceCleanupBrowseState
} from '../../../../shared/workspace-cleanup-browse-state'
import { createDefaultWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state'
const DISMISSAL = {
worktreeId: 'wt-1',
@@ -39,32 +36,6 @@ describe('workspace cleanup browse slice', () => {
vi.useRealTimers()
})
it('starts from the default browse state', () => {
const store = createStore(vi.fn().mockResolvedValue(undefined))
expect(store.getState().workspaceCleanupBrowse).toEqual(
createDefaultWorkspaceCleanupBrowseState()
)
})
it('persists the debounced state alongside the dismissals it would otherwise erase', async () => {
const uiSet = vi.fn().mockResolvedValue(undefined)
const store = createStore(uiSet)
const next = createDefaultWorkspaceCleanupBrowseState()
next.filters.query = 'stale'
store.getState().updateWorkspaceCleanupBrowseState(next)
expect(store.getState().workspaceCleanupBrowse).toBe(next)
expect(uiSet).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS)
expect(uiSet).toHaveBeenCalledTimes(1)
expect(uiSet).toHaveBeenCalledWith({
workspaceCleanup: { dismissals: { 'wt-1': DISMISSAL }, browse: next }
})
})
it('coalesces a burst of edits into one write carrying the newest state', async () => {
const uiSet = vi.fn().mockResolvedValue(undefined)
const store = createStore(uiSet)
@@ -80,18 +51,4 @@ describe('workspace cleanup browse slice', () => {
expect(uiSet).toHaveBeenCalledTimes(1)
expect(uiSet.mock.calls[0][0].workspaceCleanup.browse.filters.query).toBe('sta')
})
it('round-trips what it persisted back through hydration', async () => {
const uiSet = vi.fn().mockResolvedValue(undefined)
const store = createStore(uiSet)
const next = createDefaultWorkspaceCleanupBrowseState()
next.filters.safety.blockers = ['dirty-files']
next.sort = { field: 'size', direction: 'desc' }
store.getState().updateWorkspaceCleanupBrowseState(next)
await vi.advanceTimersByTimeAsync(WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS)
const persisted = JSON.parse(JSON.stringify(uiSet.mock.calls[0][0].workspaceCleanup.browse))
expect(normalizeWorkspaceCleanupBrowseState(persisted)).toEqual(next)
})
})
@@ -1,144 +0,0 @@
/** Same repoId::path on two hosts must remain distinct through progress merge, dismissal and removal pruning; otherwise confirmation can target a different row (STA-4343). */
import { describe, expect, it, vi } from 'vitest'
import {
getWorkspaceCleanupCandidateIdentity,
resolveWorkspaceCleanupRemovalHostId
} from '../../../../shared/workspace-cleanup-host-identity'
import {
shouldHideWorkspaceCleanupCandidate,
WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
type WorkspaceCleanupCandidate,
type WorkspaceCleanupDismissal
} from '../../../../shared/workspace-cleanup'
import { resolveWorkspaceCleanupRemovalTargets } from './workspace-cleanup-removal-targets'
import type { AppState } from '../types'
import { createTestStore, seedStore } from './store-test-helpers'
const dismissMock = vi.fn().mockResolvedValue(undefined)
// @ts-expect-error -- minimal window.api stub for dismissal persistence
globalThis.window = { api: { workspaceCleanup: { dismiss: dismissMock } } }
const WORKTREE_ID = 'repo1::/shared/workspace'
function makeCandidate(
executionHostId: WorkspaceCleanupCandidate['executionHostId'],
connectionId: string | null = null
): WorkspaceCleanupCandidate {
return {
worktreeId: WORKTREE_ID,
repoId: 'repo1',
repoName: 'Repo 1',
connectionId,
...(executionHostId ? { executionHostId } : {}),
displayName: 'shared-workspace',
branch: 'old',
path: '/shared/workspace',
tier: 'ready',
selectedByDefault: true,
reasons: ['idle-clean'],
blockers: [],
lastActivityAt: 0,
localContext: {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: 0,
newestDiffCommentAt: null,
retainedDoneAgentCount: 0
},
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 },
fingerprint: 'fp-1'
}
}
function makeDismissal(
executionHostId?: WorkspaceCleanupDismissal['executionHostId']
): WorkspaceCleanupDismissal {
return {
worktreeId: WORKTREE_ID,
dismissedAt: 0,
fingerprint: 'fp-1',
classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
...(executionHostId ? { executionHostId } : {})
}
}
describe('workspace cleanup host-qualified identity', () => {
it('reports no removal host for a row with no host evidence', () => {
expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined))).toBeNull()
// A pre-executionHostId remote row still names its connection.
expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined, 'ssh-1'))).toBe(
'ssh:ssh-1'
)
})
})
describe('workspace cleanup dismissals', () => {
it('hides only the dismissed host row', () => {
const dismissal = makeDismissal('ssh:ssh-1')
expect(
shouldHideWorkspaceCleanupCandidate(makeCandidate('ssh:ssh-1', 'ssh-1'), dismissal)
).toBe(true)
expect(shouldHideWorkspaceCleanupCandidate(makeCandidate('local'), dismissal)).toBe(false)
})
it('stores same-id dismissals independently and still reads a legacy id-only key', async () => {
const local = makeCandidate('local')
const remote = makeCandidate('ssh:ssh-1', 'ssh-1')
const store = createTestStore()
seedStore(store, {
workspaceCleanupScan: { scannedAt: 1, candidates: [local, remote], errors: [] }
} as Partial<AppState>)
await store.getState().dismissWorkspaceCleanupCandidates([local, remote])
expect(Object.keys(store.getState().workspaceCleanupDismissals).sort()).toEqual(
[
getWorkspaceCleanupCandidateIdentity(local),
getWorkspaceCleanupCandidateIdentity(remote)
].sort()
)
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([
expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) }),
expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) })
])
seedStore(store, {
workspaceCleanupDismissals: { [WORKTREE_ID]: makeDismissal() },
workspaceCleanupScan: { scannedAt: 1, candidates: [local], errors: [] }
} as Partial<AppState>)
await store.getState().dismissWorkspaceCleanupCandidates([])
expect(store.getState().workspaceCleanupScan?.candidates[0]?.blockers).toContain('dismissed')
})
})
describe('workspace cleanup removal targets', () => {
const emptyState = { worktreesByRepo: {}, detectedWorktreesByRepo: {} } as unknown as AppState
it('carries the confirmed host through as the removal owner', () => {
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
approvedCandidates: [makeCandidate('ssh:ssh-1', 'ssh-1')]
})
expect(target).toMatchObject({ kind: 'target', executionHostId: 'ssh:ssh-1' })
})
it('fails closed when one id is confirmed for two hosts at once', () => {
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
approvedCandidates: [makeCandidate('local'), makeCandidate('ssh:ssh-1', 'ssh-1')]
})
expect(target?.kind).toBe('unresolved')
})
it('fails closed for a confirmed row with no host evidence despite one known owner', () => {
const state = {
worktreesByRepo: { repo1: [{ id: WORKTREE_ID, repoId: 'repo1', hostId: 'ssh:ssh-1' }] },
detectedWorktreesByRepo: {}
} as unknown as AppState
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], state, {
approvedCandidates: [makeCandidate(undefined)]
})
expect(target?.kind).toBe('unresolved')
})
})
@@ -1,63 +0,0 @@
import { describe, expect, it } from 'vitest'
import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { shouldPreserveCleanupInspection } from './workspace-cleanup-local-evidence'
import { WORKTREE_ID, makeCandidate, makeState } from './workspace-cleanup-slice-test-harness'
describe('cleanup inspection grace survives upgrades', () => {
// Legacy persisted viewed marks lack newer fields; requiring them would silently remove existing users’ grace period.
it('honours a viewed mark persisted without any newer fields', () => {
const candidate = makeCandidate({ fingerprint: 'fingerprint-1' })
const state = makeState({
workspaceCleanupViewedCandidates: {
[WORKTREE_ID]: { viewedAt: Date.now(), fingerprint: 'fingerprint-1' }
}
})
expect(shouldPreserveCleanupInspection(candidate, state)).toBe(true)
})
it('still drops the grace once the row fingerprint changes', () => {
const candidate = makeCandidate({ fingerprint: 'fingerprint-2' })
const state = makeState({
workspaceCleanupViewedCandidates: {
[WORKTREE_ID]: { viewedAt: Date.now(), fingerprint: 'fingerprint-1' }
}
})
expect(shouldPreserveCleanupInspection(candidate, state)).toBe(false)
})
it('still expires the grace after the window', () => {
const candidate = makeCandidate({ fingerprint: 'fingerprint-1' })
const state = makeState({
workspaceCleanupViewedCandidates: {
[WORKTREE_ID]: {
viewedAt: Date.now() - 3 * 60 * 60 * 1000,
fingerprint: 'fingerprint-1'
}
}
})
expect(shouldPreserveCleanupInspection(candidate, state)).toBe(false)
})
})
describe('cleanup reads visit recency host-qualified', () => {
// Modern visits use host|worktreeId, including local; bare-ID reads miss them and report zero.
it('finds a visit stamped under the host-qualified key', async () => {
const { enrichWorkspaceCleanupCandidates } = await import('./workspace-cleanup')
const qualifiedKey = composeWorktreeHostIdentity('local', WORKTREE_ID)
expect(qualifiedKey).not.toBe(WORKTREE_ID)
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
lastVisitedAtByWorktreeId: { [qualifiedKey]: Date.now() },
browserTabsByWorktree: { [WORKTREE_ID]: [{ id: 'tab-1' }] } as never
}),
{ applyDismissals: false }
)
expect(candidate.blockers).toContain('recent-visible-context')
})
})
@@ -1,54 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { makeTerminalTab } from '@/components/worktree-jump/worktree-jump-palette-test-fixtures'
import { NOW, WORKTREE_ID, makeCandidate, makeState } from './workspace-cleanup-slice-test-harness'
vi.mock('../../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
const DAY_MS = 24 * 60 * 60 * 1000
// Plain-shell-only remote visits lack agent activity; host-owned start/exit stamps do not update Recent either.
function plainShellWorkspace(visitedDaysAgo: number) {
const candidate = makeCandidate({
executionHostId: 'ssh:box',
lastActivityAt: NOW - 45 * DAY_MS,
reasons: ['idle-clean']
})
const state = makeState({
tabsByWorktree: { [WORKTREE_ID]: [makeTerminalTab('tab-1', WORKTREE_ID, 'zsh')] },
lastVisitedAtByWorktreeId: {
[composeWorktreeHostIdentity('ssh:box', WORKTREE_ID)]: NOW - visitedDaysAgo * DAY_MS
}
})
return { candidate, state }
}
describe('cleanup reads a plain-shell workspace’s visits as activity (switch on)', () => {
beforeEach(() => {
vi.useFakeTimers({ now: NOW, toFake: ['Date'] })
})
afterEach(() => {
vi.useRealTimers()
})
it('a workspace visited two days ago is not idle, and shows that as its last activity', async () => {
const { enrichWorkspaceCleanupCandidates } = await import('./workspace-cleanup')
const { candidate, state } = plainShellWorkspace(2)
const [enriched] = await enrichWorkspaceCleanupCandidates([candidate], state, {
applyDismissals: false
})
expect(enriched.reasons).toEqual([])
expect(enriched.lastActivityAt).toBe(NOW - 2 * DAY_MS)
expect(enriched.selectedByDefault).toBe(false)
})
it('a workspace last visited 40 days ago is still idle', async () => {
const { enrichWorkspaceCleanupCandidates } = await import('./workspace-cleanup')
const { candidate, state } = plainShellWorkspace(40)
const [enriched] = await enrichWorkspaceCleanupCandidates([candidate], state, {
applyDismissals: false
})
expect(enriched.reasons).toEqual(['idle-clean'])
expect(enriched.lastActivityAt).toBe(NOW - 40 * DAY_MS)
})
})
@@ -1,18 +1,14 @@
import {
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupHostIdentity
} from '../../../../shared/workspace-cleanup-host-identity'
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup'
import { enrichWorkspaceCleanupCandidates } from './workspace-cleanup'
import './workspace-cleanup'
import {
NOW,
WORKTREE_ID,
createCleanupTestStore,
installWorkspaceCleanupApi,
makeCandidate,
makeState
makeCandidate
} from './workspace-cleanup-slice-test-harness'
describe('workspace cleanup removal and protection', () => {
@@ -97,137 +93,6 @@ describe('workspace cleanup removal and protection', () => {
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([])
})
it('returns preserved branches for the cleanup batch summary', async () => {
const candidate = makeCandidate()
installWorkspaceCleanupApi(
vi.fn(async () => ({
scannedAt: NOW,
candidates: [candidate],
errors: []
}))
)
const removeWorktree = vi.fn().mockResolvedValue({
ok: true,
preservedBranch: { branchName: 'feature/cleanup', head: 'saved-head' }
})
const store = createCleanupTestStore(removeWorktree)
store.setState({
workspaceCleanupScan: {
scannedAt: NOW,
candidates: [candidate],
errors: []
}
} as Partial<AppState>)
await expect(
store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId])
).resolves.toEqual({
removedIds: [candidate.worktreeId],
removedIdentities: [getWorkspaceCleanupHostIdentity('local', candidate.worktreeId)],
failures: [],
preservedBranches: [
{
worktreeId: candidate.worktreeId,
branchName: 'feature/cleanup',
expectedHead: 'saved-head'
}
]
})
expect(removeWorktree).toHaveBeenCalledWith(
{ id: candidate.worktreeId, executionHostId: null },
false,
{
assertBeforeDispatch: expect.any(Function),
suppressPreservedBranchToast: true
}
)
})
it('keeps a viewed active workspace visible but not removable', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
activeWorktreeId: WORKTREE_ID,
workspaceCleanupViewedCandidates: {
[WORKTREE_ID]: {
viewedAt: Date.now(),
fingerprint: 'fingerprint-1'
}
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('active-workspace')
})
it('protects recently visible old workspaces with open context', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
openFiles: [
{
id: 'file-1',
worktreeId: WORKTREE_ID,
filePath: '/tmp/old-workspace/src/app.ts',
relativePath: 'src/app.ts',
language: 'typescript',
isDirty: false
}
] as AppState['openFiles'],
lastVisitedAtByWorktreeId: {
[WORKTREE_ID]: Date.now()
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('recent-visible-context')
})
it('lets explicitly selected not-suggested workspaces reach the removal path', async () => {
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [makeCandidate()],
errors: []
} satisfies WorkspaceCleanupScanResult)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined)
}
}
}
const store = createCleanupTestStore(removeWorktree)
store.setState({
tabsByWorktree: {
[WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
} as Partial<AppState>)
await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual(
{
removedIds: [WORKTREE_ID],
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
failures: []
}
)
expect(removeWorktree).toHaveBeenCalledWith({ id: WORKTREE_ID, executionHostId: null }, false, {
assertBeforeDispatch: expect.any(Function),
suppressPreservedBranchToast: true
})
})
it('fails a queued removal that now needs a force the user never approved', async () => {
const approvedCandidate = makeCandidate({ executionHostId: 'local' })
const dirtySinceConfirmation = makeCandidate({
@@ -269,46 +134,6 @@ describe('workspace cleanup removal and protection', () => {
expect(removeWorktree).not.toHaveBeenCalled()
})
it('still force-removes rows whose approved candidate already carried git risk', async () => {
const approvedCandidate = makeCandidate({
executionHostId: 'local',
tier: 'review',
blockers: ['dirty-files'],
git: {
clean: false,
upstreamAhead: 0,
upstreamBehind: 0,
checkedAt: NOW
}
})
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [approvedCandidate],
errors: []
} satisfies WorkspaceCleanupScanResult)
installWorkspaceCleanupApi(scan)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
await expect(
store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [approvedCandidate]
})
).resolves.toEqual({
removedIds: [WORKTREE_ID],
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
failures: []
})
expect(removeWorktree).toHaveBeenCalledWith(
{ id: WORKTREE_ID, executionHostId: 'local' },
true,
{
assertBeforeDispatch: expect.any(Function),
suppressPreservedBranchToast: true
}
)
})
it('fails a removal that reveals concrete git risk after an unverified force approval', async () => {
const approvedCandidate = makeCandidate({
executionHostId: 'local',
@@ -354,38 +179,4 @@ describe('workspace cleanup removal and protection', () => {
})
expect(removeWorktree).not.toHaveBeenCalled()
})
it('does not let an idle title in another tab mask a running agent process', async () => {
;(globalThis as { window: unknown }).window = {
api: {
pty: {
hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'),
getForegroundProcess: vi.fn(async (ptyId: string) =>
ptyId === 'pty-running' ? 'codex' : 'zsh'
)
}
}
}
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
tabsByWorktree: {
[WORKTREE_ID]: [
{ id: 'tab-running', title: 'zsh' },
{ id: 'tab-idle', title: 'Codex done' }
] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: {
'tab-running': ['pty-running'],
'tab-idle': ['pty-idle']
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('running-terminal')
})
})
@@ -1,33 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { registerInFlightWorkspaceCleanupScan } from './workspace-cleanup-broad-scan-registry'
import {
beginWorkspaceCleanupScan,
invalidateWorkspaceCleanupScanProgress,
isLatestWorkspaceCleanupScan
} from './workspace-cleanup-scan-progress'
describe('workspace cleanup stale scan guard', () => {
const cancelScan = vi.fn(async () => true)
beforeEach(() => {
cancelScan.mockReset()
;(globalThis as { window: unknown }).window = {
api: { workspaceCleanup: { cancelScan } }
}
})
it('revokes the current token before removal pruning can expose late progress', () => {
const staleToken = beginWorkspaceCleanupScan()
registerInFlightWorkspaceCleanupScan(
'native:repo-1',
'scan-before-removal',
new Promise<never>(() => undefined)
)
expect(isLatestWorkspaceCleanupScan(staleToken)).toBe(true)
invalidateWorkspaceCleanupScanProgress()
expect(isLatestWorkspaceCleanupScan(staleToken)).toBe(false)
expect(cancelScan).toHaveBeenCalledExactlyOnceWith('scan-before-removal')
})
})
@@ -7,7 +7,6 @@ import type {
} from '../../../../shared/workspace-cleanup'
import {
NOW,
WORKTREE_ID,
createCleanupTestStore,
deferred,
installWorkspaceCleanupApi,
@@ -16,26 +15,6 @@ import {
import { WorkspaceCleanupScanSupersededError } from './workspace-cleanup-broad-scan-registry'
describe('workspace cleanup scan progress', () => {
it('joins duplicate broad cleanup scans', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn().mockReturnValue(pending.promise)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const first = store.getState().scanWorkspaceCleanup()
const second = store.getState().scanWorkspaceCleanup()
expect(scan).toHaveBeenCalledTimes(1)
expect(store.getState().workspaceCleanupLoading).toBe(true)
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
pending.resolve(result)
await expect(Promise.all([first, second])).resolves.toEqual([result, result])
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1)
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
@@ -65,55 +44,6 @@ describe('workspace cleanup scan progress', () => {
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('shows scanned cleanup candidates before the final broad scan resolves', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' })
const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [partialCandidate],
errors: []
})
expect(store.getState().workspaceCleanupLoading).toBe(true)
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress).toMatchObject({
scannedWorktreeCount: 1,
totalWorktreeCount: 2
})
})
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate])
pending.resolve({
scannedAt: NOW,
candidates: [partialCandidate, finalCandidate],
errors: []
})
await expect(scanPromise).resolves.toEqual({
scannedAt: NOW,
candidates: [partialCandidate, finalCandidate],
errors: []
})
expect(store.getState().workspaceCleanupLoading).toBe(false)
expect(store.getState().workspaceCleanupProgress).toMatchObject({
scannedWorktreeCount: 2,
totalWorktreeCount: 2
})
})
it('keeps same-id host rows distinct across streamed enrichment frames', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
@@ -162,133 +92,6 @@ describe('workspace cleanup scan progress', () => {
])
})
it('does not re-probe previously enriched rows during cumulative progress updates', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' })
const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const hasChildProcesses = vi.fn().mockResolvedValue(false)
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
;(
globalThis.window as unknown as {
api: {
pty?: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: typeof getForegroundProcess
}
}
}
).api.pty = { hasChildProcesses, getForegroundProcess }
const store = createCleanupTestStore()
store.setState({
tabsByWorktree: {
'repo1::/tmp/terminal': [
{ id: 'tab-1', title: 'zsh' }
] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
} as Partial<AppState>)
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [terminalCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
})
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 2,
totalWorktreeCount: 2,
candidates: [laterCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2)
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
pending.resolve({
scannedAt: NOW,
candidates: [terminalCandidate, laterCandidate],
errors: []
})
await scanPromise
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
})
it('updates count-only append progress without replacing existing candidate rows', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const candidate = makeCandidate({ worktreeId: 'repo1::/tmp/alpha' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [candidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
})
const candidatesAfterFirstAppend = store.getState().workspaceCleanupScan?.candidates
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 2,
totalWorktreeCount: 2,
candidates: [],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
expect(store.getState().workspaceCleanupScan?.candidates).toBe(candidatesAfterFirstAppend)
pending.resolve({
scannedAt: NOW,
candidates: [candidate],
errors: []
})
await scanPromise
})
it('keeps append progress rows when terminal enrichment resolves out of order', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const terminalProbe = deferred<boolean>()
@@ -575,127 +378,4 @@ describe('workspace cleanup scan progress', () => {
expect(store.getState().workspaceCleanupLoading).toBe(false)
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([refreshedCandidate])
})
it('does not join broad cleanup scans with different explicit args', async () => {
const firstPending = deferred<WorkspaceCleanupScanResult>()
const secondPending = deferred<WorkspaceCleanupScanResult>()
const firstResult = {
scannedAt: NOW,
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })],
errors: []
}
const secondResult = {
scannedAt: NOW + 1,
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })],
errors: []
}
const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) =>
args?.skipGitWorktreeIds?.includes('repo1::/tmp/first')
? firstPending.promise
: secondPending.promise
)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const first = store
.getState()
.scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] })
const second = store
.getState()
.scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] })
expect(scan).toHaveBeenCalledTimes(2)
secondPending.resolve(secondResult)
await second
expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult)
// Superseded scan settlement must neither surface nor overwrite the newer result.
firstPending.resolve(firstResult)
await expect(first).rejects.toBeInstanceOf(WorkspaceCleanupScanSupersededError)
expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult)
})
it('does not join suggestion-only and full-workspace broad scans', async () => {
const legacyPending = deferred<WorkspaceCleanupScanResult>()
const fullPending = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn((args?: { includeAllWorkspaces?: boolean }) =>
args?.includeAllWorkspaces ? fullPending.promise : legacyPending.promise
)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const legacy = store.getState().scanWorkspaceCleanup({ includeAllWorkspaces: false })
const full = store.getState().scanWorkspaceCleanup({ includeAllWorkspaces: true })
expect(scan).toHaveBeenCalledTimes(2)
fullPending.resolve({ scannedAt: NOW, candidates: [], errors: [] })
legacyPending.resolve({ scannedAt: NOW - 1, candidates: [], errors: [] })
await expect(Promise.all([legacy, full])).resolves.toHaveLength(2)
})
it('restarts a superseded key instead of joining the cancelled scan', async () => {
const firstPending = deferred<WorkspaceCleanupScanResult>()
const secondPending = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) =>
args?.skipGitWorktreeIds?.includes('second') ? secondPending.promise : firstPending.promise
)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const first = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] })
const second = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['second'] })
const firstAgain = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] })
// Reissued keys start fresh; they cannot join a superseded/cancelled fleet scan.
expect(scan).toHaveBeenCalledTimes(3)
firstPending.resolve({ scannedAt: NOW, candidates: [], errors: [] })
secondPending.resolve({ scannedAt: NOW, candidates: [], errors: [] })
await expect(first).rejects.toBeInstanceOf(WorkspaceCleanupScanSupersededError)
await expect(second).rejects.toBeInstanceOf(WorkspaceCleanupScanSupersededError)
await expect(firstAgain).resolves.toMatchObject({ scannedAt: NOW })
})
it('keeps stale cleanup results visible after a broad refresh failure', async () => {
const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
const scan = vi
.fn()
.mockResolvedValueOnce(previous)
.mockRejectedValueOnce(new Error('scan failed'))
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
await store.getState().scanWorkspaceCleanup()
await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed')
expect(store.getState().workspaceCleanupScan).toMatchObject(previous)
expect(store.getState().workspaceCleanupError).toBe('scan failed')
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('keeps focused cleanup preflight scans separate from broad scans', async () => {
const broad = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn((args?: { worktreeId?: string }) => {
if (args?.worktreeId) {
return Promise.resolve({
scannedAt: NOW + 1,
candidates: [makeCandidate({ worktreeId: args.worktreeId })],
errors: []
} satisfies WorkspaceCleanupScanResult)
}
return broad.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const broadScan = store.getState().scanWorkspaceCleanup()
const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID })
expect(scan).toHaveBeenCalledTimes(2)
expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID)
expect(store.getState().workspaceCleanupScan).toBeNull()
broad.resolve({ scannedAt: NOW, candidates: [], errors: [] })
await broadScan
expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW)
})
})
@@ -11,44 +11,6 @@ import {
} from './workspace-cleanup-slice-test-harness'
describe('workspace cleanup removal verification and consent', () => {
it.each([null, NOW])(
'fails closed when git evidence is unavailable with checkedAt=%s',
async (checkedAt) => {
const candidate = makeCandidate({
executionHostId: 'local',
blockers: ['git-status-error'],
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt
}
})
installWorkspaceCleanupApi(
vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [candidate],
errors: []
})
)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [candidate]
})
expect(result.failures).toEqual([
expect.objectContaining({
worktreeId: WORKTREE_ID,
canDeleteAnyway: true,
message: expect.stringContaining("couldn't check this workspace's git status")
})
])
expect(removeWorktree).not.toHaveBeenCalled()
}
)
it('scopes repo-level scan failures to their execution host', async () => {
const local = makeCandidate({ executionHostId: 'local' })
const remote = makeCandidate({
@@ -88,41 +50,6 @@ describe('workspace cleanup removal verification and consent', () => {
expect(removeWorktree).toHaveBeenCalledTimes(1)
})
it('fails every same-repo host when an older peer omits the error host', async () => {
const local = makeCandidate({ executionHostId: 'local' })
const remote = makeCandidate({
executionHostId: 'ssh:remote',
displayName: 'remote'
})
installWorkspaceCleanupApi(
vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [local, remote],
errors: [
{
repoId: local.repoId,
repoName: local.repoName,
message: 'Git could not list worktrees.'
}
]
})
)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
const result = await store
.getState()
.removeWorkspaceCleanupCandidates([WORKTREE_ID, WORKTREE_ID], {
approvedCandidates: [local, remote]
})
expect(result.failures).toHaveLength(2)
expect(
result.failures.every((failure) => failure.message.includes('older connected peer'))
).toBe(true)
expect(removeWorktree).not.toHaveBeenCalled()
})
it('honors one matching unverified-removal consent and rejects its replay', async () => {
const candidate = makeCandidate({
executionHostId: 'local',
@@ -163,84 +90,6 @@ describe('workspace cleanup removal verification and consent', () => {
expect(store.getState().beginUnverifiedRemovalConsent(identity)).toEqual(expect.any(String))
})
it("does not let one row's consent authorize a different identity", async () => {
const first = makeCandidate({
executionHostId: 'local',
blockers: ['git-status-error'],
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null
}
})
const second = makeCandidate({
worktreeId: 'repo1::/tmp/second',
path: '/tmp/second',
displayName: 'second',
executionHostId: 'local',
blockers: ['git-status-error'],
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null
}
})
installWorkspaceCleanupApi(
vi.fn(async (args: { worktreeIds?: string[] }) => ({
scannedAt: NOW,
candidates: [first, second].filter((candidate) =>
args.worktreeIds?.includes(candidate.worktreeId)
),
errors: []
}))
)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
const identity = getWorkspaceCleanupCandidateIdentity(first)
const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)!
const result = await store.getState().removeWorkspaceCleanupCandidates([second.worktreeId], {
approvedCandidates: [second],
unverifiedRemovalConsent: { identity, attemptId }
})
expect(result.failures).toEqual([expect.objectContaining({ worktreeId: second.worktreeId })])
expect(removeWorktree).not.toHaveBeenCalled()
})
it('fails closed when the workspace host changes before consent is consumed', async () => {
const approved = makeCandidate({
executionHostId: 'local',
blockers: ['git-status-error'],
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null
}
})
const moved = makeCandidate({ ...approved, executionHostId: 'ssh:remote' })
installWorkspaceCleanupApi(
vi.fn().mockResolvedValue({ scannedAt: NOW, candidates: [moved], errors: [] })
)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
const identity = getWorkspaceCleanupCandidateIdentity(approved)
const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)!
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [approved],
unverifiedRemovalConsent: { identity, attemptId }
})
expect(result.failures).toEqual([
expect.objectContaining({ message: 'Workspace no longer exists.' })
])
expect(removeWorktree).not.toHaveBeenCalled()
})
it('keeps a live consent bound to its call when dialog state is cleared', async () => {
const candidate = makeCandidate({
executionHostId: 'local',
@@ -271,146 +120,4 @@ describe('workspace cleanup removal verification and consent', () => {
expect.objectContaining({ removedIdentities: [identity], failures: [] })
)
})
it('releases consent when an earlier queue guard refuses the row', async () => {
const candidate = makeCandidate({
executionHostId: 'local',
blockers: ['main-worktree', 'git-status-error'],
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null
}
})
installWorkspaceCleanupApi(
vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [candidate],
errors: []
})
)
const store = createCleanupTestStore()
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)!
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [candidate],
unverifiedRemovalConsent: { identity, attemptId }
})
expect(result.failures).toHaveLength(1)
expect(store.getState().beginUnverifiedRemovalConsent(identity)).toEqual(expect.any(String))
})
it('allows different identities to hold unverified-removal grants concurrently', async () => {
const candidates = [
makeCandidate({
executionHostId: 'local',
blockers: ['git-status-error'],
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null
}
}),
makeCandidate({
worktreeId: 'repo1::/tmp/second',
path: '/tmp/second',
displayName: 'second',
executionHostId: 'local',
blockers: ['git-status-error'],
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null
}
})
]
installWorkspaceCleanupApi(
vi.fn(async (args: { worktreeIds?: string[] }) => ({
scannedAt: NOW,
candidates: candidates.filter((candidate) =>
args.worktreeIds?.includes(candidate.worktreeId)
),
errors: []
}))
)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
const consents = candidates.map((candidate) => {
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
return {
identity,
attemptId: store.getState().beginUnverifiedRemovalConsent(identity)!
}
})
const results = await Promise.all(
candidates.map((candidate, index) =>
store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId], {
approvedCandidates: [candidate],
unverifiedRemovalConsent: consents[index]
})
)
)
expect(results.flatMap((result) => result.removedIdentities)).toEqual(
expect.arrayContaining(candidates.map(getWorkspaceCleanupCandidateIdentity))
)
expect(removeWorktree).toHaveBeenCalledTimes(2)
})
it('requires recorded approval before an id-only force removal', async () => {
const candidate = makeCandidate({
blockers: ['unknown-base'],
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
})
installWorkspaceCleanupApi(
vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [candidate],
errors: []
})
)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])
expect(result.failures).toEqual([
expect.objectContaining({
message: 'Review and confirm this workspace before force deleting it.'
})
])
expect(removeWorktree).not.toHaveBeenCalled()
})
it.each([
['dismissed', makeCandidate({ executionHostId: 'local', blockers: ['dismissed'] })],
['recently active', makeCandidate({ executionHostId: 'local', reasons: [] })]
])('removes an explicitly selected %s row', async (_label, approvedCandidate) => {
const freshCandidate = makeCandidate({
...approvedCandidate,
blockers: approvedCandidate.blockers.filter((blocker) => blocker !== 'dismissed')
})
installWorkspaceCleanupApi(
vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [freshCandidate],
errors: []
})
)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [approvedCandidate]
})
expect(result.removedIds).toEqual([WORKTREE_ID])
expect(removeWorktree).toHaveBeenCalledTimes(1)
})
})
@@ -8,9 +8,6 @@ import type { ExecutionHostId } from '../../../../shared/execution-host'
const NOW = 1_700_000_000_000
const HOST_A_HOST_ID: ExecutionHostId = 'local'
const HOST_B_HOST_ID: ExecutionHostId = 'ssh:ssh-1'
const HOST_UNRESOLVED_MESSAGE =
'Orca cannot tell which host owns this workspace. Refresh projects and review it again.'
const WORKSPACE_GONE_MESSAGE = 'Workspace no longer exists.'
const mockApi = {
worktrees: {
@@ -37,7 +34,7 @@ const mockApi = {
// @ts-expect-error -- minimal window.api stub for the store under test
globalThis.window = { api: mockApi }
import { createTestStore, seedStore, makeTab, makeWorktree } from './store-test-helpers'
import { createTestStore, seedStore, makeWorktree } from './store-test-helpers'
import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees'
import type { AppState } from '../types'
import type {
@@ -164,134 +161,6 @@ afterEach(() => {
})
describe('STA-4343: cleanup deletes on the confirmed host, never the active one', () => {
it('confirming host B deletes host B and leaves ACTIVE host A intact', async () => {
const hosts = createHostDirectories()
const routedHostIds: string[] = []
installRemovalTransport(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(HOST_B_HOST_ID)
seedScan([makeHostCandidate(HOST_A_HOST_ID), hostBCandidate])
const store = createTestStore()
// Host A owns the ACTIVE workspace, so removal routing prefers host A.
seedStore(store, {
activeWorktreeId: WORKTREE_ID,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
worktreesByRepo: {
repo1: [
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_A_HOST_ID
}),
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_B_HOST_ID
})
]
},
tabsByWorktree: { [WORKTREE_ID]: [makeTab({ id: 'host-a-tab', worktreeId: WORKTREE_ID })] }
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [hostBCandidate] })
expect(
fs.existsSync(hosts.hostAMarkerPath),
`HOST A (active) worktree directory must still exist after confirming HOST B's row. worktrees.remove was routed to hostId=${routedHostIds.join(',')}`
).toBe(true)
expect(
fs.existsSync(hosts.hostBMarkerPath),
"HOST B's directory must be gone — its row is what the user confirmed"
).toBe(false)
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
expect(removal.removedIds).toEqual([WORKTREE_ID])
expect(removal.failures).toEqual([])
expect(store.getState().tabsByWorktree[WORKTREE_ID]).toEqual([
expect.objectContaining({ id: 'host-a-tab' })
])
})
it('refuses a row whose confirmed host no longer appears in the refreshed scan', async () => {
const hosts = createHostDirectories()
const routedHostIds: string[] = []
installRemovalTransport(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
// The refreshed scan only reaches host A, but host B's row was confirmed.
seedScan([makeHostCandidate(HOST_A_HOST_ID)])
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [makeHostCandidate(HOST_B_HOST_ID)]
})
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
expect(routedHostIds).toEqual([])
// Missing confirmed-host row must fail; another host’s scan cannot decide force/blockers for it.
expect(removal.failures).toEqual([
{
worktreeId: WORKTREE_ID,
executionHostId: HOST_B_HOST_ID,
displayName: 'shared-workspace',
message: WORKSPACE_GONE_MESSAGE
}
])
})
it('refuses a row that carries no host evidence at all', async () => {
const hosts = createHostDirectories()
const routedHostIds: string[] = []
installRemovalTransport({ [HOST_A_HOST_ID]: hosts.hostARoot }, routedHostIds)
const unqualifiedCandidate = makeHostCandidate(undefined, null)
seedScan([unqualifiedCandidate])
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [unqualifiedCandidate]
})
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(routedHostIds).toEqual([])
expect(removal.failures).toEqual([
{ worktreeId: WORKTREE_ID, displayName: 'shared-workspace', message: HOST_UNRESOLVED_MESSAGE }
])
})
it('keeps routing to the confirmed host after an earlier removal changes the active one', async () => {
const hosts = createHostDirectories()
const routedHostIds: string[] = []
@@ -355,185 +224,6 @@ describe('STA-4343: cleanup deletes on the confirmed host, never the active one'
})
})
describe.each([
{ label: 'normal', force: false },
{ label: 'force', force: true }
])('STA-4343: confirmed-host routing holds under $label removal', ({ force }) => {
it('deletes the confirmed remote row while the ACTIVE local one survives', async () => {
const hosts = createHostDirectories()
const routedHostIds: string[] = []
const routedForce: boolean[] = []
installRemovalTransport(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds,
routedForce
)
const hostBCandidate = makeHostCandidate(HOST_B_HOST_ID, undefined, WORKTREE_ID, force)
seedScan([makeHostCandidate(HOST_A_HOST_ID, undefined, WORKTREE_ID, force), hostBCandidate])
const store = createTestStore()
seedStore(store, {
// The ACTIVE workspace is host A's, so routing prefers host A on its own.
activeWorktreeId: WORKTREE_ID,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
worktreesByRepo: {
repo1: [
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_A_HOST_ID
}),
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_B_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [hostBCandidate] })
expect(removal.failures).toEqual([])
expect(removal.removedIds).toEqual([WORKTREE_ID])
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
expect(routedForce).toEqual([force])
expect(fs.existsSync(hosts.hostBMarkerPath), 'confirmed host B must be gone').toBe(false)
expect(fs.existsSync(hosts.hostAMarkerPath), 'ACTIVE host A must survive').toBe(true)
})
it('still deletes an ordinary single-host workspace', async () => {
const hosts = createHostDirectories()
const routedHostIds: string[] = []
const routedForce: boolean[] = []
installRemovalTransport({ [HOST_A_HOST_ID]: hosts.hostARoot }, routedHostIds, routedForce)
const candidate = makeHostCandidate(HOST_A_HOST_ID, undefined, WORKTREE_ID, force)
seedScan([candidate])
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [candidate] })
expect(removal.failures).toEqual([])
expect(removal.removedIds).toEqual([WORKTREE_ID])
expect(routedForce).toEqual([force])
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
})
})
describe('STA-4343: ordinary single-host cleanup still deletes', () => {
it('deletes the confirmed local workspace for real', async () => {
const hosts = createHostDirectories()
const routedHostIds: string[] = []
installRemovalTransport({ [HOST_A_HOST_ID]: hosts.hostARoot }, routedHostIds)
const hostACandidate = makeHostCandidate(HOST_A_HOST_ID)
seedScan([hostACandidate])
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [hostACandidate] })
expect(removal.failures).toEqual([])
expect(removal.removedIds).toEqual([WORKTREE_ID])
expect(routedHostIds).toEqual([HOST_A_HOST_ID])
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
})
it('deletes the confirmed SSH workspace for real', async () => {
const hosts = createHostDirectories()
const routedHostIds: string[] = []
installRemovalTransport({ [HOST_B_HOST_ID]: hosts.hostBRoot }, routedHostIds)
const hostBCandidate = makeHostCandidate(HOST_B_HOST_ID)
seedScan([hostBCandidate])
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_B_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [hostBCandidate] })
expect(removal.failures).toEqual([])
expect(removal.removedIds).toEqual([WORKTREE_ID])
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
})
it('deletes a host-qualified snapshot row after the refreshed owner matches', async () => {
const hosts = createHostDirectories()
const routedHostIds: string[] = []
installRemovalTransport({ [HOST_A_HOST_ID]: hosts.hostARoot }, routedHostIds)
const snapshotCandidate = makeHostCandidate(HOST_A_HOST_ID)
seedScan([makeHostCandidate(HOST_A_HOST_ID)])
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({
id: WORKTREE_ID,
repoId: 'repo1',
path: WORKTREE_PATH,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [snapshotCandidate]
})
expect(removal.failures).toEqual([])
expect(removal.removedIds).toEqual([WORKTREE_ID])
expect(routedHostIds).toEqual([HOST_A_HOST_ID])
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
})
})
import * as launchDrafts from '@/components/native-chat/native-chat-composer-draft-store'
import * as pendingChips from '@/components/native-chat/native-chat-pending-attachment-cache'
@@ -203,51 +203,6 @@ afterEach(() => {
})
describe('STA-4343 wrong-host cleanup removal (git worktree identity)', () => {
it('tears down only the confirmed host VM when another VM owns the same id', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hostA: ExecutionHostId = 'ssh:runtime-ssh-a'
const hostB: ExecutionHostId = 'ssh:runtime-ssh-b'
const hosts = createHostDirectories(worktreeId)
installRemovalTransports({ [hostA]: hosts.hostARoot, [hostB]: hosts.hostBRoot }, [])
mockApi.ephemeralVm.listRuntimes.mockResolvedValue([
{
id: 'runtime-a',
workspaceId: worktreeId,
sshTargetId: 'runtime-ssh-a',
cleanupStatus: 'not_started'
},
{
id: 'runtime-b',
workspaceId: worktreeId,
sshTargetId: 'runtime-ssh-b',
cleanupStatus: 'not_started'
}
])
const hostBCandidate = makeHostCandidate(worktreeId, hostB, worktreePath, 'runtime-ssh-b')
seedCollidingScan([
makeHostCandidate(worktreeId, hostA, worktreePath, 'runtime-ssh-a'),
hostBCandidate
])
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: worktreeId, repoId: 'repo1', path: worktreePath, hostId: hostA }),
makeWorktree({ id: worktreeId, repoId: 'repo1', path: worktreePath, hostId: hostB })
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.failures).toEqual([])
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledTimes(1)
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-b' })
})
it('confirming host B row deletes host B and leaves ACTIVE host A intact', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
@@ -387,76 +342,6 @@ describe('STA-4343 wrong-host cleanup removal (git worktree identity)', () => {
])
})
it('soundness control: with host B ACTIVE, confirming host B still deletes only host B', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_B_HOST_ID
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.failures).toEqual([])
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
expect(store.getState().activeWorktreeId).toBe(worktreeId)
expect(store.getState().activeWorkspaceExecutionHostId).toBe(HOST_A_HOST_ID)
})
it('soundness control: confirming ACTIVE host A deletes host A and leaves host B intact', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostACandidate = makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath)
seedCollidingScan([hostACandidate, makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostACandidate] })
expect(removal.failures).toEqual([])
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
expect(routedHostIds).toEqual([HOST_A_HOST_ID])
expect(store.getState().activeWorktreeId).toBe(worktreeId)
expect(store.getState().activeWorkspaceExecutionHostId).toBe(HOST_B_HOST_ID)
})
it('tears down shared state when cleanup confirms both same-id host rows', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
@@ -536,166 +421,9 @@ describe('STA-4343 wrong-host cleanup removal (git worktree identity)', () => {
expect(store.getState().tabsByWorktree[worktreeId]).toBeUndefined()
expect(store.getState().activeWorktreeId).toBeNull()
})
it('keeps the confirmed host on the preserved-branch follow-up', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds,
{ preservedBranch: { branchName: 'feature/kept', head: 'abc123' } }
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.preservedBranches).toEqual([
{
worktreeId,
branchName: 'feature/kept',
expectedHead: 'abc123',
hostId: HOST_B_HOST_ID
}
])
})
it('routes a Windows-path colliding identity to the confirmed host', async () => {
const worktreeId = 'repo1::C:\\Users\\me\\workspaces\\shared'
const worktreePath = 'C:\\Users\\me\\workspaces\\shared'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.failures).toEqual([])
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A (active) must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
})
})
describe('STA-4343 wrong-host cleanup removal (folder workspace identity)', () => {
it('folder workspace identity deletes the confirmed host, not the ACTIVE one', async () => {
const worktreeId = 'repo1::/shared/folder-workspace'
const worktreePath = '/shared/folder-workspace'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
// Folder workspaces are not Git worktrees: no worktree owner rows exist.
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'host-a-folder-tab', worktreeId })]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.removedIds).toEqual([worktreeId])
expect(removal.failures).toEqual([])
expect(
fs.existsSync(hosts.hostAMarkerPath),
`HOST A (active) folder workspace must still exist. worktrees.remove was routed to hostId=${routedHostIds.join(',')}`
).toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([
expect.objectContaining({ id: 'host-a-folder-tab' })
])
expect(store.getState().activeWorktreeId).toBe(worktreeId)
})
})
describe('STA-4343 wrong-host cleanup removal (paired runtime host)', () => {
it('confirming a paired runtime row deletes over the runtime RPC, not the local host', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_RUNTIME_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_RUNTIME_HOST_ID, worktreePath, null)
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_A_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.failures).toEqual([])
expect(fs.existsSync(hosts.hostAMarkerPath), 'local host A must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
expect(routedHostIds).toEqual([HOST_B_RUNTIME_HOST_ID])
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'hub-1' },
'worktree.rm',
expect.objectContaining({ hostId: HOST_B_RUNTIME_HOST_ID }),
expect.anything()
)
})
// Older paired hosts may discard hostId and delete another same-ID owner; refuse collisions until capability proves host-qualified routing.
it('refuses a colliding removal over the paired transport rather than trusting it', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
@@ -744,112 +472,3 @@ describe('STA-4343 wrong-host cleanup removal (paired runtime host)', () => {
expect(fs.existsSync(hosts.hostBMarkerPath), 'runtime host B must survive').toBe(true)
})
})
describe('STA-4343 cleanup removal fails closed on an unresolved owner', () => {
it('deletes nothing when the confirmed host no longer lists the row', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
// The confirm-time list had both hosts; the preflight rescan sees only host A.
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath)])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
expect(removal.removedIds).toEqual([])
expect(removal.failures).toHaveLength(1)
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
expect(routedHostIds).toEqual([])
})
it('deletes nothing for an unqualified row the store knows on two hosts', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
// A row published before host-qualified candidates existed: no host evidence.
const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null)
seedCollidingScan([legacyCandidate])
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_A_HOST_ID
}),
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: worktreePath,
hostId: HOST_B_HOST_ID
})
]
}
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] })
expect(removal.removedIds).toEqual([])
expect(removal.failures).toHaveLength(1)
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
expect(routedHostIds).toEqual([])
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
})
it('deletes nothing for an old-client row with no host qualifier or catalog owner', async () => {
const worktreeId = 'repo1::/shared/workspace/path'
const worktreePath = '/shared/workspace/path'
const hosts = createHostDirectories(worktreeId)
const routedHostIds: string[] = []
installRemovalTransports(
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
routedHostIds
)
const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null)
seedCollidingScan([legacyCandidate])
const store = createTestStore()
seedStore(store, {
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
} as Partial<AppState>)
const removal = await store
.getState()
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] })
expect(removal.removedIds).toEqual([])
expect(removal.failures).toHaveLength(1)
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
expect(routedHostIds).toEqual([])
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
})
})
@@ -1,11 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
import {
MAX_WORKSPACE_DOC_HISTORY_ENTRIES,
workspaceDocHistoryEntriesEqual,
type WorkspaceDocHistoryEntry
} from '../../../../shared/workspace-doc-history'
import '../../../../shared/workspace-doc-history'
createStoreCascadesMockApi()
const WT = 'repo1::/path/wt1'
@@ -33,55 +29,4 @@ describe('workspace document history title refresh', () => {
expect(store.getState().workspaceDocHistory[0].visitCount).toBe(2)
unsubscribe()
})
it('still trims an over-cap history that a persisted state carried in', () => {
const store = createTestStore()
const location = {
kind: 'workspace-doc' as const,
worktreeId: WT,
filePath: '/path/wt1/doc-0.html'
}
const overCap: WorkspaceDocHistoryEntry[] = Array.from(
{ length: MAX_WORKSPACE_DOC_HISTORY_ENTRIES + 5 },
(_unused, index) => ({
docLocation: {
kind: 'workspace-doc' as const,
worktreeId: WT,
filePath: `/path/wt1/doc-${index}.html`
},
title: `Doc ${index}`,
lastVisitedAt: 1_000 - index,
visitCount: 1
})
)
store.setState({ workspaceDocHistory: overCap })
// The title is already current, but skipping here would strand the list above its cap forever.
store.getState().recordWorkspaceDocVisit(location, 'Doc 0', { bump: false })
expect(store.getState().workspaceDocHistory).toHaveLength(MAX_WORKSPACE_DOC_HISTORY_ENTRIES)
expect(store.getState().workspaceDocHistory[0].title).toBe('Doc 0')
})
it('compares every entry field, so an added field cannot be silently dropped', () => {
const base: WorkspaceDocHistoryEntry = {
docLocation: { kind: 'workspace-doc', worktreeId: WT, filePath: '/path/wt1/doc.html' },
title: 'Document',
lastVisitedAt: 10,
visitCount: 1
}
expect(workspaceDocHistoryEntriesEqual(base, { ...base })).toBe(true)
expect(workspaceDocHistoryEntriesEqual(base, { ...base, title: 'Other' })).toBe(false)
expect(workspaceDocHistoryEntriesEqual(base, { ...base, visitCount: 2 })).toBe(false)
expect(workspaceDocHistoryEntriesEqual(base, { ...base, lastVisitedAt: 11 })).toBe(false)
expect(
workspaceDocHistoryEntriesEqual(base, { ...base, docLocation: { ...base.docLocation } })
).toBe(false)
expect(
workspaceDocHistoryEntriesEqual(base, {
...base,
...({ faviconUrl: 'x' } as Partial<WorkspaceDocHistoryEntry>)
})
).toBe(false)
})
})
@@ -1,198 +0,0 @@
import { create } from 'zustand'
import { describe, expect, it } from 'vitest'
import type { WorkspaceSpaceAnalysis } from '../../../../shared/workspace-space-types'
import type { AppState } from '../types'
import { createWorkspaceSpaceSlice } from './workspace-space'
function createWorkspaceSpaceTestStore() {
return create<AppState>()(
(...a) =>
({
...createWorkspaceSpaceSlice(...a)
}) as unknown as AppState
)
}
function makeAnalysis(): WorkspaceSpaceAnalysis {
return {
scannedAt: 1,
totalSizeBytes: 300,
reclaimableBytes: 300,
worktreeCount: 2,
scannedWorktreeCount: 2,
unavailableWorktreeCount: 0,
repos: [
{
repoId: 'repo-1',
displayName: 'orca',
path: '/repo/main',
isRemote: false,
worktreeCount: 2,
scannedWorktreeCount: 2,
unavailableWorktreeCount: 0,
totalSizeBytes: 300,
reclaimableBytes: 300,
error: null
}
],
worktrees: [
{
worktreeId: 'repo-1::/repo/main',
repoId: 'repo-1',
repoDisplayName: 'orca',
repoPath: '/repo/main',
displayName: 'main',
path: '/repo/main',
branch: 'refs/heads/main',
isMainWorktree: true,
isRemote: false,
isSparse: false,
status: 'ok',
error: null,
sizeBytes: 100,
reclaimableBytes: 0,
canDelete: false,
lastActivityAt: 0,
scannedAt: 1,
skippedEntryCount: 0,
topLevelItems: [],
omittedTopLevelItemCount: 0,
omittedTopLevelSizeBytes: 0
},
{
worktreeId: 'repo-1::/repo/feature',
repoId: 'repo-1',
repoDisplayName: 'orca',
repoPath: '/repo/main',
displayName: 'feature',
path: '/repo/feature',
branch: 'refs/heads/feature',
isMainWorktree: false,
isRemote: false,
isSparse: false,
status: 'ok',
error: null,
sizeBytes: 200,
reclaimableBytes: 200,
canDelete: true,
lastActivityAt: 0,
scannedAt: 1,
skippedEntryCount: 0,
topLevelItems: [],
omittedTopLevelItemCount: 0,
omittedTopLevelSizeBytes: 0
}
]
}
}
describe('workspace space slice', () => {
it('keeps streamed measurements across later progress updates', () => {
const store = createWorkspaceSpaceTestStore()
store.setState({ workspaceSpaceScanning: true })
const progress = {
scanId: 'scan-1',
state: 'running' as const,
startedAt: 1,
updatedAt: 1,
totalRepoCount: 1,
scannedRepoCount: 0,
totalWorktreeCount: 2,
scannedWorktreeCount: 1,
currentRepoDisplayName: 'orca',
currentWorktreeDisplayName: 'feature'
}
store.getState().applyWorkspaceSpaceProgress({
...progress,
completedMeasurements: [{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }]
})
store.getState().applyWorkspaceSpaceProgress({
...progress,
scannedWorktreeCount: 2,
currentWorktreeDisplayName: null
})
expect(store.getState().workspaceSpaceMeasurements).toEqual([
{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }
])
})
it('keeps state identity when removal matches no worktree', () => {
const store = createWorkspaceSpaceTestStore()
store.setState({ workspaceSpaceAnalysis: makeAnalysis() })
const before = store.getState()
store.getState().removeWorkspaceSpaceWorktrees(['repo-2::/elsewhere'])
expect(store.getState()).toBe(before)
expect(store.getState().workspaceSpaceAnalysis).toBe(before.workspaceSpaceAnalysis)
expect(store.getState().workspaceSpaceMeasurements).toBe(before.workspaceSpaceMeasurements)
})
it('removes deleted worktrees from cached analysis totals', () => {
const store = createWorkspaceSpaceTestStore()
store.setState({ workspaceSpaceAnalysis: makeAnalysis() })
store.getState().removeWorkspaceSpaceWorktrees(['repo-1::/repo/feature'])
const analysis = store.getState().workspaceSpaceAnalysis
expect(analysis?.worktreeCount).toBe(1)
expect(analysis?.totalSizeBytes).toBe(100)
expect(analysis?.reclaimableBytes).toBe(0)
expect(analysis?.repos[0]).toMatchObject({
worktreeCount: 1,
scannedWorktreeCount: 1,
totalSizeBytes: 100,
reclaimableBytes: 0
})
})
it('removes only the confirmed host row when another host shares its id', () => {
const analysis = makeAnalysis()
const feature = analysis.worktrees[1]
feature.executionHostId = 'local'
analysis.worktrees.push({
...feature,
executionHostId: 'ssh:builder',
displayName: 'feature on builder',
sizeBytes: 50,
reclaimableBytes: 50
})
analysis.worktreeCount = 3
analysis.scannedWorktreeCount = 3
analysis.totalSizeBytes = 350
analysis.reclaimableBytes = 250
const store = createWorkspaceSpaceTestStore()
store.setState({ workspaceSpaceAnalysis: analysis })
store
.getState()
.removeWorkspaceSpaceWorktrees([{ id: feature.worktreeId, executionHostId: 'local' }])
expect(store.getState().workspaceSpaceAnalysis?.worktrees).toEqual([
expect.objectContaining({ worktreeId: 'repo-1::/repo/main' }),
expect.objectContaining({
worktreeId: feature.worktreeId,
executionHostId: 'ssh:builder'
})
])
})
it('keeps a qualified row when removing an unqualified same-id row', () => {
const analysis = makeAnalysis()
const unqualified = analysis.worktrees[1]
analysis.worktrees.push({ ...unqualified, executionHostId: 'local' })
const store = createWorkspaceSpaceTestStore()
store.setState({ workspaceSpaceAnalysis: analysis })
store
.getState()
.removeWorkspaceSpaceWorktrees([{ id: unqualified.worktreeId, executionHostId: null }])
expect(store.getState().workspaceSpaceAnalysis?.worktrees).toEqual([
expect.objectContaining({ worktreeId: 'repo-1::/repo/main' }),
expect.objectContaining({ worktreeId: unqualified.worktreeId, executionHostId: 'local' })
])
})
})
@@ -1,71 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Worktree } from '../../../../shared/worktree/types'
import { applyWorktreeUpdates } from './worktree-helpers'
function makeWorktree(overrides: Partial<Worktree> & { id: string; repoId: string }): Worktree {
return {
path: '/workspace/repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true,
displayName: 'main',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
describe('applyWorktreeUpdates', () => {
it('only updates the repo bucket encoded in the worktree id', () => {
const target = makeWorktree({
id: 'repo-a::/Users/alice/project',
repoId: 'repo-a',
displayName: 'Project A'
})
const samePathDifferentProject = makeWorktree({
id: 'repo-a::/Users/alice/project',
repoId: 'repo-b',
displayName: 'Project B'
})
const result = applyWorktreeUpdates(
{
'repo-a': [target],
'repo-b': [samePathDifferentProject]
},
target.id,
{ displayName: 'Renamed A' }
)
expect(result['repo-a']?.[0]?.displayName).toBe('Renamed A')
expect(result['repo-b']?.[0]).toBe(samePathDifferentProject)
expect(result['repo-b']?.[0]?.displayName).toBe('Project B')
})
it('never lets a present-but-undefined value clobber existing worktree fields', () => {
const target = makeWorktree({
id: 'repo-a::/Users/alice/project',
repoId: 'repo-a',
displayName: 'Project A',
comment: 'notes'
})
const result = applyWorktreeUpdates({ 'repo-a': [target] }, target.id, {
displayName: undefined,
comment: 'edited'
})
expect(result['repo-a']?.[0]?.displayName).toBe('Project A')
expect(result['repo-a']?.[0]?.comment).toBe('edited')
})
})
@@ -1,172 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { Worktree } from '../../../../shared/worktree/types'
import { routeListingBranchSwitchesThroughGitIdentity } from './worktree-listing-branch-switch'
function makeWorktree(overrides: Partial<Worktree> & { id: string }): Worktree {
return {
repoId: 'repo1',
path: '/path/wt',
head: 'abc123',
branch: 'refs/heads/feature-one',
isBare: false,
isMainWorktree: false,
displayName: 'feature-one',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
const hasLinkedPR = (worktree: Worktree): boolean => worktree.linkedPR != null
const matchesAnyHost = (): boolean => true
describe('routeListingBranchSwitchesThroughGitIdentity', () => {
it('routes a listing-observed branch switch through updateWorktreeGitIdentity', () => {
const updateWorktreeGitIdentity = vi.fn()
const current = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 101 })]
routeListingBranchSwitchesThroughGitIdentity({
requestStarted: current,
current,
incoming: [
makeWorktree({
id: 'repo1::/path/wt1',
branch: 'refs/heads/feature-two',
head: 'def456'
})
],
matchesRefreshHost: matchesAnyHost,
hasBranchScopedReviewContext: hasLinkedPR,
updateWorktreeGitIdentity
})
expect(updateWorktreeGitIdentity).toHaveBeenCalledTimes(1)
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith(
'repo1::/path/wt1',
{
head: 'def456',
branch: 'refs/heads/feature-two'
},
null
)
})
it('rejects a listing response when the branch changed again after the request started', () => {
const updateWorktreeGitIdentity = vi.fn()
const current = [
makeWorktree({
id: 'repo1::/path/wt1',
branch: 'refs/heads/feature-three',
head: 'newest-head',
linkedPR: 303
})
]
const reconciled = routeListingBranchSwitchesThroughGitIdentity({
requestStarted: [
makeWorktree({
id: 'repo1::/path/wt1',
branch: 'refs/heads/feature-one',
linkedPR: 101
})
],
current,
incoming: [
makeWorktree({
id: 'repo1::/path/wt1',
branch: 'refs/heads/feature-two',
head: 'stale-head'
})
],
matchesRefreshHost: matchesAnyHost,
hasBranchScopedReviewContext: hasLinkedPR,
updateWorktreeGitIdentity
})
expect(updateWorktreeGitIdentity).not.toHaveBeenCalled()
expect(reconciled).toEqual(current)
})
it('preserves a manual relink made while the listing request was in flight', () => {
const updateWorktreeGitIdentity = vi.fn()
const requestStarted = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 101 })]
const current = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 303 })]
const reconciled = routeListingBranchSwitchesThroughGitIdentity({
requestStarted,
current,
incoming: [
makeWorktree({
id: 'repo1::/path/wt1',
branch: 'refs/heads/feature-two',
head: 'stale-head',
linkedPR: 101
})
],
matchesRefreshHost: matchesAnyHost,
hasBranchScopedReviewContext: hasLinkedPR,
updateWorktreeGitIdentity
})
expect(updateWorktreeGitIdentity).not.toHaveBeenCalled()
expect(reconciled).toEqual(current)
})
it('fails closed when the same worktree id belongs to multiple execution hosts', () => {
const updateWorktreeGitIdentity = vi.fn()
const requestStarted = [
makeWorktree({ id: 'repo1::/same/path', hostId: 'local', linkedPR: 101 }),
makeWorktree({ id: 'repo1::/same/path', hostId: 'ssh:ssh-1', linkedPR: 202 })
]
routeListingBranchSwitchesThroughGitIdentity({
requestStarted,
current: requestStarted,
incoming: [
makeWorktree({
id: 'repo1::/same/path',
branch: 'refs/heads/feature-two',
head: 'remote-head'
})
],
matchesRefreshHost: matchesAnyHost,
hasBranchScopedReviewContext: hasLinkedPR,
updateWorktreeGitIdentity
})
expect(updateWorktreeGitIdentity).not.toHaveBeenCalled()
})
it('does not route an old-host response through a row now owned by another host', () => {
const updateWorktreeGitIdentity = vi.fn()
routeListingBranchSwitchesThroughGitIdentity({
requestStarted: [
makeWorktree({ id: 'repo1::/same/path', hostId: 'ssh:ssh-1', linkedPR: 101 })
],
current: [makeWorktree({ id: 'repo1::/same/path', hostId: 'ssh:ssh-2', linkedPR: 202 })],
incoming: [
makeWorktree({
id: 'repo1::/same/path',
hostId: 'ssh:ssh-1',
branch: 'refs/heads/feature-two',
head: 'old-host-head'
})
],
matchesRefreshHost: (worktree) => worktree.hostId === 'ssh:ssh-1',
hasBranchScopedReviewContext: hasLinkedPR,
updateWorktreeGitIdentity
})
expect(updateWorktreeGitIdentity).not.toHaveBeenCalled()
})
})
@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest'
import { applyWorktreeUpdates } from './worktree-meta-update-application'
describe('applyWorktreeUpdates display-name provenance', () => {
it('returns to the current branch immediately when a label is cleared', () => {
const worktree = {
id: 'repo-1::/workspace/feature',
repoId: 'repo-1',
path: '/workspace/feature',
branch: 'refs/heads/main',
displayName: 'Agent workspace'
}
const next = applyWorktreeUpdates({ 'repo-1': [worktree as never] }, worktree.id, {
displayName: '',
displayNameIsPinned: false
})
expect(next['repo-1']?.[0]).toMatchObject({
displayName: 'main',
displayNameMode: 'automatic'
})
})
it('keeps text until the host resolves a detached fallback', () => {
const worktree = {
id: 'repo-1::/workspace/feature',
repoId: 'repo-1',
path: '/workspace/feature',
branch: '',
displayName: 'Agent workspace'
}
const next = applyWorktreeUpdates({ 'repo-1': [worktree as never] }, worktree.id, {
displayName: '',
displayNameIsPinned: false
})
expect(next['repo-1']?.[0]).toMatchObject({
displayName: 'Agent workspace',
displayNameMode: 'automatic'
})
})
})
@@ -1,316 +0,0 @@
import { createStore, type StoreApi } from 'zustand/vanilla'
import { afterEach, describe, expect, it } from 'vitest'
import type { AppState } from '../types'
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
import type { JiraIssue } from '../../../../shared/jira/jira-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import {
createWorktreeNavHistorySlice,
findPrevLiveWorktreeHistoryIndex,
setWorktreeNavActivator,
setWorktreeNavViewActivator,
type WorktreeNavHistorySimpleViewEntry
} from './worktree-nav-history'
type MinimalState = Pick<
AppState,
| 'worktreeNavHistory'
| 'worktreeNavHistoryIndex'
| 'isNavigatingHistory'
| 'recordWorktreeVisit'
| 'recordViewVisit'
| 'goBackWorktree'
| 'goForwardWorktree'
| 'worktreesByRepo'
>
function makeWorktree(id: string): Worktree {
return { id } as unknown as Worktree
}
function createHistoryStore(worktreeIds: string[] = []): StoreApi<MinimalState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createStore<any>()((set, get, api) => ({
worktreesByRepo: {
'repo-1': worktreeIds.map(makeWorktree)
},
...createWorktreeNavHistorySlice(
set as Parameters<typeof createWorktreeNavHistorySlice>[0],
get as Parameters<typeof createWorktreeNavHistorySlice>[1],
api as Parameters<typeof createWorktreeNavHistorySlice>[2]
)
})) as unknown as StoreApi<MinimalState>
}
const viewCases: { entry: WorktreeNavHistorySimpleViewEntry; label: string }[] = [
{ entry: 'tasks', label: 'Tasks' },
{ entry: 'automations', label: 'Automations' },
{ entry: 'artifacts', label: 'Artifacts' },
{ entry: 'skills', label: 'Skills' }
]
function makeGitHubWorkItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
return {
id: 'pr-95',
type: 'pr',
number: 95,
title: 'feat: add file upload command',
state: 'open',
url: 'https://github.com/acme/repo/pull/95',
labels: [],
updatedAt: '2026-05-20T00:00:00.000Z',
author: 'octocat',
repoId: 'repo-1',
...overrides
}
}
function makeGitLabWorkItem(overrides: Partial<GitLabWorkItem> = {}): GitLabWorkItem {
return {
id: 'mr-12',
type: 'mr',
number: 12,
title: 'Fix runner routing',
state: 'opened',
url: 'https://gitlab.com/acme/repo/-/merge_requests/12',
labels: [],
updatedAt: '2026-05-20T00:00:00.000Z',
author: 'gitlab-user',
repoId: 'repo-1',
...overrides
}
}
function makeJiraIssue(overrides: Partial<JiraIssue> = {}): JiraIssue {
return {
id: 'ORC-1',
key: 'ORC-1',
title: 'Fix task source context',
url: 'https://example.atlassian.net/browse/ORC-1',
siteId: 'site-1',
siteName: 'Example Jira',
project: { id: '10000', key: 'ORC', name: 'Orca', siteId: 'site-1' },
issueType: { id: '10001', name: 'Bug' },
status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' },
labels: [],
createdAt: '2026-05-30T00:00:00.000Z',
updatedAt: '2026-05-30T00:00:00.000Z',
...overrides
}
}
describe('worktree-nav-history slice: view entries', () => {
afterEach(() => {
setWorktreeNavActivator(null)
setWorktreeNavViewActivator(null)
})
for (const { entry, label } of viewCases) {
it(`A -> ${label} -> B, back lands on ${label} then A`, () => {
const store = createHistoryStore(['a', 'b'])
const activated: string[] = []
const viewed: WorktreeNavHistorySimpleViewEntry[] = []
setWorktreeNavActivator((id) => {
activated.push(id as string)
return { primaryTabId: null }
})
setWorktreeNavViewActivator((v) => {
viewed.push(v as WorktreeNavHistorySimpleViewEntry)
})
store.getState().recordWorktreeVisit('a')
store.getState().recordViewVisit(entry)
store.getState().recordWorktreeVisit('b')
expect(store.getState().worktreeNavHistory).toEqual(['a', entry, 'b'])
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
store.getState().goBackWorktree()
expect(viewed).toEqual([entry])
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
store.getState().goBackWorktree()
expect(activated).toEqual(['a'])
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
})
it(`dedupes ${label} against the current ${label} entry`, () => {
const store = createHistoryStore(['a'])
store.getState().recordWorktreeVisit('a')
store.getState().recordViewVisit(entry)
store.getState().recordViewVisit(entry)
store.getState().recordViewVisit(entry)
expect(store.getState().worktreeNavHistory).toEqual(['a', entry])
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
})
it(`skips a dead worktree when backing to a prior ${label} entry`, () => {
const store = createHistoryStore([])
const viewed: WorktreeNavHistorySimpleViewEntry[] = []
setWorktreeNavViewActivator((v) => {
viewed.push(v as WorktreeNavHistorySimpleViewEntry)
})
store.setState({
worktreeNavHistory: [entry, 'b', entry],
worktreeNavHistoryIndex: 2
})
store.getState().goBackWorktree()
expect(viewed).toEqual([entry])
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
})
it(`close-page-style rewind for ${label} preserves forward replay`, () => {
const store = createHistoryStore(['a'])
store.getState().recordWorktreeVisit('a')
store.getState().recordViewVisit(entry)
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
const prev = findPrevLiveWorktreeHistoryIndex(store.getState() as AppState)
expect(prev).toBe(0)
store.setState({ worktreeNavHistoryIndex: prev ?? store.getState().worktreeNavHistoryIndex })
const viewed: WorktreeNavHistorySimpleViewEntry[] = []
setWorktreeNavViewActivator((v) => {
viewed.push(v as WorktreeNavHistorySimpleViewEntry)
})
store.getState().goForwardWorktree()
expect(viewed).toEqual([entry])
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
})
}
it('replays task detail entries through the same back/forward stack', () => {
const store = createHistoryStore(['a'])
const detail = {
kind: 'task-detail',
source: 'github',
workItem: makeGitHubWorkItem(),
initialTab: 'checks'
} as const
const viewed: unknown[] = []
setWorktreeNavViewActivator((v) => {
viewed.push(v)
})
store.getState().recordWorktreeVisit('a')
store.getState().recordViewVisit('tasks')
store.getState().recordViewVisit(detail)
expect(store.getState().worktreeNavHistory).toEqual(['a', 'tasks', detail])
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
store.getState().goBackWorktree()
expect(viewed).toEqual(['tasks'])
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
store.getState().goForwardWorktree()
expect(viewed).toEqual(['tasks', detail])
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
})
it('keeps same GitHub item details separate when the source host differs', () => {
const store = createHistoryStore(['a'])
const workItem = makeGitHubWorkItem()
const localSource: TaskSourceContext = {
kind: 'task-source',
provider: 'github',
projectId: 'project-1',
hostId: 'local',
repoId: 'repo-1',
providerIdentity: { provider: 'github', owner: 'acme', repo: 'repo' }
}
const sshSource: TaskSourceContext = {
...localSource,
hostId: 'ssh:devbox',
projectHostSetupId: 'setup-ssh'
}
store.getState().recordViewVisit({
kind: 'task-detail',
source: 'github',
workItem,
sourceContext: localSource
})
store.getState().recordViewVisit({
kind: 'task-detail',
source: 'github',
workItem,
sourceContext: sshSource
})
expect(store.getState().worktreeNavHistory).toHaveLength(2)
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
})
it('keeps same GitLab item details separate when the source host differs', () => {
const store = createHistoryStore(['a'])
const workItem = makeGitLabWorkItem()
const localSource: TaskSourceContext = {
kind: 'task-source',
provider: 'gitlab',
projectId: 'project-1',
hostId: 'local',
repoId: 'repo-1',
providerIdentity: { provider: 'gitlab', projectId: '1234' }
}
const sshSource: TaskSourceContext = {
...localSource,
hostId: 'ssh:devbox',
projectHostSetupId: 'setup-ssh'
}
store.getState().recordViewVisit({
kind: 'task-detail',
source: 'gitlab',
workItem,
sourceContext: localSource
})
store.getState().recordViewVisit({
kind: 'task-detail',
source: 'gitlab',
workItem,
sourceContext: sshSource
})
expect(store.getState().worktreeNavHistory).toHaveLength(2)
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
})
it('keeps same Jira issue details separate when the source host differs', () => {
const store = createHistoryStore(['a'])
const issue = makeJiraIssue()
const localSource: TaskSourceContext = {
kind: 'task-source',
provider: 'jira',
projectId: 'project-1',
hostId: 'local',
providerIdentity: { provider: 'jira', siteId: 'site-1' }
}
const remoteSource: TaskSourceContext = {
...localSource,
hostId: 'runtime:remote-server'
}
store.getState().recordViewVisit({
kind: 'task-detail',
source: 'jira',
issue,
sourceContext: localSource
})
store.getState().recordViewVisit({
kind: 'task-detail',
source: 'jira',
issue,
sourceContext: remoteSource
})
expect(store.getState().worktreeNavHistory).toHaveLength(2)
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
})
})
@@ -1,258 +0,0 @@
import { createStore, type StoreApi } from 'zustand/vanilla'
import { afterEach, describe, expect, it } from 'vitest'
import type { AppState } from '../types'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import {
canGoBackWorktreeHistory,
canGoForwardWorktreeHistory,
createWorktreeNavHistorySlice,
setWorktreeNavActivator
} from './worktree-nav-history'
type MinimalState = Pick<
AppState,
| 'worktreeNavHistory'
| 'worktreeNavHistoryIndex'
| 'isNavigatingHistory'
| 'recordWorktreeVisit'
| 'recordViewVisit'
| 'goBackWorktree'
| 'goForwardWorktree'
| 'worktreesByRepo'
| 'folderWorkspaces'
>
function makeWorktree(id: string): Worktree {
// Only `id` is read by findWorktreeById, which is what the slice uses for
// live-entry checks. Cast covers fields irrelevant to these tests.
return { id } as unknown as Worktree
}
function makeFolderWorkspace(id: string): FolderWorkspace {
return {
id,
name: id,
folderPath: `/folders/${id}`,
projectGroupId: 'group-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
createdAt: 1,
lastActivityAt: 1,
updatedAt: 1
}
}
function createHistoryStore(
worktreeIds: string[] = [],
folderWorkspaceIds: string[] = []
): StoreApi<MinimalState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createStore<any>()((set, get, api) => ({
worktreesByRepo: {
'repo-1': worktreeIds.map(makeWorktree)
},
folderWorkspaces: folderWorkspaceIds.map(makeFolderWorkspace),
...createWorktreeNavHistorySlice(
set as Parameters<typeof createWorktreeNavHistorySlice>[0],
get as Parameters<typeof createWorktreeNavHistorySlice>[1],
api as Parameters<typeof createWorktreeNavHistorySlice>[2]
)
})) as unknown as StoreApi<MinimalState>
}
describe('worktree-nav-history slice: recordWorktreeVisit', () => {
it('appends new entries and advances the index', () => {
const store = createHistoryStore()
store.getState().recordWorktreeVisit('a')
store.getState().recordWorktreeVisit('b')
store.getState().recordWorktreeVisit('c')
expect(store.getState().worktreeNavHistory).toEqual(['a', 'b', 'c'])
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
})
it('de-dupes only the current entry (A -> B -> A is valid)', () => {
const store = createHistoryStore()
store.getState().recordWorktreeVisit('a')
store.getState().recordWorktreeVisit('b')
store.getState().recordWorktreeVisit('a')
store.getState().recordWorktreeVisit('a')
expect(store.getState().worktreeNavHistory).toEqual(['a', 'b', 'a'])
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
})
it('truncates forward entries when recording from a non-head index', () => {
const store = createHistoryStore(['a', 'b', 'c'])
store.getState().recordWorktreeVisit('a')
store.getState().recordWorktreeVisit('b')
store.getState().recordWorktreeVisit('c')
store.setState({ worktreeNavHistoryIndex: 0 })
store.getState().recordWorktreeVisit('d')
expect(store.getState().worktreeNavHistory).toEqual(['a', 'd'])
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
})
it('caps the history at 50 entries, evicting oldest', () => {
const store = createHistoryStore()
for (let i = 0; i < 60; i++) {
store.getState().recordWorktreeVisit(`w${i}`)
}
const state = store.getState()
expect(state.worktreeNavHistory).toHaveLength(50)
expect(state.worktreeNavHistory[0]).toBe('w10')
expect(state.worktreeNavHistory[49]).toBe('w59')
expect(state.worktreeNavHistoryIndex).toBe(49)
})
})
describe('worktree-nav-history slice: goBack / goForward', () => {
// Reset module activator after every case, including throws, to prevent sibling-test contamination.
afterEach(() => {
setWorktreeNavActivator(null)
})
it('moves the index without mutating the history array on success', () => {
const store = createHistoryStore(['a', 'b', 'c'])
setWorktreeNavActivator(() => ({ primaryTabId: null }))
store.getState().recordWorktreeVisit('a')
store.getState().recordWorktreeVisit('b')
store.getState().recordWorktreeVisit('c')
store.getState().goBackWorktree()
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
expect(store.getState().worktreeNavHistory).toEqual(['a', 'b', 'c'])
expect(store.getState().isNavigatingHistory).toBe(false)
store.getState().goForwardWorktree()
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
})
it('leaves the index untouched when activator returns false', () => {
const store = createHistoryStore(['a', 'b', 'c'])
setWorktreeNavActivator(() => false)
store.getState().recordWorktreeVisit('a')
store.getState().recordWorktreeVisit('b')
store.getState().goBackWorktree()
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
expect(store.getState().isNavigatingHistory).toBe(false)
})
it('skips deleted worktrees when searching for the prev live entry', () => {
const store = createHistoryStore(['a', 'c'])
const activated: string[] = []
setWorktreeNavActivator((id) => {
activated.push(id as string)
return { primaryTabId: null }
})
store.setState({
worktreeNavHistory: ['a', 'b', 'c'],
worktreeNavHistoryIndex: 2
})
store.getState().goBackWorktree()
expect(activated).toEqual(['a'])
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
})
it('treats folder workspaces as live history entries', () => {
const folderKey = folderWorkspaceKey('folder-1')
const store = createHistoryStore(['child'], ['folder-1'])
const activated: string[] = []
setWorktreeNavActivator((id) => {
activated.push(id as string)
return { primaryTabId: null }
})
store.setState({
worktreeNavHistory: [folderKey, 'child'],
worktreeNavHistoryIndex: 1
})
store.getState().goBackWorktree()
expect(activated).toEqual([folderKey])
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
})
it('no-ops when the entire direction is dead', () => {
const store = createHistoryStore(['c'])
const activated: string[] = []
setWorktreeNavActivator((id) => {
activated.push(id as string)
return { primaryTabId: null }
})
store.setState({
worktreeNavHistory: ['a', 'b', 'c'],
worktreeNavHistoryIndex: 2
})
store.getState().goBackWorktree()
expect(activated).toEqual([])
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
})
it('two rapid back presses each decrement the index by one', () => {
const store = createHistoryStore(['a', 'b', 'c'])
setWorktreeNavActivator(() => ({ primaryTabId: null }))
store.getState().recordWorktreeVisit('a')
store.getState().recordWorktreeVisit('b')
store.getState().recordWorktreeVisit('c')
store.getState().goBackWorktree()
store.getState().goBackWorktree()
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
expect(store.getState().isNavigatingHistory).toBe(false)
})
})
describe('worktree-nav-history selectors', () => {
it('reports back availability only when a live prior entry exists', () => {
const store = createHistoryStore(['c'])
store.setState({
worktreeNavHistory: ['a', 'b', 'c'],
worktreeNavHistoryIndex: 2
})
expect(canGoBackWorktreeHistory(store.getState() as AppState)).toBe(false)
store.setState({
worktreesByRepo: {
'repo-1': [makeWorktree('a'), makeWorktree('c')]
}
})
expect(canGoBackWorktreeHistory(store.getState() as AppState)).toBe(true)
})
it('reports forward availability only when a live next entry exists', () => {
const store = createHistoryStore(['a'])
store.setState({
worktreeNavHistory: ['a', 'b', 'c'],
worktreeNavHistoryIndex: 0
})
expect(canGoForwardWorktreeHistory(store.getState() as AppState)).toBe(false)
store.setState({
worktreesByRepo: {
'repo-1': [makeWorktree('a'), makeWorktree('c')]
}
})
expect(canGoForwardWorktreeHistory(store.getState() as AppState)).toBe(true)
})
})
@@ -1,300 +0,0 @@
/** Exercise single and bulk removal eviction of worktree caches plus browser page/workspace maps. Fresh ephemeral keys cannot reclaim missed entries, so omissions leak across the renderer session. */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types'
import {
getAgentHibernationPaneOutputEpoch,
recordAgentHibernationPaneOutput,
resetAgentHibernationOutputActivityForTests
} from '@/lib/agent-hibernation-output-activity'
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
unregisterPtyDataHandlers: vi.fn()
}))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
const mockApi = {
worktrees: {
remove: vi.fn().mockResolvedValue(undefined),
forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }),
updateMeta: vi.fn().mockResolvedValue({})
},
pty: { kill: vi.fn().mockResolvedValue(undefined) },
runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) }
}
// @ts-expect-error -- minimal window.api stub for the store under test
globalThis.window = { api: mockApi }
import {
createTestStore,
seedStore,
makeWorktree,
makeOpenFile,
makeTab
} from './store-test-helpers'
const WT1 = 'repo1::/path/wt1'
const WT2 = 'repo1::/path/wt2'
function makeWorkspace(id: string, worktreeId: string): BrowserWorkspace {
return {
id,
worktreeId,
url: 'about:blank',
title: '',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
}
function makePage(id: string, workspaceId: string, worktreeId: string): BrowserPage {
return {
id,
workspaceId,
worktreeId,
url: 'about:blank',
title: '',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
}
describe('worktree removal evicts the per-worktree + per-page maps it previously missed', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApi.worktrees.remove.mockResolvedValue(undefined)
resetAgentHibernationOutputActivityForTests()
})
function seedWorktreeKeyedMaps(store: ReturnType<typeof createTestStore>): void {
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
]
},
remoteStatusesByWorktree: {
[WT1]: { hasUpstream: true, ahead: 1, behind: 0 },
[WT2]: { hasUpstream: true, ahead: 0, behind: 2 }
},
recentlyClosedEditorTabsByWorktree: {
[WT1]: [makeOpenFile({ id: '/path/wt1/closed.ts', worktreeId: WT1 })],
[WT2]: [makeOpenFile({ id: '/path/wt2/closed.ts', worktreeId: WT2 })]
},
recentlyClosedTerminalTabsByWorktree: {
[WT1]: [{ startupCwd: '/path/wt1' }],
[WT2]: [{ startupCwd: '/path/wt2' }]
},
recentlyClosedTabKindsByWorktree: { [WT1]: ['terminal'], [WT2]: ['terminal'] },
defaultTerminalTabsAppliedByWorktreeId: { [WT1]: true, [WT2]: true }
})
}
it('bulk purgeWorktreeTerminalState drops worktree-keyed maps for the removed worktree only', () => {
const store = createTestStore()
seedWorktreeKeyedMaps(store)
store.getState().purgeWorktreeTerminalState([WT1])
const s = store.getState()
expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined()
expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined()
expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined()
expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined()
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined()
expect(s.remoteStatusesByWorktree[WT2]).toBeDefined()
expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined()
expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined()
expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined()
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true)
})
it('single removeWorktree drops worktree-keyed maps for the removed worktree only', async () => {
const store = createTestStore()
seedWorktreeKeyedMaps(store)
const result = await store.getState().removeWorktree({ id: WT1, executionHostId: null })
expect(result).toEqual({ ok: true })
const s = store.getState()
expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined()
expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined()
expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined()
expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined()
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined()
expect(s.remoteStatusesByWorktree[WT2]).toBeDefined()
expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined()
expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined()
expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined()
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true)
})
it('worktree removal drops the hibernation output-epoch map for the removed worktree only', () => {
const store = createTestStore()
const LEAF = '11111111-1111-4111-8111-111111111111'
const TAB1 = 'tab-wt1'
const TAB2 = 'tab-wt2'
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
]
},
tabsByWorktree: {
[WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })],
[WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })]
}
})
const removedPaneKey = `${TAB1}:${LEAF}`
const survivingPaneKey = `${TAB2}:${LEAF}`
recordAgentHibernationPaneOutput(removedPaneKey)
recordAgentHibernationPaneOutput(survivingPaneKey)
store.getState().purgeWorktreeTerminalState([WT1])
expect(getAgentHibernationPaneOutputEpoch(removedPaneKey)).toBe(0)
expect(getAgentHibernationPaneOutputEpoch(survivingPaneKey)).toBe(1)
})
it('bulk purgeWorktreeTerminalState drops native-chat launch prompts for removed tabs only', () => {
const store = createTestStore()
const TAB1 = 'tab-wt1'
const TAB2 = 'tab-wt2'
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
]
},
tabsByWorktree: {
[WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })],
[WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })]
},
nativeChatLaunchPromptByTabId: {
[TAB1]: { tabId: TAB1, agent: 'codex', text: 'fix wt1', createdAt: 1 },
[TAB2]: { tabId: TAB2, agent: 'codex', text: 'fix wt2', createdAt: 2 }
}
})
store.getState().purgeWorktreeTerminalState([WT1])
const s = store.getState()
expect(s.nativeChatLaunchPromptByTabId[TAB1]).toBeUndefined()
expect(s.nativeChatLaunchPromptByTabId[TAB2]).toEqual({
tabId: TAB2,
agent: 'codex',
text: 'fix wt2',
createdAt: 2
})
})
it('single removeWorktree drops native-chat launch prompts for removed tabs only', async () => {
const store = createTestStore()
const TAB1 = 'tab-wt1'
const TAB2 = 'tab-wt2'
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
]
},
tabsByWorktree: {
[WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })],
[WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })]
},
nativeChatLaunchPromptByTabId: {
[TAB1]: { tabId: TAB1, agent: 'claude', text: 'fix wt1', createdAt: 1 },
[TAB2]: { tabId: TAB2, agent: 'claude', text: 'fix wt2', createdAt: 2 }
}
})
const result = await store.getState().removeWorktree({ id: WT1, executionHostId: null })
expect(result).toEqual({ ok: true })
const s = store.getState()
expect(s.nativeChatLaunchPromptByTabId[TAB1]).toBeUndefined()
expect(s.nativeChatLaunchPromptByTabId[TAB2]).toEqual({
tabId: TAB2,
agent: 'claude',
text: 'fix wt2',
createdAt: 2
})
})
it('bulk purgeWorktreeTerminalState drops page/workspace-keyed browser maps for the removed worktree only', () => {
const store = createTestStore()
const WS1 = 'ws-1'
const WS2 = 'ws-2'
const P1 = 'page-1'
const P2 = 'page-2'
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
]
},
browserTabsByWorktree: {
[WT1]: [makeWorkspace(WS1, WT1)],
[WT2]: [makeWorkspace(WS2, WT2)]
},
browserPagesByWorkspace: {
[WS1]: [makePage(P1, WS1, WT1)],
[WS2]: [makePage(P2, WS2, WT2)]
},
browserAnnotationsByPageId: { [P1]: [], [P2]: [] },
browserAnnotationMarkerIdsByPageId: { [P1]: ['note-1'], [P2]: ['note-2'] },
remoteBrowserPageHandlesByPageId: {
[P1]: { environmentId: 'env-1', remotePageId: 'r-1' },
[P2]: { environmentId: 'env-2', remotePageId: 'r-2' }
},
pendingAddressBarFocusByPageId: { [P1]: true, [P2]: true },
// createBrowserTab writes BOTH the workspace id and the page id here.
pendingAddressBarFocusByTabId: { [WS1]: true, [P1]: true, [WS2]: true, [P2]: true },
recentlyClosedBrowserPagesByWorkspace: {
[WS1]: [makePage(P1, WS1, WT1)],
[WS2]: [makePage(P2, WS2, WT2)]
}
})
store.getState().purgeWorktreeTerminalState([WT1])
const s = store.getState()
expect(s.browserAnnotationsByPageId[P1]).toBeUndefined()
expect(s.browserAnnotationMarkerIdsByPageId[P1]).toBeUndefined()
expect(s.remoteBrowserPageHandlesByPageId[P1]).toBeUndefined()
expect(s.pendingAddressBarFocusByPageId[P1]).toBeUndefined()
expect(s.pendingAddressBarFocusByTabId[WS1]).toBeUndefined()
expect(s.pendingAddressBarFocusByTabId[P1]).toBeUndefined()
expect(s.recentlyClosedBrowserPagesByWorkspace[WS1]).toBeUndefined()
expect(s.browserAnnotationsByPageId[P2]).toBeDefined()
expect(s.browserAnnotationMarkerIdsByPageId[P2]).toEqual(['note-2'])
expect(s.remoteBrowserPageHandlesByPageId[P2]).toBeDefined()
expect(s.pendingAddressBarFocusByPageId[P2]).toBe(true)
expect(s.pendingAddressBarFocusByTabId[WS2]).toBe(true)
expect(s.pendingAddressBarFocusByTabId[P2]).toBe(true)
expect(s.recentlyClosedBrowserPagesByWorkspace[WS2]).toBeDefined()
})
})
@@ -1,13 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as intent from '@/lib/worktree-sleep-intent'
import { buildWorktreePurgeState } from './worktrees/teardown/worktree-purge-state'
import './worktrees/teardown/worktree-purge-state'
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
import {
getStructuredAgentSessionReadOwner,
findStructuredAgentSessionReadOwner,
resetStructuredAgentSessionReadOwnersForTests
} from '@/components/native-chat/structured-agent-session-read-owner'
import { resetStructuredAgentSessionReadOwnersForTests } from '@/components/native-chat/structured-agent-session-read-owner'
const { clearWorktreeSleepIntent, hasWorktreeSleepIntent, markWorktreeSleepIntent } = intent
const WORKTREE_ID = 'repo1::/path/wt1'
@@ -33,97 +29,6 @@ describe('worktree sleep intent lifecycle', () => {
resetStructuredAgentSessionReadOwnersForTests()
})
it('is released by activating the worktree', () => {
const store = createTestStore()
seedWorktree(store)
markWorktreeSleepIntent(WORKTREE_ID)
store.getState().setActiveWorktree(WORKTREE_ID)
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
})
it('survives the sleep flow clearing the active selection', () => {
const store = createTestStore()
seedWorktree(store)
markWorktreeSleepIntent(WORKTREE_ID)
store.getState().setActiveWorktree(null)
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true)
})
it('is released by activating a folder workspace', () => {
const store = createTestStore()
store.setState({
folderWorkspaces: [
{
id: 'folder-1',
projectGroupId: 'group-1',
name: 'Folder',
folderPath: '/folder',
executionHostId: 'local',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
]
})
markWorktreeSleepIntent(FOLDER_KEY)
store.getState().setActiveFolderWorkspace('folder-1')
expect(hasWorktreeSleepIntent(FOLDER_KEY)).toBe(false)
})
it('is released when any PTY binds to a tab in the worktree', () => {
const store = createTestStore()
seedWorktree(store)
const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false })
markWorktreeSleepIntent(WORKTREE_ID)
store.getState().updateTabPtyId(tab.id, 'pty-cli-created')
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
})
it('is released when a tab is created with a live PTY', () => {
const store = createTestStore()
seedWorktree(store)
markWorktreeSleepIntent(WORKTREE_ID)
store.getState().createTab(WORKTREE_ID, undefined, undefined, {
activate: false,
initialPtyId: 'pty-cli-created'
})
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
})
it('notifies wake listeners once and only on a real clear', () => {
const { onWorktreeSleepIntentCleared } = intent
const woke = vi.fn()
markWorktreeSleepIntent(WORKTREE_ID)
const unsubscribe = onWorktreeSleepIntentCleared(WORKTREE_ID, woke)
clearWorktreeSleepIntent('repo1::/path/other')
expect(woke).not.toHaveBeenCalled()
clearWorktreeSleepIntent(WORKTREE_ID)
clearWorktreeSleepIntent(WORKTREE_ID)
expect(woke).toHaveBeenCalledTimes(1)
markWorktreeSleepIntent(WORKTREE_ID)
clearWorktreeSleepIntent(WORKTREE_ID)
expect(woke).toHaveBeenCalledTimes(1)
unsubscribe()
})
it('ignores a PTY bind that lands while the sleep teardown is in flight', async () => {
const store = createTestStore()
seedWorktree(store)
@@ -141,52 +46,4 @@ describe('worktree sleep intent lifecycle', () => {
store.getState().updateTabPtyId(tab.id, 'pty-after-teardown')
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
})
it('keeps notifying siblings when one wake listener throws', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const woke = vi.fn()
markWorktreeSleepIntent(WORKTREE_ID)
intent.onWorktreeSleepIntentCleared(WORKTREE_ID, () => {
throw new Error('boom')
})
intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke)
expect(() => clearWorktreeSleepIntent(WORKTREE_ID)).not.toThrow()
expect(woke).toHaveBeenCalledTimes(1)
errorSpy.mockRestore()
})
it('is forgotten without waking panes when the worktree is purged', () => {
const store = createTestStore()
seedWorktree(store)
markWorktreeSleepIntent(WORKTREE_ID)
const woke = vi.fn()
intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke)
store.setState(buildWorktreePurgeState(store.getState(), [WORKTREE_ID]))
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
expect(woke).not.toHaveBeenCalled()
})
it.each([WORKTREE_ID, FOLDER_KEY])(
'retires created chat readers when %s is purged',
(workspaceId) => {
const store = createTestStore()
seedWorktree(store)
const target = { kind: 'environment', environmentId: 'server-1' } as const
const owner = getStructuredAgentSessionReadOwner('created-chat', target)
const local = getStructuredAgentSessionReadOwner('created-chat', { kind: 'local' })
store.getState().createUnifiedTab(workspaceId, 'agent-session', {
entityId: 'created-chat',
executionHostId: 'runtime:server-1',
activate: false
})
const dispose = vi.spyOn(owner, 'dispose')
store.setState(buildWorktreePurgeState(store.getState(), [workspaceId]))
expect(findStructuredAgentSessionReadOwner('created-chat', target)).toBeUndefined()
expect(findStructuredAgentSessionReadOwner('created-chat', { kind: 'local' })).toBe(local)
expect(dispose).toHaveBeenCalledOnce()
}
)
})
@@ -1,112 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const unregisterPtyDataHandlers = vi.hoisted(() => vi.fn(() => []))
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
unregisterPtyDataHandlers
}))
const runtimeCall = vi.fn()
const killPty = vi.fn().mockResolvedValue(undefined)
globalThis.window = {
api: {
pty: { kill: killPty },
runtimeEnvironments: { call: runtimeCall }
}
} as never
import { getDefaultSettings } from '../../../../shared/constants'
import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { createTestStore, makeRuntimeOwnedWorktree, makeTab, seedStore } from './store-test-helpers'
const worktreeId = 'repo1::/srv/worktree'
const ptyId = 'remote:env-1@@pty-1'
function seedRuntimeOwnedWorktree(store: ReturnType<typeof createTestStore>): void {
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'env-1' },
worktreesByRepo: {
repo1: [
makeRuntimeOwnedWorktree(
{ id: worktreeId, repoId: 'repo1', path: '/srv/worktree' },
'env-1'
)
]
},
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId })]
},
ptyIdsByTabId: { 'tab-1': [ptyId] }
})
}
describe('worktree terminal removal teardown', () => {
beforeEach(() => {
vi.clearAllMocks()
clearRuntimeCompatibilityCacheForTests()
runtimeCall.mockImplementation((args: { method: string }) =>
Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc-default',
ok: true,
result: {},
_meta: { runtimeId: 'remote-runtime' }
}
)
)
})
it('retires renderer bindings without stopping the already-removed remote workspace again', async () => {
const store = createTestStore()
seedRuntimeOwnedWorktree(store)
await store.getState().shutdownWorktreeTerminals(worktreeId, {
shutdownReason: 'remove-worktree',
backendOwnsPtyTeardown: true
})
expect(runtimeCall.mock.calls.filter(([args]) => args.method === 'terminal.stop')).toHaveLength(
0
)
expect(killPty).not.toHaveBeenCalled()
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([])
expect(store.getState().pendingPtyShutdownIds[ptyId]).toBeUndefined()
})
// Why: the skip is an opt-in for backend-paired removal; a bare call must still kill remote PTYs or they leak with no local trace.
it('still stops remote terminals when the caller passes no options', async () => {
const store = createTestStore()
seedRuntimeOwnedWorktree(store)
await store.getState().shutdownWorktreeTerminals(worktreeId)
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'env-1',
method: 'terminal.stop',
params: { worktree: `id:${worktreeId}` }
})
)
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([])
})
it('still stops remote terminals for an explicit remove reason without the opt-in', async () => {
const store = createTestStore()
seedRuntimeOwnedWorktree(store)
await store
.getState()
.shutdownWorktreeTerminals(worktreeId, { shutdownReason: 'remove-worktree' })
expect(runtimeCall.mock.calls.filter(([args]) => args.method === 'terminal.stop')).toHaveLength(
1
)
})
})