diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx
index ada73377803..acc427c37e4 100644
--- a/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx
+++ b/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx
@@ -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
+ return (
+
+ )
})
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(null)
const now = useNow(30_000)
diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts
index 2adac5b7517..e006d6e1694 100644
--- a/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts
+++ b/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts
@@ -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),
diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts
index c446bd0068e..bac3c53e6ba 100644
--- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts
+++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts
@@ -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>()
+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)
]
diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-secondary-rows.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-secondary-rows.tsx
index 26c7f4531bf..374002987c6 100644
--- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-secondary-rows.tsx
+++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-secondary-rows.tsx
@@ -62,6 +62,7 @@ export function WorktreeCardSecondaryRows({
{showInlineAgentList && (
diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx
index 4095877b8be..5c8255c8c7f 100644
--- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx
+++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx
@@ -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(
() => ({ offline, exceptions, unread, lineageStatus, at, resolveTargets }),
diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.test.ts b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.test.ts
new file mode 100644
index 00000000000..2d481cb478b
--- /dev/null
+++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.test.ts
@@ -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()
+ return {
+ ...actual,
+ workspaceUnreadTargetsOutsideAgentRows: (
+ ...args: Parameters
+ ) => {
+ 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)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.ts b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.ts
new file mode 100644
index 00000000000..1a1ae03f640
--- /dev/null
+++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.ts
@@ -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
+ }
+}
diff --git a/src/renderer/src/components/sidebar/workspace-card/template/card-activity-parts.tsx b/src/renderer/src/components/sidebar/workspace-card/template/card-activity-parts.tsx
index 8677f4cecb6..faa1fe8e319 100644
--- a/src/renderer/src/components/sidebar/workspace-card/template/card-activity-parts.tsx
+++ b/src/renderer/src/components/sidebar/workspace-card/template/card-activity-parts.tsx
@@ -53,6 +53,7 @@ export const CARD_ACTIVITY_PARTS = {
Agents: part(['tight'], (props, _children, card) => (
({ reads: [] as string[] }))
+vi.mock('./workspace-card/activity/worktree-agent-rows-projection', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getSharedWorktreeAgentRowsSelector: (
+ ...args: Parameters
+ ) => {
+ const select = actual.getSharedWorktreeAgentRowsSelector(...args)
+ return (state: Parameters[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]))
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts
index 58eaeb20ea6..1954bfc71d9 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts
@@ -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
+ 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,
+ 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, state: AppState): void {
+ for (const tab of getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).values()) {
+ if (!tab.executionHostId) {
+ result.add(tab.worktreeId)
+ }
+ }
+}
+
+function addChangedOrchestrationWorkspaces(
+ result: Set,
+ 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 | null>>()
export function changedSlotWorkspaces(
@@ -47,68 +138,71 @@ export function changedSlotWorkspaces(
if (cached !== undefined) {
return cached
}
- let result: Set | null = null
- if (after.structure.every((value, index) => value === before.structure[index])) {
- result = new Set()
- 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 | null = new Set()
+ 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) {
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-layout-invalidation.ts b/src/renderer/src/components/sidebar/worktree-card-slot-layout-invalidation.ts
new file mode 100644
index 00000000000..15e4a2a6d09
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-layout-invalidation.ts
@@ -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>
+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,
+ before: SlotLayout,
+ after: SlotLayout,
+ tabOwners: ReadonlyMap,
+ 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
+}
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-range.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-range.test.ts
new file mode 100644
index 00000000000..b8f6cc3dfd1
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-range.test.ts
@@ -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 | null }))
+vi.mock('./worktree-card-slot-invalidation', () => ({
+ slotSnapshot: (state: { snapshot: object }) => state.snapshot,
+ changedSlotWorkspaces: () => invalidation.changed
+}))
+
+type FakeState = AppState & { snapshot: object; latest: Map }
+
+function makeState(latest: Map): 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)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-range.ts b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts
index 223254982bf..06215d1b1c1 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-range.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts
@@ -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
+ /** Slots to re-project before reading; reads find them without scanning their range. */
+ stale: StaleSlotSet
indicesById: Map
}
const treeSlots = new WeakMap()
@@ -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()
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({ 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 }
}
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.scaling.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.scaling.test.ts
new file mode 100644
index 00000000000..5585c1a3dd4
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.scaling.test.ts
@@ -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(),
+ selectorReads: 0,
+ builds: 0
+}))
+vi.mock('./workspace-card/activity/worktree-agent-rows-projection', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getSharedWorktreeAgentRowsSelector: (
+ ...args: Parameters
+ ) => {
+ counts.selectorKeys.add(`${args[1]}\n${args[0]}`)
+ const select = actual.getSharedWorktreeAgentRowsSelector(...args)
+ return (state: Parameters[0]) => {
+ counts.selectorReads++
+ return select(state)
+ }
+ }
+ }
+})
+vi.mock('./workspace-card/activity/worktree-agent-rows', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ buildWorktreeAgentRows: (...args: Parameters) => {
+ 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)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts
index f10d2c35f8b..bc3f813bb7b 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts
@@ -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
->()
-
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)
}
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-signal.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-signal.test.ts
new file mode 100644
index 00000000000..3ccff767efe
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-signal.test.ts
@@ -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)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts
index 4519a460c51..67853956586 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts
@@ -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')
+ )
+}
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.test.ts
new file mode 100644
index 00000000000..ab0d6947ca2
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest'
+import { createStaleSlotSet } from './worktree-card-slot-stale-set'
+
+function staleIn(set: ReturnType, 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))
+ }
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.ts b/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.ts
new file mode 100644
index 00000000000..93058636c1e
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.ts
@@ -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
+ }
+ }
+}
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-targets.scaling.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-targets.scaling.test.ts
new file mode 100644
index 00000000000..8933e38dce3
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-targets.scaling.test.ts
@@ -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(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 = {}
+ 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'])
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts
index 65c8bbcba85..8d7d35fd6b1 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts
@@ -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)
}
diff --git a/src/renderer/src/components/sidebar/zz-slot-invalidation.bench-tmp.test.ts b/src/renderer/src/components/sidebar/zz-slot-invalidation.bench-tmp.test.ts
new file mode 100644
index 00000000000..b951e6dbfff
--- /dev/null
+++ b/src/renderer/src/components/sidebar/zz-slot-invalidation.bench-tmp.test.ts
@@ -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,
+ acks: Record,
+ 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 = {}
+ const acks: Record = {}
+ 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`
+ )
+})
diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts
index 6f9b5c5e6d0..426f041b8f2 100644
--- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts
+++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts
@@ -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
diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts b/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts
index 96dec70ae32..42e559d7a90 100644
--- a/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts
+++ b/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts
@@ -25,6 +25,54 @@ export function paneKeyTabId(paneKey: string): string | null {
return separator > 0 ? paneKey.slice(0, separator) : null
}
+const completionKeysByTabCache = new WeakMap>()
+let previousCompletionKeysByTab: ReadonlyMap = 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 {
+ const cached = completionKeysByTabCache.get(completions)
+ if (cached) {
+ return cached
+ }
+ const byTab = new Map()
+ 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) =>
diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts b/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts
index 2522fdb0d84..062d066aee3 100644
--- a/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts
+++ b/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts
@@ -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
+ stampedHostByTerminalId: ReadonlyMap
+}
+
+/** 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()
+ for (const terminal of state.tabsByWorktree[workspaceId] ?? []) {
+ if (!terminalPtyById.has(terminal.id)) {
+ terminalPtyById.set(terminal.id, terminal.ptyId)
+ }
+ }
+ const stampedHostByTerminalId = new Map()
+ 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()
+
+/** The pane whose status entry decides a marker tab's host: its active leaf, or its chat session. */
+export function markerTabPaneKey(state: Pick, 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 {
+ 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)
)
}
diff --git a/src/shared/sidebar-list/lineage-range-aggregate.ts b/src/shared/sidebar-list/lineage-range-aggregate.ts
index fb8d0c9e6a7..3dbb2c03ba2 100644
--- a/src/shared/sidebar-list/lineage-range-aggregate.ts
+++ b/src/shared/sidebar-list/lineage-range-aggregate.ts
@@ -1,5 +1,7 @@
export type LineageRangeAggregate = {
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(
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