Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions surface/web/src/components/Timeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
27 changes: 11 additions & 16 deletions surface/web/src/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, ChatMessage[]>();
// 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,
};
Expand Down
Loading