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
This commit is contained in:
Brennan Benson
2026-10-16 15:25:03 -07:00
committed by GitHub
parent afcca77a5e
commit f9acfffa03
16 changed files with 171 additions and 108 deletions
@@ -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
@@ -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
@@ -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
@@ -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<string, number> | undefined,
state: AgentRowUnreadState,
worktreeId: string,
cache: DashboardBucketCountsCache | undefined
): Record<DashboardBucket, number> {
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
@@ -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(
@@ -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<DashboardAgentRow, 'paneKey' | 'entry' | 'state' | 'startedAt'>,
acknowledgedAgentsByPaneKey?: Record<string, number>
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 })
)
@@ -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<string, number> = {}
// The store's hook turn records; renderLabels defaults them to the rendered rows' entries.
let liveTurns: Record<string, DashboardAgentRow['entry']> = {}
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(
<TooltipProvider>
@@ -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)
@@ -70,6 +70,7 @@ function buildMockStoreState(): Record<string, unknown> {
return {
agentActivityDisplayMode: mockAgentActivityDisplayMode,
acknowledgedAgentsByPaneKey: {},
retainedAgentsByPaneKey: {},
cacheTimerByKey: {},
dropAgentStatus: vi.fn(),
dismissRetainedAgent: vi.fn(),
@@ -38,6 +38,7 @@ vi.mock('@/store', () => ({
selector({
agentActivityDisplayMode: 'compact',
acknowledgedAgentsByPaneKey: {},
retainedAgentsByPaneKey: {},
cacheTimerByKey: {},
dropAgentStatus: vi.fn(),
dismissRetainedAgent: vi.fn(),
@@ -92,6 +92,7 @@ vi.mock('@/store', () => ({
selector({
agentActivityDisplayMode: 'full',
acknowledgedAgentsByPaneKey: {},
retainedAgentsByPaneKey: {},
dropAgentStatus: vi.fn(),
dismissRetainedAgent: vi.fn(),
acknowledgeAgents: vi.fn(),
@@ -66,6 +66,8 @@ function mockAgent({
}
let mockAgents: unknown[] = [mockAgent()]
// The hook records behind mockAgents; the unread (bold) rule reads them.
let mockAgentStatusByPaneKey: Record<string, { stateStartedAt: number }> = {}
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({
@@ -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<string, boolean> = {}
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) => {
@@ -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
}
@@ -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<string, number>
const TURNS = {
'tab-a:leaf-a': { stateStartedAt: 10 },
'tab-a:leaf-b': { stateStartedAt: 20 }
}
const useAckStore = create<AckStoreState>(() => ({ acknowledgedAgentsByPaneKey: {} }))
function unreadState(acknowledgedAgentsByPaneKey: Record<string, number>): AgentRowUnreadState {
return {
agentStatusByPaneKey: TURNS,
retainedAgentsByPaneKey: {},
acknowledgedAgentsByPaneKey
}
}
const useAckStore = create<AgentRowUnreadState>(() => unreadState({}))
let renderCount = 0
let selectedTimes: readonly number[] | undefined
let selectedTimes: readonly boolean[] | undefined
function SelectionProbe({ agents }: { agents: readonly Pick<DashboardAgentRow, 'paneKey'>[] }) {
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)
})
})
@@ -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<AppState, 'acknowledgedAgentsByPaneKey'>
type AcknowledgedAgentRows = readonly Pick<DashboardAgentRow, 'paneKey'>[]
// Both keys are weak so departed rows and old acknowledgement snapshots can be collected.
const projections = new WeakMap<
AcknowledgedAgentTimesState['acknowledgedAgentsByPaneKey'],
WeakMap<AcknowledgedAgentRows, readonly number[]>
>()
/** 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<DashboardAgentRow, 'paneKey'>[]
): boolean[] {
const records = readAgentRowTurnRecords(state)
return agents.map((agent) => isAgentSubjectUnread(records, agent.paneKey))
}
@@ -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
}
}