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, };