| author | |
| committer | |
| log | 590a579f2e9292ce314c69e459e19620004578fe |
| tree | bfd334c0383510f569cb97d4442f03ff84327eb5 |
| parent | 0222aa255d11babd242dbe5ed0947e5fc5eaefee |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>4 files changed, 118 insertions(+), 60 deletions(-)
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+57-38| ... | ... | @@ -826,7 +826,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 826 | 826 | "assistant-final-entry", |
| 827 | 827 | "user-followup-entry", |
| 828 | 828 | "working-indicator-row", |
| 829 | "thinking-indicator-row", | |
| 829 | "live-activity-row", | |
| 830 | 830 | ]); |
| 831 | 831 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); |
| 832 | 832 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); |
| ... | ... | @@ -879,11 +879,11 @@ describe("deriveMessagesTimelineRows", () => { |
| 879 | 879 | expect(rows.map((row) => row.id)).toEqual([ |
| 880 | 880 | "working-indicator-row", |
| 881 | 881 | "assistant-thought-entry", |
| 882 | "work-live:work-entry-1", | |
| 882 | "live-activity-row", | |
| 883 | 883 | ]); |
| 884 | 884 | }); |
| 885 | 885 | |
| 886 | it("keeps adjacent active tool calls in one replacing row", () => { | |
| 886 | it("keeps an actually running tool in the shared activity row", () => { | |
| 887 | 887 | const rows = deriveMessagesTimelineRows({ |
| 888 | 888 | timelineEntries: [ |
| 889 | 889 | { |
| ... | ... | @@ -948,6 +948,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 948 | 948 | expect(rows.some((row) => row.kind === "thinking")).toBe(false); |
| 949 | 949 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ |
| 950 | 950 | entry: { id: "running-command" }, |
| 951 | active: true, | |
| 951 | 952 | groupedEntries: [ |
| 952 | 953 | { id: "running-command" }, |
| 953 | 954 | { id: "completed-edit" }, |
| ... | ... | @@ -1196,40 +1197,58 @@ describe("deriveMessagesTimelineRows", () => { |
| 1196 | 1197 | ]); |
| 1197 | 1198 | }); |
| 1198 | 1199 | |
| 1199 | it("shows thinking after the latest tool call completes while the turn is running", () => { | |
| 1200 | const rows = deriveMessagesTimelineRows({ | |
| 1201 | timelineEntries: [ | |
| 1202 | { | |
| 1203 | id: "latest-command-entry", | |
| 1204 | kind: "work", | |
| 1205 | createdAt: "2026-01-01T00:00:05Z", | |
| 1206 | entry: { | |
| 1207 | id: "latest-command", | |
| 1208 | createdAt: "2026-01-01T00:00:05Z", | |
| 1209 | turnId: "turn-1" as never, | |
| 1210 | label: "Ran rg", | |
| 1211 | command: "rg toolCall", | |
| 1212 | requestKind: "command", | |
| 1213 | tone: "tool" as const, | |
| 1214 | toolLifecycleStatus: "completed" as const, | |
| 1215 | }, | |
| 1200 | it("reuses one activity row for initial thinking and the latest tool", () => { | |
| 1201 | const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) => | |
| 1202 | deriveMessagesTimelineRows({ | |
| 1203 | timelineEntries: | |
| 1204 | toolLifecycleStatus === null | |
| 1205 | ? [] | |
| 1206 | : [ | |
| 1207 | { | |
| 1208 | id: "latest-command-entry", | |
| 1209 | kind: "work", | |
| 1210 | createdAt: "2026-01-01T00:00:05Z", | |
| 1211 | entry: { | |
| 1212 | id: "latest-command", | |
| 1213 | createdAt: "2026-01-01T00:00:05Z", | |
| 1214 | turnId: "turn-1" as never, | |
| 1215 | label: toolLifecycleStatus === "inProgress" ? "Running rg" : "Ran rg", | |
| 1216 | command: "rg toolCall", | |
| 1217 | requestKind: "command", | |
| 1218 | tone: "tool" as const, | |
| 1219 | toolLifecycleStatus, | |
| 1220 | }, | |
| 1221 | }, | |
| 1222 | ], | |
| 1223 | latestTurn: { | |
| 1224 | turnId: "turn-1" as never, | |
| 1225 | state: "running", | |
| 1226 | startedAt: "2026-01-01T00:00:00Z", | |
| 1227 | completedAt: null, | |
| 1216 | 1228 | }, |
| 1217 | ], | |
| 1218 | latestTurn: { | |
| 1219 | turnId: "turn-1" as never, | |
| 1220 | state: "running", | |
| 1221 | startedAt: "2026-01-01T00:00:00Z", | |
| 1222 | completedAt: null, | |
| 1223 | }, | |
| 1224 | isWorking: true, | |
| 1225 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 1226 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 1227 | revertTurnCountByUserMessageId: new Map(), | |
| 1228 | }); | |
| 1229 | isWorking: true, | |
| 1230 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 1231 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 1232 | revertTurnCountByUserMessageId: new Map(), | |
| 1233 | }); | |
| 1229 | 1234 | |
| 1230 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); | |
| 1231 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); | |
| 1232 | expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); | |
| 1235 | const initialRows = deriveRows(null); | |
| 1236 | const runningRows = deriveRows("inProgress"); | |
| 1237 | const completedRows = deriveRows("completed"); | |
| 1238 | const declinedRows = deriveRows("declined"); | |
| 1239 | const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); | |
| 1240 | const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); | |
| 1241 | const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row"); | |
| 1242 | ||
| 1243 | expect(initialActivityRow).toMatchObject({ kind: "thinking" }); | |
| 1244 | expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); | |
| 1245 | expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); | |
| 1246 | expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); | |
| 1247 | expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); | |
| 1248 | expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); | |
| 1249 | expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); | |
| 1250 | expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); | |
| 1251 | expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); | |
| 1233 | 1252 | }); |
| 1234 | 1253 | |
| 1235 | 1254 | it("does not fold the session's running turn when latestTurn regresses", () => { |
| ... | ... | @@ -1290,7 +1309,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1290 | 1309 | expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ |
| 1291 | 1310 | "turn-1", |
| 1292 | 1311 | ]); |
| 1293 | expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); | |
| 1312 | expect(rows.map((row) => row.id)).toContain("live-activity-row"); | |
| 1294 | 1313 | }); |
| 1295 | 1314 | |
| 1296 | 1315 | it("only shows assistant metadata on the terminal assistant message", () => { |
| ... | ... | @@ -1587,8 +1606,8 @@ describe("computeStableMessagesTimelineRows", () => { |
| 1587 | 1606 | initial, |
| 1588 | 1607 | ); |
| 1589 | 1608 | |
| 1590 | const initialThinking = initial.byId.get("thinking-indicator-row"); | |
| 1591 | const updatedThinking = updated.byId.get("thinking-indicator-row"); | |
| 1609 | const initialThinking = initial.byId.get("live-activity-row"); | |
| 1610 | const updatedThinking = updated.byId.get("live-activity-row"); | |
| 1592 | 1611 | expect(initialThinking).toMatchObject({ kind: "thinking" }); |
| 1593 | 1612 | expect(updatedThinking).toBe(initialThinking); |
| 1594 | 1613 | expect(updated.result.at(-1)).toBe(updatedThinking); |
apps/web/src/components/chat/MessagesTimeline.logic.ts+18-10| ... | ... | @@ -15,6 +15,7 @@ export { |
| 15 | 15 | import { |
| 16 | 16 | formatDuration, |
| 17 | 17 | workEntryDisplayIndicatesToolFailure, |
| 18 | workEntryIndicatesToolSuccess, | |
| 18 | 19 | workEntryIndicatesToolNeutralStatus, |
| 19 | 20 | workLogEntryIsToolLike, |
| 20 | 21 | type TimelineEntry, |
| ... | ... | @@ -188,6 +189,8 @@ export type TimelineLatestTurn = Pick< |
| 188 | 189 | "turnId" | "state" | "startedAt" | "completedAt" |
| 189 | 190 | >; |
| 190 | 191 | |
| 192 | const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; | |
| 193 | ||
| 191 | 194 | export type MessagesTimelineRow = |
| 192 | 195 | | { |
| 193 | 196 | kind: "work"; |
| ... | ... | @@ -598,21 +601,26 @@ export function deriveMessagesTimelineRows(input: { |
| 598 | 601 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => |
| 599 | 602 | workEntryIsActiveTurnActivity(entry.entry), |
| 600 | 603 | ); |
| 601 | const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; | |
| 604 | const latestToolKeepsActivityLive = | |
| 605 | latestRunningToolEntry !== undefined || | |
| 606 | (latestVisibleToolEntry !== undefined && | |
| 607 | workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); | |
| 602 | 608 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; |
| 603 | 609 | const activeWorkRow = |
| 604 | activeWorkAnchor && displayedToolEntry | |
| 610 | activeWorkAnchor && latestVisibleToolEntry | |
| 605 | 611 | ? (() => { |
| 606 | 612 | const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); |
| 607 | 613 | return { |
| 608 | 614 | kind: "work-live" as const, |
| 609 | id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, | |
| 615 | id: latestToolKeepsActivityLive | |
| 616 | ? LIVE_ACTIVITY_ROW_ID | |
| 617 | : `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, | |
| 610 | 618 | createdAt: activeWorkAnchor.createdAt, |
| 611 | entry: displayedToolEntry.entry, | |
| 619 | entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, | |
| 612 | 620 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), |
| 613 | 621 | groupId, |
| 614 | 622 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, |
| 615 | active: latestRunningToolEntry !== undefined, | |
| 623 | active: latestToolKeepsActivityLive, | |
| 616 | 624 | }; |
| 617 | 625 | })() |
| 618 | 626 | : null; |
| ... | ... | @@ -626,11 +634,11 @@ export function deriveMessagesTimelineRows(input: { |
| 626 | 634 | createdAt: input.activeTurnStartedAt, |
| 627 | 635 | }); |
| 628 | 636 | }; |
| 629 | let hasLiveWorkRow = false; | |
| 637 | let hasActivityRow = false; | |
| 630 | 638 | const appendActiveWorkRows = () => { |
| 631 | 639 | if (activeWorkRow === null) return; |
| 632 | 640 | nextRows.push(activeWorkRow); |
| 633 | hasLiveWorkRow ||= activeWorkRow.active; | |
| 641 | hasActivityRow ||= activeWorkRow.active; | |
| 634 | 642 | if (!activeWorkRow.expanded) return; |
| 635 | 643 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { |
| 636 | 644 | nextRows.push({ |
| ... | ... | @@ -730,7 +738,7 @@ export function deriveMessagesTimelineRows(input: { |
| 730 | 738 | expanded, |
| 731 | 739 | active: true, |
| 732 | 740 | }); |
| 733 | hasLiveWorkRow = true; | |
| 741 | hasActivityRow = true; | |
| 734 | 742 | if (expanded) { |
| 735 | 743 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
| 736 | 744 | nextRows.push({ |
| ... | ... | @@ -832,10 +840,10 @@ export function deriveMessagesTimelineRows(input: { |
| 832 | 840 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 833 | 841 | appendWorkingRow(); |
| 834 | 842 | } |
| 835 | if (input.isWorking && !hasLiveWorkRow) { | |
| 843 | if (input.isWorking && !hasActivityRow) { | |
| 836 | 844 | nextRows.push({ |
| 837 | 845 | kind: "thinking", |
| 838 | id: "thinking-indicator-row", | |
| 846 | id: LIVE_ACTIVITY_ROW_ID, | |
| 839 | 847 | createdAt: input.activeTurnStartedAt, |
| 840 | 848 | }); |
| 841 | 849 | } |
apps/web/src/components/chat/MessagesTimeline.test.tsx+34-8| ... | ... | @@ -1308,7 +1308,30 @@ describe("MessagesTimeline", () => { |
| 1308 | 1308 | expect(markup).not.toContain("tool call failed"); |
| 1309 | 1309 | }); |
| 1310 | 1310 | |
| 1311 | it("keeps declined command copy visible while thinking continues", () => { | |
| 1311 | it("renders initial thinking as the shared live activity row", () => { | |
| 1312 | const turnId = TurnId.make("turn-live"); | |
| 1313 | const markup = renderToStaticMarkup( | |
| 1314 | <MessagesTimeline | |
| 1315 | {...buildProps()} | |
| 1316 | isWorking | |
| 1317 | activeTurnStartedAt={MESSAGE_CREATED_AT} | |
| 1318 | latestTurn={{ | |
| 1319 | turnId, | |
| 1320 | state: "running", | |
| 1321 | startedAt: MESSAGE_CREATED_AT, | |
| 1322 | completedAt: null, | |
| 1323 | }} | |
| 1324 | runningTurnId={turnId} | |
| 1325 | timelineEntries={[]} | |
| 1326 | />, | |
| 1327 | ); | |
| 1328 | ||
| 1329 | expect(markup).toContain("Thinking"); | |
| 1330 | expect(markup).toContain("lucide-brain"); | |
| 1331 | expect(markup).toContain('data-timeline-row-id="live-activity-row"'); | |
| 1332 | }); | |
| 1333 | ||
| 1334 | it("keeps the completed command in the shared activity row", () => { | |
| 1312 | 1335 | const turnId = TurnId.make("turn-live"); |
| 1313 | 1336 | const markup = renderToStaticMarkup( |
| 1314 | 1337 | <MessagesTimeline |
| ... | ... | @@ -1324,28 +1347,31 @@ describe("MessagesTimeline", () => { |
| 1324 | 1347 | runningTurnId={turnId} |
| 1325 | 1348 | timelineEntries={[ |
| 1326 | 1349 | { |
| 1327 | id: "entry-declined", | |
| 1350 | id: "entry-completed", | |
| 1328 | 1351 | kind: "work", |
| 1329 | 1352 | createdAt: MESSAGE_CREATED_AT, |
| 1330 | 1353 | entry: { |
| 1331 | id: "work-declined", | |
| 1354 | id: "work-completed", | |
| 1332 | 1355 | createdAt: MESSAGE_CREATED_AT, |
| 1333 | 1356 | turnId, |
| 1334 | toolCallId: "call-declined", | |
| 1357 | toolCallId: "call-completed", | |
| 1335 | 1358 | label: "Run lint", |
| 1336 | 1359 | tone: "tool", |
| 1337 | 1360 | itemType: "command_execution", |
| 1338 | 1361 | command: "pnpm lint", |
| 1339 | toolLifecycleStatus: "declined", | |
| 1362 | toolLifecycleStatus: "completed", | |
| 1340 | 1363 | }, |
| 1341 | 1364 | }, |
| 1342 | 1365 | ]} |
| 1343 | 1366 | />, |
| 1344 | 1367 | ); |
| 1345 | 1368 | |
| 1346 | expect(markup).toContain("Declined pnpm"); | |
| 1347 | expect(markup).toContain("Thinking"); | |
| 1348 | expect(markup).toContain("tool call failed"); | |
| 1369 | expect(markup).toContain("Running pnpm"); | |
| 1370 | expect(markup).toContain("lucide-terminal"); | |
| 1371 | expect(markup).toContain("live-activity-focus"); | |
| 1372 | expect(markup).not.toContain("Ran pnpm"); | |
| 1373 | expect(markup).not.toContain("Thinking"); | |
| 1374 | expect(markup).not.toContain('data-timeline-row-kind="thinking"'); | |
| 1349 | 1375 | }); |
| 1350 | 1376 | |
| 1351 | 1377 | it("renders review comment contexts as structured cards instead of raw tags", () => { |
apps/web/src/components/chat/MessagesTimeline.tsx+9-4| ... | ... | @@ -62,6 +62,7 @@ import { |
| 62 | 62 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 63 | 63 | import { |
| 64 | 64 | BotIcon, |
| 65 | BrainIcon, | |
| 65 | 66 | CheckIcon, |
| 66 | 67 | ChevronDownIcon, |
| 67 | 68 | ChevronRightIcon, |
| ... | ... | @@ -985,14 +986,15 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 985 | 986 | : "pb-0" |
| 986 | 987 | : isExpandedToolGroupHeader |
| 987 | 988 | ? "pb-0" |
| 988 | : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" | |
| 989 | : row.kind === "turn-fold" || row.kind === "working" | |
| 989 | 990 | ? "pb-1.5" |
| 990 | 991 | : (row.kind === "message" && |
| 991 | 992 | row.message.role === "assistant" && |
| 992 | 993 | !row.showAssistantMeta) || |
| 993 | 994 | row.kind === "work" || |
| 994 | 995 | row.kind === "work-live" || |
| 995 | row.kind === "work-toggle" | |
| 996 | row.kind === "work-toggle" || | |
| 997 | row.kind === "thinking" | |
| 996 | 998 | ? "pb-2" |
| 997 | 999 | : "pb-4", |
| 998 | 1000 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, |
| ... | ... | @@ -1364,7 +1366,7 @@ function ThinkingTimelineRow() { |
| 1364 | 1366 | // Reserve the activity row during setup so the handoff keeps the same height. |
| 1365 | 1367 | return ( |
| 1366 | 1368 | <div className="min-h-7"> |
| 1367 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />} | |
| 1369 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />} | |
| 1368 | 1370 | </div> |
| 1369 | 1371 | ); |
| 1370 | 1372 | } |
| ... | ... | @@ -2123,6 +2125,7 @@ function formatWorkingTimerNow(startIso: string): string { |
| 2123 | 2125 | |
| 2124 | 2126 | type WorkEntryIconName = |
| 2125 | 2127 | | "bot" |
| 2128 | | "brain" | |
| 2126 | 2129 | | "check" |
| 2127 | 2130 | | "circle-alert" |
| 2128 | 2131 | | "eye" |
| ... | ... | @@ -2140,6 +2143,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN |
| 2140 | 2143 | switch (name) { |
| 2141 | 2144 | case "bot": |
| 2142 | 2145 | return <BotIcon className={className} aria-hidden />; |
| 2146 | case "brain": | |
| 2147 | return <BrainIcon className={className} aria-hidden />; | |
| 2143 | 2148 | case "check": |
| 2144 | 2149 | return <CheckIcon className={className} aria-hidden />; |
| 2145 | 2150 | case "circle-alert": |
| ... | ... | @@ -2179,7 +2184,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { |
| 2179 | 2184 | } |
| 2180 | 2185 | if (tone === "thinking") { |
| 2181 | 2186 | return { |
| 2182 | iconName: "bot", | |
| 2187 | iconName: "brain", | |
| 2183 | 2188 | className: "text-foreground", |
| 2184 | 2189 | }; |
| 2185 | 2190 | } |