Files
hapi/web/src/hooks/useUnseenBlockCount.ts
T
Haoqing WangandGitHub 3a931e3c81 fix(web): count unseen messages by rendered block, not raw message (#1255)
* fix(web): count unseen messages by rendered block, not raw message

The "N new messages" pill counted raw DecryptedMessages while the
timeline renders folded blocks, so the two never agreed. A subagent run
is dozens of sidechain messages but a single Task card; a tool_use and
its tool_result are two messages and one card; consecutive tools collapse
into one group. The pill could read "47 new messages" when scrolling down
revealed two new rows.

collectNewUnseenIds never inspected isSidechain, and it could not: the
reducer's grouping is stateful (it needs the Task tool_use before it can
map parentToolUseId), so a per-message predicate in the store cannot
reproduce it. Adding an isSidechain check there would also invert the
error for orphan sidechain messages, which tracer.ts falls back to
emitting at the top level.

Instead, drop the store's unseen bookkeeping entirely and count what the
renderer actually produced. Watermark the visible blocks when the user
scrolls away from the tail, then count the blocks past the last one they
had seen.

The count is anchor-based rather than timestamp-based because the blocks
array is not monotonic in createdAt: messages sort by invokedAt ??
createdAt, so a queued message carries an old createdAt while sitting at
the end. Anchoring also makes prepended history free, since older blocks
land before the anchor.

Known limit, documented at the call site: once the history window fills
up, mergeIntoWindow trims incoming messages off the tail, so the pill
reports 0 instead of a count. Under-reporting is preferable here, and
returning to the tail force-refetches the latest page anyway.

* fix(web): keep unseen watermark stable across optimistic id replacement

The watermark snapshotted only block.id, but that id is not stable for
the user's own messages: mergeMessages replaces an optimistic row with a
stored row that keeps localId under a new server id, and the user block
renders with the message id. Scrolling into history while an own message
was still optimistic meant its echo anchored one block earlier and bumped
the pill by one, with no new rendered row.

Track localId alongside id in the watermark and match on either.

Reported by HAPI Bot on #1255.

* fix(web): count joined assistant cards, not pre-join blocks

visibleBlocks is still not one-to-one with rendered rows: assistant-ui
joins a run of adjacent assistant-role blocks into a single card, so a
response made of reasoning + text + a tool call was reported as three new
messages instead of one, and appending another block to an in-flight
response bumped the pill without adding a row.

Walk the blocks after the anchor and only start a new row where the
assistant run breaks.

Role assignment is the part that would drift, so rather than restating it,
visibleBlockRole moves from assistant-runtime.ts to toolGroups.ts (next to
the VisibleChatBlock definition it describes) and both the runtime and the
counter import the one copy.

Reported by HAPI Bot on #1255.
2026-07-30 23:24:01 +08:00

35 lines
1.5 KiB
TypeScript

import { useMemo, useState } from 'react'
import type { VisibleChatBlock } from '@/chat/toolGroups'
import { countUnseenBlocks, createUnseenWatermark, type UnseenWatermark } from '@/chat/unseenBlocks'
/**
* Tracks how many rendered blocks appeared since the user scrolled away from
* the tail, for the "N new messages" pill.
*
* The watermark is captured during render rather than in an effect so the first
* frame after leaving the tail already reports 0 instead of briefly showing a
* stale count. This is React's "adjust state during render" pattern: the
* setState pair runs only on the frame where viewMode actually flips, and
* updating prevViewMode makes the condition false on the immediate re-render,
* so it converges instead of looping.
*
* viewMode is seeded from the caller's current value because the message window
* store keeps view mode per session in a module-level map — returning to a
* session that was left in history mode must not be mistaken for a fresh
* tail -> history transition and capture a watermark the user never saw.
*/
export function useUnseenBlockCount(
viewMode: 'tail' | 'history',
blocks: readonly VisibleChatBlock[]
): number {
const [prevViewMode, setPrevViewMode] = useState(viewMode)
const [watermark, setWatermark] = useState<UnseenWatermark | null>(null)
if (viewMode !== prevViewMode) {
setPrevViewMode(viewMode)
setWatermark(viewMode === 'history' ? createUnseenWatermark(blocks) : null)
}
return useMemo(() => countUnseenBlocks(blocks, watermark), [blocks, watermark])
}