From 73036f25e929650a5d3296956fb740c98ce0f149 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 16 Jul 2026 22:59:32 -0400 Subject: [PATCH] refactor(web): one predicate decides what anchors into a root's cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two conditions described the same set — agent-voice broadcast events that anchor into their loaded root's cluster — and only one of them excluded question requests. A message dropped from the feed by the looser condition but skipped by the stricter one would render nowhere at all while still counting toward unread: the same shape as the deleted-tail bug in #562, where something counted but could never be seen. Nothing reaches it today, because question requests are not emitted as broadcasts. Nothing enforced that either. Both call sites now derive from one predicate, so the two cannot disagree: a message leaves the feed only if the cluster actually takes it, and a broadcast question request stays a row of its own rather than vanishing. No reachable behavior changes. --- surface/web/src/components/Timeline.test.tsx | 24 +++++++++++++++++ surface/web/src/components/Timeline.tsx | 27 ++++++++------------ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/surface/web/src/components/Timeline.test.tsx b/surface/web/src/components/Timeline.test.tsx index 14ddf1d8..fef0651b 100644 --- a/surface/web/src/components/Timeline.test.tsx +++ b/surface/web/src/components/Timeline.test.tsx @@ -350,6 +350,30 @@ describe('Timeline anchored agent answers', () => { expect(screen.queryByRole('button', { name: '↳ replied to a thread' })).toBeNull(); }); + it('keeps a broadcast question request standalone when the root is loaded', () => { + renderTimeline({ + messages: [ + message({ id: 1, text: 'Please ship it' }), + message({ + id: 10, + threadRootEventId: 1, + sessionId: 's-1', + sessionEventType: 'question_requested', + sessionEventPayload: { + questionId: 'q-1', + questions: [{ id: 'prompt-1', header: 'Scope', question: 'Should this include the migration?' }], + }, + broadcast: true, + author: { id: 'agent:s-1', handle: 'agent', displayName: 'Agent' }, + }), + ], + unreadDividerAfterId: null, + }); + + expect(screen.getByText('Should this include the migration?')).toBeTruthy(); + expect(screen.queryByTestId('channel-annotation-cluster')).toBeNull(); + }); + it('keeps the standalone answer when its root is outside the loaded window', () => { renderTimeline({ messages: [answer()], unreadDividerAfterId: null }); diff --git a/surface/web/src/components/Timeline.tsx b/surface/web/src/components/Timeline.tsx index d32f24a6..c96c093e 100644 --- a/surface/web/src/components/Timeline.tsx +++ b/surface/web/src/components/Timeline.tsx @@ -96,34 +96,29 @@ function TimelineImpl({ .filter((message) => message.threadRootEventId == null && message.id != null) .map((message) => message.id!), ); + const anchorsIntoRootCluster = (message: ChatMessage): message is ChatMessage & { threadRootEventId: number } => + isAgentVoiceBroadcast(message) && + message.sessionEventType !== 'question_requested' && + message.threadRootEventId != null && + rootIds.has(message.threadRootEventId); const answers = new Map(); - // Only AGENT-VOICE broadcast replies (session answers/questions) anchor + // Only AGENT-VOICE broadcast events the root cluster can present anchor // under their loaded root — the cluster's slot presentation carries the // agent identity. A human "also send to channel" reply stays in the feed // as its own row, attributed to its author; rendering one message twice // (cluster preview + standalone row) is still the failure mode, so the // cluster suppresses its compact preview for broadcast replies. for (const message of messages) { - if ( - isAgentVoiceBroadcast(message) && - message.sessionEventType !== 'question_requested' && - message.threadRootEventId != null && - rootIds.has(message.threadRootEventId) - ) { - const current = answers.get(message.threadRootEventId) ?? []; - current.push(message); - answers.set(message.threadRootEventId, current); - } + if (!anchorsIntoRootCluster(message)) continue; + const current = answers.get(message.threadRootEventId) ?? []; + current.push(message); + answers.set(message.threadRootEventId, current); } - const isAnchoredAnnotationEvent = (message: ChatMessage) => - isAgentVoiceBroadcast(message) && message.threadRootEventId != null && rootIds.has(message.threadRootEventId); // `isRenderableMessage` keeps a message that paints nothing (a deleted one // with no replies left) out of every set derived from the feed — otherwise // it becomes a "newest message" no scroll can ever reach. return { - visibleMessages: messages.filter( - (message) => isRenderableMessage(message) && !isAnchoredAnnotationEvent(message), - ), + visibleMessages: messages.filter((message) => isRenderableMessage(message) && !anchorsIntoRootCluster(message)), answersByRoot: answers, loadedRootIds: rootIds, };