diff --git a/packages/thread-view/src/completed-turn-grouping.ts b/packages/thread-view/src/completed-turn-grouping.ts index df6a8e6a2d..e4e0eafdbb 100644 --- a/packages/thread-view/src/completed-turn-grouping.ts +++ b/packages/thread-view/src/completed-turn-grouping.ts @@ -7,7 +7,9 @@ import { getMessageStartedAt } from "./format-helpers.js"; import { findLastTerminalTimelineMessage, isSingletonContextManagementOperation, + isTimelineTerminalMessage, isTimelineUngroupableMessage, + isTimelineUserInputBoundaryMessage, } from "./timeline-message-helpers.js"; export interface CompletedTurnSummaryGroup { @@ -167,6 +169,7 @@ function groupCompletedTurnSummaryMessages( let groupedMessages: EventProjectionMessage[] = []; let segmentIndex = 0; let externalBoundaryIndex = 0; + let preserveNextTerminalMessage = false; function appendSummaryGroup(sourceMessages: EventProjectionMessage[]): void { if (sourceMessages.length === 0) { @@ -193,6 +196,8 @@ function groupCompletedTurnSummaryMessages( // Human follow-ups split one provider turn into multiple visible exchange // segments. Keep each segment's last assistant/error message beside the // user row instead of burying it inside that segment's collapsed summary. + // The first assistant/error message after user input is kept too — it is + // the direct reply the user already read while the turn was streaming. const sourceMessages = groupedMessages; groupedMessages = []; const terminalMessage = preserveLastTerminalMessage @@ -222,19 +227,31 @@ function groupCompletedTurnSummaryMessages( ) { flushGroupedMessages(true); externalBoundaryIndex += 1; + preserveNextTerminalMessage = true; } } for (const message of summaryMessages) { flushExternalBoundariesBefore(message); if (isTimelineUngroupableMessage(message)) { - flushGroupedMessages( - message.kind === "user" && message.initiator === "user", - ); + const isUserInputBoundary = isTimelineUserInputBoundaryMessage(message); + flushGroupedMessages(isUserInputBoundary); items.push({ kind: "ungrouped-message", message, }); + if (isUserInputBoundary) { + preserveNextTerminalMessage = true; + } + continue; + } + if (preserveNextTerminalMessage && isTimelineTerminalMessage(message)) { + flushGroupedMessages(); + items.push({ + kind: "ungrouped-message", + message, + }); + preserveNextTerminalMessage = false; continue; } groupedMessages.push(message); @@ -244,7 +261,7 @@ function groupCompletedTurnSummaryMessages( flushGroupedMessages(true); externalBoundaryIndex += 1; } - flushGroupedMessages(); + flushGroupedMessages(true); return applySingleSummaryTurnBounds(turn, items); } diff --git a/packages/thread-view/src/timeline-message-helpers.ts b/packages/thread-view/src/timeline-message-helpers.ts index 8e3d2c989a..d65a911997 100644 --- a/packages/thread-view/src/timeline-message-helpers.ts +++ b/packages/thread-view/src/timeline-message-helpers.ts @@ -25,9 +25,24 @@ export function isTimelineUngroupableMessage( if (message.kind === "assistant-text") { return message.isLegacyUserMessage === true; } + if (message.kind === "user-question-lifecycle") { + return message.lifecycle === "answered"; + } return message.kind === "debug/raw-event"; } +export function isTimelineUserInputBoundaryMessage( + message: EventProjectionMessage, +): boolean { + if (message.kind === "user") { + return message.initiator === "user"; + } + return ( + message.kind === "user-question-lifecycle" && + message.lifecycle === "answered" + ); +} + export function isTimelineSummaryCountedMessage( message: EventProjectionMessage, ): boolean { diff --git a/packages/thread-view/test/completed-turn-grouping.test.ts b/packages/thread-view/test/completed-turn-grouping.test.ts index aa24884073..b0279fbaf0 100644 --- a/packages/thread-view/test/completed-turn-grouping.test.ts +++ b/packages/thread-view/test/completed-turn-grouping.test.ts @@ -266,7 +266,7 @@ describe("groupCompletedTurnMessages", () => { { kind: "summary", startedAt: 1, - completedAt: null, + completedAt: 3, segmentIndex: 0, summaryCount: 1, }, @@ -277,17 +277,13 @@ describe("groupCompletedTurnMessages", () => { }, }, { - kind: "summary", - startedAt: 3, - completedAt: null, - segmentIndex: 1, - summaryCount: 1, + kind: "ungrouped-message", + message: { + id: "assistant-after", + }, }, ]); - expect(summarySourceMessageIds(groups)).toEqual([ - ["assistant-before"], - ["assistant-after"], - ]); + expect(summarySourceMessageIds(groups)).toEqual([["assistant-before"]]); }); it("slices terminal and trailing messages out of the summary groups", () => { diff --git a/packages/thread-view/test/completed-turn-user-input-visibility.test.ts b/packages/thread-view/test/completed-turn-user-input-visibility.test.ts new file mode 100644 index 0000000000..94370f4592 --- /dev/null +++ b/packages/thread-view/test/completed-turn-user-input-visibility.test.ts @@ -0,0 +1,158 @@ +import type { ThreadEventRow } from "@bb/domain"; +import { turnScope } from "@bb/domain"; +import { describe, expect, it } from "vitest"; +import { + createTimelineEventFactory, + renderTimelineFixture, +} from "./timeline-test-harness.js"; +import type { TimelineEventFactory } from "./timeline-test-harness.js"; + +function topLevelAssistantTexts( + rows: ReturnType["rows"], +): string[] { + return rows.flatMap((row) => + row.kind === "conversation" && row.role === "assistant" ? [row.text] : [], + ); +} + +function renderTimeline( + events: ThreadEventRow[], + threadStatus: "active" | "idle", +) { + return renderTimelineFixture({ + events, + projectionOptions: { + threadStatus, + turnMessageDetail: "summary", + }, + }); +} + +function answeredUserQuestionEvent(seq: number): ThreadEventRow { + return { + id: `evt-user-question-${seq}`, + threadId: "thread-1", + seq, + createdAt: seq, + scope: turnScope("turn-1"), + type: "system/userQuestion/lifecycle", + data: { + interactionId: "pint_question_1", + providerId: "claude-code", + providerRequestId: "request-question-1", + status: "resolved", + resolution: { + kind: "user_answer", + answers: { + "question-1": { selected: ["all"] }, + }, + }, + statusReason: null, + payload: { + kind: "user_question", + questions: [ + { + id: "question-1", + prompt: "Which changes should I include?", + shortLabel: "Scope", + multiSelect: false, + options: [ + { value: "all", label: "All of them" }, + { value: "none", label: "None" }, + ], + allowFreeText: false, + }, + ], + }, + }, + } as ThreadEventRow; +} + +describe("completed turn user input visibility", () => { + function buildSteerEvents(event: TimelineEventFactory, completed: boolean) { + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "Check my router setup", + }); + const steer = event.clientTurnRequested({ + target: { kind: "steer", expectedTurnId: "turn-1" }, + source: "tell", + text: "explore but do not apply changes", + requestId: "creq_steersteer" as never, + }); + const events: ThreadEventRow[] = [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.assistantCompleted({ itemId: "a1", text: "Starting the audit." }), + steer, + event.inputAccepted({ clientRequestId: steer.data.requestId }), + event.assistantCompleted({ + itemId: "a2", + text: "Understood - read-only only.", + }), + event.commandCompleted({ itemId: "tool-1", command: "ssh router" }), + event.assistantCompleted({ itemId: "a3", text: "Login works." }), + event.assistantCompleted({ + itemId: "a4", + text: "Audit complete. Nothing was changed.", + }), + event.assistantCompleted({ itemId: "a5", text: "Final runbook." }), + ]; + if (completed) { + events.push(event.turnCompleted()); + } + return events; + } + + it("keeps the direct reply and the segment-final message visible after a steered turn completes", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const runningTexts = topLevelAssistantTexts( + renderTimeline(buildSteerEvents(event, false), "active").rows, + ); + expect(runningTexts).toContain("Understood - read-only only."); + expect(runningTexts).toContain("Audit complete. Nothing was changed."); + + const completedEvent = createTimelineEventFactory({ threadId: "thread-1" }); + const completedTexts = topLevelAssistantTexts( + renderTimeline(buildSteerEvents(completedEvent, true), "idle").rows, + ); + expect(completedTexts).toContain("Understood - read-only only."); + expect(completedTexts).toContain("Audit complete. Nothing was changed."); + expect(completedTexts).toContain("Final runbook."); + expect(completedTexts).not.toContain("Login works."); + }); + + it("keeps an answered question and the report preceding it visible after the turn completes", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "Audit the router", + }); + const timeline = renderTimeline( + [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.commandCompleted({ itemId: "tool-1", command: "ssh router" }), + event.assistantCompleted({ + itemId: "a1", + text: "Audit complete. Which changes should I include?", + }), + answeredUserQuestionEvent(6), + event.assistantCompleted({ itemId: "a2", text: "Final runbook." }), + event.turnCompleted(), + ], + "idle", + ); + + const texts = topLevelAssistantTexts(timeline.rows); + expect(texts).toContain("Audit complete. Which changes should I include?"); + expect(texts).toContain("Final runbook."); + expect( + timeline.rows.some( + (row) => row.kind === "work" && row.workKind === "question", + ), + ).toBe(true); + }); +});