Improve sidebar activity rollup performance

- Refresh only stale descendant slots and scope unread updates to their workspace.
- Share agent row projections and index unread markers to reduce repeated work.
- Preserve host-specific agent activation and show the latest activity time.
This commit is contained in:
Jinjing
2026-11-13 18:28:59 -08:00
parent cce8bfedc6
commit a4f08912df
26 changed files with 1800 additions and 173 deletions
@@ -4,6 +4,7 @@ import { useNow } from '@/hooks/use-now'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { cn } from '@/lib/class-names'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { CompactAgentExpansion, CompactAgentSummaryButton } from './worktree-card-compact-agents'
import { CompactAgentRow } from './worktree-card-compact-agent-row'
import { revealCompactAgentCard } from '../../worktree-list/navigation/worktree-sidebar-reveal'
@@ -15,33 +16,49 @@ import { useWorktreeAgentListModel } from './use-worktree-agent-list-model'
type Props = {
worktreeId: string
/** The card's host; local and remote copies can share a workspace ID, so activation must name it. */
executionHostId?: ExecutionHostId
agents?: DashboardAgentRowData[]
className?: string
}
const WorktreeCardAgents = React.memo(function WorktreeCardAgents({
worktreeId,
executionHostId,
agents: precomputedAgents,
className
}: Props) {
useUiLanguageRevision()
const selectedAgents = useWorktreeAgentRows(worktreeId, precomputedAgents === undefined)
const selectedAgents = useWorktreeAgentRows(
worktreeId,
precomputedAgents === undefined,
executionHostId
)
const agents = precomputedAgents ?? selectedAgents
if (agents.length === 0) {
return null
}
// Only non-empty bodies mount the 30s clock, avoiding timer cost for idle worktrees.
return <WorktreeCardAgentsBody worktreeId={worktreeId} agents={agents} className={className} />
return (
<WorktreeCardAgentsBody
worktreeId={worktreeId}
executionHostId={executionHostId}
agents={agents}
className={className}
/>
)
})
type BodyProps = {
worktreeId: string
executionHostId?: ExecutionHostId
agents: DashboardAgentRowData[]
className?: string
}
const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
worktreeId,
executionHostId,
agents,
className
}: BodyProps) {
@@ -67,7 +84,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
toggleIdleChildGroup,
isLineageExpanded,
isFocusedOrHidesFocus
} = useWorktreeAgentListModel(worktreeId, agents)
} = useWorktreeAgentListModel(worktreeId, agents, executionHostId)
const compactAgentListRootRef = useRef<HTMLDivElement | null>(null)
const now = useNow(30_000)
@@ -1,15 +1,25 @@
import { useCallback, useMemo, useSyncExternalStore } from 'react'
import { useAppStore } from '@/store'
import { useAppStateView } from '@/store/retained-views/app-state-view'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { createWorktreeAgentRowsSelector, EMPTY_AGENT_ROWS } from './worktree-agent-rows-projection'
import {
createWorktreeAgentRowsSelector,
EMPTY_AGENT_ROWS,
getSharedWorktreeAgentRowsSelector
} from './worktree-agent-rows-projection'
const subscribeToNothing = (): (() => void) => () => {}
export function useWorktreeAgentRows(worktreeId: string, active = true, hostId?: ExecutionHostId) {
const store = useAppStateView()
// Live reads share the rollup's selector; a retained view's frozen state keeps its own memo.
const live = store === useAppStore
const selectRows = useMemo(
() => createWorktreeAgentRowsSelector(worktreeId, hostId),
[worktreeId, hostId]
() =>
live
? getSharedWorktreeAgentRowsSelector(worktreeId, hostId)
: createWorktreeAgentRowsSelector(worktreeId, hostId),
[live, worktreeId, hostId]
)
const getRows = useCallback(
() => (active ? selectRows(store.getState()) : EMPTY_AGENT_ROWS),
@@ -113,8 +113,31 @@ export function createWorktreeAgentRowsSelector(worktreeId: string, hostId?: Exe
}
}
/** Structural changes can alter agent ownership; live status and acknowledgements invalidate only their owners. */
export function worktreeAgentStructureInputs(state: AppState): readonly unknown[] {
const sharedSelectors = new Map<string, ReturnType<typeof createWorktreeAgentRowsSelector>>()
const SHARED_SELECTOR_LIMIT = 2048
/**
* One live-store row selector per workspace and host, shared by the card's own rows and the
* lineage rollup so a status change projects the rows once. Least recently used is evicted, so
* workspaces still being read keep their memo above the cap.
*/
export function getSharedWorktreeAgentRowsSelector(worktreeId: string, hostId?: ExecutionHostId) {
const key = `${hostId ?? ''}\n${worktreeId}`
let selector = sharedSelectors.get(key)
if (selector) {
sharedSelectors.delete(key)
} else {
selector = createWorktreeAgentRowsSelector(worktreeId, hostId)
if (sharedSelectors.size >= SHARED_SELECTOR_LIMIT) {
sharedSelectors.delete(sharedSelectors.keys().next().value!)
}
}
sharedSelectors.set(key, selector)
return selector
}
/** Tab and pane layout inputs; a change here can move any agent between workspaces. */
export function worktreeAgentLayoutInputs(state: AppState): readonly unknown[] {
return [
state.tabsByWorktree,
state.unifiedTabsByWorktree,
@@ -123,7 +146,14 @@ export function worktreeAgentStructureInputs(state: AppState): readonly unknown[
state.ptyIdsByTabId,
state.terminalLayoutsByTabId,
state.runtimeAgentOrchestrationByPaneKey,
state.paneForegroundAgentByPaneKey,
state.paneForegroundAgentByPaneKey
]
}
/** Structural changes can alter agent ownership; live status and acknowledgements invalidate only their owners. */
export function worktreeAgentStructureInputs(state: AppState): readonly unknown[] {
return [
...worktreeAgentLayoutInputs(state),
selectWorktreesByRepo(state),
...structuredAgentSessionOwnerInputs(state)
]
@@ -62,6 +62,7 @@ export function WorktreeCardSecondaryRows({
{showInlineAgentList && (
<WorktreeCardAgents
worktreeId={worktree.id}
executionHostId={worktree.hostId}
agents={inlineAgentRows}
className={hasMetaRow || remoteBranchConflict ? 'mt-0' : '-mt-1'}
/>
@@ -10,9 +10,6 @@ import {
composeWorktreeHostIdentity,
type HostScopedWorktreeKey
} from '../../../../../../shared/worktree/host-qualified-identity'
import type { AppState } from '@/store/types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { worktreeAgentProjectionInputs } from '../activity/worktree-agent-rows-projection'
import { selectAgentRowsUnread } from '../activity/worktree-card-agent-ack-inputs'
import { selectSendTargetControlInputs } from '../activity/worktree-card-send-target-inputs'
import { useWorktreeAgentExpansionState } from '../activity/worktree-card-agents-expansion-state'
@@ -26,7 +23,7 @@ import {
buildSlotAgentSignals,
type SlotAgentSignals
} from '../../worktree-card-slot-agent-signals'
import { titleRowSlot, type SlotException } from '../../worktree-card-slot-signal'
import { titleRowSlot, titleRowUnread, type SlotException } from '../../worktree-card-slot-signal'
import {
expandWorkspaceUnreadTargets,
slotTargetsFromAgentLines,
@@ -35,6 +32,7 @@ import {
workspaceUnreadTargetsOutsideAgentRows,
type SlotTarget
} from '../../worktree-card-slot-targets'
import { createWorkspaceUnreadSelector } from './worktree-card-workspace-unread-selector'
import {
WorktreeCardSlot,
WorktreeCardTitlePills,
@@ -64,31 +62,6 @@ function useSlotAgentSignals(agents: DashboardAgentRow[]): SlotAgentSignals {
return useMemo(() => buildSlotAgentSignals(agents, unread), [agents, unread])
}
function createWorkspaceUnreadSelector(worktree: Worktree, visibleAgents: readonly SlotTarget[]) {
const target = workspaceUnreadTarget(
worktree.id,
worktree.lastActivityAt || null,
worktree.hostId
)
let inputs: readonly unknown[] = []
let unread = false
return (state: AppState) => {
const next = [
...worktreeAgentProjectionInputs(state),
state.unreadTerminalTabs,
state.unreadAgentCompletionPanes,
state.workspacesMarkedUnreadByUser
]
if (!next.every((value, index) => value === inputs[index])) {
inputs = next
unread =
worktree.isUnread &&
workspaceUnreadTargetsOutsideAgentRows(state, target, visibleAgents).length > 0
}
return unread
}
}
/**
* Computes the card's rolled-up status once, for the status lane, the title and the title's
* right-hand slot: rows mount by the hundreds, so the three must not each subscribe.
@@ -121,7 +94,6 @@ export function WorktreeCardTitleStatusProvider({
const hiddenDescendants = lineageCollapsed ? descendants : NO_DESCENDANTS
const collapsedLineage = useCollapsedLineageSlots(hiddenDescendants)
const lineageLatestAt = useLineageLatestAt(descendants)
const agentUnread = signals.allSignals.some((signal) => signal.kind === 'unread')
const lastActivityAt = worktree.lastActivityAt > 0 ? worktree.lastActivityAt : null
const visibleAgentTargets = useMemo(
() => (linesShown ? slotTargetsFromAgentLines(worktree.id, signals, worktree.hostId) : []),
@@ -176,7 +148,7 @@ export function WorktreeCardTitleStatusProvider({
const at = slot.kind === 'status' ? slot.at : null
// Why: a collapsed parent rolls up its children's unread agent lines, so each child must show
// the same dot even when the line is visible, or expanding hides where the unread came from.
const unread = agentUnread || exceptions.some((exception) => exception.kind === 'unread')
const unread = titleRowUnread({ lines: signals.allSignals, collapsedLineage, workspaceUnread })
const lineageStatus = lineageLaneStatus(collapsedLineage)
const value = useMemo<TitleStatus>(
() => ({ offline, exceptions, unread, lineageStatus, at, resolveTargets }),
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import type * as SlotTargetsModule from '../../worktree-card-slot-targets'
import { makeTab } from '@/store/terminals/terminal-tab.test-fixture'
import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
import { createWorkspaceUnreadSelector } from './worktree-card-workspace-unread-selector'
const resolve = vi.hoisted(() => ({ calls: 0 }))
vi.mock('../../worktree-card-slot-targets', async (importOriginal) => {
const actual = await importOriginal<typeof SlotTargetsModule>()
return {
...actual,
workspaceUnreadTargetsOutsideAgentRows: (
...args: Parameters<typeof actual.workspaceUnreadTargetsOutsideAgentRows>
) => {
resolve.calls++
return actual.workspaceUnreadTargetsOutsideAgentRows(...args)
}
}
})
const MARKER = { at: 1, kind: 'agent-completion' } as never
const ENTRY = (state: string) => ({ state, worktreeId: 'x' }) as never
function baseState(): AppState {
return {
workspacesMarkedUnreadByUser: {},
unreadTerminalTabs: {},
unreadAgentCompletionPanes: { 'a1:leaf': MARKER, 'b1:leaf': MARKER },
tabsByWorktree: {
A: [makeTab({ id: 'a1', worktreeId: 'A', ptyId: 'pty-a1' })],
B: [makeTab({ id: 'b1', worktreeId: 'B', ptyId: 'pty-b1' })]
},
unifiedTabsByWorktree: {},
terminalLayoutsByTabId: {
a1: { root: null, activeLeafId: 'leaf', expandedLeafId: null },
b1: { root: null, activeLeafId: 'leaf', expandedLeafId: null }
},
ptyIdsByTabId: {},
agentStatusByPaneKey: { 'a1:leaf': ENTRY('done'), 'b1:leaf': ENTRY('working') },
agentStatusEpoch: 0,
acknowledgedAgentsByPaneKey: {},
runtimePaneTitlesByTabId: {},
activeWorktreeId: null
} as unknown as AppState
}
beforeEach(() => {
resolve.calls = 0
})
describe('createWorkspaceUnreadSelector', () => {
const worktree = makeWorktree({ id: 'A', repoId: 'r', hostId: 'local', isUnread: true })
it('ignores agent, marker, layout and selection changes in other workspaces', () => {
const select = createWorkspaceUnreadSelector(worktree, [])
let state = baseState()
expect(select(state)).toBe(true)
expect(resolve.calls).toBe(1)
state = {
...state,
agentStatusByPaneKey: { ...state.agentStatusByPaneKey, 'b1:leaf': ENTRY('done') },
agentStatusEpoch: 1,
acknowledgedAgentsByPaneKey: { 'b1:leaf': 1 } as never,
runtimePaneTitlesByTabId: { b1: 'title' } as never,
unreadAgentCompletionPanes: { ...state.unreadAgentCompletionPanes, 'b1:other': MARKER },
terminalLayoutsByTabId: {
...state.terminalLayoutsByTabId,
b1: { root: null, activeLeafId: 'other', expandedLeafId: null }
},
activeWorktreeId: 'B'
} as AppState
expect(select(state)).toBe(true)
expect(resolve.calls).toBe(1)
})
it('re-resolves when this workspace’s marker, status, or tabs change', () => {
const select = createWorkspaceUnreadSelector(worktree, [])
let state = baseState()
select(state)
state = {
...state,
agentStatusByPaneKey: { ...state.agentStatusByPaneKey, 'a1:leaf': ENTRY('working') }
} as AppState
select(state)
expect(resolve.calls).toBe(2)
state = {
...state,
unreadAgentCompletionPanes: { 'b1:leaf': MARKER }
} as AppState
select(state)
expect(resolve.calls).toBe(3)
state = {
...state,
unreadTerminalTabs: { a1: MARKER }
} as AppState
select(state)
expect(resolve.calls).toBe(4)
state = {
...state,
tabsByWorktree: { ...state.tabsByWorktree, A: [...state.tabsByWorktree.A!] }
} as AppState
select(state)
expect(resolve.calls).toBe(5)
})
it('does no work for a card that is not unread', () => {
const select = createWorkspaceUnreadSelector({ ...worktree, isUnread: false }, [])
expect(select(baseState())).toBe(false)
expect(resolve.calls).toBe(0)
})
})
@@ -0,0 +1,75 @@
import type { AppState } from '@/store/types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { structuredAgentSessionOwnerInputs } from '@/runtime/native-chat/session-ownership/structured-agent-session-owner'
import {
completionPaneKeysByTabId,
workspaceMarkerTabs
} from '@/store/slices/workspace-attention/workspace-live-markers'
import { markerTabPaneKey } from '@/store/slices/workspace-attention/workspace-marker-host'
import {
workspaceUnreadTarget,
workspaceUnreadTargetsOutsideAgentRows,
type SlotTarget
} from '../../worktree-card-slot-targets'
/**
* Exactly what `workspaceUnreadTargetsOutsideAgentRows` reads for this workspace, so an agent
* update, marker or layout change in another workspace does not re-resolve this card. O(tabs
* in this workspace); completion markers come from the shared per-snapshot tab index.
*/
export function workspaceUnreadInputs(state: AppState, worktree: Worktree): unknown[] {
const identity = composeWorktreeHostIdentity(worktree.hostId ?? 'local', worktree.id)
const inputs: unknown[] = [
state.workspacesMarkedUnreadByUser[identity],
state.tabsByWorktree[worktree.id],
state.unifiedTabsByWorktree[worktree.id]
]
const completions = completionPaneKeysByTabId(state.unreadAgentCompletionPanes)
let hasStructuredTab = false
for (const tab of workspaceMarkerTabs(state, worktree.id)) {
hasStructuredTab ||= tab.sessionId !== null
inputs.push(
state.unreadTerminalTabs[tab.id],
completions.get(tab.id),
// Host resolution for this tab: its layout, pty ids, and status entry.
state.terminalLayoutsByTabId[tab.id],
state.ptyIdsByTabId[tab.id],
state.agentStatusByPaneKey[markerTabPaneKey(state, tab)]
)
}
if (hasStructuredTab) {
// Chat ownership can fall back to workspace and runtime state when a tab has no stamped host.
inputs.push(...structuredAgentSessionOwnerInputs(state))
}
return inputs
}
/** Whether the card's workspace unread has a source its visible agent rows don't already show. */
export function createWorkspaceUnreadSelector(
worktree: Worktree,
visibleAgents: readonly SlotTarget[]
) {
const target = workspaceUnreadTarget(
worktree.id,
worktree.lastActivityAt || null,
worktree.hostId
)
let inputs: readonly unknown[] | null = null
let unread = false
return (state: AppState) => {
if (!worktree.isUnread) {
return false
}
const next = workspaceUnreadInputs(state, worktree)
if (
inputs === null ||
next.length !== inputs.length ||
!next.every((value, index) => value === inputs![index])
) {
inputs = next
unread = workspaceUnreadTargetsOutsideAgentRows(state, target, visibleAgents).length > 0
}
return unread
}
}
@@ -53,6 +53,7 @@ export const CARD_ACTIVITY_PARTS = {
Agents: part(['tight'], (props, _children, card) => (
<WorktreeCardAgents
worktreeId={card.worktree.id}
executionHostId={card.worktree.hostId}
agents={card.inlineAgentRows}
// Why: counterbalance the card stack gap so agents right after the title read as one group.
className={props.tight === true ? '-mt-1' : 'mt-0'}
@@ -0,0 +1,203 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type * as ProjectionModule from './workspace-card/activity/worktree-agent-rows-projection'
import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
import { TEST_REPO } from '@/store/repos/repo.test-fixture'
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 { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
import { selectCollapsedLineageSlots, selectLineageLatestAt } from './worktree-card-slot-rollup'
// Counts per-workspace re-checks: each stale slot reads its workspace's row selector once.
const counts = vi.hoisted(() => ({ reads: [] as string[] }))
vi.mock('./workspace-card/activity/worktree-agent-rows-projection', async (importOriginal) => {
const actual = await importOriginal<typeof ProjectionModule>()
return {
...actual,
getSharedWorktreeAgentRowsSelector: (
...args: Parameters<typeof actual.getSharedWorktreeAgentRowsSelector>
) => {
const select = actual.getSharedWorktreeAgentRowsSelector(...args)
return (state: Parameters<typeof select>[0]) => {
counts.reads.push(args[0])
return select(state)
}
}
}
})
const original = useAppStore.getState()
const LEAF = '11111111-1111-4111-8111-111111111111'
function tab(id: string, worktreeId: string): TerminalTab {
return {
id,
ptyId: `pty-${id}`,
worktreeId,
title: id,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
function entry(paneKey: string, worktreeId: string): AgentStatusEntry {
return {
paneKey,
worktreeId,
state: 'done',
agentType: 'claude',
prompt: '',
updatedAt: 2_000_000,
stateStartedAt: 1_900_000,
stateHistory: []
}
}
/** A parent with `size` descendants, each owning one tab, then one unrelated root after them. */
function seedLineage(size: number): { range: WorktreeLineageRange; rows: Worktree[] } {
const rows = Array.from({ length: size + 2 }, (_, i) =>
makeWorktree({ id: `${TEST_REPO.id}::/l${size}-w${i}`, repoId: TEST_REPO.id, hostId: 'local' })
)
useAppStore.setState({
worktreesByRepo: { [TEST_REPO.id]: rows },
tabsByWorktree: Object.fromEntries(rows.map((row, i) => [row.id, [tab(`t${i}`, row.id)]]))
})
const tree = {
identities: rows.map(getWorktreeHostIdentity),
worktrees: rows,
ends: rows.map((_, i) => (i === 0 ? size + 1 : i + 1))
}
return { range: { tree, index: 0, length: size }, rows }
}
function readParent(range: WorktreeLineageRange): void {
const state = useAppStore.getState()
selectCollapsedLineageSlots(state, range)
selectLineageLatestAt(state, range)
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(2_000_000)
useAppStore.setState({
tabsByWorktree: {},
unifiedTabsByWorktree: {},
agentStatusByPaneKey: {},
agentStatusEpoch: 1,
retainedAgentsByPaneKey: {},
acknowledgedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
runtimeAgentOrchestrationByPaneKey: {},
paneForegroundAgentByPaneKey: {},
repos: [TEST_REPO],
activeWorktreeId: null
})
counts.reads = []
})
afterEach(() => {
useAppStore.setState(original, true)
vi.useRealTimers()
})
describe.each([10, 100, 1000])('layout invalidation under a parent of %i', (size) => {
it('re-checks only the owner when one tab’s ptys or layout change', () => {
const { range, rows } = seedLineage(size)
readParent(range)
counts.reads = []
useAppStore.setState({ ptyIdsByTabId: { t3: ['pty-t3'] } })
readParent(range)
expect(counts.reads).toEqual([rows[3]!.id])
counts.reads = []
useAppStore.setState({
terminalLayoutsByTabId: {
t4: { root: null, activeLeafId: null, expandedLeafId: null }
}
})
readParent(range)
expect(counts.reads).toEqual([rows[4]!.id])
})
it('skips the lineage entirely for a change outside it', () => {
const { range, rows } = seedLineage(size)
readParent(range)
counts.reads = []
const outside = rows.length - 1
useAppStore.setState({ ptyIdsByTabId: { [`t${outside}`]: ['pty-x'] } })
readParent(range)
expect(counts.reads).toEqual([])
})
it('re-checks only the owner when a tab is replaced in place', () => {
const { range, rows } = seedLineage(size)
readParent(range)
counts.reads = []
const tabs = useAppStore.getState().tabsByWorktree
useAppStore.setState({
tabsByWorktree: { ...tabs, [rows[2]!.id]: [{ ...tab('t2', rows[2]!.id), title: 'renamed' }] }
})
readParent(range)
expect(counts.reads).toEqual([rows[2]!.id])
})
it('re-checks the old and new owner when a retained agent changes', () => {
const { range, rows } = seedLineage(size)
const paneKey = makePaneKey('gone-tab', LEAF)
const retained = (worktreeId: string) => ({
entry: entry(paneKey, worktreeId),
worktreeId,
tab: tab('gone-tab', worktreeId),
agentType: 'claude' as const,
startedAt: 1_900_000
})
useAppStore.setState({ retainedAgentsByPaneKey: { [paneKey]: retained(rows[1]!.id) } })
readParent(range)
counts.reads = []
useAppStore.setState({ retainedAgentsByPaneKey: { [paneKey]: retained(rows[5]!.id) } })
readParent(range)
expect(new Set(counts.reads)).toEqual(new Set([rows[1]!.id, rows[5]!.id]))
})
it('still re-checks every descendant when a tab moves between workspaces', () => {
const { range, rows } = seedLineage(size)
readParent(range)
counts.reads = []
const tabs = useAppStore.getState().tabsByWorktree
useAppStore.setState({
tabsByWorktree: {
...tabs,
[rows[2]!.id]: [],
[rows[3]!.id]: [tab('t3', rows[3]!.id), tab('t2', rows[3]!.id)]
}
})
readParent(range)
expect(counts.reads).toHaveLength(size)
})
})
describe('orchestration invalidation', () => {
it('re-checks the parent pane’s workspace, not only the child’s', () => {
const { range, rows } = seedLineage(10)
const child = makePaneKey('t6', LEAF)
const parent = makePaneKey('t2', LEAF)
useAppStore.setState({ agentStatusByPaneKey: { [child]: entry(child, rows[6]!.id) } })
readParent(range)
counts.reads = []
useAppStore.setState({
runtimeAgentOrchestrationByPaneKey: {
[child]: { taskId: 'task', dispatchId: 'dispatch', parentPaneKey: parent }
}
})
readParent(range)
expect(new Set(counts.reads)).toEqual(new Set([rows[2]!.id, rows[6]!.id]))
})
})
@@ -6,14 +6,27 @@ import {
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 { 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'
import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
import {
worktreeAgentProjectionInputs,
worktreeAgentStructureInputs
} from './workspace-card/activity/worktree-agent-rows-projection'
addChangedLayoutWorkspaces,
forEachChangedKey,
type SlotLayout
} from './worktree-card-slot-layout-invalidation'
export type SlotSnapshot = {
inputs: readonly unknown[]
structure: readonly unknown[]
layout: SlotLayout
/**
* Orchestration links reach across workspaces (a child pane shows on its parent's workspace,
* host aliasing reads tab and pty provenance), so it is diffed by its per-workspace output.
*/
orchestration: ReturnType<typeof selectWorktreeAgentOrchestrationIndex>
worktreesByRepo: AppState['worktreesByRepo']
/** Workspace selection, settings and catalog: only consulted for chats with no stamped host. */
owner: readonly unknown[]
live: AppState['agentStatusByPaneKey']
acknowledgements: AppState['acknowledgedAgentsByPaneKey']
titles: AppState['runtimePaneTitlesByTabId']
@@ -26,7 +39,19 @@ export function slotSnapshot(state: AppState): SlotSnapshot {
if (!current || !inputs.every((value, index) => value === current!.inputs[index])) {
current = {
inputs,
structure: worktreeAgentStructureInputs(state),
layout: {
tabsByWorktree: state.tabsByWorktree,
unifiedTabsByWorktree: state.unifiedTabsByWorktree,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
ptyIdsByTabId: state.ptyIdsByTabId,
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
runtimeAgentOrchestrationByPaneKey: state.runtimeAgentOrchestrationByPaneKey,
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey
},
orchestration: selectWorktreeAgentOrchestrationIndex(state),
worktreesByRepo: selectWorktreesByRepo(state),
owner: structuredAgentSessionOwnerInputs(state),
live: state.agentStatusByPaneKey,
acknowledgements: state.acknowledgedAgentsByPaneKey,
titles: state.runtimePaneTitlesByTabId,
@@ -36,6 +61,72 @@ export function slotSnapshot(state: AppState): SlotSnapshot {
}
return current
}
function sameInputs(left: readonly unknown[], right: readonly unknown[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
/** Workspaces whose catalog rows were added, removed or replaced; untouched repos are skipped. */
function addChangedCatalogWorkspaces(
result: Set<string>,
before: AppState['worktreesByRepo'],
after: AppState['worktreesByRepo']
): void {
forEachChangedKey(before, after, (repoId) => {
const previous = before[repoId] ?? []
const next = after[repoId] ?? []
const previousRows = new Set(previous)
const nextRows = new Set(next)
for (const row of next) {
if (!previousRows.has(row)) {
result.add(row.id)
}
}
for (const row of previous) {
if (!nextRows.has(row)) {
result.add(row.id)
}
}
})
}
/**
* Chats without a stamped host resolve their owner from the workspace catalog and selection
* (to disambiguate same-ID twins); only their workspaces can change when those inputs do.
*/
function addUnstampedChatWorkspaces(result: Set<string>, state: AppState): void {
for (const tab of getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).values()) {
if (!tab.executionHostId) {
result.add(tab.worktreeId)
}
}
}
function addChangedOrchestrationWorkspaces(
result: Set<string>,
before: SlotSnapshot['orchestration'],
after: SlotSnapshot['orchestration']
): void {
if (before === after) {
return
}
// The index reuses an unchanged workspace's record, so identity marks what moved.
for (const [worktreeId, record] of after) {
if (before.get(worktreeId) !== record) {
result.add(worktreeId)
}
}
for (const worktreeId of before.keys()) {
if (!after.has(worktreeId)) {
result.add(worktreeId)
}
}
}
/**
* 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.
*/
const changes = new WeakMap<SlotSnapshot, WeakMap<SlotSnapshot, Set<string> | null>>()
export function changedSlotWorkspaces(
@@ -47,68 +138,71 @@ export function changedSlotWorkspaces(
if (cached !== undefined) {
return cached
}
let result: Set<string> | null = null
if (after.structure.every((value, index) => value === before.structure[index])) {
result = new Set<string>()
const tabOwners = getTabIdToWorktreeId(state.tabsByWorktree)
if (before.titles !== after.titles) {
for (const tabId of new Set([...Object.keys(before.titles), ...Object.keys(after.titles)])) {
if (before.titles[tabId] !== after.titles[tabId]) {
const owner = tabOwners.get(tabId)
if (owner) {
result.add(owner)
}
}
let result: Set<string> | null = new Set<string>()
const tabOwners = getTabIdToWorktreeId(state.tabsByWorktree)
const represented = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree)
const includePane = (paneKey: string): void => {
const parsed = parsePaneKey(paneKey)
const tabOwner = parsed && tabOwners.get(parsed.tabId)
if (tabOwner) {
result!.add(tabOwner)
}
// Structured sessions may have no backing terminal tab.
const chat = represented.get(paneKey)
if (chat) {
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)
}
}
const includePane = (paneKey: string): void => {
const parsed = parsePaneKey(paneKey)
const tabOwner = parsed && tabOwners.get(parsed.tabId)
const represented = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).get(
paneKey
)
if (tabOwner) {
result!.add(tabOwner)
}
// Structured sessions may have no backing terminal tab.
if (represented) {
result!.add(represented.worktreeId)
}
for (const entry of [
before.live[paneKey],
after.live[paneKey],
state.retainedAgentsByPaneKey[paneKey]?.entry
]) {
if (entry?.worktreeId) {
result!.add(entry.worktreeId)
}
for (const retained of [
before.layout.retainedAgentsByPaneKey[paneKey],
after.layout.retainedAgentsByPaneKey[paneKey]
]) {
if (retained?.worktreeId) {
result!.add(retained.worktreeId)
}
}
if (before.live !== after.live || before.epoch !== after.epoch) {
for (const paneKey of new Set([...Object.keys(before.live), ...Object.keys(after.live)])) {
const previous = before.live[paneKey]
const next = after.live[paneKey]
}
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 (
previous !== next ||
(next &&
before.epoch !== after.epoch &&
isAgentStatusFresh(next, before.at, AGENT_STATUS_STALE_AFTER_MS) !==
isAgentStatusFresh(next, after.at, AGENT_STATUS_STALE_AFTER_MS))
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)
}
}
}
if (before.acknowledgements !== after.acknowledgements) {
for (const paneKey of new Set([
...Object.keys(before.acknowledgements),
...Object.keys(after.acknowledgements)
])) {
if (before.acknowledgements[paneKey] !== after.acknowledgements[paneKey]) {
includePane(paneKey)
}
}
}
forEachChangedKey(before.acknowledgements, after.acknowledgements, includePane)
} else {
result = null
}
let next = changes.get(before)
if (!next) {
@@ -0,0 +1,141 @@
import type { AppState } from '@/store/types'
import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs'
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { readRecordChanges } from '@/store/keyed-record-change-reads'
export type SlotLayout = Pick<
AppState,
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
| 'migrationUnsupportedByPtyId'
| 'retainedAgentsByPaneKey'
| 'ptyIdsByTabId'
| 'terminalLayoutsByTabId'
| 'runtimeAgentOrchestrationByPaneKey'
| 'paneForegroundAgentByPaneKey'
>
type KeyedRecord = Readonly<Record<string, unknown>>
const EMPTY_RECORD: KeyedRecord = Object.freeze({})
/** Visits every key whose value differs, using keyed-record journal entries when available. */
export function forEachChangedKey(
before: KeyedRecord | undefined,
after: KeyedRecord | undefined,
visit: (key: string) => void
): void {
const previous = before ?? EMPTY_RECORD
const next = after ?? EMPTY_RECORD
if (previous === next) {
return
}
const changes = readRecordChanges(next, previous)
if (changes?.kind === 'addressed') {
changes.keys.forEach(visit)
return
}
for (const key in next) {
if (Object.hasOwn(next, key) && previous[key] !== next[key]) {
visit(key)
}
}
for (const key in previous) {
if (Object.hasOwn(previous, key) && !Object.hasOwn(next, key)) {
visit(key)
}
}
}
function sameTabIds(
previous: readonly { id: string }[] | undefined,
next: readonly { id: string }[] | undefined
): boolean {
const previousIds = new Set((previous ?? []).map((tab) => tab.id))
const nextIds = new Set((next ?? []).map((tab) => tab.id))
return previousIds.size === nextIds.size && [...nextIds].every((id) => previousIds.has(id))
}
/**
* 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.
*/
export function addChangedLayoutWorkspaces(
result: Set<string>,
before: SlotLayout,
after: SlotLayout,
tabOwners: ReadonlyMap<string, string>,
includePane: (paneKey: string) => void
): boolean {
let scoped = true
forEachChangedKey(before.tabsByWorktree, after.tabsByWorktree, (worktreeId) => {
if (!sameTabIds(before.tabsByWorktree[worktreeId], after.tabsByWorktree[worktreeId])) {
scoped = false
}
result.add(worktreeId)
})
if (!scoped) {
return false
}
forEachChangedKey(before.unifiedTabsByWorktree, after.unifiedTabsByWorktree, (worktreeId) => {
const previous = getStructuredAgentSessionTabsByPaneKey(
before.unifiedTabsByWorktree?.[worktreeId]
)
const next = getStructuredAgentSessionTabsByPaneKey(after.unifiedTabsByWorktree?.[worktreeId])
if (previous === next) {
return
}
if (previous.size !== next.size) {
scoped = false
return
}
for (const [paneKey, tab] of next) {
if (!previous.has(paneKey)) {
scoped = false
return
}
if (previous.get(paneKey) !== tab) {
result.add(worktreeId)
}
}
})
if (!scoped) {
return false
}
const addTabOwner = (tabId: string): void => {
const owner = tabOwners.get(tabId)
if (owner) {
result.add(owner)
} else {
scoped = false
}
}
forEachChangedKey(before.ptyIdsByTabId, after.ptyIdsByTabId, addTabOwner)
forEachChangedKey(before.terminalLayoutsByTabId, after.terminalLayoutsByTabId, addTabOwner)
if (!scoped) {
return false
}
forEachChangedKey(
before.migrationUnsupportedByPtyId,
after.migrationUnsupportedByPtyId,
(ptyId) => {
for (const entry of [
before.migrationUnsupportedByPtyId[ptyId],
after.migrationUnsupportedByPtyId[ptyId]
]) {
const parsed = entry?.paneKey ? parsePaneKey(entry.paneKey) : null
const owner = parsed && tabOwners.get(parsed.tabId)
if (owner) {
result.add(owner)
}
}
}
)
forEachChangedKey(before.retainedAgentsByPaneKey, after.retainedAgentsByPaneKey, includePane)
forEachChangedKey(
before.paneForegroundAgentByPaneKey,
after.paneForegroundAgentByPaneKey,
includePane
)
return true
}
@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { HostScopedWorktreeKey } from '../../../../shared/worktree/host-qualified-identity'
import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import type { WorktreeLineageTree } from '../../../../shared/sidebar-list/worktree-lineage-range'
import { toLineageSlotRecord } from './worktree-card-slot-summary'
import { createLineageSlotReader, type WorkspaceSlot } from './worktree-card-slot-range'
// The reader's contract with invalidation: a snapshot per state, and either the changed workspace
// ids or null for a structural change. Tests drive both directly.
const invalidation = vi.hoisted(() => ({ changed: null as Set<string> | null }))
vi.mock('./worktree-card-slot-invalidation', () => ({
slotSnapshot: (state: { snapshot: object }) => state.snapshot,
changedSlotWorkspaces: () => invalidation.changed
}))
type FakeState = AppState & { snapshot: object; latest: Map<string, number> }
function makeState(latest: Map<string, number>): FakeState {
return { snapshot: {}, latest } as unknown as FakeState
}
/** Root with one child at 0..1, then `siblings` leaf roots: a large section, a tiny lineage. */
function makeTree(siblings: number): WorktreeLineageTree {
const size = 2 + siblings
const identities = Array.from({ length: size }, (_, i) =>
composeWorktreeHostIdentity('local', `w${i}`)
)
const ends = Array.from({ length: size }, (_, i) => (i === 0 ? 2 : i + 1))
return { identities, worktrees: [] as Worktree[], ends }
}
function makeReader() {
const calls: HostScopedWorktreeKey[] = []
const reader = createLineageSlotReader((state, identity): WorkspaceSlot => {
calls.push(identity)
const at = (state as FakeState).latest.get(identity) ?? null
return { targets: [], record: toLineageSlotRecord([]), latestAt: at }
})
return { reader, calls }
}
describe('createLineageSlotReader', () => {
it.each([100, 1000])(
'projects only the queried range after a structural change (section of %i)',
(siblings) => {
const tree = makeTree(siblings)
const { reader, calls } = makeReader()
const child = tree.identities[1]!
const state = makeState(new Map([[child, 5]]))
expect(reader.descendantSlot(state, tree, 0).latestAt).toBe(5)
expect(calls).toEqual([child])
calls.length = 0
invalidation.changed = null
const next = makeState(new Map([[child, 9]]))
expect(reader.descendantSlot(next, tree, 0).latestAt).toBe(9)
expect(calls).toEqual([child])
}
)
it('does not project again for an unchanged snapshot', () => {
const tree = makeTree(50)
const { reader, calls } = makeReader()
const state = makeState(new Map())
reader.descendantSlot(state, tree, 0)
calls.length = 0
reader.descendantSlot(state, tree, 0)
reader.descendantSlots(state, tree, 0)
expect(calls).toEqual([])
})
it('re-projects only changed workspaces on a localized change', () => {
const tree: WorktreeLineageTree = {
identities: ['a', 'b', 'c', 'd'].map((id) => composeWorktreeHostIdentity('local', id)),
worktrees: [],
ends: [4, 4, 4, 4]
}
const { reader, calls } = makeReader()
const [, b, c, d] = tree.identities
reader.descendantSlot(
makeState(
new Map([
[b!, 1],
[c!, 2],
[d!, 3]
])
),
tree,
0
)
calls.length = 0
invalidation.changed = new Set(['c'])
const next = makeState(
new Map([
[b!, 1],
[c!, 7],
[d!, 3]
])
)
expect(reader.descendantSlot(next, tree, 0).latestAt).toBe(7)
expect(calls).toEqual([c])
expect(reader.descendantSlots(next, tree, 0).map((slot) => slot.latestAt)).toEqual([1, 7, 3])
})
it('re-projects one changed descendant in a 10,000-descendant range', () => {
const size = 10_001
const tree: WorktreeLineageTree = {
identities: Array.from({ length: size }, (_, i) =>
composeWorktreeHostIdentity('local', `n${i}`)
),
worktrees: [],
ends: Array.from({ length: size }, (_, i) => (i === 0 ? size : i + 1))
}
const { reader, calls } = makeReader()
const changed = tree.identities[4321]!
reader.descendantSlot(makeState(new Map()), tree, 0)
calls.length = 0
invalidation.changed = new Set(['n4321'])
expect(reader.descendantSlot(makeState(new Map([[changed, 42]])), tree, 0).latestAt).toBe(42)
expect(calls).toEqual([changed])
})
it('aggregates fresh and lazily refreshed slots across nested ranges', () => {
// a ─ b ─ c, then d: reading b's range first must not leave a's aggregate stale.
const tree: WorktreeLineageTree = {
identities: ['a', 'b', 'c', 'd'].map((id) => composeWorktreeHostIdentity('local', id)),
worktrees: [],
ends: [3, 3, 3, 4]
}
const { reader } = makeReader()
const [, b, c] = tree.identities
invalidation.changed = null
const state = makeState(
new Map([
[b!, 4],
[c!, 6]
])
)
expect(reader.descendantSlot(state, tree, 1).latestAt).toBe(6)
expect(reader.descendantSlot(state, tree, 0).latestAt).toBe(6)
const next = makeState(
new Map([
[b!, 8],
[c!, 6]
])
)
expect(reader.descendantSlot(next, tree, 0).latestAt).toBe(8)
expect(reader.descendantSlot(next, tree, 1).latestAt).toBe(6)
})
})
@@ -18,20 +18,28 @@ import {
changedSlotWorkspaces,
type SlotSnapshot
} from './worktree-card-slot-invalidation'
import { createStaleSlotSet, type StaleSlotSet } from './worktree-card-slot-stale-set'
export type WorkspaceSlot = {
targets: SlotTarget[]
record: LineageSlotRecord
latestAt: number | null
}
/**
* Lineage rollups read one parent's preorder descendant range. Slots refresh lazily: a structural
* change only marks the tree stale, and each read projects just the stale slots inside the range
* it asks for, so a parent with one child costs one projection even in a thousand-row section.
*/
export function createLineageSlotReader(
ownSlot: (state: AppState, identity: HostScopedWorktreeKey, fallback?: Worktree) => WorkspaceSlot
) {
type SlotAggregate = { record: LineageSlotRecord; latestAt: number | null }
type TreeSlots = {
snapshot: SlotSnapshot
own: WorkspaceSlot[]
own: (WorkspaceSlot | undefined)[]
aggregate: LineageRangeAggregate<SlotAggregate>
/** Slots to re-project before reading; reads find them without scanning their range. */
stale: StaleSlotSet
indicesById: Map<string, number[]>
}
const treeSlots = new WeakMap<WorktreeLineageTree, TreeSlots>()
@@ -41,34 +49,31 @@ export function createLineageSlotReader(
mergeRecord(record, right.record)
return { record, latestAt: Math.max(left.latestAt ?? 0, right.latestAt ?? 0) || null }
}
function updateTreeSlot(slots: TreeSlots, index: number, own: WorkspaceSlot): void {
slots.own[index] = own
slots.aggregate.update(index, own)
}
function slotsForTree(state: AppState, tree: WorktreeLineageTree): TreeSlots {
/** Brings the tree's bookkeeping up to the current snapshot without projecting any slot. */
function syncTree(state: AppState, tree: WorktreeLineageTree): TreeSlots {
const snapshot = slotSnapshot(state)
const cached = treeSlots.get(tree)
if (cached?.snapshot === snapshot) {
return cached
}
const changed = cached && changedSlotWorkspaces(state, cached.snapshot, snapshot)
if (cached && changed) {
for (const id of changed) {
for (const index of cached.indicesById.get(id) ?? []) {
updateTreeSlot(
cached,
index,
ownSlot(state, tree.identities[index]!, tree.worktrees[index])
)
if (cached) {
const changed = changedSlotWorkspaces(state, cached.snapshot, snapshot)
if (changed) {
for (const id of changed) {
for (const index of cached.indicesById.get(id) ?? []) {
cached.stale.mark(index)
}
}
} else {
cached.stale.markAll()
}
cached.snapshot = snapshot
return cached
}
const own = tree.identities.map((identity, index) =>
ownSlot(state, identity, tree.worktrees[index])
)
const aggregate = createLineageRangeAggregate(own, emptyAggregate, combineSlots)
const size = tree.identities.length
const stale = createStaleSlotSet(size)
stale.markAll()
const indicesById = new Map<string, number[]>()
tree.identities.forEach((identity, index) => {
const id = getWorktreeIdFromHostIdentity(identity)
@@ -76,11 +81,71 @@ export function createLineageSlotReader(
indices.push(index)
indicesById.set(id, indices)
})
const result = { snapshot, own, aggregate, indicesById }
const result: TreeSlots = {
snapshot,
own: Array.from<WorkspaceSlot | undefined>({ length: size }),
aggregate: createLineageRangeAggregate(
Array.from({ length: size }, emptyAggregate),
emptyAggregate,
combineSlots
),
stale,
indicesById
}
treeSlots.set(tree, result)
return result
}
/**
* Re-projects only the stale slots in [start, end), batching each contiguous run into one
* aggregate update. Finding them costs O(k log N) for k stale slots, independent of range size.
*/
function refreshRange(
state: AppState,
tree: WorktreeLineageTree,
start: number,
end: number
): TreeSlots {
const slots = syncTree(state, tree)
const staleIndices: number[] = []
slots.stale.forEachStale(start, end, (index) => staleIndices.push(index))
let runStart = -1
let run: WorkspaceSlot[] = []
const flush = (): void => {
if (run.length > 0) {
slots.aggregate.updateRun(runStart, run)
run = []
}
}
for (const index of staleIndices) {
if (run.length > 0 && index !== runStart + run.length) {
flush()
}
if (run.length === 0) {
runStart = index
}
const own = ownSlot(state, tree.identities[index]!, tree.worktrees[index])
slots.own[index] = own
slots.stale.clear(index)
run.push(own)
}
flush()
return slots
}
/** The descendants' own slots, fresh for the current snapshot. */
function descendantSlots(
state: AppState,
tree: WorktreeLineageTree,
index: number
): WorkspaceSlot[] {
const end = tree.ends[index]!
if (end <= index + 1) {
return []
}
return refreshRange(state, tree, index + 1, end).own.slice(index + 1, end) as WorkspaceSlot[]
}
/** Range queries and localized updates merge O(log N) records, without reprojecting siblings. */
function descendantSlot(
state: AppState,
@@ -91,8 +156,8 @@ export function createLineageSlotReader(
if (end <= index + 1) {
return emptyAggregate()
}
return slotsForTree(state, tree).aggregate.read(index + 1, end)
return refreshRange(state, tree, index + 1, end).aggregate.read(index + 1, end)
}
return { slotsForTree, descendantSlot }
return { descendantSlots, descendantSlot }
}
@@ -0,0 +1,179 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type * as ProjectionModule from './workspace-card/activity/worktree-agent-rows-projection'
import type * as RowsModule from './workspace-card/activity/worktree-agent-rows'
import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
import { TEST_REPO } from '@/store/repos/repo.test-fixture'
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 { selectCollapsedLineageSlots, selectLineageLatestAt } from './worktree-card-slot-rollup'
import { getSharedWorktreeAgentRowsSelector } from './workspace-card/activity/worktree-agent-rows-projection'
const counts = vi.hoisted(() => ({
selectorKeys: new Set<string>(),
selectorReads: 0,
builds: 0
}))
vi.mock('./workspace-card/activity/worktree-agent-rows-projection', async (importOriginal) => {
const actual = await importOriginal<typeof ProjectionModule>()
return {
...actual,
getSharedWorktreeAgentRowsSelector: (
...args: Parameters<typeof actual.getSharedWorktreeAgentRowsSelector>
) => {
counts.selectorKeys.add(`${args[1]}\n${args[0]}`)
const select = actual.getSharedWorktreeAgentRowsSelector(...args)
return (state: Parameters<typeof select>[0]) => {
counts.selectorReads++
return select(state)
}
}
}
})
vi.mock('./workspace-card/activity/worktree-agent-rows', async (importOriginal) => {
const actual = await importOriginal<typeof RowsModule>()
return {
...actual,
buildWorktreeAgentRows: (...args: Parameters<typeof actual.buildWorktreeAgentRows>) => {
counts.builds++
return actual.buildWorktreeAgentRows(...args)
}
}
})
const original = useAppStore.getState()
const LEAF = '11111111-1111-4111-8111-111111111111'
function resetCounts(): void {
counts.selectorKeys.clear()
counts.selectorReads = 0
counts.builds = 0
}
/** One parent with one child, then `siblings` unrelated roots in the same section. */
function seedSection(siblings: number): { range: WorktreeLineageRange; rows: Worktree[] } {
const rows = Array.from({ length: siblings + 2 }, (_, i) =>
makeWorktree({
id: `${TEST_REPO.id}::/s${siblings}-w${i}`,
repoId: TEST_REPO.id,
hostId: 'local'
})
)
useAppStore.setState({ worktreesByRepo: { [TEST_REPO.id]: rows } })
const tree = {
identities: rows.map(getWorktreeHostIdentity),
worktrees: rows,
ends: rows.map((_, i) => (i === 0 ? 2 : i + 1))
}
return { range: { tree, index: 0, length: 1 }, rows }
}
function readParent(range: WorktreeLineageRange): void {
const state = useAppStore.getState()
selectCollapsedLineageSlots(state, range)
selectLineageLatestAt(state, range)
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(2_000_000)
useAppStore.setState({
tabsByWorktree: {},
unifiedTabsByWorktree: {},
agentStatusByPaneKey: {},
agentStatusEpoch: 1,
retainedAgentsByPaneKey: {},
acknowledgedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
runtimeAgentOrchestrationByPaneKey: {},
paneForegroundAgentByPaneKey: {},
repos: [TEST_REPO],
activeWorktreeId: null
})
resetCounts()
})
afterEach(() => {
useAppStore.setState(original, true)
vi.useRealTimers()
})
describe.each([10, 100])('lineage rollup in a section with %i unrelated siblings', (siblings) => {
it('projects only the nested workspace on mount', () => {
const { range } = seedSection(siblings)
readParent(range)
expect(counts.selectorKeys.size).toBe(1)
expect(counts.builds).toBe(1)
})
it('does no per-workspace work when the active workspace changes', () => {
const { range, rows } = seedSection(siblings)
readParent(range)
resetCounts()
useAppStore.setState({ activeWorktreeId: rows[5]!.id, activeWorkspaceExecutionHostId: 'local' })
readParent(range)
expect(counts.selectorReads).toBe(0)
})
it('re-projects only the child when a catalog row changes', () => {
const { range, rows } = seedSection(siblings)
readParent(range)
resetCounts()
const sibling = { ...rows[3]!, lastActivityAt: 5 }
useAppStore.setState({
worktreesByRepo: { [TEST_REPO.id]: rows.map((row, i) => (i === 3 ? sibling : row)) }
})
readParent(range)
expect(counts.selectorReads).toBe(0)
const latest = useAppStore.getState().worktreesByRepo[TEST_REPO.id]!
useAppStore.setState({
worktreesByRepo: {
[TEST_REPO.id]: latest.map((row, i) =>
i === 1 ? { ...row, lastActivityAt: 1_500_000 } : row
)
}
})
expect(selectLineageLatestAt(useAppStore.getState(), range)).toBe(1_500_000)
expect(counts.selectorReads).toBe(1)
})
it('shares the child card’s row projection with the rollup', () => {
const { range, rows } = seedSection(siblings)
readParent(range)
resetCounts()
// What the mounted child card's useWorktreeAgentRows reads on the live store.
const ownRows = getSharedWorktreeAgentRowsSelector(rows[1]!.id, 'local')
expect(ownRows(useAppStore.getState())).toEqual([])
expect(counts.builds).toBe(0)
})
it('ignores agent activity in sibling workspaces', () => {
const { range, rows } = seedSection(siblings)
readParent(range)
resetCounts()
const paneKey = makePaneKey('sibling-tab', LEAF)
useAppStore.setState({
agentStatusByPaneKey: {
[paneKey]: {
paneKey,
worktreeId: rows[4]!.id,
state: 'working',
agentType: 'claude',
prompt: '',
updatedAt: 2_000_000,
stateStartedAt: 1_900_000,
stateHistory: []
}
}
})
readParent(range)
expect(counts.selectorReads).toBe(0)
expect(counts.builds).toBe(0)
})
})
@@ -24,7 +24,7 @@ import {
type WorktreeLineageSource
} from '../../../../shared/sidebar-list/worktree-lineage-range'
import {
createWorktreeAgentRowsSelector,
getSharedWorktreeAgentRowsSelector,
worktreeAgentProjectionInputs
} from './workspace-card/activity/worktree-agent-rows-projection'
import { selectAgentRowsUnread } from './workspace-card/activity/worktree-card-agent-ack-inputs'
@@ -46,11 +46,6 @@ function worktreeForIdentity(
).find((row) => getWorktreeHostIdentity(row) === identity)
}
const ownSelectors = new Map<
HostScopedWorktreeKey,
ReturnType<typeof createWorktreeAgentRowsSelector>
>()
const ownSlots = new Map<
HostScopedWorktreeKey,
{
@@ -68,18 +63,10 @@ function ownSlot(
): WorkspaceSlot {
const cached = ownSlots.get(identity)
const worktree = worktreeForIdentity(state, identity) ?? fallback ?? cached?.worktree
let selector = ownSelectors.get(identity)
if (!selector) {
selector = createWorktreeAgentRowsSelector(
getWorktreeIdFromHostIdentity(identity),
getExecutionHostIdFromWorktreeHostIdentity(identity)
)
ownSelectors.set(identity, selector)
if (ownSelectors.size > 2048) {
ownSelectors.delete(ownSelectors.keys().next().value!)
}
}
const agents = selector(state)
const agents = getSharedWorktreeAgentRowsSelector(
getWorktreeIdFromHostIdentity(identity),
getExecutionHostIdFromWorktreeHostIdentity(identity)
)(state)
const unread = selectAgentRowsUnread(state, agents)
if (
cached &&
@@ -88,6 +75,9 @@ function ownSlot(
unread.length === cached.unread.length &&
unread.every((value, index) => value === cached.unread[index])
) {
// Refresh recency so the cap evicts least recently used, not oldest inserted.
ownSlots.delete(identity)
ownSlots.set(identity, cached)
return cached.slot
}
const signals = buildSlotAgentSignals(agents, unread)
@@ -118,7 +108,7 @@ function ownSlot(
return result
}
const { slotsForTree, descendantSlot } = createLineageSlotReader(ownSlot)
const { descendantSlots, descendantSlot } = createLineageSlotReader(ownSlot)
/** What the card shows as unread: its flag, or an agent result the user hasn't read. */
export function selectWorkspaceShowsUnread(state: AppState, worktree: Worktree): boolean {
@@ -138,12 +128,7 @@ export function collectLineageSlotTargets(
if (source.length === 0) {
return []
}
const slots = slotsForTree(state, source.tree)
const result: SlotTarget[] = []
for (let i = source.index + 1; i < source.tree.ends[source.index]!; i++) {
result.push(...slots.own[i]!.targets)
}
return result
return descendantSlots(state, source.tree, source.index).flatMap((slot) => slot.targets)
}
return source.flatMap((identity) => ownSlot(state, identity).targets)
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { titleRowSlot, titleRowUnread } from './worktree-card-slot-signal'
const base = {
offline: false,
collapsedLineage: [],
workspaceUnread: null,
ownLinesShown: true
}
describe('titleRowSlot quiet timestamp', () => {
it('prefers newer workspace activity over an older acknowledged agent line', () => {
const slot = titleRowSlot({
...base,
lines: [{ kind: 'seen', at: 1_000 }],
lastActivityAt: 5_000
})
expect(slot).toEqual({ kind: 'status', exceptions: [], at: 5_000 })
})
it('keeps a newer agent line over older workspace activity', () => {
const slot = titleRowSlot({
...base,
lines: [{ kind: 'seen', at: 9_000 }],
lastActivityAt: 5_000
})
expect(slot).toMatchObject({ at: 9_000 })
})
it('takes newer lineage activity over its own', () => {
const slot = titleRowSlot({
...base,
lines: [{ kind: 'idle', at: 1_000 }],
lastActivityAt: 2_000,
lineageLatestAt: 3_000
})
expect(slot).toMatchObject({ at: 3_000 })
})
it('keeps the working start time even when workspace activity is newer', () => {
const slot = titleRowSlot({
...base,
ownLinesShown: false,
lines: [{ kind: 'working', at: 1_000 }],
lastActivityAt: 5_000
})
expect(slot).toMatchObject({ at: 1_000 })
})
it('returns null when nothing has a time', () => {
expect(titleRowSlot({ ...base, lines: [], lastActivityAt: null })).toMatchObject({ at: null })
})
})
describe('titleRowUnread', () => {
const unreadChild = [{ kind: 'unread' as const, at: 1_000, manual: false }]
it('keeps a collapsed child’s unread while the parent is offline', () => {
// The offline slot drops every exception; the dot must not depend on it.
expect(
titleRowSlot({
...base,
offline: true,
collapsedLineage: unreadChild,
lines: [],
lastActivityAt: null
})
).toEqual({ kind: 'offline' })
expect(
titleRowUnread({ lines: [], collapsedLineage: unreadChild, workspaceUnread: false })
).toBe(true)
})
it('agrees with the online slot’s unread exception', () => {
for (const lines of [[], [{ kind: 'unread' as const, at: 1 }]]) {
for (const collapsedLineage of [[], unreadChild]) {
for (const workspaceUnread of [false, true]) {
for (const ownLinesShown of [false, true]) {
const slot = titleRowSlot({
...base,
ownLinesShown,
lines,
collapsedLineage,
workspaceUnread: workspaceUnread ? { manual: false } : null,
lastActivityAt: null
})
const fromSlot =
slot.kind === 'status' && slot.exceptions.some((e) => e.kind === 'unread')
// Shown lines are left out of the slot but still mark the card unread.
const shownLineUnread = ownLinesShown && lines.length > 0
expect(titleRowUnread({ lines, collapsedLineage, workspaceUnread })).toBe(
fromSlot || shownLineUnread
)
}
}
}
}
})
it('is quiet with nothing unread', () => {
expect(
titleRowUnread({
lines: [{ kind: 'seen', at: 1 }],
collapsedLineage: [],
workspaceUnread: false
})
).toBe(false)
})
})
@@ -66,6 +66,13 @@ function latest(lines: readonly SlotLineSignal[]): number | null {
return at
}
function maxAt(a: number | null, b: number | null): number | null {
if (a === null) {
return b
}
return b === null || a >= b ? a : b
}
export type SlotException = Extract<
SlotSignal,
{ kind: 'needs' } | { kind: 'failed' } | { kind: 'unread' } | { kind: 'working' }
@@ -164,11 +171,28 @@ export function titleRowSlot(args: {
return { kind: 'status', exceptions, at: exception.at }
}
}
const ownAt = latest(args.lines) ?? args.lastActivityAt
const lineageAt = args.lineageLatestAt ?? null
// Quiet fallback: the newest of agent lines, workspace activity, and lineage, so a card
// never shows an older time than its own workspace (or its rollup parent) reports.
return {
kind: 'status',
exceptions,
at: ownAt === null || (lineageAt !== null && lineageAt > ownAt) ? lineageAt : ownAt
at: maxAt(maxAt(latest(args.lines), args.lastActivityAt), args.lineageLatestAt ?? null)
}
}
/**
* Whether the card shows the unread dot: any of its agent lines (shown or hidden), the workspace's
* own unread, or a collapsed child's. Read from the sources rather than the title slot, because an
* offline slot carries no exceptions and a disconnected parent must still surface an unread child.
*/
export function titleRowUnread(args: {
lines: readonly SlotLineSignal[]
collapsedLineage: readonly SlotException[]
workspaceUnread: boolean
}): boolean {
return (
args.workspaceUnread ||
args.lines.some((line) => line.kind === 'unread') ||
args.collapsedLineage.some((exception) => exception.kind === 'unread')
)
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { createStaleSlotSet } from './worktree-card-slot-stale-set'
function staleIn(set: ReturnType<typeof createStaleSlotSet>, start: number, end: number) {
const found: number[] = []
const visited = set.forEachStale(start, end, (index) => found.push(index))
return { found, visited }
}
describe('createStaleSlotSet', () => {
it.each([100, 1000, 10_000])(
'finds one stale descendant among %i without scanning the range',
(descendants) => {
// Parent at 0, descendants at 1..n: the shape a mounted parent card reads.
const set = createStaleSlotSet(descendants + 1)
set.markAll()
for (let i = 0; i <= descendants; i++) {
set.clear(i)
}
const changed = Math.floor(descendants / 2)
set.mark(changed)
const { found, visited } = staleIn(set, 1, descendants + 1)
expect(found).toEqual([changed])
// Two boundary paths plus the path to the stale leaf: O(log N), not O(N).
expect(visited).toBeLessThanOrEqual(6 * Math.ceil(Math.log2(descendants + 1)) + 4)
}
)
it('visits O(log N) nodes for a fully fresh range even when slots elsewhere are stale', () => {
const set = createStaleSlotSet(10_000)
set.markAll()
for (let i = 2000; i < 9000; i++) {
set.clear(i)
}
const { found, visited } = staleIn(set, 2000, 9000)
expect(found).toEqual([])
expect(visited).toBeLessThanOrEqual(6 * 14 + 4)
})
it('matches a brute-force scan for random marks and ranges', () => {
let seed = 7
const random = (limit: number) => {
seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31
return seed % limit
}
for (const length of [1, 2, 3, 17, 64, 100]) {
const set = createStaleSlotSet(length)
const truth = Array.from({ length }, () => true)
set.markAll()
for (let step = 0; step < 200; step++) {
const index = random(length)
if (random(2) === 0) {
set.mark(index)
truth[index] = true
} else {
set.clear(index)
truth[index] = false
}
const start = random(length + 1)
const end = start + random(length - start + 1)
const expected = truth
.map((stale, i) => (stale ? i : -1))
.filter((i) => i >= start && i < end)
expect(staleIn(set, start, end).found).toEqual(expected)
}
set.markAll()
expect(staleIn(set, 0, length).found).toEqual(Array.from({ length }, (_, i) => i))
}
})
})
@@ -0,0 +1,63 @@
/**
* Which lineage slots must be re-projected before they are read. A counting segment tree: each
* node holds how many stale slots its subtree covers, so a read finds the k stale slots in its
* range in O(k log N) by skipping fresh subtrees, never by scanning the range.
*/
export type StaleSlotSet = {
/** Every slot stale (a structural change): O(N) integer writes, no projections. */
markAll: () => void
mark: (index: number) => void
clear: (index: number) => void
/** Stale slots in [start, end), ascending. Returns how many tree nodes it visited. */
forEachStale: (start: number, end: number, visit: (index: number) => void) => number
}
export function createStaleSlotSet(length: number): StaleSlotSet {
const size = 2 ** Math.ceil(Math.log2(Math.max(length, 1)))
const counts = new Int32Array(size * 2)
function setLeaf(index: number, value: 0 | 1): void {
let node = size + index
const delta = value - counts[node]!
if (delta === 0) {
return
}
while (node >= 1) {
counts[node]! += delta
node = Math.floor(node / 2)
}
}
return {
markAll() {
counts.fill(0)
counts.fill(1, size, size + length)
for (let node = size - 1; node > 0; node--) {
counts[node] = counts[node * 2]! + counts[node * 2 + 1]!
}
},
mark: (index) => setLeaf(index, 1),
clear: (index) => setLeaf(index, 0),
forEachStale(start, end, visit) {
let visited = 0
// Explicit stack, left child pushed last so leaves come out in ascending order.
const stack: number[] = [1, 0, size]
while (stack.length > 0) {
const high = stack.pop()!
const low = stack.pop()!
const node = stack.pop()!
visited++
if (counts[node] === 0 || high <= start || low >= end) {
continue
}
if (node >= size) {
visit(node - size)
continue
}
const middle = (low + high) / 2
stack.push(node * 2 + 1, middle, high, node * 2, low, middle)
}
return visited
}
}
}
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import { makeTab } from '@/store/terminals/terminal-tab.test-fixture'
import { makeUnifiedTab } from '@/store/slices/tabs/tab-group.test-fixture'
import { structuredAgentSessionPaneKey } from '../../../../shared/agent-session/view/structured-agent-session-projection'
import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { expandWorkspaceUnreadTargets, workspaceUnreadTarget } from './worktree-card-slot-targets'
const WS = 'ws-scale'
const REMOTE = 'runtime:host-B' as const
const MARKER = { at: 1, kind: 'agent-completion' } as never
type Counter = { visits: number }
/** Count element reads so a per-tab `Array.find` shows up as quadratic visits. */
function countingArray<T extends object>(items: T[], counter: Counter): T[] {
return new Proxy(items, {
get(target, prop, receiver) {
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
counter.visits++
}
return Reflect.get(target, prop, receiver)
}
})
}
/**
* T marked terminal tabs alternating local/remote, one structured chat whose old session also
* left a completion, and C unrelated completion markers in other workspaces.
*/
function buildState(tabCount: number, unrelatedCount: number, counter: Counter): AppState {
const terminals = Array.from({ length: tabCount }, (_, i) =>
makeTab({
id: `t${i}`,
worktreeId: WS,
ptyId: i % 2 === 0 ? `remote:host-B@@pty-${i}` : `pty-${i}`
})
)
const chat = makeUnifiedTab({
id: 'chat',
worktreeId: WS,
groupId: 'g',
contentType: 'agent-session',
entityId: 'session-2',
executionHostId: REMOTE,
agentSessionAgent: 'claude'
})
const unified = [
...terminals.map((tab) =>
makeUnifiedTab({
id: `u-${tab.id}`,
worktreeId: WS,
groupId: 'g',
entityId: tab.id
})
),
chat
]
const completions: Record<string, unknown> = {}
for (const tab of terminals) {
completions[`${tab.id}:leaf`] = MARKER
}
completions[structuredAgentSessionPaneKey('chat', 'session-1')] = MARKER
completions[structuredAgentSessionPaneKey('chat', 'session-2')] = MARKER
for (let j = 0; j < unrelatedCount; j++) {
completions[`other${j}:leaf`] = MARKER
}
return {
workspacesMarkedUnreadByUser: {},
unreadTerminalTabs: {},
unreadAgentCompletionPanes: completions,
tabsByWorktree: { [WS]: countingArray(terminals, counter) },
unifiedTabsByWorktree: { [WS]: countingArray(unified, counter) },
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
agentStatusByPaneKey: {}
} as unknown as AppState
}
/** Visits = tab element reads + pane-key parses (paneKeyTabId uses indexOf). */
function measure(tabCount: number, unrelatedCount: number): number {
const counter: Counter = { visits: 0 }
const state = buildState(tabCount, unrelatedCount, counter)
const indexOf = vi.spyOn(String.prototype, 'indexOf')
expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, REMOTE)])
expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, 'local')])
const parses = indexOf.mock.calls.length
indexOf.mockRestore()
return counter.visits + parses
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('expandWorkspaceUnreadTargets scaling', () => {
it('grows linearly with marked tabs and unrelated completion markers', () => {
const small = measure(20, 200)
const large = measure(200, 2000)
// Linear work scales ~10x; the former T×C and per-tab find scans scaled ~100x.
expect(large / small).toBeLessThan(15)
})
it('keeps host ownership and structured-session filtering', () => {
const state = buildState(4, 3, { visits: 0 })
const remote = expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, REMOTE)])
expect(remote.map((target) => [target.tabId, target.unreadPaneKeys])).toEqual([
['t0', ['t0:leaf']],
['t2', ['t2:leaf']],
['chat', [structuredAgentSessionPaneKey('chat', 'session-2')]]
])
const local = expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, 'local')])
expect(local.map((target) => [target.tabId, target.unreadPaneKeys])).toEqual([
['t1', ['t1:leaf']],
['t3', ['t3:leaf']]
])
})
it('keeps a manual mark as its own target alongside tab targets', () => {
const state = buildState(2, 0, { visits: 0 })
state.workspacesMarkedUnreadByUser = {
[composeWorktreeHostIdentity('local', WS)]: true
} as never
const local = expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, 'local')])
expect(
local.map((target) => target.tabId ?? (target.markedByUser ? 'manual' : 'workspace'))
).toEqual(['t1', 'manual'])
})
})
@@ -13,6 +13,7 @@ import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { workspaceMarkerTabIdsForHost } from '@/store/slices/workspace-attention/workspace-marker-host'
import {
completionPaneKeysByTabId,
paneKeyTabId,
workspaceLiveMarkerTabIds
} from '@/store/slices/workspace-attention/workspace-live-markers'
@@ -102,7 +103,7 @@ export function expandWorkspaceUnreadTargets(
]
)
const ownedTabs = workspaceMarkerTabIdsForHost(state, target.worktreeId, target.executionHostId)
const completionKeys = Object.keys(state.unreadAgentCompletionPanes)
const completionKeysByTab = completionPaneKeysByTabId(state.unreadAgentCompletionPanes)
const representedTabs = getStructuredAgentSessionTabsByPaneKey(
state.unifiedTabsByWorktree[target.worktreeId]
)
@@ -113,10 +114,9 @@ export function expandWorkspaceUnreadTargets(
...target,
tabId,
terminalBell: state.unreadTerminalTabs[tabId] === 'terminal-bell',
unreadPaneKeys: completionKeys.filter(
(key) =>
paneKeyTabId(key) === tabId &&
(!structuredKeysByTabId.has(tabId) || structuredKeysByTabId.get(tabId) === key)
unreadPaneKeys: unreadPaneKeysForTab(
completionKeysByTab.get(tabId) ?? [],
structuredKeysByTabId.get(tabId)
)
}))
return tabTargets.length === 0 || markedByUser
@@ -125,6 +125,14 @@ export function expandWorkspaceUnreadTargets(
})
}
/** A structured chat tab owns only its current session's key; a terminal tab owns all of its panes. */
function unreadPaneKeysForTab(
tabKeys: readonly string[],
structuredKey: string | undefined
): readonly string[] {
return structuredKey === undefined ? tabKeys : tabKeys.filter((key) => key === structuredKey)
}
function workspaceKey(target: SlotTarget) {
return composeWorktreeHostIdentity(target.executionHostId, target.worktreeId)
}
@@ -0,0 +1,79 @@
import { it } from 'vitest'
import { appendFileSync } from 'node:fs'
import type { AppState } from '@/store/types'
import { changedSlotWorkspaces, type SlotSnapshot } from './worktree-card-slot-invalidation'
function snap(
live: Record<string, unknown>,
acks: Record<string, unknown>,
epoch: number
): SlotSnapshot {
const layout = {
tabsByWorktree: {},
unifiedTabsByWorktree: {},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
runtimeAgentOrchestrationByPaneKey: {},
paneForegroundAgentByPaneKey: {}
}
return {
inputs: [],
layout,
orchestration: new Map(),
worktreesByRepo: {},
owner: [],
live,
acknowledgements: acks,
titles: {},
epoch,
at: 2_000_000
} as unknown as SlotSnapshot
}
it.each([100, 1000, 10000])('live diff cost with %i panes', (n) => {
const live: Record<string, unknown> = {}
const acks: Record<string, unknown> = {}
for (let i = 0; i < n; i++) {
live[`tab${i}:leaf`] = { worktreeId: `w${i}`, updatedAt: 2_000_000, state: 'working' }
acks[`tab${i}:leaf`] = { at: 1 }
}
const state = { tabsByWorktree: {}, unifiedTabsByWorktree: {} } as unknown as AppState
const results: string[] = []
for (const [label, mk] of [
[
'one live entry',
(prev: SlotSnapshot) =>
snap(
{
...prev.live,
'tab0:leaf': { worktreeId: 'w0', updatedAt: 2_000_001, state: 'working' }
},
prev.acknowledgements,
prev.epoch
)
],
['epoch bump', (prev: SlotSnapshot) => snap(prev.live, prev.acknowledgements, prev.epoch + 1)],
[
'one ack',
(prev: SlotSnapshot) =>
snap(prev.live, { ...prev.acknowledgements, 'tab0:leaf': { at: 2 } }, prev.epoch)
]
] as const) {
const runs: number[] = []
for (let r = 0; r < 50; r++) {
const before = snap(live, acks, 1)
const after = mk(before)
const t = performance.now()
changedSlotWorkspaces(state, before, after)
runs.push(performance.now() - t)
}
runs.sort((a, b) => a - b)
results.push(`${label}: median ${runs[25]!.toFixed(3)}ms p95 ${runs[47]!.toFixed(3)}ms`)
}
appendFileSync(
'/private/tmp/claude-501/-Users-jinjingliang-Documents-projects-orca-new-worktree-sidebar/891ff09f-c478-44b2-94a1-7e1df9183934/scratchpad/bench.txt',
`n=${n}\n ${results.join('\n ')}\n`
)
})
@@ -180,11 +180,13 @@ export const createWorkspaceAttentionSlice: StateCreator<
...workspaceAgentPaneKeys(get(), worktreeId)
])
const state = get()
// One index per action: a lookup per pane is O(tabs × panes) for a workspace with many tabs.
const tabsById = new Map((state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]))
const hostPaneKeys = [...paneKeys].filter((paneKey) => {
const retained = state.retainedAgentsByPaneKey[paneKey]
const entry = state.agentStatusByPaneKey[paneKey] ?? retained?.entry
const tabId = paneKeyTabId(paneKey)
const tab = state.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)
const tab = tabId ? tabsById.get(tabId) : undefined
return (
getAgentPaneExecutionHost(state, paneKey, entry, tab?.ptyId ?? retained?.tab.ptyId) ===
executionHostId
@@ -25,6 +25,54 @@ export function paneKeyTabId(paneKey: string): string | null {
return separator > 0 ? paneKey.slice(0, separator) : null
}
const completionKeysByTabCache = new WeakMap<MarkerRecord, ReadonlyMap<string, readonly string[]>>()
let previousCompletionKeysByTab: ReadonlyMap<string, readonly string[]> = new Map()
function sameKeys(left: readonly string[], right: readonly string[] | undefined): boolean {
return (
right !== undefined &&
left.length === right.length &&
left.every((key, index) => key === right[index])
)
}
/**
* Completion pane keys grouped by owning tab. Cached per record snapshot (store updates replace the
* record) so every mounted card shares one O(C) pass instead of rescanning all markers per tab.
* A tab whose keys did not change keeps its previous array, so per-workspace readers can compare
* by identity and ignore completions elsewhere.
*/
export function completionPaneKeysByTabId(
completions: MarkerRecord
): ReadonlyMap<string, readonly string[]> {
const cached = completionKeysByTabCache.get(completions)
if (cached) {
return cached
}
const byTab = new Map<string, string[]>()
for (const key of Object.keys(completions)) {
const tabId = paneKeyTabId(key)
if (tabId === null) {
continue
}
const keys = byTab.get(tabId)
if (keys) {
keys.push(key)
} else {
byTab.set(tabId, [key])
}
}
for (const [tabId, keys] of byTab) {
const previous = previousCompletionKeysByTab.get(tabId)
if (previous !== keys && sameKeys(keys, previous)) {
byTab.set(tabId, previous as string[])
}
}
completionKeysByTabCache.set(completions, byTab)
previousCompletionKeysByTab = byTab
return byTab
}
/** The tabs of a workspace that can own a marker. */
export function workspaceMarkerTabs(sources: LiveMarkerSources, workspaceId: string): MarkerTab[] {
const tabs: MarkerTab[] = (sources.tabsByWorktree?.[workspaceId] ?? []).map((tab) => ({
@@ -51,9 +99,7 @@ export function workspaceLiveMarkerTabIds(
sources: LiveMarkerSources & { unreadAgentCompletionPanes: MarkerRecord },
workspaceId: string
): string[] {
const completionTabIds = new Set(
Object.keys(sources.unreadAgentCompletionPanes).map(paneKeyTabId)
)
const completionTabIds = completionPaneKeysByTabId(sources.unreadAgentCompletionPanes)
return workspaceMarkerTabs(sources, workspaceId)
.filter(
(tab) =>
@@ -4,22 +4,59 @@ import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-sessi
import { getAgentPaneExecutionHost } from '../agent-status/agent-pane-execution-host'
import { workspaceMarkerTabs, type MarkerTab } from './workspace-live-markers'
function markerTabHost(state: AppState, workspaceId: string, tab: MarkerTab) {
const terminal = state.tabsByWorktree[workspaceId]?.find((candidate) => candidate.id === tab.id)
type WorkspaceTabIndex = {
terminalPtyById: ReadonlyMap<string, string | null | undefined>
stampedHostByTerminalId: ReadonlyMap<string, ExecutionHostId | undefined>
}
/** Index the workspace's tab arrays once so resolving T marker tabs is O(T), not O(T²). */
function indexWorkspaceTabs(state: AppState, workspaceId: string): WorkspaceTabIndex {
const terminalPtyById = new Map<string, string | null | undefined>()
for (const terminal of state.tabsByWorktree[workspaceId] ?? []) {
if (!terminalPtyById.has(terminal.id)) {
terminalPtyById.set(terminal.id, terminal.ptyId)
}
}
const stampedHostByTerminalId = new Map<string, ExecutionHostId | undefined>()
for (const tab of state.unifiedTabsByWorktree[workspaceId] ?? []) {
// First match wins, matching the previous Array.find semantics.
if (tab.contentType === 'terminal' && !stampedHostByTerminalId.has(tab.entityId)) {
stampedHostByTerminalId.set(tab.entityId, tab.executionHostId)
}
}
return { terminalPtyById, stampedHostByTerminalId }
}
const structuredPaneKeys = new Map<string, string>()
/** The pane whose status entry decides a marker tab's host: its active leaf, or its chat session. */
export function markerTabPaneKey(state: Pick<AppState, 'terminalLayoutsByTabId'>, tab: MarkerTab) {
if (tab.sessionId === null) {
return `${tab.id}:${state.terminalLayoutsByTabId[tab.id]?.activeLeafId ?? ''}`
}
// Why memoized: the structured key hashes the session id, and selectors call this per update.
const cacheKey = `${tab.id}\n${tab.sessionId}`
let paneKey = structuredPaneKeys.get(cacheKey)
if (paneKey === undefined) {
paneKey = structuredAgentSessionPaneKey(tab.id, tab.sessionId)
structuredPaneKeys.set(cacheKey, paneKey)
if (structuredPaneKeys.size > 4096) {
structuredPaneKeys.delete(structuredPaneKeys.keys().next().value!)
}
}
return paneKey
}
function markerTabHost(state: AppState, index: WorkspaceTabIndex, tab: MarkerTab) {
const layout = state.terminalLayoutsByTabId[tab.id]
const paneKey =
tab.sessionId === null
? `${tab.id}:${layout?.activeLeafId ?? ''}`
: structuredAgentSessionPaneKey(tab.id, tab.sessionId)
const paneKey = markerTabPaneKey(state, tab)
const entry = state.agentStatusByPaneKey[paneKey]
const ptyId =
(layout?.activeLeafId ? layout.ptyIdsByLeafId?.[layout.activeLeafId] : undefined) ??
terminal?.ptyId ??
index.terminalPtyById.get(tab.id) ??
state.ptyIdsByTabId[tab.id]?.[0]
if (tab.sessionId === null && !entry && !ptyId) {
const stampedHost = state.unifiedTabsByWorktree[workspaceId]?.find(
(candidate) => candidate.contentType === 'terminal' && candidate.entityId === tab.id
)?.executionHostId
const stampedHost = index.stampedHostByTerminalId.get(tab.id)
if (stampedHost) {
return stampedHost
}
@@ -33,13 +70,12 @@ export function workspaceMarkerTabIdsForHost(
workspaceId: string,
executionHostId: ExecutionHostId | undefined
): Set<string> {
const tabs = workspaceMarkerTabs(state, workspaceId)
if (executionHostId === undefined) {
return new Set(tabs.map((tab) => tab.id))
}
const index = indexWorkspaceTabs(state, workspaceId)
return new Set(
workspaceMarkerTabs(state, workspaceId)
.filter(
(tab) =>
executionHostId === undefined ||
markerTabHost(state, workspaceId, tab) === executionHostId
)
.map((tab) => tab.id)
tabs.filter((tab) => markerTabHost(state, index, tab) === executionHostId).map((tab) => tab.id)
)
}
@@ -1,5 +1,7 @@
export type LineageRangeAggregate<T> = {
update: (index: number, value: T) => void
/** Replace a contiguous run of leaves, recombining each shared ancestor once: O(k + log N). */
updateRun: (start: number, values: readonly T[]) => void
read: (start: number, end: number) => T
}
@@ -26,6 +28,23 @@ export function createLineageRangeAggregate<T>(
segments[node] = combine(segments[node * 2]!, segments[node * 2 + 1]!)
}
},
updateRun(start, values) {
if (values.length === 0) {
return
}
let low = size + start
let high = low + values.length - 1
values.forEach((value, offset) => {
segments[low + offset] = value
})
while (low > 1) {
low = Math.floor(low / 2)
high = Math.floor(high / 2)
for (let node = low; node <= high; node++) {
segments[node] = combine(segments[node * 2]!, segments[node * 2 + 1]!)
}
}
},
read(start, end) {
start += size
end += size