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:
Jinjing
2026-11-13 18:28:59 -08:00
parent 7951ad37de
commit d10bdaddb9
24 changed files with 872 additions and 168 deletions
@@ -142,7 +142,7 @@ export function useWorktreeAgentListModel(
toggleLineageParent: toggleLineageParentState,
toggleIdleChildGroup: toggleIdleChildGroupState,
toggleCompactRootList
} = useWorktreeAgentExpansionState(worktreeId)
} = useWorktreeAgentExpansionState(worktreeId, executionHostId)
const toggleLineageParent = useCallback(
(paneKey: string) => {
dispatchSuppressScrollAdjustment()
@@ -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> {
@@ -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)
})
})
@@ -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,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'
@@ -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' &&
@@ -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
@@ -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)
@@ -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)