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
53 changes: 53 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 21 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -111,7 +113,10 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli
export function deriveMessagesTimelineRows(input: {
timelineEntries: ReadonlyArray<TimelineEntry>;
completionDividerBeforeEntryId: string | null;
completionSummary?: string | null;
isWorking: boolean;
activeTurnInProgress?: boolean;
activeTurnId?: TurnId | null;
activeTurnStartedAt: string | null;
turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
Expand Down Expand Up @@ -157,19 +162,29 @@ 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,
createdAt: timelineEntry.createdAt,
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)
Expand Down Expand Up @@ -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
);
Expand Down
31 changes: 13 additions & 18 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,8 @@ interface TimelineRowSharedState {
}

interface TimelineRowActivityState {
activeTurnInProgress: boolean;
activeTurnId: TurnId | null;
isWorking: boolean;
isRevertingCheckpoint: boolean;
completionSummary: string | null;
}

const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
Expand Down Expand Up @@ -164,15 +161,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({
deriveMessagesTimelineRows({
timelineEntries,
completionDividerBeforeEntryId,
completionSummary,
isWorking,
activeTurnInProgress,
activeTurnId: activeTurnId ?? null,
activeTurnStartedAt,
turnDiffSummaryByAssistantMessageId,
revertTurnCountByUserMessageId,
}),
[
timelineEntries,
completionDividerBeforeEntryId,
completionSummary,
isWorking,
activeTurnInProgress,
activeTurnId,
activeTurnStartedAt,
turnDiffSummaryByAssistantMessageId,
revertTurnCountByUserMessageId,
Expand Down Expand Up @@ -233,13 +236,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
);
const activityState = useMemo<TimelineRowActivityState>(
() => ({
activeTurnInProgress,
activeTurnId: activeTurnId ?? null,
isWorking,
isRevertingCheckpoint,
completionSummary,
}),
[activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking],
[isRevertingCheckpoint, isWorking],
);

// Stable renderItem — no closure deps. Row components read shared state
Expand Down Expand Up @@ -412,7 +412,9 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess

return (
<>
{row.showCompletionDivider && <AssistantCompletionDivider />}
{row.showCompletionDivider && (
<AssistantCompletionDivider completionSummary={row.completionSummary} />
)}
<div className="min-w-0 px-1 py-0.5">
<ChatMarkdown
text={messageText}
Expand Down Expand Up @@ -449,30 +451,23 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
);
}

function AssistantCompletionDivider() {
const activity = use(TimelineRowActivityCtx);

function AssistantCompletionDivider({ completionSummary }: { completionSummary: string | null }) {
return (
<div className="my-3 flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
{activity.completionSummary ? `Response • ${activity.completionSummary}` : "Response"}
{completionSummary ? `Response • ${completionSummary}` : "Response"}
</span>
<span className="h-px flex-1 bg-border" />
</div>
);
}

function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
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) {
Expand Down
Loading