From d10bdaddb98672a5f56d3adee71d7e2c066b73ca Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:42:47 -0800 Subject: [PATCH] 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. --- .../activity/use-worktree-agent-list-model.ts | 2 +- .../activity/worktree-agent-row-selectors.ts | 3 +- ...rktree-card-agents-expansion-state.test.ts | 50 +++++ .../worktree-card-agents-expansion-state.ts | 101 +++++---- .../review/worktree-card-pr-derivation.ts | 2 +- .../surface/worktree-card-title-status.tsx | 2 +- ...ree-card-slot-invalidation.scaling.test.ts | 69 ++++++- .../worktree-card-slot-invalidation.ts | 181 +++++++++------- .../worktree-card-slot-pane-index.test.ts | 103 ++++++++++ .../sidebar/worktree-card-slot-pane-index.ts | 194 ++++++++++++++++++ .../sidebar/worktree-card-slot-range.ts | 2 +- .../sidebar/worktree-card-slot-rollup.ts | 10 + .../sidebar/worktree-card-slot-targets.ts | 2 +- .../src/hooks/client-workspace-view-report.ts | 2 +- .../src/hooks/on-screen-attention-scan.ts | 2 +- .../attention-removal-follow-up.ts | 2 +- .../workspace-attention-contract.ts | 7 + .../workspace-attention-slice.ts | 16 +- .../workspace-unread-explicit-read.test.ts | 38 +++- .../workspace-unread-flag-reads.ts | 58 ++++++ .../workspace-unread-flag.ts | 117 ++++++----- .../teardown/remove-worktree-store-cleanup.ts | 2 + .../removed-worktree-slot-pane-index.test.ts | 72 +++++++ .../teardown/worktree-purge-state.ts | 3 + 24 files changed, 872 insertions(+), 168 deletions(-) create mode 100644 src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-pane-index.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-pane-index.ts create mode 100644 src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-reads.ts create mode 100644 src/renderer/src/store/slices/worktrees/teardown/removed-worktree-slot-pane-index.test.ts diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts index 5cef249f4ea..3f0f72965d8 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts @@ -142,7 +142,7 @@ export function useWorktreeAgentListModel( toggleLineageParent: toggleLineageParentState, toggleIdleChildGroup: toggleIdleChildGroupState, toggleCompactRootList - } = useWorktreeAgentExpansionState(worktreeId) + } = useWorktreeAgentExpansionState(worktreeId, executionHostId) const toggleLineageParent = useCallback( (paneKey: string) => { dispatchSuppressScrollAdjustment() diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-row-selectors.ts index cb405ca512d..1c24641082f 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-row-selectors.ts @@ -76,7 +76,8 @@ export function reuseArrayIfEqual(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 { diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.test.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.test.ts new file mode 100644 index 00000000000..b3fca536423 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.ts index 92cd17eec26..c4f2ab802c0 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.ts @@ -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() +const expansionByKey = new Map() + +/** + * 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 void>>() +const expansionListeners = new Map 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) } diff --git a/src/renderer/src/components/sidebar/workspace-card/review/worktree-card-pr-derivation.ts b/src/renderer/src/components/sidebar/workspace-card/review/worktree-card-pr-derivation.ts index 940503e9304..6097c7109b4 100644 --- a/src/renderer/src/components/sidebar/workspace-card/review/worktree-card-pr-derivation.ts +++ b/src/renderer/src/components/sidebar/workspace-card/review/worktree-card-pr-derivation.ts @@ -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' diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx index 5c8255c8c7f..080e00768ae 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx @@ -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' && diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.scaling.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.scaling.test.ts index 6a7c3194229..816d25c6d0e 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.scaling.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.scaling.test.ts @@ -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])) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts index 1954bfc71d9..d1b78dd1838 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts @@ -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 | 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 | null { + const result = new Set() + 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 | null>>() - -export function changedSlotWorkspaces( +function changedBetween( state: AppState, before: SlotSnapshot, after: SlotSnapshot ): Set | null { - const cached = changes.get(before)?.get(after) - if (cached !== undefined) { - return cached + const result = new Set() + const add = (worktreeId: string): void => { + result.add(worktreeId) } - let result: Set | null = new Set() 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 } diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-pane-index.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-pane-index.test.ts new file mode 100644 index 00000000000..28f807b26f1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-pane-index.test.ts @@ -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) { + 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']) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-pane-index.ts b/src/renderer/src/components/sidebar/worktree-card-slot-pane-index.ts new file mode 100644 index 00000000000..a53369e3f77 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-pane-index.ts @@ -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>() +const panesByIdentity = new Map() + +/** 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>() + 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() + + 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() +} diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-range.ts b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts index 06215d1b1c1..5f27e7bde42 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-range.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts @@ -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) ?? []) { diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts index bc3f813bb7b..85a752e4430 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts @@ -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), diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts index 8d7d35fd6b1..79b226e6548 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts @@ -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 { diff --git a/src/renderer/src/hooks/client-workspace-view-report.ts b/src/renderer/src/hooks/client-workspace-view-report.ts index 17ca75bfa08..b396f43e7b9 100644 --- a/src/renderer/src/hooks/client-workspace-view-report.ts +++ b/src/renderer/src/hooks/client-workspace-view-report.ts @@ -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' diff --git a/src/renderer/src/hooks/on-screen-attention-scan.ts b/src/renderer/src/hooks/on-screen-attention-scan.ts index 7aeadfb8d89..b0f1568ab7c 100644 --- a/src/renderer/src/hooks/on-screen-attention-scan.ts +++ b/src/renderer/src/hooks/on-screen-attention-scan.ts @@ -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' diff --git a/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts b/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts index 09dfaa49108..5059a3bdfa9 100644 --- a/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts +++ b/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts @@ -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) diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-contract.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-contract.ts index 5881983f51a..74f6ad88eab 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-contract.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-contract.ts @@ -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> /** Host-qualified workspaces the user marked unread this session. Renderer-local. */ workspacesMarkedUnreadByUser: Partial> /** Sidebar "Mark as read" (also removes every marker and reads every agent in the workspace) / "Mark as unread". */ diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts index 426f041b8f2..e9055e55d69 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts @@ -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 diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts index 3bc7546b087..db77d9a396e 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts @@ -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) diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-reads.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-reads.ts new file mode 100644 index 00000000000..474b4454914 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-reads.ts @@ -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, + worktreeId: string +): ExecutionHostId | null { + return worktreeId === state.activeWorktreeId ? state.activeWorkspaceExecutionHostId : null +} + +/** The flag {@link clearWorkspaceUnreadFlag} would clear. */ +export function isWorkspaceFlagSet( + state: Parameters[0] & Pick, + 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) +} diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts index 559c0b556d7..8a336d413a1 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts @@ -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, - worktreeId: string -): ExecutionHostId | null { - return worktreeId === state.activeWorktreeId ? state.activeWorkspaceExecutionHostId : null -} - -/** The flag {@link clearWorkspaceUnreadFlag} would clear. */ -export function isWorkspaceFlagSet( - state: Parameters[0] & Pick, - 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) } diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index 83bf95a996f..870212377fd 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -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 = (m: Record | undefined) => omitRecordKeys(m, worktreeIds) diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-slot-pane-index.test.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-slot-pane-index.test.ts new file mode 100644 index 00000000000..66c16b70c30 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-slot-pane-index.test.ts @@ -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([]) +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index e48a9fc9883..80cffc1036b 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -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 ): Partial { 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)