mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
+13
-3
@@ -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),
|
||||
|
||||
+33
-3
@@ -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)
|
||||
]
|
||||
|
||||
+1
@@ -62,6 +62,7 @@ export function WorktreeCardSecondaryRows({
|
||||
{showInlineAgentList && (
|
||||
<WorktreeCardAgents
|
||||
worktreeId={worktree.id}
|
||||
executionHostId={worktree.hostId}
|
||||
agents={inlineAgentRows}
|
||||
className={hasMetaRow || remoteBranchConflict ? 'mt-0' : '-mt-1'}
|
||||
/>
|
||||
|
||||
+3
-31
@@ -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 }),
|
||||
|
||||
+117
@@ -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)
|
||||
})
|
||||
})
|
||||
+75
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user