| 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 | 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 | 324 | it("projects assistant diff summaries and user revert counts onto the affected rows", () => { |
| 272 | 325 | const assistantTurnDiffSummary = { |
| 273 | 326 | turnId: "turn-1" as never, |
apps/web/src/components/chat/MessagesTimeline.logic.ts+21-4| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | 2 | import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; |
| 3 | 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 | 6 | export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 7 | 7 | |
| ... | ... | @@ -26,7 +26,9 @@ export type MessagesTimelineRow = |
| 26 | 26 | message: ChatMessage; |
| 27 | 27 | durationStart: string; |
| 28 | 28 | showCompletionDivider: boolean; |
| 29 | completionSummary: string | null; | |
| 29 | 30 | showAssistantCopyButton: boolean; |
| 31 | assistantCopyStreaming: boolean; | |
| 30 | 32 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; |
| 31 | 33 | revertTurnCount?: number | undefined; |
| 32 | 34 | } |
| ... | ... | @@ -111,7 +113,10 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli |
| 111 | 113 | export function deriveMessagesTimelineRows(input: { |
| 112 | 114 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 113 | 115 | completionDividerBeforeEntryId: string | null; |
| 116 | completionSummary?: string | null; | |
| 114 | 117 | isWorking: boolean; |
| 118 | activeTurnInProgress?: boolean; | |
| 119 | activeTurnId?: TurnId | null; | |
| 115 | 120 | activeTurnStartedAt: string | null; |
| 116 | 121 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; |
| 117 | 122 | revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>; |
| ... | ... | @@ -157,6 +162,16 @@ export function deriveMessagesTimelineRows(input: { |
| 157 | 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 | 175 | nextRows.push({ |
| 161 | 176 | kind: "message", |
| 162 | 177 | id: timelineEntry.id, |
| ... | ... | @@ -164,12 +179,12 @@ export function deriveMessagesTimelineRows(input: { |
| 164 | 179 | message: timelineEntry.message, |
| 165 | 180 | durationStart: |
| 166 | 181 | durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, |
| 167 | showCompletionDivider: | |
| 168 | timelineEntry.message.role === "assistant" && | |
| 169 | input.completionDividerBeforeEntryId === timelineEntry.id, | |
| 182 | showCompletionDivider, | |
| 183 | completionSummary: showCompletionDivider ? (input.completionSummary ?? null) : null, | |
| 170 | 184 | showAssistantCopyButton: |
| 171 | 185 | timelineEntry.message.role === "assistant" && |
| 172 | 186 | terminalAssistantMessageIds.has(timelineEntry.message.id), |
| 187 | assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress, | |
| 173 | 188 | assistantTurnDiffSummary: |
| 174 | 189 | timelineEntry.message.role === "assistant" |
| 175 | 190 | ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) |
| ... | ... | @@ -232,7 +247,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 232 | 247 | a.message === bm.message && |
| 233 | 248 | a.durationStart === bm.durationStart && |
| 234 | 249 | a.showCompletionDivider === bm.showCompletionDivider && |
| 250 | a.completionSummary === bm.completionSummary && | |
| 235 | 251 | a.showAssistantCopyButton === bm.showAssistantCopyButton && |
| 252 | a.assistantCopyStreaming === bm.assistantCopyStreaming && | |
| 236 | 253 | a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && |
| 237 | 254 | a.revertTurnCount === bm.revertTurnCount |
| 238 | 255 | ); |
apps/web/src/components/chat/MessagesTimeline.tsx+13-18| ... | ... | @@ -89,11 +89,8 @@ interface TimelineRowSharedState { |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | 91 | interface TimelineRowActivityState { |
| 92 | activeTurnInProgress: boolean; | |
| 93 | activeTurnId: TurnId | null; | |
| 94 | 92 | isWorking: boolean; |
| 95 | 93 | isRevertingCheckpoint: boolean; |
| 96 | completionSummary: string | null; | |
| 97 | 94 | } |
| 98 | 95 | |
| 99 | 96 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| ... | ... | @@ -164,7 +161,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 164 | 161 | deriveMessagesTimelineRows({ |
| 165 | 162 | timelineEntries, |
| 166 | 163 | completionDividerBeforeEntryId, |
| 164 | completionSummary, | |
| 167 | 165 | isWorking, |
| 166 | activeTurnInProgress, | |
| 167 | activeTurnId: activeTurnId ?? null, | |
| 168 | 168 | activeTurnStartedAt, |
| 169 | 169 | turnDiffSummaryByAssistantMessageId, |
| 170 | 170 | revertTurnCountByUserMessageId, |
| ... | ... | @@ -172,7 +172,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 172 | 172 | [ |
| 173 | 173 | timelineEntries, |
| 174 | 174 | completionDividerBeforeEntryId, |
| 175 | completionSummary, | |
| 175 | 176 | isWorking, |
| 177 | activeTurnInProgress, | |
| 178 | activeTurnId, | |
| 176 | 179 | activeTurnStartedAt, |
| 177 | 180 | turnDiffSummaryByAssistantMessageId, |
| 178 | 181 | revertTurnCountByUserMessageId, |
| ... | ... | @@ -233,13 +236,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 233 | 236 | ); |
| 234 | 237 | const activityState = useMemo<TimelineRowActivityState>( |
| 235 | 238 | () => ({ |
| 236 | activeTurnInProgress, | |
| 237 | activeTurnId: activeTurnId ?? null, | |
| 238 | 239 | isWorking, |
| 239 | 240 | isRevertingCheckpoint, |
| 240 | completionSummary, | |
| 241 | 241 | }), |
| 242 | [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking], | |
| 242 | [isRevertingCheckpoint, isWorking], | |
| 243 | 243 | ); |
| 244 | 244 | |
| 245 | 245 | // Stable renderItem — no closure deps. Row components read shared state |
| ... | ... | @@ -412,7 +412,9 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 412 | 412 | |
| 413 | 413 | return ( |
| 414 | 414 | <> |
| 415 | {row.showCompletionDivider && <AssistantCompletionDivider />} | |
| 415 | {row.showCompletionDivider && ( | |
| 416 | <AssistantCompletionDivider completionSummary={row.completionSummary} /> | |
| 417 | )} | |
| 416 | 418 | <div className="min-w-0 px-1 py-0.5"> |
| 417 | 419 | <ChatMarkdown |
| 418 | 420 | text={messageText} |
| ... | ... | @@ -449,14 +451,12 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 449 | 451 | ); |
| 450 | 452 | } |
| 451 | 453 | |
| 452 | function AssistantCompletionDivider() { | |
| 453 | const activity = use(TimelineRowActivityCtx); | |
| 454 | ||
| 454 | function AssistantCompletionDivider({ completionSummary }: { completionSummary: string | null }) { | |
| 455 | 455 | return ( |
| 456 | 456 | <div className="my-3 flex items-center gap-3"> |
| 457 | 457 | <span className="h-px flex-1 bg-border" /> |
| 458 | 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 | 460 | </span> |
| 461 | 461 | <span className="h-px flex-1 bg-border" /> |
| 462 | 462 | </div> |
| ... | ... | @@ -464,15 +464,10 @@ function AssistantCompletionDivider() { |
| 464 | 464 | } |
| 465 | 465 | |
| 466 | 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 | 467 | const assistantCopyState = resolveAssistantMessageCopyState({ |
| 473 | 468 | text: row.message.text ?? null, |
| 474 | 469 | showCopyButton: row.showAssistantCopyButton, |
| 475 | streaming: row.message.streaming || assistantTurnStillInProgress, | |
| 470 | streaming: row.assistantCopyStreaming, | |
| 476 | 471 | }); |
| 477 | 472 | |
| 478 | 473 | if (!assistantCopyState.visible) { |