diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 633fb5d6bef..2f2b8924a84 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -268,6 +268,59 @@ describe("deriveMessagesTimelineRows", () => { expect(assistantRows[1]?.showCompletionDivider).toBe(true); }); + it("marks only the active assistant turn as streaming for copy controls", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "assistant-one-entry", + kind: "message", + createdAt: "2026-01-01T00:00:10Z", + message: { + id: "assistant-one" as never, + role: "assistant", + text: "Earlier response.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:10Z", + completedAt: "2026-01-01T00:00:11Z", + streaming: false, + }, + }, + { + id: "assistant-two-entry", + kind: "message", + createdAt: "2026-01-01T00:00:20Z", + message: { + id: "assistant-two" as never, + role: "assistant", + text: "Active response.", + turnId: "turn-2" as never, + createdAt: "2026-01-01T00:00:20Z", + completedAt: "2026-01-01T00:00:30Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: "assistant-two-entry", + completionSummary: "done", + isWorking: false, + activeTurnInProgress: true, + activeTurnId: "turn-2" as never, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const assistantRows = rows.filter( + (row): row is Extract<(typeof rows)[number], { kind: "message" }> => + row.kind === "message" && row.message.role === "assistant", + ); + + expect(assistantRows[0]?.assistantCopyStreaming).toBe(false); + expect(assistantRows[0]?.completionSummary).toBeNull(); + expect(assistantRows[1]?.assistantCopyStreaming).toBe(true); + expect(assistantRows[1]?.completionSummary).toBe("done"); + }); + it("projects assistant diff summaries and user revert counts onto the affected rows", () => { const assistantTurnDiffSummary = { turnId: "turn-1" as never, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c99f0b98a66..ad54dd8bdeb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,7 @@ import * as Equal from "effect/Equal"; import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { type MessageId } from "@t3tools/contracts"; +import { type MessageId, type TurnId } from "@t3tools/contracts"; export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; @@ -26,7 +26,9 @@ export type MessagesTimelineRow = message: ChatMessage; durationStart: string; showCompletionDivider: boolean; + completionSummary: string | null; showAssistantCopyButton: boolean; + assistantCopyStreaming: boolean; assistantTurnDiffSummary?: TurnDiffSummary | undefined; revertTurnCount?: number | undefined; } @@ -111,7 +113,10 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray; completionDividerBeforeEntryId: string | null; + completionSummary?: string | null; isWorking: boolean; + activeTurnInProgress?: boolean; + activeTurnId?: TurnId | null; activeTurnStartedAt: string | null; turnDiffSummaryByAssistantMessageId: ReadonlyMap; revertTurnCountByUserMessageId: ReadonlyMap; @@ -157,6 +162,16 @@ export function deriveMessagesTimelineRows(input: { continue; } + const assistantTurnStillInProgress = + timelineEntry.message.role === "assistant" && + input.activeTurnInProgress === true && + input.activeTurnId != null && + timelineEntry.message.turnId === input.activeTurnId; + + const showCompletionDivider = + timelineEntry.message.role === "assistant" && + input.completionDividerBeforeEntryId === timelineEntry.id; + nextRows.push({ kind: "message", id: timelineEntry.id, @@ -164,12 +179,12 @@ export function deriveMessagesTimelineRows(input: { message: timelineEntry.message, durationStart: durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, - showCompletionDivider: - timelineEntry.message.role === "assistant" && - input.completionDividerBeforeEntryId === timelineEntry.id, + showCompletionDivider, + completionSummary: showCompletionDivider ? (input.completionSummary ?? null) : null, showAssistantCopyButton: timelineEntry.message.role === "assistant" && terminalAssistantMessageIds.has(timelineEntry.message.id), + assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress, assistantTurnDiffSummary: timelineEntry.message.role === "assistant" ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) @@ -232,7 +247,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.message === bm.message && a.durationStart === bm.durationStart && a.showCompletionDivider === bm.showCompletionDivider && + a.completionSummary === bm.completionSummary && a.showAssistantCopyButton === bm.showAssistantCopyButton && + a.assistantCopyStreaming === bm.assistantCopyStreaming && a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && a.revertTurnCount === bm.revertTurnCount ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 48fc5ef41e9..ff2f8ce1379 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -89,11 +89,8 @@ interface TimelineRowSharedState { } interface TimelineRowActivityState { - activeTurnInProgress: boolean; - activeTurnId: TurnId | null; isWorking: boolean; isRevertingCheckpoint: boolean; - completionSummary: string | null; } const TimelineRowCtx = createContext(null!); @@ -164,7 +161,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ deriveMessagesTimelineRows({ timelineEntries, completionDividerBeforeEntryId, + completionSummary, isWorking, + activeTurnInProgress, + activeTurnId: activeTurnId ?? null, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, @@ -172,7 +172,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [ timelineEntries, completionDividerBeforeEntryId, + completionSummary, isWorking, + activeTurnInProgress, + activeTurnId, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, @@ -233,13 +236,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const activityState = useMemo( () => ({ - activeTurnInProgress, - activeTurnId: activeTurnId ?? null, isWorking, isRevertingCheckpoint, - completionSummary, }), - [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking], + [isRevertingCheckpoint, isWorking], ); // Stable renderItem — no closure deps. Row components read shared state @@ -412,7 +412,9 @@ function AssistantTimelineRow({ row }: { row: Extract - {row.showCompletionDivider && } + {row.showCompletionDivider && ( + + )}
- {activity.completionSummary ? `Response • ${activity.completionSummary}` : "Response"} + {completionSummary ? `Response • ${completionSummary}` : "Response"}
@@ -464,15 +464,10 @@ function AssistantCompletionDivider() { } function AssistantCopyButton({ row }: { row: Extract }) { - const activity = use(TimelineRowActivityCtx); - const assistantTurnStillInProgress = - activity.activeTurnInProgress && - activity.activeTurnId !== null && - row.message.turnId === activity.activeTurnId; const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, - streaming: row.message.streaming || assistantTurnStillInProgress, + streaming: row.assistantCopyStreaming, }); if (!assistantCopyState.visible) {