From f9acfffa033411d8d9ebfb25ea8792100310d065 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:25:03 -0700 Subject: [PATCH] fix(unread): bold an agent row only while looking at it can clear it (#28830) * fix(unread): one rule decides when an agent row is unread; title-only rows never are The workspace card bolded a row Orca knows only from its terminal title or process ("Claude Code - Idle") because that row restamps its state time on every rebuild, so no acknowledgement ever covered it: opening the workspace left it bold, clicking the row cleared it, and the next rebuild re-bolded it. The dashboard already skipped such rows. isAgentRowUnseen is now the single rule for the card, the dashboard snapshot and counts, and the pop-out patch. It is built on the acknowledgement module's own turn test, so a row is bold only when viewing it can clear it. Fixes #28306 * fix(unread): bold an agent row only while auto-acknowledge can clear it The row unread rule now reads the store's live and retained turn records, the same records auto-acknowledgement clears, instead of a startedAt === 0 marker on the row. Rows with no record of their own (title/process-derived, running subagent children, migration-blocked panes) are never bold, so none can stay bold with nothing able to clear it. Running subagent rows previously stayed bold for their whole run. * chore: drop accidental pnpm-lock.yaml churn * perf(unread): build the row turn records once per pass, not once per row * docs(unread): name the rule in the dashboard snapshot comment --- .../agent-attention-acknowledgement.ts | 30 ++++++-- .../dashboard-agent-status-patch.ts | 4 +- ...ashboard-bucket-counts.equivalence.test.ts | 4 +- .../build-dashboard-bucket-counts.ts | 23 ++++--- .../dashboard/build-dashboard-snapshot.ts | 8 +-- .../dashboard/dashboard-row-bucket.ts | 32 ++++++++- ...rdAgents.acknowledgement-emphasis.test.tsx | 31 ++++++++- .../WorktreeCardAgents.activation.test.tsx | 1 + ...ktreeCardAgents.expansion-remount.test.tsx | 1 + .../WorktreeCardAgents.send-target.test.tsx | 1 + .../sidebar/WorktreeCardAgents.test.tsx | 8 ++- .../components/sidebar/WorktreeCardAgents.tsx | 11 ++- ...ee-agent-rows-subscriptions.bench.test.tsx | 4 +- .../worktree-card-agent-ack-inputs.test.tsx | 68 ++++++++----------- .../sidebar/worktree-card-agent-ack-inputs.ts | 41 ++++------- .../worktree-title-derived-agent-rows.ts | 12 ++-- 16 files changed, 171 insertions(+), 108 deletions(-) diff --git a/src/renderer/src/attention/agent-attention-acknowledgement.ts b/src/renderer/src/attention/agent-attention-acknowledgement.ts index 75eebd84e6c..6d4a77cd4fe 100644 --- a/src/renderer/src/attention/agent-attention-acknowledgement.ts +++ b/src/renderer/src/attention/agent-attention-acknowledgement.ts @@ -22,6 +22,25 @@ export function readAgentAttentionTurnStartedAt( ) } +/** A turn is unread until acknowledged at or after its start; with no turn there is nothing to read. */ +export function isAgentTurnUnacknowledged( + acknowledgedAt: number, + turnStartedAt: number | null | undefined +): boolean { + return turnStartedAt != null && acknowledgedAt < turnStartedAt +} + +/** Whether a subject's current turn is unread: the same records and test auto-acknowledgement clears. */ +export function isAgentSubjectUnread( + records: AgentAttentionTurnRecords, + subjectKey: string +): boolean { + return isAgentTurnUnacknowledged( + records.acknowledgedTurnStartedAt[subjectKey] ?? 0, + readAgentAttentionTurnStartedAt(records, subjectKey) + ) +} + /** * Subjects on the viewed surface whose current turn has not been acknowledged yet. * @@ -37,12 +56,15 @@ export function computeAgentAcknowledgementTargets( } const targets: string[] = [] const acknowledgedAt = records.acknowledgedTurnStartedAt[subjectKey] ?? 0 - const liveTurn = records.liveTurns[subjectKey] - if (liveTurn && acknowledgedAt < liveTurn.stateStartedAt) { + if (isAgentTurnUnacknowledged(acknowledgedAt, records.liveTurns[subjectKey]?.stateStartedAt)) { targets.push(subjectKey) } - const retainedTurn = records.retainedTurns[subjectKey] - if (retainedTurn && acknowledgedAt < retainedTurn.entry.stateStartedAt) { + if ( + isAgentTurnUnacknowledged( + acknowledgedAt, + records.retainedTurns[subjectKey]?.entry.stateStartedAt + ) + ) { targets.push(subjectKey) } return targets diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts index b4f67ef3f32..7073610e276 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -7,6 +7,7 @@ import { } from '../../../../shared/dashboard-snapshot' import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket' import { dashboardCardDotState } from '../dashboard/dashboard-row-bucket' +import { isAgentTurnUnacknowledged } from '@/attention/agent-attention-acknowledgement' export type DashboardAgentStatusPatchResult = { matched: boolean @@ -48,7 +49,8 @@ export function patchDashboardSnapshotFromAgentStatus( } const stateChanged = event.stateStartedAt > card.stateChangedAt - const unseen = stateChanged ? card.startedAt !== 0 : card.unseen + // Why ack 0: the event is a live turn and the pop-out holds no acks; the next snapshot has them. + const unseen = stateChanged ? isAgentTurnUnacknowledged(0, event.stateStartedAt) : card.unseen const dotState = event.state const workingMode = event.state === 'working' && event.workingMode === 'monitoring' ? event.workingMode : undefined diff --git a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts index 2bca8fdf0e0..f317173d749 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts @@ -17,7 +17,7 @@ import { createDashboardBucketCountsCache } from './build-dashboard-bucket-counts' import { selectDashboardOrchestration } from './dashboard-orchestration-selection' -import { dashboardRowBucketProjection } from './dashboard-row-bucket' +import { dashboardRowBucketProjection, readAgentRowTurnRecords } from './dashboard-row-bucket' import { collectActiveDashboardWorkspaces } from './dashboard-snapshot-workspaces' import { selectWorktreeAgentRowsCached } from './worktree-agent-rows-cache' @@ -65,7 +65,7 @@ function oracleBucketCounts( if (row.rowSource === 'subagent') { continue } - counts[dashboardRowBucketProjection(row, state.acknowledgedAgentsByPaneKey).bucket] += 1 + counts[dashboardRowBucketProjection(row, readAgentRowTurnRecords(state)).bucket] += 1 } } return counts diff --git a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts index e673ad73298..abdab81ebaa 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts @@ -6,7 +6,11 @@ import { type DashboardWorkspaceState } from './dashboard-snapshot-workspaces' import { selectDashboardOrchestration } from './dashboard-orchestration-selection' -import { dashboardRowBucketProjection } from './dashboard-row-bucket' +import { + dashboardRowBucketProjection, + readAgentRowTurnRecords, + type AgentRowUnreadState +} from './dashboard-row-bucket' import type { DashboardAgentRowWithLineage } from './agent-row-lineage' import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/worktree-agent-orchestration-batch' import { @@ -104,17 +108,19 @@ function countsEqual( * One worktree's bucket tally, reused while its rows and the acknowledgement map * both hold their identity. * - * `dashboardRowBucketProjection` reads nothing but the row and - * `acknowledgedAgentsByPaneKey[row.paneKey]`, so those two identities are the - * whole input. Keying on the ack slice rather than folding it into the row cache - * keeps main's property that an acknowledgement recounts without rebuilding rows. + * `dashboardRowBucketProjection` reads the row, the acknowledgement map, and the + * live/retained records under the row's own key. Those records are this worktree's + * row-cache inputs, so `rows` identity already covers them; keying on the ack slice + * rather than folding it into the row cache keeps main's property that an + * acknowledgement recounts without rebuilding rows. */ function tallyWorktreeRows( rows: DashboardAgentRowWithLineage[], - acknowledgedAgentsByPaneKey: Record | undefined, + state: AgentRowUnreadState, worktreeId: string, cache: DashboardBucketCountsCache | undefined ): Record { + const { acknowledgedAgentsByPaneKey } = state const memo = cache?.tallyByWorktree.get(worktreeId) if ( memo && @@ -123,6 +129,7 @@ function tallyWorktreeRows( ) { return memo.tally } + const turnRecords = readAgentRowTurnRecords(state) const tally = { attention: 0, working: 0, done: 0, idle: 0 } satisfies Record< DashboardBucket, number @@ -131,7 +138,7 @@ function tallyWorktreeRows( if (row.rowSource === 'subagent') { continue } - tally[dashboardRowBucketProjection(row, acknowledgedAgentsByPaneKey).bucket] += 1 + tally[dashboardRowBucketProjection(row, turnRecords).bucket] += 1 } cache?.tallyByWorktree.set(worktreeId, { rows, @@ -186,7 +193,7 @@ export function buildDashboardBucketCounts( generation, cache }) - const tally = tallyWorktreeRows(rows, state.acknowledgedAgentsByPaneKey, worktreeId, cache) + const tally = tallyWorktreeRows(rows, state, worktreeId, cache) counts.attention += tally.attention counts.working += tally.working counts.done += tally.done diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index fbdf01fe551..0c3523f3ea1 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -45,7 +45,7 @@ import { import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options' import { groupSubagentsByParentPaneKey } from './dashboard-subagent-cards' import { selectDashboardOrchestration } from './dashboard-orchestration-selection' -import { dashboardRowBucketProjection } from './dashboard-row-bucket' +import { dashboardRowBucketProjection, readAgentRowTurnRecords } from './dashboard-row-bucket' /** The store slices the snapshot builder reads. Kept as a Pick so unit tests * can pass a partial store without constructing the whole AppState. */ @@ -99,6 +99,7 @@ export function buildDashboardSnapshot( const includeCardDetails = options.includeCardDetails !== false const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true const showIdle = state.settings?.experimentalAgentDashboardShowIdle === true + const turnRecords = readAgentRowTurnRecords(state) const activeWorktrees = collectActiveDashboardWorkspaces(state, includeCardDetails) const filterOptions = options.includeFilterOptions === false @@ -168,7 +169,7 @@ export function buildDashboardSnapshot( // agent LABEL and a status word like "Idle". They're marked by // startedAt === 0, and must NOT be shown as real conversation. const { isTitleDerived, dotState, workingMode, unseen, bucket } = - dashboardRowBucketProjection(row, state.acknowledgedAgentsByPaneKey) + dashboardRowBucketProjection(row, turnRecords) const routingPaneKey = row.activationPaneKey ?? row.paneKey const parsed = parsePaneKey(routingPaneKey) const tabId = parsed?.tabId ?? row.tab.id @@ -244,8 +245,7 @@ export function buildDashboardSnapshot( finishedAt, stateChangedAt: row.entry.stateStartedAt || row.startedAt, statusUpdatedAt: row.entry.updatedAt, - // Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the - // board and the sidebar bold/mute the same agents at the same time. + // `isAgentSubjectUnread`, the rule the sidebar card bolds by too. unseen, askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined, conversationName: boundedLabelOrUndefined( diff --git a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts index 0b4377ac9aa..e5303040679 100644 --- a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts +++ b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts @@ -6,6 +6,10 @@ import { } from '../../../../shared/dashboard-snapshot' import { dashboardBucketForDotState } from './dashboard-card-bucket' import type { AgentRowState } from '@/lib/agent-row-decay-state' +import { + isAgentSubjectUnread, + type AgentAttentionTurnRecords +} from '@/attention/agent-attention-acknowledgement' /** * Project a row state onto the published card vocabulary. @@ -19,6 +23,29 @@ export function dashboardCardDotState(state: AgentRowState): DashboardCardDotSta return state === 'unverifiable' ? 'idle' : state } +/** The store slices the agent-row unread rule reads. */ +export type AgentRowUnreadState = { + agentStatusByPaneKey: AgentAttentionTurnRecords['liveTurns'] + retainedAgentsByPaneKey: AgentAttentionTurnRecords['retainedTurns'] + acknowledgedAgentsByPaneKey: AgentAttentionTurnRecords['acknowledgedTurnStartedAt'] +} + +/** + * The records behind the one unread (bold) rule for an agent row, on every surface that draws one: + * `isAgentSubjectUnread(records, row.paneKey)`. + * + * Why the store's turn records, not the row: auto-acknowledgement clears exactly these, so a row + * is bold only while viewing or clicking it can clear it. Rows built from a terminal title, a + * subagent or a migration block have no record and are never unread. + */ +export function readAgentRowTurnRecords(state: AgentRowUnreadState): AgentAttentionTurnRecords { + return { + liveTurns: state.agentStatusByPaneKey, + retainedTurns: state.retainedAgentsByPaneKey, + acknowledgedTurnStartedAt: state.acknowledgedAgentsByPaneKey + } +} + export type DashboardRowBucketProjection = { isTitleDerived: boolean dotState: DashboardCardDotState @@ -30,7 +57,7 @@ export type DashboardRowBucketProjection = { /** Derive the shared dashboard presentation state for one agent row. */ export function dashboardRowBucketProjection( row: Pick, - acknowledgedAgentsByPaneKey?: Record + turnRecords: AgentAttentionTurnRecords ): DashboardRowBucketProjection { const isTitleDerived = row.startedAt === 0 const dotState = dashboardCardDotState(row.state) @@ -38,8 +65,7 @@ export function dashboardRowBucketProjection( row.state === 'working' && row.entry.workingMode === 'monitoring' ? row.entry.workingMode : undefined - const unseen = - !isTitleDerived && (acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt + const unseen = isAgentSubjectUnread(turnRecords, row.paneKey) const bucket = dashboardBucketForDotState( dashboardCardDisplayState({ dotState, workingMode, unseen }) ) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx index 1459e7f1bb3..fc86fb3424e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx @@ -15,6 +15,8 @@ const WORKSPACE = 'acknowledgement-workspace' const PANE_A = makePaneKey('tab-a', '11111111-1111-4111-8111-111111111111') const PANE_B = makePaneKey('tab-b', '22222222-2222-4222-8222-222222222222') let acknowledgements: Record = {} +// The store's hook turn records; renderLabels defaults them to the rendered rows' entries. +let liveTurns: Record = {} let focusedPaneKey: string | null = null vi.mock('@/store', () => ({ @@ -22,6 +24,8 @@ vi.mock('@/store', () => ({ selector({ agentActivityDisplayMode: 'compact', acknowledgedAgentsByPaneKey: acknowledgements, + agentStatusByPaneKey: liveTurns, + retainedAgentsByPaneKey: {}, agentSendPopoverTargetMode: null, dropAgentStatus: vi.fn(), dismissRetainedAgent: vi.fn(), @@ -79,7 +83,11 @@ function agentRow( } } -function renderLabels(agents: DashboardAgentRow[]): HTMLElement[] { +function renderLabels( + agents: DashboardAgentRow[], + turns = Object.fromEntries(agents.map((agent) => [agent.paneKey, agent.entry])) +): HTMLElement[] { + liveTurns = turns const container = document.createElement('div') container.innerHTML = renderToStaticMarkup( @@ -100,6 +108,7 @@ function expectEmphasis(label: HTMLElement | undefined, unvisited: boolean): voi beforeEach(() => { acknowledgements = { [PANE_B]: 2000 } + liveTurns = {} focusedPaneKey = null clearWorktreeAgentExpansionStateForTests() seedWorktreeAgentExpansionStateForTests(WORKSPACE, { @@ -165,6 +174,26 @@ describe('compact acknowledgement emphasis through WorktreeCardAgents', () => { expect(label?.nextElementSibling?.textContent).toContain('Interrupted by user') }) + it('emphasizes only rows with an unread hook turn, never title-derived or subagent rows', () => { + // Title and subagent rows have no turn record; title rows restamp stateStartedAt on every rebuild. + acknowledgements = {} + const hook = agentRow(PANE_A) + const title = { ...agentRow(PANE_B, 'idle', { stateStartedAt: 9000 }), startedAt: 0 } + const child = { + ...agentRow(`${PANE_A}\u0000subagent:s1`, 'working'), + rowSource: 'subagent' as const, + activationPaneKey: PANE_A + } + const turns = { [PANE_A]: hook.entry } + const labels = renderLabels([hook, title, child], turns) + expect(labels).toHaveLength(3) + expectEmphasis(labels[0], true) + expectEmphasis(labels[1], false) + expectEmphasis(labels[2], false) + acknowledgements = { [PANE_A]: 2000 } + expectEmphasis(renderLabels([hook, title, child], turns)[0], false) + }) + it('does not treat same-state output as a new turn, but admits a newer timestamp', () => { acknowledgements = { [PANE_A]: 2000 } expectEmphasis(renderLabels([agentRow(PANE_A)])[0], false) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx index 37e003efab6..72431d0146e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx @@ -70,6 +70,7 @@ function buildMockStoreState(): Record { return { agentActivityDisplayMode: mockAgentActivityDisplayMode, acknowledgedAgentsByPaneKey: {}, + retainedAgentsByPaneKey: {}, cacheTimerByKey: {}, dropAgentStatus: vi.fn(), dismissRetainedAgent: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx index 01cb92957b1..bb66a01f7a0 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx @@ -38,6 +38,7 @@ vi.mock('@/store', () => ({ selector({ agentActivityDisplayMode: 'compact', acknowledgedAgentsByPaneKey: {}, + retainedAgentsByPaneKey: {}, cacheTimerByKey: {}, dropAgentStatus: vi.fn(), dismissRetainedAgent: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx index e7cef4d8a7b..32403ded3f4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx @@ -92,6 +92,7 @@ vi.mock('@/store', () => ({ selector({ agentActivityDisplayMode: 'full', acknowledgedAgentsByPaneKey: {}, + retainedAgentsByPaneKey: {}, dropAgentStatus: vi.fn(), dismissRetainedAgent: vi.fn(), acknowledgeAgents: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index 73b9a430bd7..23f67ab7c03 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -66,6 +66,8 @@ function mockAgent({ } let mockAgents: unknown[] = [mockAgent()] +// The hook records behind mockAgents; the unread (bold) rule reads them. +let mockAgentStatusByPaneKey: Record = {} let mockFocusedAgentPaneKey: string | null = null let mockAgentActivityDisplayMode: 'compact' | 'full' | undefined let mockPromptCacheTimerEnabled = true @@ -86,12 +88,13 @@ vi.mock('@/store', () => ({ selector({ agentActivityDisplayMode: mockAgentActivityDisplayMode, acknowledgedAgentsByPaneKey: {}, + retainedAgentsByPaneKey: {}, cacheTimerByKey: mockCacheTimerByKey, dropAgentStatus: vi.fn(), dismissRetainedAgent: vi.fn(), acknowledgeAgents: vi.fn(), agentSendPopoverTargetMode: null, - agentStatusByPaneKey: {}, + agentStatusByPaneKey: mockAgentStatusByPaneKey, tabsByWorktree: {}, terminalLayoutsByTabId: {}, sendPromptToSidebarAgentTarget: vi.fn(), @@ -186,6 +189,7 @@ describe('WorktreeCardAgents', () => { beforeEach(() => { vi.clearAllMocks() mockAgents = [mockAgent()] + mockAgentStatusByPaneKey = {} mockFocusedAgentPaneKey = null mockAgentActivityDisplayMode = undefined mockPromptCacheTimerEnabled = true @@ -221,6 +225,7 @@ describe('WorktreeCardAgents', () => { it('emphasizes unvisited non-focused compact agent row text', async () => { mockAgentActivityDisplayMode = 'compact' + mockAgentStatusByPaneKey = { 'tab-1:1': { stateStartedAt: 1000 } } mockAgents = [ mockAgent({ agentType: 'codex', @@ -241,6 +246,7 @@ describe('WorktreeCardAgents', () => { it('keeps focused compact agent row text legible', async () => { mockAgentActivityDisplayMode = 'compact' + mockAgentStatusByPaneKey = { 'tab-1:1': { stateStartedAt: 1000 } } mockFocusedAgentPaneKey = 'tab-1:1' mockAgents = [ mockAgent({ diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 425a142625b..432d2607058 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -28,7 +28,7 @@ import { revealElementInScrollContainer } from './worktree-sidebar-reveal' import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state' import { translate } from '@/i18n/i18n' import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' -import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs' +import { selectAgentRowsUnread } from './worktree-card-agent-ack-inputs' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = 'orca-suppress-worktree-list-scroll-adjustment' @@ -94,17 +94,14 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ // Why: acknowledgement writes are app-global; project only this card's rows // so unrelated worktree activity does not rerender every agent body. - const acknowledgedAgentTimes = useAppStore( - useShallow((s) => selectAcknowledgedAgentTimes(s, agents)) - ) + const agentRowsUnread = useAppStore(useShallow((s) => selectAgentRowsUnread(s, agents))) const unvisitedByPaneKey = useMemo(() => { const out: Record = {} for (const [index, agent] of agents.entries()) { - const ackAt = acknowledgedAgentTimes[index] ?? 0 - out[agent.paneKey] = ackAt < agent.entry.stateStartedAt + out[agent.paneKey] = agentRowsUnread[index] ?? false } return out - }, [agents, acknowledgedAgentTimes]) + }, [agents, agentRowsUnread]) const handleDismissAgent = useCallback( (paneKey: string) => { diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows-subscriptions.bench.test.tsx b/src/renderer/src/components/sidebar/worktree-agent-rows-subscriptions.bench.test.tsx index 9f4142923b3..c3ee27bb9e5 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows-subscriptions.bench.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-agent-rows-subscriptions.bench.test.tsx @@ -10,7 +10,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { useShallow } from 'zustand/react/shallow' -import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs' +import { selectAgentRowsUnread } from './worktree-card-agent-ack-inputs' Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) @@ -68,7 +68,7 @@ it.skipIf(!process.env.ORCA_AGENT_ROWS_BENCH)( function VisibleProbe({ index }: { index: number }) { const rows = useWorktreeAgentRows(`wt-${index}`, true) useWorktreeAgentRows(`wt-${index}`, false) - useAppStore(useShallow((state) => selectAcknowledgedAgentTimes(state, rows))) + useAppStore(useShallow((state) => selectAgentRowsUnread(state, rows))) renders += 1 return null } diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx b/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx index 109ab4a0380..da3712b2614 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx @@ -6,33 +6,43 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' import { useShallow } from 'zustand/react/shallow' import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs' +import type { AgentRowUnreadState } from '@/components/dashboard/dashboard-row-bucket' +import { selectAgentRowsUnread } from './worktree-card-agent-ack-inputs' const AGENTS = [{ paneKey: 'tab-a:leaf-a' }, { paneKey: 'tab-a:leaf-b' }] as const ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true -type AckStoreState = { - acknowledgedAgentsByPaneKey: Record +const TURNS = { + 'tab-a:leaf-a': { stateStartedAt: 10 }, + 'tab-a:leaf-b': { stateStartedAt: 20 } } -const useAckStore = create(() => ({ acknowledgedAgentsByPaneKey: {} })) +function unreadState(acknowledgedAgentsByPaneKey: Record): AgentRowUnreadState { + return { + agentStatusByPaneKey: TURNS, + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey + } +} + +const useAckStore = create(() => unreadState({})) let renderCount = 0 -let selectedTimes: readonly number[] | undefined +let selectedTimes: readonly boolean[] | undefined function SelectionProbe({ agents }: { agents: readonly Pick[] }) { - selectedTimes = useAckStore(useShallow((state) => selectAcknowledgedAgentTimes(state, agents))) + selectedTimes = useAckStore(useShallow((state) => selectAgentRowsUnread(state, agents))) renderCount += 1 return null } -describe('selectAcknowledgedAgentTimes', () => { +describe('selectAgentRowsUnread', () => { let root: Root let container: HTMLDivElement beforeEach(() => { - useAckStore.setState({ acknowledgedAgentsByPaneKey: {} }) + useAckStore.setState(unreadState({})) renderCount = 0 selectedTimes = undefined container = document.createElement('div') @@ -50,24 +60,18 @@ describe('selectAcknowledgedAgentTimes', () => { const initialTimes = selectedTimes act(() => { - useAckStore.setState({ acknowledgedAgentsByPaneKey: { 'tab-other:leaf': 99 } }) + useAckStore.setState(unreadState({ 'tab-other:leaf': 99 })) }) expect(renderCount).toBe(1) expect(selectedTimes).toBe(initialTimes) - const before = selectAcknowledgedAgentTimes( - { acknowledgedAgentsByPaneKey: { 'tab-a:leaf-a': 10, 'tab-a:leaf-b': 20 } }, + const before = selectAgentRowsUnread( + unreadState({ 'tab-a:leaf-a': 10, 'tab-a:leaf-b': 20 }), AGENTS ) - const after = selectAcknowledgedAgentTimes( - { - acknowledgedAgentsByPaneKey: { - 'tab-a:leaf-a': 10, - 'tab-a:leaf-b': 20, - 'tab-other:leaf': 99 - } - }, + const after = selectAgentRowsUnread( + unreadState({ 'tab-a:leaf-a': 10, 'tab-a:leaf-b': 20, 'tab-other:leaf': 99 }), AGENTS ) @@ -75,35 +79,19 @@ describe('selectAcknowledgedAgentTimes', () => { expect(shallow(after, before)).toBe(true) }) - it('reuses the projection across status-only publications and handles a different row list', () => { - const state = { acknowledgedAgentsByPaneKey: { 'tab-a:leaf-a': 11, 'tab-a:leaf-b': 22 } } - const first = selectAcknowledgedAgentTimes(state, AGENTS) - expect(selectAcknowledgedAgentTimes({ ...state }, AGENTS)).toBe(first) - expect(selectAcknowledgedAgentTimes(state, [AGENTS[1], AGENTS[0]])).toEqual([22, 11]) - expect(selectAcknowledgedAgentTimes({ acknowledgedAgentsByPaneKey: {} }, AGENTS)).toEqual([ - 0, 0 - ]) - }) - it('updates when one of this card rows is acknowledged', () => { const initialTimes = selectedTimes act(() => { - useAckStore.setState({ acknowledgedAgentsByPaneKey: { 'tab-a:leaf-a': 11 } }) + useAckStore.setState(unreadState({ 'tab-a:leaf-a': 11 })) }) expect(renderCount).toBe(2) expect(selectedTimes).not.toBe(initialTimes) - expect(selectedTimes).toEqual([11, 0]) + expect(selectedTimes).toEqual([false, true]) - const before = selectAcknowledgedAgentTimes( - { acknowledgedAgentsByPaneKey: { 'tab-a:leaf-a': 10 } }, - AGENTS - ) - const after = selectAcknowledgedAgentTimes( - { acknowledgedAgentsByPaneKey: { 'tab-a:leaf-a': 11 } }, - AGENTS - ) + const before = selectAgentRowsUnread(unreadState({ 'tab-a:leaf-a': 9 }), AGENTS) + const after = selectAgentRowsUnread(unreadState({ 'tab-a:leaf-a': 11 }), AGENTS) - expect(after).toEqual([11, 0]) + expect(after).toEqual([false, true]) expect(shallow(after, before)).toBe(false) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.ts index 3fe7a53a8a7..14cd9ef830c 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.ts @@ -1,32 +1,15 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import type { AppState } from '@/store/types' +import { isAgentSubjectUnread } from '@/attention/agent-attention-acknowledgement' +import { + readAgentRowTurnRecords, + type AgentRowUnreadState +} from '@/components/dashboard/dashboard-row-bucket' -type AcknowledgedAgentTimesState = Pick - -type AcknowledgedAgentRows = readonly Pick[] - -// Both keys are weak so departed rows and old acknowledgement snapshots can be collected. -const projections = new WeakMap< - AcknowledgedAgentTimesState['acknowledgedAgentsByPaneKey'], - WeakMap ->() - -/** Projects only the acknowledgement timestamps rendered by one card. */ -export function selectAcknowledgedAgentTimes( - state: AcknowledgedAgentTimesState, - agents: AcknowledgedAgentRows -): readonly number[] { - const source = state.acknowledgedAgentsByPaneKey - let byRows = projections.get(source) - if (!byRows) { - byRows = new WeakMap() - projections.set(source, byRows) - } - const cached = byRows.get(agents) - if (cached) { - return cached - } - const times = Object.freeze(agents.map((agent) => source[agent.paneKey] ?? 0)) - byRows.set(agents, times) - return times +/** Projects only the unread flags rendered by one card. */ +export function selectAgentRowsUnread( + state: AgentRowUnreadState, + agents: readonly Pick[] +): boolean[] { + const records = readAgentRowTurnRecords(state) + return agents.map((agent) => isAgentSubjectUnread(records, agent.paneKey)) } diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 7e73363f709..ca2d197b9ee 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -231,12 +231,12 @@ function buildTitleDerivedAgentRow(args: { agentType, rowSource: 'live', state: rowState, - // Load-bearing zero, not a placeholder: `dashboardRowBucketProjection` reads `startedAt === 0` - // as "title-derived" and short-circuits `unseen`. That is the ONLY reason the `args.now` stamps - // on `entry` above (updatedAt / stateStartedAt / observation) cannot move this row's bucket. - // Dashboard bucket caches key their invalidation on the freshness boundary in - // `isExplicitAgentStatusFresh` alone; give this a real timestamp and every one of them starts - // serving stale counts, with no test failing at the point of the change. + // Load-bearing zero, not a placeholder: dashboard readers treat `startedAt === 0` as + // "title-derived" and hide its synthetic prompt text. The `args.now` stamps on `entry` above + // never reach unread, which reads the store's turn records (`readAgentRowTurnRecords`); + // this row has none. + // Dashboard bucket caches key their invalidation on the freshness boundary alone, so a bucket + // that read these stamps would serve stale counts with no test failing at the change. startedAt: 0 } }