| author | |
| committer | |
| log | a41f4895ce06dadfc1359867344cc4607f944796 |
| tree | a89125faef80a62537505b3e517f70e5c0a6bce7 |
| parent | d15909af15602622c6bd1d10aa2e3e165117a1c8 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>3 files changed, 87 insertions(+), 22 deletions(-)
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+53| ... | @@ -268,6 +268,59 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -268,6 +268,59 @@ describe("deriveMessagesTimelineRows", () => { |
| 268 | expect(assistantRows[1]?.showCompletionDivider).toBe(true); | 268 | expect(assistantRows[1]?.showCompletionDivider).toBe(true); |
| 269 | }); | 269 | }); |
| 270 | 270 | ||
| 271 | it("marks only the active assistant turn as streaming for copy controls", () => { | ||
| 272 | const rows = deriveMessagesTimelineRows({ | ||
| 273 | timelineEntries: [ | ||
| 274 | { | ||
| 275 | id: "assistant-one-entry", | ||
| 276 | kind: "message", | ||
| 277 | createdAt: "2026-01-01T00:00:10Z", | ||
| 278 | message: { | ||
| 279 | id: "assistant-one" as never, | ||
| 280 | role: "assistant", | ||
| 281 | text: "Earlier response.", | ||
| 282 | turnId: "turn-1" as never, | ||
| 283 | createdAt: "2026-01-01T00:00:10Z", | ||
| 284 | completedAt: "2026-01-01T00:00:11Z", | ||
| 285 | streaming: false, | ||
| 286 | }, | ||
| 287 | }, | ||
| 288 | { | ||
| 289 | id: "assistant-two-entry", | ||
| 290 | kind: "message", | ||
| 291 | createdAt: "2026-01-01T00:00:20Z", | ||
| 292 | message: { | ||
| 293 | id: "assistant-two" as never, | ||
| 294 | role: "assistant", | ||
| 295 | text: "Active response.", | ||
| 296 | turnId: "turn-2" as never, | ||
| 297 | createdAt: "2026-01-01T00:00:20Z", | ||
| 298 | completedAt: "2026-01-01T00:00:30Z", | ||
| 299 | streaming: false, | ||
| 300 | }, | ||
| 301 | }, | ||
| 302 | ], | ||
| 303 | completionDividerBeforeEntryId: "assistant-two-entry", | ||
| 304 | completionSummary: "done", | ||
| 305 | isWorking: false, | ||
| 306 | activeTurnInProgress: true, | ||
| 307 | activeTurnId: "turn-2" as never, | ||
| 308 | activeTurnStartedAt: null, | ||
| 309 | turnDiffSummaryByAssistantMessageId: new Map(), | ||
| 310 | revertTurnCountByUserMessageId: new Map(), | ||
| 311 | }); | ||
| 312 | |||
| 313 | const assistantRows = rows.filter( | ||
| 314 | (row): row is Extract<(typeof rows)[number], { kind: "message" }> => | ||
| 315 | row.kind === "message" && row.message.role === "assistant", | ||
| 316 | ); | ||
| 317 | |||
| 318 | expect(assistantRows[0]?.assistantCopyStreaming).toBe(false); | ||
| 319 | expect(assistantRows[0]?.completionSummary).toBeNull(); | ||
| 320 | expect(assistantRows[1]?.assistantCopyStreaming).toBe(true); | ||
| 321 | expect(assistantRows[1]?.completionSummary).toBe("done"); | ||
| 322 | }); | ||
| 323 | |||
| 271 | it("projects assistant diff summaries and user revert counts onto the affected rows", () => { | 324 | it("projects assistant diff summaries and user revert counts onto the affected rows", () => { |
| 272 | const assistantTurnDiffSummary = { | 325 | const assistantTurnDiffSummary = { |
| 273 | turnId: "turn-1" as never, | 326 | turnId: "turn-1" as never, |
apps/web/src/components/chat/MessagesTimeline.logic.ts+21-4| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import * as Equal from "effect/Equal"; | 1 | import * as Equal from "effect/Equal"; |
| 2 | import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; | 2 | import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; |
| 3 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; | 3 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; |
| 4 | import { type MessageId } from "@t3tools/contracts"; | 4 | import { type MessageId, type TurnId } from "@t3tools/contracts"; |
| 5 | 5 | ||
| 6 | export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | 6 | export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 7 | 7 | ||
| ... | @@ -26,7 +26,9 @@ export type MessagesTimelineRow = | ... | @@ -26,7 +26,9 @@ export type MessagesTimelineRow = |
| 26 | message: ChatMessage; | 26 | message: ChatMessage; |
| 27 | durationStart: string; | 27 | durationStart: string; |
| 28 | showCompletionDivider: boolean; | 28 | showCompletionDivider: boolean; |
| 29 | completionSummary: string | null; | ||
| 29 | showAssistantCopyButton: boolean; | 30 | showAssistantCopyButton: boolean; |
| 31 | assistantCopyStreaming: boolean; | ||
| 30 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; | 32 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; |
| 31 | revertTurnCount?: number | undefined; | 33 | revertTurnCount?: number | undefined; |
| 32 | } | 34 | } |
| ... | @@ -111,7 +113,10 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli | ... | @@ -111,7 +113,10 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli |
| 111 | export function deriveMessagesTimelineRows(input: { | 113 | export function deriveMessagesTimelineRows(input: { |
| 112 | timelineEntries: ReadonlyArray<TimelineEntry>; | 114 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 113 | completionDividerBeforeEntryId: string | null; | 115 | completionDividerBeforeEntryId: string | null; |
| 116 | completionSummary?: string | null; | ||
| 114 | isWorking: boolean; | 117 | isWorking: boolean; |
| 118 | activeTurnInProgress?: boolean; | ||
| 119 | activeTurnId?: TurnId | null; | ||
| 115 | activeTurnStartedAt: string | null; | 120 | activeTurnStartedAt: string | null; |
| 116 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; | 121 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; |
| 117 | revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>; | 122 | revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>; |
| ... | @@ -157,6 +162,16 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -157,6 +162,16 @@ export function deriveMessagesTimelineRows(input: { |
| 157 | continue; | 162 | continue; |
| 158 | } | 163 | } |
| 159 | 164 | ||
| 165 | const assistantTurnStillInProgress = | ||
| 166 | timelineEntry.message.role === "assistant" && | ||
| 167 | input.activeTurnInProgress === true && | ||
| 168 | input.activeTurnId != null && | ||
| 169 | timelineEntry.message.turnId === input.activeTurnId; | ||
| 170 | |||
| 171 | const showCompletionDivider = | ||
| 172 | timelineEntry.message.role === "assistant" && | ||
| 173 | input.completionDividerBeforeEntryId === timelineEntry.id; | ||
| 174 | |||
| 160 | nextRows.push({ | 175 | nextRows.push({ |
| 161 | kind: "message", | 176 | kind: "message", |
| 162 | id: timelineEntry.id, | 177 | id: timelineEntry.id, |
| ... | @@ -164,12 +179,12 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -164,12 +179,12 @@ export function deriveMessagesTimelineRows(input: { |
| 164 | message: timelineEntry.message, | 179 | message: timelineEntry.message, |
| 165 | durationStart: | 180 | durationStart: |
| 166 | durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, | 181 | durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, |
| 167 | showCompletionDivider: | 182 | showCompletionDivider, |
| 168 | timelineEntry.message.role === "assistant" && | 183 | completionSummary: showCompletionDivider ? (input.completionSummary ?? null) : null, |
| 169 | input.completionDividerBeforeEntryId === timelineEntry.id, | ||
| 170 | showAssistantCopyButton: | 184 | showAssistantCopyButton: |
| 171 | timelineEntry.message.role === "assistant" && | 185 | timelineEntry.message.role === "assistant" && |
| 172 | terminalAssistantMessageIds.has(timelineEntry.message.id), | 186 | terminalAssistantMessageIds.has(timelineEntry.message.id), |
| 187 | assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress, | ||
| 173 | assistantTurnDiffSummary: | 188 | assistantTurnDiffSummary: |
| 174 | timelineEntry.message.role === "assistant" | 189 | timelineEntry.message.role === "assistant" |
| 175 | ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) | 190 | ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) |
| ... | @@ -232,7 +247,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -232,7 +247,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 232 | a.message === bm.message && | 247 | a.message === bm.message && |
| 233 | a.durationStart === bm.durationStart && | 248 | a.durationStart === bm.durationStart && |
| 234 | a.showCompletionDivider === bm.showCompletionDivider && | 249 | a.showCompletionDivider === bm.showCompletionDivider && |
| 250 | a.completionSummary === bm.completionSummary && | ||
| 235 | a.showAssistantCopyButton === bm.showAssistantCopyButton && | 251 | a.showAssistantCopyButton === bm.showAssistantCopyButton && |
| 252 | a.assistantCopyStreaming === bm.assistantCopyStreaming && | ||
| 236 | a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && | 253 | a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && |
| 237 | a.revertTurnCount === bm.revertTurnCount | 254 | a.revertTurnCount === bm.revertTurnCount |
| 238 | ); | 255 | ); |
apps/web/src/components/chat/MessagesTimeline.tsx+13-18| ... | @@ -89,11 +89,8 @@ interface TimelineRowSharedState { | ... | @@ -89,11 +89,8 @@ interface TimelineRowSharedState { |
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | interface TimelineRowActivityState { | 91 | interface TimelineRowActivityState { |
| 92 | activeTurnInProgress: boolean; | ||
| 93 | activeTurnId: TurnId | null; | ||
| 94 | isWorking: boolean; | 92 | isWorking: boolean; |
| 95 | isRevertingCheckpoint: boolean; | 93 | isRevertingCheckpoint: boolean; |
| 96 | completionSummary: string | null; | ||
| 97 | } | 94 | } |
| 98 | 95 | ||
| 99 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); | 96 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| ... | @@ -164,7 +161,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -164,7 +161,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 164 | deriveMessagesTimelineRows({ | 161 | deriveMessagesTimelineRows({ |
| 165 | timelineEntries, | 162 | timelineEntries, |
| 166 | completionDividerBeforeEntryId, | 163 | completionDividerBeforeEntryId, |
| 164 | completionSummary, | ||
| 167 | isWorking, | 165 | isWorking, |
| 166 | activeTurnInProgress, | ||
| 167 | activeTurnId: activeTurnId ?? null, | ||
| 168 | activeTurnStartedAt, | 168 | activeTurnStartedAt, |
| 169 | turnDiffSummaryByAssistantMessageId, | 169 | turnDiffSummaryByAssistantMessageId, |
| 170 | revertTurnCountByUserMessageId, | 170 | revertTurnCountByUserMessageId, |
| ... | @@ -172,7 +172,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -172,7 +172,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 172 | [ | 172 | [ |
| 173 | timelineEntries, | 173 | timelineEntries, |
| 174 | completionDividerBeforeEntryId, | 174 | completionDividerBeforeEntryId, |
| 175 | completionSummary, | ||
| 175 | isWorking, | 176 | isWorking, |
| 177 | activeTurnInProgress, | ||
| 178 | activeTurnId, | ||
| 176 | activeTurnStartedAt, | 179 | activeTurnStartedAt, |
| 177 | turnDiffSummaryByAssistantMessageId, | 180 | turnDiffSummaryByAssistantMessageId, |
| 178 | revertTurnCountByUserMessageId, | 181 | revertTurnCountByUserMessageId, |
| ... | @@ -233,13 +236,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -233,13 +236,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 233 | ); | 236 | ); |
| 234 | const activityState = useMemo<TimelineRowActivityState>( | 237 | const activityState = useMemo<TimelineRowActivityState>( |
| 235 | () => ({ | 238 | () => ({ |
| 236 | activeTurnInProgress, | ||
| 237 | activeTurnId: activeTurnId ?? null, | ||
| 238 | isWorking, | 239 | isWorking, |
| 239 | isRevertingCheckpoint, | 240 | isRevertingCheckpoint, |
| 240 | completionSummary, | ||
| 241 | }), | 241 | }), |
| 242 | [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking], | 242 | [isRevertingCheckpoint, isWorking], |
| 243 | ); | 243 | ); |
| 244 | 244 | ||
| 245 | // Stable renderItem — no closure deps. Row components read shared state | 245 | // Stable renderItem — no closure deps. Row components read shared state |
| ... | @@ -412,7 +412,9 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess | ... | @@ -412,7 +412,9 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 412 | 412 | ||
| 413 | return ( | 413 | return ( |
| 414 | <> | 414 | <> |
| 415 | {row.showCompletionDivider && <AssistantCompletionDivider />} | 415 | {row.showCompletionDivider && ( |
| 416 | <AssistantCompletionDivider completionSummary={row.completionSummary} /> | ||
| 417 | )} | ||
| 416 | <div className="min-w-0 px-1 py-0.5"> | 418 | <div className="min-w-0 px-1 py-0.5"> |
| 417 | <ChatMarkdown | 419 | <ChatMarkdown |
| 418 | text={messageText} | 420 | text={messageText} |
| ... | @@ -449,14 +451,12 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess | ... | @@ -449,14 +451,12 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 449 | ); | 451 | ); |
| 450 | } | 452 | } |
| 451 | 453 | ||
| 452 | function AssistantCompletionDivider() { | 454 | function AssistantCompletionDivider({ completionSummary }: { completionSummary: string | null }) { |
| 453 | const activity = use(TimelineRowActivityCtx); | ||
| 454 | |||
| 455 | return ( | 455 | return ( |
| 456 | <div className="my-3 flex items-center gap-3"> | 456 | <div className="my-3 flex items-center gap-3"> |
| 457 | <span className="h-px flex-1 bg-border" /> | 457 | <span className="h-px flex-1 bg-border" /> |
| 458 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | 458 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> |
| 459 | {activity.completionSummary ? `Response • ${activity.completionSummary}` : "Response"} | 459 | {completionSummary ? `Response • ${completionSummary}` : "Response"} |
| 460 | </span> | 460 | </span> |
| 461 | <span className="h-px flex-1 bg-border" /> | 461 | <span className="h-px flex-1 bg-border" /> |
| 462 | </div> | 462 | </div> |
| ... | @@ -464,15 +464,10 @@ function AssistantCompletionDivider() { | ... | @@ -464,15 +464,10 @@ function AssistantCompletionDivider() { |
| 464 | } | 464 | } |
| 465 | 465 | ||
| 466 | function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | 466 | function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 467 | const activity = use(TimelineRowActivityCtx); | ||
| 468 | const assistantTurnStillInProgress = | ||
| 469 | activity.activeTurnInProgress && | ||
| 470 | activity.activeTurnId !== null && | ||
| 471 | row.message.turnId === activity.activeTurnId; | ||
| 472 | const assistantCopyState = resolveAssistantMessageCopyState({ | 467 | const assistantCopyState = resolveAssistantMessageCopyState({ |
| 473 | text: row.message.text ?? null, | 468 | text: row.message.text ?? null, |
| 474 | showCopyButton: row.showAssistantCopyButton, | 469 | showCopyButton: row.showAssistantCopyButton, |
| 475 | streaming: row.message.streaming || assistantTurnStillInProgress, | 470 | streaming: row.assistantCopyStreaming, |
| 476 | }); | 471 | }); |
| 477 | 472 | ||
| 478 | if (!assistantCopyState.visible) { | 473 | if (!assistantCopyState.visible) { |