mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
Fix sidebar attention state and optimize slot invalidation
- Preserve unread flags raised by users or earlier sessions when markers clear. - Scope agent list expansion to each host. - Track displayed panes and freshness deadlines to limit slot updates.
This commit is contained in:
+1
-1
@@ -142,7 +142,7 @@ export function useWorktreeAgentListModel(
|
||||
toggleLineageParent: toggleLineageParentState,
|
||||
toggleIdleChildGroup: toggleIdleChildGroupState,
|
||||
toggleCompactRootList
|
||||
} = useWorktreeAgentExpansionState(worktreeId)
|
||||
} = useWorktreeAgentExpansionState(worktreeId, executionHostId)
|
||||
const toggleLineageParent = useCallback(
|
||||
(paneKey: string) => {
|
||||
dispatchSuppressScrollAdjustment()
|
||||
|
||||
+2
-1
@@ -76,7 +76,8 @@ export function reuseArrayIfEqual<T>(previous: T[] | undefined, next: T[]): T[]
|
||||
|
||||
export { getTabIdToWorktreeId }
|
||||
|
||||
function getLiveTabIdToWorktreeId(
|
||||
/** Terminal tabs plus agent-session chat tabs: the tab map live status is bucketed by. */
|
||||
export function getLiveTabIdToWorktreeId(
|
||||
tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'],
|
||||
unifiedTabsByWorktree: WorktreeAgentRowsState['unifiedTabsByWorktree']
|
||||
): Map<string, string> {
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearWorktreeAgentExpansionStateForTests,
|
||||
expandWorktreeAgentList,
|
||||
useWorktreeAgentExpansionState
|
||||
} from './worktree-card-agents-expansion-state'
|
||||
|
||||
const WS = 'repo1::/path/a'
|
||||
|
||||
afterEach(() => {
|
||||
clearWorktreeAgentExpansionStateForTests()
|
||||
})
|
||||
|
||||
describe('worktree agent expansion state', () => {
|
||||
it('keeps disclosure separate for same-ID workspaces on different hosts', () => {
|
||||
const local = renderHook(() => useWorktreeAgentExpansionState(WS, 'local'))
|
||||
const remote = renderHook(() => useWorktreeAgentExpansionState(WS, 'runtime:box'))
|
||||
|
||||
act(() => {
|
||||
local.result.current.toggleCompactRootList()
|
||||
local.result.current.toggleLineageParent('tab:pane')
|
||||
})
|
||||
|
||||
expect(local.result.current.compactRootListExpanded).toBe(true)
|
||||
expect([...local.result.current.expandedLineageParents]).toEqual(['tab:pane'])
|
||||
expect(remote.result.current.compactRootListExpanded).toBe(false)
|
||||
expect(remote.result.current.expandedLineageParents.size).toBe(0)
|
||||
})
|
||||
|
||||
it('unfolds only the jumped-to host’s card', () => {
|
||||
const local = renderHook(() => useWorktreeAgentExpansionState(WS, 'local'))
|
||||
const remote = renderHook(() => useWorktreeAgentExpansionState(WS, 'runtime:box'))
|
||||
|
||||
act(() => expandWorktreeAgentList(WS, 'runtime:box'))
|
||||
|
||||
expect(remote.result.current.compactRootListExpanded).toBe(true)
|
||||
expect(local.result.current.compactRootListExpanded).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an unstamped host as local, matching the card’s slot targets', () => {
|
||||
const unstamped = renderHook(() => useWorktreeAgentExpansionState(WS, undefined))
|
||||
|
||||
act(() => expandWorktreeAgentList(WS, 'local'))
|
||||
|
||||
expect(unstamped.result.current.compactRootListExpanded).toBe(true)
|
||||
})
|
||||
})
|
||||
+63
-38
@@ -1,4 +1,9 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
type HostScopedWorktreeKey
|
||||
} from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
|
||||
export type WorktreeAgentExpansionState = {
|
||||
// Child lists start collapsed so spawning subagents cannot grow the card and shift the sidebar.
|
||||
@@ -17,65 +22,83 @@ const DEFAULT_EXPANSION_STATE: WorktreeAgentExpansionState = {
|
||||
|
||||
// Retain disclosures across virtualizer/lineage remounts in a bounded renderer-session cache; reload resets them.
|
||||
export const MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS = 512
|
||||
const expansionByWorktreeId = new Map<string, WorktreeAgentExpansionState>()
|
||||
const expansionByKey = new Map<HostScopedWorktreeKey, WorktreeAgentExpansionState>()
|
||||
|
||||
/**
|
||||
* Local and remote copies of a workspace share its id, so disclosure is keyed per host. Unstamped
|
||||
* rows default to local, the same default the card's title status and slot targets use.
|
||||
*/
|
||||
function expansionKey(
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId | undefined
|
||||
): HostScopedWorktreeKey {
|
||||
return composeWorktreeHostIdentity(executionHostId ?? 'local', worktreeId)
|
||||
}
|
||||
|
||||
function trimPersistedExpansions(): void {
|
||||
while (expansionByWorktreeId.size > MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS) {
|
||||
const oldest = expansionByWorktreeId.keys().next().value
|
||||
while (expansionByKey.size > MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS) {
|
||||
const oldest = expansionByKey.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
expansionByWorktreeId.delete(oldest)
|
||||
expansionByKey.delete(oldest)
|
||||
notifyExpansion(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
function readExpansionState(worktreeId: string): WorktreeAgentExpansionState {
|
||||
return expansionByWorktreeId.get(worktreeId) ?? DEFAULT_EXPANSION_STATE
|
||||
function readExpansionState(key: HostScopedWorktreeKey): WorktreeAgentExpansionState {
|
||||
return expansionByKey.get(key) ?? DEFAULT_EXPANSION_STATE
|
||||
}
|
||||
|
||||
function persistExpansionState(worktreeId: string, state: WorktreeAgentExpansionState): void {
|
||||
function persistExpansionState(
|
||||
key: HostScopedWorktreeKey,
|
||||
state: WorktreeAgentExpansionState
|
||||
): void {
|
||||
// Refresh LRU order and retain only non-default disclosures.
|
||||
expansionByWorktreeId.delete(worktreeId)
|
||||
expansionByKey.delete(key)
|
||||
if (
|
||||
state.compactRootListExpanded ||
|
||||
state.expandedLineageParents.size > 0 ||
|
||||
state.expandedIdleChildGroups.size > 0
|
||||
) {
|
||||
expansionByWorktreeId.set(worktreeId, state)
|
||||
expansionByKey.set(key, state)
|
||||
trimPersistedExpansions()
|
||||
}
|
||||
}
|
||||
|
||||
const expansionListeners = new Map<string, Set<() => void>>()
|
||||
const expansionListeners = new Map<HostScopedWorktreeKey, Set<() => void>>()
|
||||
|
||||
/** Unfold a card's agent list from outside the card, e.g. when a rolled-up slot opens one of its agents. */
|
||||
export function expandWorktreeAgentList(worktreeId: string): void {
|
||||
const base = readExpansionState(worktreeId)
|
||||
export function expandWorktreeAgentList(
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId | undefined
|
||||
): void {
|
||||
const key = expansionKey(worktreeId, executionHostId)
|
||||
const base = readExpansionState(key)
|
||||
if (base.compactRootListExpanded) {
|
||||
return
|
||||
}
|
||||
persistExpansionState(worktreeId, { ...base, compactRootListExpanded: true })
|
||||
notifyExpansion(worktreeId)
|
||||
persistExpansionState(key, { ...base, compactRootListExpanded: true })
|
||||
notifyExpansion(key)
|
||||
}
|
||||
|
||||
function notifyExpansion(worktreeId: string): void {
|
||||
for (const listener of expansionListeners.get(worktreeId) ?? []) {
|
||||
function notifyExpansion(key: HostScopedWorktreeKey): void {
|
||||
for (const listener of expansionListeners.get(key) ?? []) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeExpansion(worktreeId: string, listener: () => void): () => void {
|
||||
let listeners = expansionListeners.get(worktreeId)
|
||||
function subscribeExpansion(key: HostScopedWorktreeKey, listener: () => void): () => void {
|
||||
let listeners = expansionListeners.get(key)
|
||||
if (!listeners) {
|
||||
listeners = new Set()
|
||||
expansionListeners.set(worktreeId, listeners)
|
||||
expansionListeners.set(key, listeners)
|
||||
}
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0) {
|
||||
expansionListeners.delete(worktreeId)
|
||||
expansionListeners.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,45 +121,46 @@ export type WorktreeAgentExpansionControls = {
|
||||
}
|
||||
|
||||
/** Restore each worktree’s disclosures across virtualizer and sibling-lineage remounts. */
|
||||
export function useWorktreeAgentExpansionState(worktreeId: string): WorktreeAgentExpansionControls {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => subscribeExpansion(worktreeId, listener),
|
||||
[worktreeId]
|
||||
)
|
||||
const getSnapshot = useCallback(() => readExpansionState(worktreeId), [worktreeId])
|
||||
export function useWorktreeAgentExpansionState(
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId | undefined
|
||||
): WorktreeAgentExpansionControls {
|
||||
const key = expansionKey(worktreeId, executionHostId)
|
||||
const subscribe = useCallback((listener: () => void) => subscribeExpansion(key, listener), [key])
|
||||
const getSnapshot = useCallback(() => readExpansionState(key), [key])
|
||||
const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
const commit = useCallback(
|
||||
(next: WorktreeAgentExpansionState) => {
|
||||
persistExpansionState(worktreeId, next)
|
||||
notifyExpansion(worktreeId)
|
||||
persistExpansionState(key, next)
|
||||
notifyExpansion(key)
|
||||
},
|
||||
[worktreeId]
|
||||
[key]
|
||||
)
|
||||
|
||||
const toggleLineageParent = useCallback(
|
||||
(paneKey: string) => {
|
||||
const base = readExpansionState(worktreeId)
|
||||
const base = readExpansionState(key)
|
||||
commit({ ...base, expandedLineageParents: toggleKey(base.expandedLineageParents, paneKey) })
|
||||
},
|
||||
[commit, worktreeId]
|
||||
[commit, key]
|
||||
)
|
||||
|
||||
const toggleIdleChildGroup = useCallback(
|
||||
(parentPaneKey: string) => {
|
||||
const base = readExpansionState(worktreeId)
|
||||
const base = readExpansionState(key)
|
||||
commit({
|
||||
...base,
|
||||
expandedIdleChildGroups: toggleKey(base.expandedIdleChildGroups, parentPaneKey)
|
||||
})
|
||||
},
|
||||
[commit, worktreeId]
|
||||
[commit, key]
|
||||
)
|
||||
|
||||
const toggleCompactRootList = useCallback(() => {
|
||||
const base = readExpansionState(worktreeId)
|
||||
const base = readExpansionState(key)
|
||||
commit({ ...base, compactRootListExpanded: !base.compactRootListExpanded })
|
||||
}, [commit, worktreeId])
|
||||
}, [commit, key])
|
||||
|
||||
return {
|
||||
expandedLineageParents: current.expandedLineageParents,
|
||||
@@ -149,12 +173,13 @@ export function useWorktreeAgentExpansionState(worktreeId: string): WorktreeAgen
|
||||
}
|
||||
|
||||
export function clearWorktreeAgentExpansionStateForTests(): void {
|
||||
expansionByWorktreeId.clear()
|
||||
expansionByKey.clear()
|
||||
}
|
||||
|
||||
export function seedWorktreeAgentExpansionStateForTests(
|
||||
worktreeId: string,
|
||||
state: WorktreeAgentExpansionState
|
||||
state: WorktreeAgentExpansionState,
|
||||
executionHostId?: ExecutionHostId
|
||||
): void {
|
||||
persistExpansionState(worktreeId, state)
|
||||
persistExpansionState(expansionKey(worktreeId, executionHostId), state)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getGitHubPRCacheKey } from '../../../../../../shared/github/github-cache-key'
|
||||
import { getGitHubPRCacheKey } from '../../../../../../shared/github/cache/github-cache-key'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review/hosted-review-cache-identity'
|
||||
import { hostedReviewInfoFromGitHubPRInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-github'
|
||||
import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts'
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ export function WorktreeCardTitleStatusProvider({
|
||||
const pickingSendTarget = useAppStore(
|
||||
(s) => selectSendTargetControlInputs(s, worktree.id).targetMode !== null
|
||||
)
|
||||
const { compactRootListExpanded } = useWorktreeAgentExpansionState(worktree.id)
|
||||
const { compactRootListExpanded } = useWorktreeAgentExpansionState(worktree.id, worktree.hostId)
|
||||
// Compact mode folds two or more agents behind a summary row until it is expanded.
|
||||
const foldedBehindSummary =
|
||||
card.agentActivityDisplayMode === 'compact' &&
|
||||
|
||||
+68
-1
@@ -8,9 +8,11 @@ import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
|
||||
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
|
||||
import type { WorktreeLineageRange } from '../../../../shared/sidebar-list/worktree-lineage-range'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { TerminalTab } from '../../../../shared/tab-types'
|
||||
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness'
|
||||
import { selectCollapsedLineageSlots, selectLineageLatestAt } from './worktree-card-slot-rollup'
|
||||
import { slotSnapshot } from './worktree-card-slot-invalidation'
|
||||
|
||||
// Counts per-workspace re-checks: each stale slot reads its workspace's row selector once.
|
||||
const counts = vi.hoisted(() => ({ reads: [] as string[] }))
|
||||
@@ -201,3 +203,68 @@ describe('orchestration invalidation', () => {
|
||||
expect(new Set(counts.reads)).toEqual(new Set([rows[2]!.id, rows[6]!.id]))
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([10, 100, 1000])('freshness invalidation under a parent of %i', (size) => {
|
||||
function working(paneKey: string, worktreeId: string, evidenceAt: number): AgentStatusEntry {
|
||||
return {
|
||||
...entry(paneKey, worktreeId),
|
||||
state: 'working',
|
||||
updatedAt: evidenceAt,
|
||||
evidenceObservedAt: evidenceAt
|
||||
}
|
||||
}
|
||||
|
||||
it('re-checks nothing on an epoch tick that leaves every agent fresh', () => {
|
||||
const { range, rows } = seedLineage(size)
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: Object.fromEntries(
|
||||
rows.map((row, i) => {
|
||||
const paneKey = makePaneKey(`t${i}`, LEAF)
|
||||
return [paneKey, working(paneKey, row.id, 2_000_000)]
|
||||
})
|
||||
)
|
||||
})
|
||||
readParent(range)
|
||||
counts.reads = []
|
||||
vi.setSystemTime(2_000_000 + 60_000)
|
||||
useAppStore.setState({ agentStatusEpoch: 2 })
|
||||
readParent(range)
|
||||
expect(counts.reads).toEqual([])
|
||||
})
|
||||
|
||||
it('re-checks only the workspace whose agent went stale on an epoch tick', () => {
|
||||
const { range, rows } = seedLineage(size)
|
||||
const AGES = AGENT_STATUS_STALE_AFTER_MS
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: Object.fromEntries(
|
||||
rows.map((row, i) => {
|
||||
const paneKey = makePaneKey(`t${i}`, LEAF)
|
||||
// Only workspace 4's evidence is old enough to expire within the next minute.
|
||||
const evidenceAt = i === 4 ? 2_000_000 - AGES + 30_000 : 2_000_000
|
||||
return [paneKey, working(paneKey, row.id, evidenceAt)]
|
||||
})
|
||||
)
|
||||
})
|
||||
readParent(range)
|
||||
counts.reads = []
|
||||
vi.setSystemTime(2_000_000 + 60_000)
|
||||
useAppStore.setState({ agentStatusEpoch: 2 })
|
||||
readParent(range)
|
||||
expect(counts.reads).toEqual([rows[4]!.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('journal', () => {
|
||||
it('combines several steps for a tree that skipped reads', () => {
|
||||
const { range, rows } = seedLineage(100)
|
||||
readParent(range)
|
||||
counts.reads = []
|
||||
for (const index of [3, 7, 9]) {
|
||||
useAppStore.setState({ ptyIdsByTabId: { [`t${index}`]: [`pty-${index}`] } })
|
||||
// Another reader advances the shared journal without this tree.
|
||||
slotSnapshot(useAppStore.getState())
|
||||
}
|
||||
readParent(range)
|
||||
expect(new Set(counts.reads)).toEqual(new Set([rows[3]!.id, rows[7]!.id, rows[9]!.id]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
isAgentStatusFresh
|
||||
} from '../../../../shared/agent-status/contracts/agent-status-freshness'
|
||||
import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs'
|
||||
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
|
||||
import { getTabIdToWorktreeId } from './workspace-card/activity/worktree-agent-row-selectors'
|
||||
import {
|
||||
getLiveTabIdToWorktreeId,
|
||||
getTabIdToWorktreeId
|
||||
} from './workspace-card/activity/worktree-agent-row-selectors'
|
||||
import { liveEntryWorktreeId } from './workspace-card/activity/worktree-agent-live-index-patch'
|
||||
import { createFreshnessDeadlines, forEachPaneDisplayer } from './worktree-card-slot-pane-index'
|
||||
import { selectWorktreeAgentOrchestrationIndex } from './workspace-card/activity/worktree-agent-orchestration-index'
|
||||
import { worktreeAgentProjectionInputs } from './workspace-card/activity/worktree-agent-rows-projection'
|
||||
import { structuredAgentSessionOwnerInputs } from '@/runtime/native-chat/session-ownership/structured-agent-session-owner'
|
||||
@@ -31,13 +32,28 @@ export type SlotSnapshot = {
|
||||
acknowledgements: AppState['acknowledgedAgentsByPaneKey']
|
||||
titles: AppState['runtimePaneTitlesByTabId']
|
||||
epoch: number
|
||||
/** The clock freshness is judged at; re-sampled only when the epoch moves. */
|
||||
at: number
|
||||
/** The snapshot this one replaced, while the journal still reaches back to it. */
|
||||
previous: SlotSnapshot | undefined
|
||||
/** Workspaces whose slots may differ from `previous`'s; null when any slot may. */
|
||||
changedFromPrevious: Set<string> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots form a short journal: each one's changes are computed once, when it replaces the
|
||||
* previous one, and every lineage tree combines the steps since the snapshot it last saw. A tree
|
||||
* that falls further behind than the journal re-checks all of its slots.
|
||||
*/
|
||||
const JOURNAL_LENGTH = 16
|
||||
let current: SlotSnapshot | undefined
|
||||
const freshnessDeadlines = createFreshnessDeadlines()
|
||||
|
||||
export function slotSnapshot(state: AppState): SlotSnapshot {
|
||||
const inputs = worktreeAgentProjectionInputs(state)
|
||||
if (!current || !inputs.every((value, index) => value === current!.inputs[index])) {
|
||||
current = {
|
||||
if (!current || !sameInputs(inputs, current.inputs)) {
|
||||
const previous = current
|
||||
const next: SlotSnapshot = {
|
||||
inputs,
|
||||
layout: {
|
||||
tabsByWorktree: state.tabsByWorktree,
|
||||
@@ -56,11 +72,52 @@ export function slotSnapshot(state: AppState): SlotSnapshot {
|
||||
acknowledgements: state.acknowledgedAgentsByPaneKey,
|
||||
titles: state.runtimePaneTitlesByTabId,
|
||||
epoch: state.agentStatusEpoch,
|
||||
at: current?.epoch === state.agentStatusEpoch ? current.at : Date.now()
|
||||
at: previous?.epoch === state.agentStatusEpoch ? previous.at : Date.now(),
|
||||
previous,
|
||||
changedFromPrevious: null
|
||||
}
|
||||
if (previous) {
|
||||
next.changedFromPrevious = changedBetween(state, previous, next)
|
||||
} else {
|
||||
freshnessDeadlines.clear()
|
||||
for (const paneKey in next.live) {
|
||||
freshnessDeadlines.set(paneKey, next.live[paneKey], next.at)
|
||||
}
|
||||
}
|
||||
trimJournal(next)
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function trimJournal(latest: SlotSnapshot): void {
|
||||
let cursor: SlotSnapshot | undefined = latest
|
||||
for (let depth = 0; cursor && depth < JOURNAL_LENGTH; depth += 1) {
|
||||
cursor = cursor.previous
|
||||
}
|
||||
if (cursor) {
|
||||
cursor.previous = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Workspaces whose slots may differ between two snapshots; null when any slot may. */
|
||||
export function changedSlotWorkspaces(
|
||||
before: SlotSnapshot,
|
||||
after: SlotSnapshot
|
||||
): Set<string> | null {
|
||||
const result = new Set<string>()
|
||||
for (let cursor = after; cursor !== before;) {
|
||||
if (!cursor.previous || !cursor.changedFromPrevious) {
|
||||
return null
|
||||
}
|
||||
for (const id of cursor.changedFromPrevious) {
|
||||
result.add(id)
|
||||
}
|
||||
cursor = cursor.previous
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function sameInputs(left: readonly unknown[], right: readonly unknown[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
@@ -123,92 +180,78 @@ function addChangedOrchestrationWorkspaces(
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the workspaces a layout change can touch. Returns false when the change moved panes between
|
||||
* workspaces (a tab or chat added, removed or moved, or a pane in a tab no workspace owns): only
|
||||
* then must every slot be re-checked.
|
||||
* One journal step. Pane-keyed records (status, acknowledgement, retention, foreground agent,
|
||||
* freshness) reach the workspaces the projection recorded as displaying the pane, plus the
|
||||
* owner the live bucket assigns it, so ownership is never re-derived with separate rules.
|
||||
*/
|
||||
const changes = new WeakMap<SlotSnapshot, WeakMap<SlotSnapshot, Set<string> | null>>()
|
||||
|
||||
export function changedSlotWorkspaces(
|
||||
function changedBetween(
|
||||
state: AppState,
|
||||
before: SlotSnapshot,
|
||||
after: SlotSnapshot
|
||||
): Set<string> | null {
|
||||
const cached = changes.get(before)?.get(after)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
const result = new Set<string>()
|
||||
const add = (worktreeId: string): void => {
|
||||
result.add(worktreeId)
|
||||
}
|
||||
let result: Set<string> | null = new Set<string>()
|
||||
const tabOwners = getTabIdToWorktreeId(state.tabsByWorktree)
|
||||
const liveTabOwners = getLiveTabIdToWorktreeId(state.tabsByWorktree, state.unifiedTabsByWorktree)
|
||||
const represented = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree)
|
||||
const includePane = (paneKey: string): void => {
|
||||
forEachPaneDisplayer(paneKey, add)
|
||||
// A pane no slot has displayed yet lands where the live bucket or its tab puts it.
|
||||
const parsed = parsePaneKey(paneKey)
|
||||
const tabOwner = parsed && tabOwners.get(parsed.tabId)
|
||||
const tabOwner = parsed && liveTabOwners.get(parsed.tabId)
|
||||
if (tabOwner) {
|
||||
result!.add(tabOwner)
|
||||
result.add(tabOwner)
|
||||
}
|
||||
// Structured sessions may have no backing terminal tab.
|
||||
const chat = represented.get(paneKey)
|
||||
if (chat) {
|
||||
result!.add(chat.worktreeId)
|
||||
result.add(chat.worktreeId)
|
||||
}
|
||||
for (const entry of [
|
||||
before.live[paneKey],
|
||||
after.live[paneKey],
|
||||
before.layout.retainedAgentsByPaneKey[paneKey]?.entry,
|
||||
after.layout.retainedAgentsByPaneKey[paneKey]?.entry
|
||||
]) {
|
||||
if (entry?.worktreeId) {
|
||||
result!.add(entry.worktreeId)
|
||||
for (const entry of [before.live[paneKey], after.live[paneKey]]) {
|
||||
const owner = entry && liveEntryWorktreeId(paneKey, entry, liveTabOwners, represented)
|
||||
if (owner) {
|
||||
result.add(owner)
|
||||
}
|
||||
}
|
||||
for (const retained of [
|
||||
before.layout.retainedAgentsByPaneKey[paneKey],
|
||||
after.layout.retainedAgentsByPaneKey[paneKey]
|
||||
]) {
|
||||
if (retained?.worktreeId) {
|
||||
result!.add(retained.worktreeId)
|
||||
if (retained) {
|
||||
result.add(retained.worktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (addChangedLayoutWorkspaces(result, before.layout, after.layout, tabOwners, includePane)) {
|
||||
addChangedOrchestrationWorkspaces(result, before.orchestration, after.orchestration)
|
||||
if (before.worktreesByRepo !== after.worktreesByRepo) {
|
||||
addChangedCatalogWorkspaces(result, before.worktreesByRepo, after.worktreesByRepo)
|
||||
}
|
||||
if (!sameInputs(after.owner, before.owner)) {
|
||||
addUnstampedChatWorkspaces(result, state)
|
||||
}
|
||||
forEachChangedKey(before.titles, after.titles, (tabId) => {
|
||||
const owner = tabOwners.get(tabId)
|
||||
if (owner) {
|
||||
result!.add(owner)
|
||||
}
|
||||
})
|
||||
forEachChangedKey(before.live, after.live, includePane)
|
||||
if (before.epoch !== after.epoch) {
|
||||
// A new epoch moves the clock: entries that kept their identity may still go stale.
|
||||
for (const paneKey in after.live) {
|
||||
const entry = after.live[paneKey]
|
||||
if (
|
||||
entry &&
|
||||
before.live[paneKey] === entry &&
|
||||
isAgentStatusFresh(entry, before.at, AGENT_STATUS_STALE_AFTER_MS) !==
|
||||
isAgentStatusFresh(entry, after.at, AGENT_STATUS_STALE_AFTER_MS)
|
||||
) {
|
||||
includePane(paneKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
forEachChangedKey(before.acknowledgements, after.acknowledgements, includePane)
|
||||
} else {
|
||||
result = null
|
||||
|
||||
// Freshness bookkeeping follows every step, even one that re-checks every slot.
|
||||
forEachChangedKey(before.live, after.live, (paneKey) => {
|
||||
freshnessDeadlines.set(paneKey, after.live[paneKey], after.at)
|
||||
})
|
||||
const expired: string[] = []
|
||||
if (before.at !== after.at) {
|
||||
// Only entries whose stale deadline fell between the two clock samples flip.
|
||||
freshnessDeadlines.expire(after.at, (paneKey) => expired.push(paneKey))
|
||||
}
|
||||
let next = changes.get(before)
|
||||
if (!next) {
|
||||
next = new WeakMap()
|
||||
changes.set(before, next)
|
||||
|
||||
if (!addChangedLayoutWorkspaces(result, before.layout, after.layout, tabOwners, includePane)) {
|
||||
return null
|
||||
}
|
||||
next.set(after, result)
|
||||
addChangedOrchestrationWorkspaces(result, before.orchestration, after.orchestration)
|
||||
if (before.worktreesByRepo !== after.worktreesByRepo) {
|
||||
addChangedCatalogWorkspaces(result, before.worktreesByRepo, after.worktreesByRepo)
|
||||
}
|
||||
if (!sameInputs(after.owner, before.owner)) {
|
||||
addUnstampedChatWorkspaces(result, state)
|
||||
}
|
||||
forEachChangedKey(before.titles, after.titles, (tabId) => {
|
||||
const owner = tabOwners.get(tabId)
|
||||
if (owner) {
|
||||
result.add(owner)
|
||||
}
|
||||
})
|
||||
forEachChangedKey(before.live, after.live, includePane)
|
||||
expired.forEach(includePane)
|
||||
forEachChangedKey(before.acknowledgements, after.acknowledgements, includePane)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
isAgentStatusFresh
|
||||
} from '../../../../shared/agent-status/contracts/agent-status-freshness'
|
||||
import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
|
||||
import {
|
||||
agentStatusStaleAt,
|
||||
createFreshnessDeadlines,
|
||||
forEachPaneDisplayer,
|
||||
recordDisplayedPanes,
|
||||
resetSlotPaneIndexForTests
|
||||
} from './worktree-card-slot-pane-index'
|
||||
|
||||
function entry(paneKey: string, evidenceAt: number, extra?: Partial<AgentStatusEntry>) {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: evidenceAt,
|
||||
evidenceObservedAt: evidenceAt,
|
||||
stateStartedAt: evidenceAt,
|
||||
stateHistory: [],
|
||||
paneKey,
|
||||
...extra
|
||||
} as AgentStatusEntry
|
||||
}
|
||||
|
||||
function displayers(paneKey: string): string[] {
|
||||
const out: string[] = []
|
||||
forEachPaneDisplayer(paneKey, (id) => out.push(id))
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetSlotPaneIndexForTests()
|
||||
})
|
||||
|
||||
describe('displayed pane index', () => {
|
||||
it('replaces a workspace’s panes on each projection, per host', () => {
|
||||
const local = composeWorktreeHostIdentity('local', 'w1')
|
||||
const remote = composeWorktreeHostIdentity('runtime:box', 'w1')
|
||||
recordDisplayedPanes(local, ['a', 'b'])
|
||||
recordDisplayedPanes(remote, ['b'])
|
||||
expect(displayers('b')).toEqual(['w1', 'w1'])
|
||||
|
||||
recordDisplayedPanes(local, ['c'])
|
||||
expect(displayers('a')).toEqual([])
|
||||
expect(displayers('b')).toEqual(['w1'])
|
||||
expect(displayers('c')).toEqual(['w1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('freshness deadlines', () => {
|
||||
it('agrees with isAgentStatusFresh at the boundary', () => {
|
||||
const tracked = entry('p', 1000)
|
||||
const staleAt = agentStatusStaleAt(tracked)!
|
||||
expect(isAgentStatusFresh(tracked, staleAt, AGENT_STATUS_STALE_AFTER_MS)).toBe(true)
|
||||
expect(isAgentStatusFresh(tracked, staleAt + 1, AGENT_STATUS_STALE_AFTER_MS)).toBe(false)
|
||||
expect(agentStatusStaleAt(entry('p', 1000, { structuredHostOwned: true }))).toBeNull()
|
||||
expect(agentStatusStaleAt(entry('p', 1000, { restoredUnconfirmed: true }))).toBeNull()
|
||||
})
|
||||
|
||||
it('visits only entries whose freshness flipped, in deadline order', () => {
|
||||
const deadlines = createFreshnessDeadlines()
|
||||
const live = Array.from({ length: 1000 }, (_, i) => entry(`p${i}`, i * 1000))
|
||||
live.forEach((tracked) => deadlines.set(tracked.paneKey, tracked, 0))
|
||||
|
||||
const visited: string[] = []
|
||||
deadlines.expire(AGENT_STATUS_STALE_AFTER_MS + 2500, (paneKey) => visited.push(paneKey))
|
||||
expect(visited).toEqual(['p0', 'p1', 'p2'])
|
||||
|
||||
visited.length = 0
|
||||
deadlines.expire(AGENT_STATUS_STALE_AFTER_MS + 2500, (paneKey) => visited.push(paneKey))
|
||||
expect(visited).toEqual([])
|
||||
})
|
||||
|
||||
it('skips superseded and removed entries, and ignores ones already stale', () => {
|
||||
const deadlines = createFreshnessDeadlines()
|
||||
deadlines.set('a', entry('a', 0), 0)
|
||||
deadlines.set('b', entry('b', 0), 0)
|
||||
// Fresh evidence moves a's deadline; b goes away; c arrives already stale.
|
||||
deadlines.set('a', entry('a', 10_000), 10_000)
|
||||
deadlines.set('b', undefined, 10_000)
|
||||
deadlines.set('c', entry('c', 0), AGENT_STATUS_STALE_AFTER_MS + 1)
|
||||
|
||||
const visited: string[] = []
|
||||
deadlines.expire(AGENT_STATUS_STALE_AFTER_MS + 1, (paneKey) => visited.push(paneKey))
|
||||
expect(visited).toEqual([])
|
||||
deadlines.expire(AGENT_STATUS_STALE_AFTER_MS + 10_001, (paneKey) => visited.push(paneKey))
|
||||
expect(visited).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('stays bounded when one pane is updated many times', () => {
|
||||
const deadlines = createFreshnessDeadlines()
|
||||
for (let i = 0; i < 10_000; i += 1) {
|
||||
deadlines.set('busy', entry('busy', i), i)
|
||||
}
|
||||
const visited: string[] = []
|
||||
deadlines.expire(Number.MAX_SAFE_INTEGER, (paneKey) => visited.push(paneKey))
|
||||
expect(visited).toEqual(['busy'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
agentStatusEvidenceObservedAt
|
||||
} from '../../../../shared/agent-status/contracts/agent-status-freshness'
|
||||
import {
|
||||
getExecutionHostIdFromWorktreeHostIdentity,
|
||||
getWorktreeIdFromHostIdentity,
|
||||
type HostScopedWorktreeKey
|
||||
} from '../../../../shared/worktree/host-qualified-identity'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
/**
|
||||
* What the slot projection itself recorded about each pane: which workspaces display it, and when
|
||||
* its live status stops being fresh. Invalidation reads these instead of re-deriving ownership
|
||||
* with its own rules, so a pane-keyed change (status, acknowledgement, freshness) reaches exactly
|
||||
* the workspaces whose slots were built from it.
|
||||
*/
|
||||
|
||||
const displayersByPane = new Map<string, Set<HostScopedWorktreeKey>>()
|
||||
const panesByIdentity = new Map<HostScopedWorktreeKey, readonly string[]>()
|
||||
|
||||
/** Called with every projection of a workspace slot; replaces that workspace's previous panes. */
|
||||
export function recordDisplayedPanes(
|
||||
identity: HostScopedWorktreeKey,
|
||||
panes: readonly string[]
|
||||
): void {
|
||||
forgetDisplayedPanes(identity)
|
||||
if (panes.length === 0) {
|
||||
return
|
||||
}
|
||||
panesByIdentity.set(identity, panes)
|
||||
for (const pane of panes) {
|
||||
let displayers = displayersByPane.get(pane)
|
||||
if (!displayers) {
|
||||
displayers = new Set()
|
||||
displayersByPane.set(pane, displayers)
|
||||
}
|
||||
displayers.add(identity)
|
||||
}
|
||||
}
|
||||
|
||||
function forgetDisplayedPanes(identity: HostScopedWorktreeKey): void {
|
||||
for (const pane of panesByIdentity.get(identity) ?? []) {
|
||||
const displayers = displayersByPane.get(pane)
|
||||
displayers?.delete(identity)
|
||||
if (displayers?.size === 0) {
|
||||
displayersByPane.delete(pane)
|
||||
}
|
||||
}
|
||||
panesByIdentity.delete(identity)
|
||||
}
|
||||
|
||||
/** Retire removed owners without evicting associations still used by cached lineage trees. */
|
||||
export function forgetDisplayedPanesForWorktrees(
|
||||
targets: readonly { id: string; hostId?: ExecutionHostId }[]
|
||||
): void {
|
||||
const hostsById = new Map<string, Set<ExecutionHostId | undefined>>()
|
||||
for (const { id, hostId } of targets) {
|
||||
let hosts = hostsById.get(id)
|
||||
if (!hosts) {
|
||||
hosts = new Set()
|
||||
hostsById.set(id, hosts)
|
||||
}
|
||||
hosts.add(hostId)
|
||||
}
|
||||
for (const identity of panesByIdentity.keys()) {
|
||||
const hosts = hostsById.get(getWorktreeIdFromHostIdentity(identity))
|
||||
if (
|
||||
hosts &&
|
||||
(hosts.has(undefined) || hosts.has(getExecutionHostIdFromWorktreeHostIdentity(identity)))
|
||||
) {
|
||||
forgetDisplayedPanes(identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bare ids of the workspaces whose last projection displayed the pane. */
|
||||
export function forEachPaneDisplayer(paneKey: string, visit: (worktreeId: string) => void): void {
|
||||
for (const identity of displayersByPane.get(paneKey) ?? []) {
|
||||
visit(getWorktreeIdFromHostIdentity(identity))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment a live entry stops being fresh, or null when time alone never changes it
|
||||
* (unconfirmed restores are never fresh; host-owned structured sessions always are).
|
||||
* Mirrors `isAgentStatusFresh`: fresh while `now - evidence <= staleAfter`.
|
||||
*/
|
||||
export function agentStatusStaleAt(entry: AgentStatusEntry): number | null {
|
||||
if (entry.restoredUnconfirmed === true || entry.structuredHostOwned === true) {
|
||||
return null
|
||||
}
|
||||
return agentStatusEvidenceObservedAt(entry) + AGENT_STATUS_STALE_AFTER_MS
|
||||
}
|
||||
|
||||
type Deadline = { at: number; paneKey: string; entry: AgentStatusEntry }
|
||||
|
||||
/**
|
||||
* Live entries ordered by when they go stale, so a clock advance visits only the entries whose
|
||||
* freshness flipped: O(k log n), not a scan of every live entry. Keyed by pane; superseded heap
|
||||
* items are skipped on pop and compacted away when they outnumber the live ones.
|
||||
*/
|
||||
export function createFreshnessDeadlines() {
|
||||
const heap: Deadline[] = []
|
||||
const current = new Map<string, Deadline>()
|
||||
|
||||
const less = (a: Deadline, b: Deadline): boolean => a.at < b.at
|
||||
function siftUp(index: number): void {
|
||||
const item = heap[index]!
|
||||
while (index > 0) {
|
||||
const parent = (index - 1) >> 1
|
||||
if (!less(item, heap[parent]!)) {
|
||||
break
|
||||
}
|
||||
heap[index] = heap[parent]!
|
||||
index = parent
|
||||
}
|
||||
heap[index] = item
|
||||
}
|
||||
function siftDown(index: number): void {
|
||||
const item = heap[index]!
|
||||
for (;;) {
|
||||
const left = 2 * index + 1
|
||||
if (left >= heap.length) {
|
||||
break
|
||||
}
|
||||
const right = left + 1
|
||||
const child = right < heap.length && less(heap[right]!, heap[left]!) ? right : left
|
||||
if (!less(heap[child]!, item)) {
|
||||
break
|
||||
}
|
||||
heap[index] = heap[child]!
|
||||
index = child
|
||||
}
|
||||
heap[index] = item
|
||||
}
|
||||
function pop(): Deadline | undefined {
|
||||
const top = heap[0]
|
||||
const last = heap.pop()
|
||||
if (heap.length > 0 && last) {
|
||||
heap[0] = last
|
||||
siftDown(0)
|
||||
}
|
||||
return top
|
||||
}
|
||||
function compact(): void {
|
||||
heap.length = 0
|
||||
for (const deadline of current.values()) {
|
||||
heap.push(deadline)
|
||||
}
|
||||
for (let index = (heap.length >> 1) - 1; index >= 0; index -= 1) {
|
||||
siftDown(index)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Track the pane's entry as of clock `now`; an entry already stale, or absent, is dropped. */
|
||||
set(paneKey: string, entry: AgentStatusEntry | undefined, now: number): void {
|
||||
const at = entry ? agentStatusStaleAt(entry) : null
|
||||
if (!entry || at === null || at < now) {
|
||||
current.delete(paneKey)
|
||||
return
|
||||
}
|
||||
const deadline = { at, paneKey, entry }
|
||||
current.set(paneKey, deadline)
|
||||
heap.push(deadline)
|
||||
siftUp(heap.length - 1)
|
||||
if (heap.length > 64 && heap.length > 2 * current.size) {
|
||||
compact()
|
||||
}
|
||||
},
|
||||
/** Visit each tracked pane fresh at `before` and stale at `after`, and stop tracking it. */
|
||||
expire(after: number, visit: (paneKey: string, entry: AgentStatusEntry) => void): void {
|
||||
while (heap.length > 0 && heap[0]!.at < after) {
|
||||
const deadline = pop()!
|
||||
if (current.get(deadline.paneKey) !== deadline) {
|
||||
continue
|
||||
}
|
||||
current.delete(deadline.paneKey)
|
||||
visit(deadline.paneKey, deadline.entry)
|
||||
}
|
||||
},
|
||||
clear(): void {
|
||||
heap.length = 0
|
||||
current.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSlotPaneIndexForTests(): void {
|
||||
displayersByPane.clear()
|
||||
panesByIdentity.clear()
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function createLineageSlotReader(
|
||||
return cached
|
||||
}
|
||||
if (cached) {
|
||||
const changed = changedSlotWorkspaces(state, cached.snapshot, snapshot)
|
||||
const changed = changedSlotWorkspaces(cached.snapshot, snapshot)
|
||||
if (changed) {
|
||||
for (const id of changed) {
|
||||
for (const index of cached.indicesById.get(id) ?? []) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from './workspace-card/activity/worktree-agent-rows-projection'
|
||||
import { selectAgentRowsUnread } from './workspace-card/activity/worktree-card-agent-ack-inputs'
|
||||
import { buildSlotAgentSignals } from './worktree-card-slot-agent-signals'
|
||||
import { recordDisplayedPanes } from './worktree-card-slot-pane-index'
|
||||
import { slotExceptionsFor, type SlotException } from './worktree-card-slot-signal'
|
||||
import {
|
||||
slotTargetsFromAgentLines,
|
||||
@@ -80,6 +81,15 @@ function ownSlot(
|
||||
ownSlots.set(identity, cached)
|
||||
return cached.slot
|
||||
}
|
||||
// Invalidation finds this slot through the panes it was built from.
|
||||
recordDisplayedPanes(
|
||||
identity,
|
||||
agents.flatMap((agent) =>
|
||||
agent.activationPaneKey && agent.activationPaneKey !== agent.paneKey
|
||||
? [agent.paneKey, agent.activationPaneKey]
|
||||
: [agent.paneKey]
|
||||
)
|
||||
)
|
||||
const signals = buildSlotAgentSignals(agents, unread)
|
||||
const targets = slotTargetsFromAgentLines(
|
||||
getWorktreeIdFromHostIdentity(identity),
|
||||
|
||||
@@ -205,7 +205,7 @@ export function openSlotTarget(target: SlotTarget): void {
|
||||
activateAndRevealWorkspace(target.worktreeId, { executionHostId: target.executionHostId })
|
||||
return
|
||||
}
|
||||
expandWorktreeAgentList(target.worktreeId)
|
||||
expandWorktreeAgentList(target.worktreeId, target.executionHostId)
|
||||
if (target.retained) {
|
||||
activateRetainedAgentRow(target.worktreeId, tabId, target.paneKey, target.executionHostId)
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import type { ClientWorkspaceView } from '../../../shared/rpc-contract/client-ui/client-workspace-view-params'
|
||||
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
|
||||
import { tryRuntimeTargetForWorktreeOwner } from '@/store/slices/worktrees/listing/worktree-owner-target'
|
||||
import { onScreenHostId } from '@/store/slices/workspace-attention/workspace-unread-flag'
|
||||
import { onScreenHostId } from '@/store/slices/workspace-attention/workspace-unread-flag-reads'
|
||||
import { presenceRemainingMs, subscribeAutoAckPresenceSignals } from './agent-auto-ack-presence'
|
||||
import type { AutoAckTabTarget } from './agent-auto-ack-targets'
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import {
|
||||
isWorkspaceFlagSet,
|
||||
onScreenHostId
|
||||
} from '@/store/slices/workspace-attention/workspace-unread-flag'
|
||||
} from '@/store/slices/workspace-attention/workspace-unread-flag-reads'
|
||||
import { paneKeyTabId } from '@/store/slices/workspace-attention/workspace-live-markers'
|
||||
import { isOrcaWindowForegroundFocused } from '@/attention/orca-window-foreground-focus'
|
||||
import type { ClientWorkspaceViewReporter } from './client-workspace-view-report'
|
||||
|
||||
@@ -139,7 +139,7 @@ export function settleAttentionRemovedBy(
|
||||
}
|
||||
if (options?.settleFlags !== false) {
|
||||
for (const workspaceId of removed.workspaceIds) {
|
||||
settleWorkspaceUnreadFlag(set, get, workspaceId)
|
||||
settleWorkspaceUnreadFlag(set, get, workspaceId, 'markers')
|
||||
}
|
||||
}
|
||||
dismissReadNotifications(removed.paneKeys)
|
||||
|
||||
@@ -39,6 +39,13 @@ export type WorkspaceAttentionSlice = {
|
||||
screen: OnScreenAttention,
|
||||
options: { screenArrived: boolean; markersRead?: boolean }
|
||||
) => void
|
||||
/**
|
||||
* Workspaces whose persisted flag this renderer's markers raised (the flag was down when the
|
||||
* marker arrived). Only these lower when their last marker goes; a flag the user, a peer or a
|
||||
* previous session raised waits for a visit or Mark as read. Renderer-local, so a restart
|
||||
* starts empty and never lowers a flag it cannot explain.
|
||||
*/
|
||||
workspaceUnreadRaisedByMarkers: Partial<Record<string, true>>
|
||||
/** Host-qualified workspaces the user marked unread this session. Renderer-local. */
|
||||
workspacesMarkedUnreadByUser: Partial<Record<HostScopedWorktreeKey, true>>
|
||||
/** Sidebar "Mark as read" (also removes every marker and reads every agent in the workspace) / "Mark as unread". */
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
import { removeTabsAttention } from './subject-attention-transitions'
|
||||
import {
|
||||
markWorkspaceUnreadFlag,
|
||||
onScreenHostId,
|
||||
settleWorkspaceUnreadFlag,
|
||||
noteWorkspaceRead
|
||||
noteWorkspaceRead,
|
||||
forgetMarkerRaise
|
||||
} from './workspace-unread-flag'
|
||||
import { onScreenHostId } from './workspace-unread-flag-reads'
|
||||
import { workspaceMarkerTabs, paneKeyTabId } from './workspace-live-markers'
|
||||
import { workspaceMarkerTabIdsForHost } from './workspace-marker-host'
|
||||
import {
|
||||
@@ -154,16 +155,25 @@ export const createWorkspaceAttentionSlice: StateCreator<
|
||||
screenArrived
|
||||
) {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
settleWorkspaceUnreadFlag(set, get, workspaceId, onScreenHostId(get(), workspaceId))
|
||||
settleWorkspaceUnreadFlag(
|
||||
set,
|
||||
get,
|
||||
workspaceId,
|
||||
screenArrived ? 'arrival' : 'markers',
|
||||
onScreenHostId(get(), workspaceId)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
workspaceUnreadRaisedByMarkers: {},
|
||||
workspacesMarkedUnreadByUser: {},
|
||||
setWorkspaceUnreadByUser: (worktreeId, isUnread, executionHostId) => {
|
||||
setWorkspacesMarkedUnreadByUser(
|
||||
[composeWorktreeHostIdentity(executionHostId, worktreeId)],
|
||||
isUnread
|
||||
)
|
||||
// The user owns the flag now: a later marker's removal must not lower their mark.
|
||||
forgetMarkerRaise(set, get, worktreeId, { evenIfFlagged: true })
|
||||
if (isUnread) {
|
||||
void get().updateWorktreeMeta(worktreeId, { isUnread }, { executionHostId })
|
||||
return
|
||||
|
||||
+37
-1
@@ -11,7 +11,7 @@ import {
|
||||
type PaneTarget
|
||||
} from './workspace-unread-characterization-fixture'
|
||||
import { completeAgentTurn, toggleUnreadByUser } from './workspace-unread-characterization-driver'
|
||||
import { isWorkspaceFlagSet } from './workspace-unread-flag'
|
||||
import { isWorkspaceFlagSet } from './workspace-unread-flag-reads'
|
||||
import { selectAgentRowsUnread } from '@/components/sidebar/workspace-card/activity/worktree-card-agent-ack-inputs'
|
||||
import { makeTab } from '../../terminals/terminal-tab.test-fixture'
|
||||
import { makeUnifiedTab } from '../tabs/tab-group.test-fixture'
|
||||
@@ -184,6 +184,42 @@ describe('explicit workspace and agent reads', () => {
|
||||
expect(selectAgentRowsUnread(store.getState(), [pane])).toEqual([false])
|
||||
})
|
||||
|
||||
// Closing a tab removes its markers through the settle path (acknowledgement alone never settles).
|
||||
it('closing the last marked tab keeps a workspace the user marked unread this session', () => {
|
||||
const { store } = createAttentionStore()
|
||||
store.getState().setWorkspaceUnreadByUser(WS_A, true, 'local')
|
||||
const pane = finishedAgentOffScreen(store)
|
||||
|
||||
store.getState().closeTab(pane.tabId)
|
||||
|
||||
expect(store.getState().unreadAgentCompletionPanes).toEqual({})
|
||||
expect(isWorkspaceFlagSet(store.getState(), WS_A)).toBe(true)
|
||||
})
|
||||
|
||||
it('closing the last marked tab keeps a flag raised before restart', () => {
|
||||
const { store: previousStore } = createAttentionStore()
|
||||
previousStore.getState().setWorkspaceUnreadByUser(WS_A, true, 'local')
|
||||
const { store } = createAttentionStore()
|
||||
store.setState({ worktreesByRepo: previousStore.getState().worktreesByRepo })
|
||||
const pane = finishedAgentOffScreen(store)
|
||||
|
||||
store.getState().closeTab(pane.tabId)
|
||||
|
||||
expect(store.getState().unreadAgentCompletionPanes).toEqual({})
|
||||
expect(isWorkspaceFlagSet(store.getState(), WS_A)).toBe(true)
|
||||
})
|
||||
|
||||
it('closing the last marked tab lowers a flag only its marker raised', () => {
|
||||
const { store } = createAttentionStore()
|
||||
const pane = finishedAgentOffScreen(store)
|
||||
expect(isWorkspaceFlagSet(store.getState(), WS_A)).toBe(true)
|
||||
|
||||
store.getState().closeTab(pane.tabId)
|
||||
|
||||
expect(isWorkspaceFlagSet(store.getState(), WS_A)).toBe(false)
|
||||
expect(store.getState().workspaceUnreadRaisedByMarkers).toEqual({})
|
||||
})
|
||||
|
||||
it('reads only the selected host, including retained agents and agents awaiting tab hydration', () => {
|
||||
const { store } = createAttentionStore({ windowOnScreen: false })
|
||||
const local = finishedAgentOffScreen(store)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { parseWorkspaceKey } from '../../../../../shared/workspace-scope'
|
||||
import { findKnownWorktreeById } from '../worktrees/listing/detected-worktree-meta'
|
||||
import type { ExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import type { FolderWorkspace } from '../../../../../shared/folder-workspaces/folder-workspace-types'
|
||||
import '../../../../../shared/folder-workspaces/folder-workspace-execution-host'
|
||||
import { getFolderWorkspaceHostId } from '../../../../../shared/folder-workspaces/folder-workspace-host-id'
|
||||
import type { AppState } from '../../types'
|
||||
|
||||
// Read-only lookups of the persisted workspace flag and the row it lives on.
|
||||
|
||||
/** Same-id folder rows: the on-screen host's, else the first. */
|
||||
export function folderRowToMark(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
folderWorkspaceId: string
|
||||
): FolderWorkspace | undefined {
|
||||
return folderRowOnHost(state, folderWorkspaceId, onScreenHostId(state, worktreeId))
|
||||
}
|
||||
|
||||
/** The folder row on `host`, or the first same-id row when no host is named. */
|
||||
export function folderRowOnHost(
|
||||
state: AppState,
|
||||
folderWorkspaceId: string,
|
||||
host: ExecutionHostId | null
|
||||
): FolderWorkspace | undefined {
|
||||
return state.folderWorkspaces.find(
|
||||
(workspace) =>
|
||||
workspace.id === folderWorkspaceId &&
|
||||
(!host || getFolderWorkspaceHostId(workspace, state.projectGroups) === host)
|
||||
)
|
||||
}
|
||||
|
||||
/** The host whose row is on screen: the active workspace's, else null (every same-id row). */
|
||||
export function onScreenHostId(
|
||||
state: Pick<AppState, 'activeWorktreeId' | 'activeWorkspaceExecutionHostId'>,
|
||||
worktreeId: string
|
||||
): ExecutionHostId | null {
|
||||
return worktreeId === state.activeWorktreeId ? state.activeWorkspaceExecutionHostId : null
|
||||
}
|
||||
|
||||
/** The flag {@link clearWorkspaceUnreadFlag} would clear. */
|
||||
export function isWorkspaceFlagSet(
|
||||
state: Parameters<typeof findKnownWorktreeById>[0] & Pick<AppState, 'projectGroups'>,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId | null
|
||||
): boolean {
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
return state.folderWorkspaces.some(
|
||||
(workspace) =>
|
||||
workspace.id === workspaceScope.folderWorkspaceId &&
|
||||
Boolean(workspace.isUnread) &&
|
||||
(!executionHostId ||
|
||||
getFolderWorkspaceHostId(workspace, state.projectGroups) === executionHostId)
|
||||
)
|
||||
}
|
||||
return Boolean(findKnownWorktreeById(state, worktreeId)?.isUnread)
|
||||
}
|
||||
@@ -8,12 +8,12 @@ import type { ExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import type { FolderWorkspace } from '../../../../../shared/folder-workspaces/folder-workspace-types'
|
||||
import '../../../../../shared/folder-workspaces/folder-workspace-execution-host'
|
||||
import { getFolderWorkspaceHostId } from '../../../../../shared/folder-workspaces/folder-workspace-host-id'
|
||||
import type { AppState } from '../../types'
|
||||
import { persistPassiveWorktreeMetaForOwner } from '../worktrees/listing/worktree-owner-settings'
|
||||
import type { WorkspaceAttentionGet, WorkspaceAttentionSet } from './workspace-attention-contract'
|
||||
import { workspaceHasLiveMarker } from './workspace-live-markers'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../../shared/sidebar-inputs-switch'
|
||||
import { ownerHostOwnsSidebarInputs } from '../worktrees/listing/owner-host-sidebar-inputs'
|
||||
import { folderRowOnHost, folderRowToMark, isWorkspaceFlagSet } from './workspace-unread-flag-reads'
|
||||
|
||||
// The persisted workspace flag `isUnread`. Only the workspace-attention module writes it.
|
||||
|
||||
@@ -22,6 +22,7 @@ export function markWorkspaceUnreadFlag(
|
||||
get: WorkspaceAttentionGet,
|
||||
worktreeId: string
|
||||
): void {
|
||||
noteMarkerRaise(set, get, worktreeId)
|
||||
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
raiseWorkspaceUnreadFlag(set, get, worktreeId)
|
||||
return
|
||||
@@ -66,26 +67,52 @@ export function noteWorkspaceRead(worktreeId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Same-id folder rows: the on-screen host's, else the first. */
|
||||
function folderRowToMark(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
folderWorkspaceId: string
|
||||
): FolderWorkspace | undefined {
|
||||
return folderRowOnHost(state, folderWorkspaceId, onScreenHostId(state, worktreeId))
|
||||
/**
|
||||
* Record that a marker raises this workspace's flag, unless the flag is already up for another
|
||||
* reason (Mark as unread, a peer, a previous session): settling must not lower what it did not raise.
|
||||
* Checked before the raise, while the row still says whether the flag was down.
|
||||
*/
|
||||
function noteMarkerRaise(
|
||||
set: WorkspaceAttentionSet,
|
||||
get: WorkspaceAttentionGet,
|
||||
worktreeId: string
|
||||
): void {
|
||||
const state = get()
|
||||
if (state.workspaceUnreadRaisedByMarkers[worktreeId]) {
|
||||
return
|
||||
}
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
const row =
|
||||
workspaceScope?.type === 'folder'
|
||||
? folderRowToMark(state, worktreeId, workspaceScope.folderWorkspaceId)
|
||||
: findKnownWorktreeById(state, worktreeId)
|
||||
if (!row || row.isUnread) {
|
||||
return
|
||||
}
|
||||
set((s) => ({
|
||||
workspaceUnreadRaisedByMarkers: { ...s.workspaceUnreadRaisedByMarkers, [worktreeId]: true }
|
||||
}))
|
||||
}
|
||||
|
||||
/** The folder row on `host`, or the first same-id row when no host is named. */
|
||||
function folderRowOnHost(
|
||||
state: AppState,
|
||||
folderWorkspaceId: string,
|
||||
host: ExecutionHostId | null
|
||||
): FolderWorkspace | undefined {
|
||||
return state.folderWorkspaces.find(
|
||||
(workspace) =>
|
||||
workspace.id === folderWorkspaceId &&
|
||||
(!host || getFolderWorkspaceHostId(workspace, state.projectGroups) === host)
|
||||
)
|
||||
/** Drop the marker-raised record once no row of the workspace is flagged, or when the user takes the flag over. */
|
||||
export function forgetMarkerRaise(
|
||||
set: WorkspaceAttentionSet,
|
||||
get: WorkspaceAttentionGet,
|
||||
worktreeId: string,
|
||||
options?: { evenIfFlagged?: boolean }
|
||||
): void {
|
||||
const state = get()
|
||||
if (!state.workspaceUnreadRaisedByMarkers[worktreeId]) {
|
||||
return
|
||||
}
|
||||
if (!options?.evenIfFlagged && isWorkspaceFlagSet(state, worktreeId)) {
|
||||
return
|
||||
}
|
||||
set((s) => {
|
||||
const next = { ...s.workspaceUnreadRaisedByMarkers }
|
||||
delete next[worktreeId]
|
||||
return { workspaceUnreadRaisedByMarkers: next }
|
||||
})
|
||||
}
|
||||
|
||||
function raiseWorkspaceUnreadFlag(
|
||||
@@ -169,6 +196,16 @@ export function clearWorkspaceUnreadFlag(
|
||||
worktreeId: string,
|
||||
/** Folder rows share an id across hosts: the on-screen host's row, or null for every row. */
|
||||
executionHostId?: ExecutionHostId | null
|
||||
): void {
|
||||
lowerWorkspaceUnreadFlag(set, get, worktreeId, executionHostId)
|
||||
forgetMarkerRaise(set, get, worktreeId)
|
||||
}
|
||||
|
||||
function lowerWorkspaceUnreadFlag(
|
||||
set: WorkspaceAttentionSet,
|
||||
get: WorkspaceAttentionGet,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId | null
|
||||
): void {
|
||||
noteWorkspaceRead(worktreeId)
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
@@ -239,43 +276,29 @@ export function clearWorkspaceUnreadFlag(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the flag is being settled. `arrival`: the workspace came on screen, which reads any flag with
|
||||
* no live marker left (Mark as unread, a peer's, a restart's). `markers`: markers went away
|
||||
* elsewhere (acknowledged, closed, read on screen), which lowers only a flag those markers raised.
|
||||
*/
|
||||
export type WorkspaceUnreadSettleCause = 'arrival' | 'markers'
|
||||
|
||||
/** Lower flags only after the last live marker disappears. Unscoped same-ID folders share workspace-keyed markers, so lower every row only when none remain. */
|
||||
export function settleWorkspaceUnreadFlag(
|
||||
set: WorkspaceAttentionSet,
|
||||
get: WorkspaceAttentionGet,
|
||||
worktreeId: string,
|
||||
cause: WorkspaceUnreadSettleCause,
|
||||
executionHostId?: ExecutionHostId | null
|
||||
): void {
|
||||
// Why only downward: raising here would override another client's Mark as read with this
|
||||
// renderer's local markers; marking already raises the flag together with its marker.
|
||||
if (!workspaceHasLiveMarker(get(), worktreeId)) {
|
||||
clearWorkspaceUnreadFlag(set, get, worktreeId, executionHostId)
|
||||
const state = get()
|
||||
if (workspaceHasLiveMarker(state, worktreeId)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** The host whose row is on screen: the active workspace's, else null (every same-id row). */
|
||||
export function onScreenHostId(
|
||||
state: Pick<AppState, 'activeWorktreeId' | 'activeWorkspaceExecutionHostId'>,
|
||||
worktreeId: string
|
||||
): ExecutionHostId | null {
|
||||
return worktreeId === state.activeWorktreeId ? state.activeWorkspaceExecutionHostId : null
|
||||
}
|
||||
|
||||
/** The flag {@link clearWorkspaceUnreadFlag} would clear. */
|
||||
export function isWorkspaceFlagSet(
|
||||
state: Parameters<typeof findKnownWorktreeById>[0] & Pick<AppState, 'projectGroups'>,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId | null
|
||||
): boolean {
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
return state.folderWorkspaces.some(
|
||||
(workspace) =>
|
||||
workspace.id === workspaceScope.folderWorkspaceId &&
|
||||
Boolean(workspace.isUnread) &&
|
||||
(!executionHostId ||
|
||||
getFolderWorkspaceHostId(workspace, state.projectGroups) === executionHostId)
|
||||
)
|
||||
if (cause === 'markers' && !state.workspaceUnreadRaisedByMarkers[worktreeId]) {
|
||||
return
|
||||
}
|
||||
return Boolean(findKnownWorktreeById(state, worktreeId)?.isUnread)
|
||||
clearWorkspaceUnreadFlag(set, get, worktreeId, executionHostId)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import { forgetCombinedDiffViewsForWorktrees } from '@/components/editor/combined-diff/remember-view/combined-diff-view-cache'
|
||||
import { forgetDisplayedPanesForWorktrees } from '@/components/sidebar/worktree-card-slot-pane-index'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { WorktreeSliceSet } from '../listing/worktree-slice-types'
|
||||
import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
|
||||
@@ -24,6 +25,7 @@ export function applyRemoveWorktreeSuccessState(
|
||||
// Clear module caches outside store state; reused IDs must re-arm ambiguity warnings.
|
||||
forgetAmbiguousOwnerWarnings([worktreeId])
|
||||
forgetCombinedDiffViewsForWorktrees([worktreeId])
|
||||
forgetDisplayedPanesForWorktrees([{ id: worktreeId }])
|
||||
set((s) => {
|
||||
const worktreeIds = [worktreeId]
|
||||
const omitByWorktree = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, worktreeIds)
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
hostWorktreeTarget
|
||||
} from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import {
|
||||
forEachPaneDisplayer,
|
||||
recordDisplayedPanes
|
||||
} from '@/components/sidebar/worktree-card-slot-pane-index'
|
||||
import { createTestStore } from '../../../testing/app-store-test-fixture'
|
||||
import { makeWorktree } from '../worktree.test-fixture'
|
||||
import { applyRemoveWorktreeSuccessState } from './remove-worktree-store-cleanup'
|
||||
import { buildWorktreePurgeState } from './worktree-purge-state'
|
||||
|
||||
function displayers(paneKey: string): string[] {
|
||||
const ids: string[] = []
|
||||
forEachPaneDisplayer(paneKey, (id) => ids.push(id))
|
||||
return ids
|
||||
}
|
||||
|
||||
it('retires displayed pane owners after explicit workspace removal and identity reuse', () => {
|
||||
const store = createTestStore()
|
||||
const identity = composeWorktreeHostIdentity('local', 'removed-slot')
|
||||
recordDisplayedPanes(identity, ['old-pane', 'reused-pane'])
|
||||
applyRemoveWorktreeSuccessState(store.setState, 'removed-slot', new Set())
|
||||
expect(displayers('old-pane')).toEqual([])
|
||||
expect(displayers('reused-pane')).toEqual([])
|
||||
|
||||
recordDisplayedPanes(identity, ['new-pane'])
|
||||
expect(displayers('new-pane')).toEqual(['removed-slot'])
|
||||
applyRemoveWorktreeSuccessState(store.setState, 'removed-slot', new Set())
|
||||
expect(displayers('new-pane')).toEqual([])
|
||||
})
|
||||
|
||||
it('retires historical owners during repeated bulk workspace churn', () => {
|
||||
const store = createTestStore()
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
const id = `churn-slot-${index}`
|
||||
recordDisplayedPanes(composeWorktreeHostIdentity('local', id), ['churn-pane'])
|
||||
recordDisplayedPanes(composeWorktreeHostIdentity('runtime:box', id), ['churn-pane'])
|
||||
buildWorktreePurgeState(store.getState(), [id])
|
||||
}
|
||||
expect(displayers('churn-pane')).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves the surviving host twin when retiring a pane owner', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
repo: [
|
||||
makeWorktree({ id: 'repo::/slot-twin', repoId: 'repo', hostId: 'local' }),
|
||||
makeWorktree({ id: 'repo::/slot-twin', repoId: 'repo', hostId: 'runtime:box' })
|
||||
]
|
||||
}
|
||||
})
|
||||
recordDisplayedPanes(composeWorktreeHostIdentity('local', 'repo::/slot-twin'), [
|
||||
'twin-pane',
|
||||
'local-twin-pane'
|
||||
])
|
||||
recordDisplayedPanes(composeWorktreeHostIdentity('runtime:box', 'repo::/slot-twin'), [
|
||||
'twin-pane',
|
||||
'remote-twin-pane'
|
||||
])
|
||||
buildWorktreePurgeState(store.getState(), [
|
||||
hostWorktreeTarget({ id: 'repo::/slot-twin', hostId: 'runtime:box' })
|
||||
])
|
||||
expect(displayers('twin-pane')).toEqual(['repo::/slot-twin'])
|
||||
expect(displayers('local-twin-pane')).toEqual(['repo::/slot-twin'])
|
||||
expect(displayers('remote-twin-pane')).toEqual([])
|
||||
buildWorktreePurgeState(store.getState(), ['repo::/slot-twin'])
|
||||
expect(displayers('twin-pane')).toEqual([])
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { retireRemovedWorktreeCommitDrafts } from '@/lib/source-control/drafts/source-control-commit-draft-session'
|
||||
import { forgetCombinedDiffViewsForWorktrees } from '@/components/editor/combined-diff/remember-view/combined-diff-view-cache'
|
||||
import { forgetDisplayedPanesForWorktrees } from '@/components/sidebar/worktree-card-slot-pane-index'
|
||||
import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control/warnings/source-control-huge-repo-warning-dismissals'
|
||||
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation'
|
||||
@@ -72,6 +73,7 @@ export function buildWorktreePurgeState(
|
||||
),
|
||||
...[...twinScopes].flatMap(([hostId, twins]) => [...twins.keys()].map((id) => ({ id, hostId })))
|
||||
]
|
||||
forgetDisplayedPanesForWorktrees(hostKeys)
|
||||
const next = { ...s, ...patch }
|
||||
return {
|
||||
...patch,
|
||||
@@ -93,6 +95,7 @@ export function buildFullWorktreePurgeState(
|
||||
changedPaneKeys?: Set<string>
|
||||
): Partial<AppState> {
|
||||
const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id))
|
||||
forgetDisplayedPanesForWorktrees(normalizedTargets.map(({ id }) => ({ id })))
|
||||
forgetCombinedDiffViewsForWorktrees(worktreeIdSet)
|
||||
// Why: bulk purges bypass close actions, so provisional ownership must die before tab state does.
|
||||
const cancelledSessionIds = cancelStructuredLaunchesForWorktrees(worktreeIdSet)
|
||||
|
||||
Reference in New Issue
Block a user