mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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
|
||||
|
||||
+2
-2
@@ -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 })
|
||||
)
|
||||
|
||||
+30
-1
@@ -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) => {
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user