| author | |
| committer | |
| log | 163d50846bc8989b5858802bb02253254dcee0dd |
| tree | 081095c340b415e68511fe7ceb76ec4d8283a7ef |
| parent | 03542836834d008a097450cf24d0d9c6f965b859 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 59 insertions(+), 106 deletions(-)
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+38-53| ... | ... | @@ -826,7 +826,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 826 | 826 | "assistant-final-entry", |
| 827 | 827 | "user-followup-entry", |
| 828 | 828 | "working-indicator-row", |
| 829 | "live-activity-row", | |
| 829 | "thinking-indicator-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 | "live-activity-row", | |
| 882 | "work-live:work-entry-1", | |
| 883 | 883 | ]); |
| 884 | 884 | }); |
| 885 | 885 | |
| 886 | it("keeps an actually running tool in the shared activity row", () => { | |
| 886 | it("keeps adjacent active tool calls in one replacing row", () => { | |
| 887 | 887 | const rows = deriveMessagesTimelineRows({ |
| 888 | 888 | timelineEntries: [ |
| 889 | 889 | { |
| ... | ... | @@ -948,7 +948,6 @@ 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, | |
| 952 | 951 | groupedEntries: [ |
| 953 | 952 | { id: "running-command" }, |
| 954 | 953 | { id: "completed-edit" }, |
| ... | ... | @@ -1197,54 +1196,40 @@ describe("deriveMessagesTimelineRows", () => { |
| 1197 | 1196 | ]); |
| 1198 | 1197 | }); |
| 1199 | 1198 | |
| 1200 | it("reuses one activity row for initial thinking and the latest tool", () => { | |
| 1201 | const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | 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, | |
| 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 | }, | |
| 1228 | 1216 | }, |
| 1229 | isWorking: true, | |
| 1230 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 1231 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 1232 | revertTurnCountByUserMessageId: new Map(), | |
| 1233 | }); | |
| 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 | }); | |
| 1234 | 1229 | |
| 1235 | const initialRows = deriveRows(null); | |
| 1236 | const runningRows = deriveRows("inProgress"); | |
| 1237 | const completedRows = deriveRows("completed"); | |
| 1238 | const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); | |
| 1239 | const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); | |
| 1240 | const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row"); | |
| 1241 | ||
| 1242 | expect(initialActivityRow).toMatchObject({ kind: "thinking" }); | |
| 1243 | expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); | |
| 1244 | expect(completedActivityRow).toMatchObject({ kind: "work-live", active: false }); | |
| 1245 | expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); | |
| 1246 | expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); | |
| 1247 | expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); | |
| 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" }); | |
| 1248 | 1233 | }); |
| 1249 | 1234 | |
| 1250 | 1235 | it("does not fold the session's running turn when latestTurn regresses", () => { |
| ... | ... | @@ -1305,7 +1290,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1305 | 1290 | expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ |
| 1306 | 1291 | "turn-1", |
| 1307 | 1292 | ]); |
| 1308 | expect(rows.map((row) => row.id)).toContain("live-activity-row"); | |
| 1293 | expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); | |
| 1309 | 1294 | }); |
| 1310 | 1295 | |
| 1311 | 1296 | it("only shows assistant metadata on the terminal assistant message", () => { |
| ... | ... | @@ -1602,8 +1587,8 @@ describe("computeStableMessagesTimelineRows", () => { |
| 1602 | 1587 | initial, |
| 1603 | 1588 | ); |
| 1604 | 1589 | |
| 1605 | const initialThinking = initial.byId.get("live-activity-row"); | |
| 1606 | const updatedThinking = updated.byId.get("live-activity-row"); | |
| 1590 | const initialThinking = initial.byId.get("thinking-indicator-row"); | |
| 1591 | const updatedThinking = updated.byId.get("thinking-indicator-row"); | |
| 1607 | 1592 | expect(initialThinking).toMatchObject({ kind: "thinking" }); |
| 1608 | 1593 | expect(updatedThinking).toBe(initialThinking); |
| 1609 | 1594 | expect(updated.result.at(-1)).toBe(updatedThinking); |
apps/web/src/components/chat/MessagesTimeline.logic.ts+9-10| ... | ... | @@ -188,8 +188,6 @@ export type TimelineLatestTurn = Pick< |
| 188 | 188 | "turnId" | "state" | "startedAt" | "completedAt" |
| 189 | 189 | >; |
| 190 | 190 | |
| 191 | const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; | |
| 192 | ||
| 193 | 191 | export type MessagesTimelineRow = |
| 194 | 192 | | { |
| 195 | 193 | kind: "work"; |
| ... | ... | @@ -600,16 +598,17 @@ export function deriveMessagesTimelineRows(input: { |
| 600 | 598 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => |
| 601 | 599 | workEntryIsActiveTurnActivity(entry.entry), |
| 602 | 600 | ); |
| 601 | const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; | |
| 603 | 602 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; |
| 604 | 603 | const activeWorkRow = |
| 605 | activeWorkAnchor && latestVisibleToolEntry | |
| 604 | activeWorkAnchor && displayedToolEntry | |
| 606 | 605 | ? (() => { |
| 607 | 606 | const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); |
| 608 | 607 | return { |
| 609 | 608 | kind: "work-live" as const, |
| 610 | id: LIVE_ACTIVITY_ROW_ID, | |
| 609 | id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, | |
| 611 | 610 | createdAt: activeWorkAnchor.createdAt, |
| 612 | entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, | |
| 611 | entry: displayedToolEntry.entry, | |
| 613 | 612 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), |
| 614 | 613 | groupId, |
| 615 | 614 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, |
| ... | ... | @@ -627,11 +626,11 @@ export function deriveMessagesTimelineRows(input: { |
| 627 | 626 | createdAt: input.activeTurnStartedAt, |
| 628 | 627 | }); |
| 629 | 628 | }; |
| 630 | let hasActivityRow = false; | |
| 629 | let hasLiveWorkRow = false; | |
| 631 | 630 | const appendActiveWorkRows = () => { |
| 632 | 631 | if (activeWorkRow === null) return; |
| 633 | 632 | nextRows.push(activeWorkRow); |
| 634 | hasActivityRow = true; | |
| 633 | hasLiveWorkRow ||= activeWorkRow.active; | |
| 635 | 634 | if (!activeWorkRow.expanded) return; |
| 636 | 635 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { |
| 637 | 636 | nextRows.push({ |
| ... | ... | @@ -731,7 +730,7 @@ export function deriveMessagesTimelineRows(input: { |
| 731 | 730 | expanded, |
| 732 | 731 | active: true, |
| 733 | 732 | }); |
| 734 | hasActivityRow = true; | |
| 733 | hasLiveWorkRow = true; | |
| 735 | 734 | if (expanded) { |
| 736 | 735 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
| 737 | 736 | nextRows.push({ |
| ... | ... | @@ -833,10 +832,10 @@ export function deriveMessagesTimelineRows(input: { |
| 833 | 832 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 834 | 833 | appendWorkingRow(); |
| 835 | 834 | } |
| 836 | if (input.isWorking && !hasActivityRow) { | |
| 835 | if (input.isWorking && !hasLiveWorkRow) { | |
| 837 | 836 | nextRows.push({ |
| 838 | 837 | kind: "thinking", |
| 839 | id: LIVE_ACTIVITY_ROW_ID, | |
| 838 | id: "thinking-indicator-row", | |
| 840 | 839 | createdAt: input.activeTurnStartedAt, |
| 841 | 840 | }); |
| 842 | 841 | } |
apps/web/src/components/chat/MessagesTimeline.test.tsx+8-34| ... | ... | @@ -1308,30 +1308,7 @@ describe("MessagesTimeline", () => { |
| 1308 | 1308 | expect(markup).not.toContain("tool call failed"); |
| 1309 | 1309 | }); |
| 1310 | 1310 | |
| 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", () => { | |
| 1311 | it("keeps declined command copy visible while thinking continues", () => { | |
| 1335 | 1312 | const turnId = TurnId.make("turn-live"); |
| 1336 | 1313 | const markup = renderToStaticMarkup( |
| 1337 | 1314 | <MessagesTimeline |
| ... | ... | @@ -1347,31 +1324,28 @@ describe("MessagesTimeline", () => { |
| 1347 | 1324 | runningTurnId={turnId} |
| 1348 | 1325 | timelineEntries={[ |
| 1349 | 1326 | { |
| 1350 | id: "entry-completed", | |
| 1327 | id: "entry-declined", | |
| 1351 | 1328 | kind: "work", |
| 1352 | 1329 | createdAt: MESSAGE_CREATED_AT, |
| 1353 | 1330 | entry: { |
| 1354 | id: "work-completed", | |
| 1331 | id: "work-declined", | |
| 1355 | 1332 | createdAt: MESSAGE_CREATED_AT, |
| 1356 | 1333 | turnId, |
| 1357 | toolCallId: "call-completed", | |
| 1334 | toolCallId: "call-declined", | |
| 1358 | 1335 | label: "Run lint", |
| 1359 | 1336 | tone: "tool", |
| 1360 | 1337 | itemType: "command_execution", |
| 1361 | 1338 | command: "pnpm lint", |
| 1362 | toolLifecycleStatus: "completed", | |
| 1339 | toolLifecycleStatus: "declined", | |
| 1363 | 1340 | }, |
| 1364 | 1341 | }, |
| 1365 | 1342 | ]} |
| 1366 | 1343 | />, |
| 1367 | 1344 | ); |
| 1368 | 1345 | |
| 1369 | expect(markup).toContain("Ran pnpm"); | |
| 1370 | expect(markup).toContain("lucide-terminal"); | |
| 1371 | expect(markup).not.toContain("live-activity-focus"); | |
| 1372 | expect(markup).not.toContain("Running pnpm"); | |
| 1373 | expect(markup).not.toContain("Thinking"); | |
| 1374 | expect(markup).not.toContain('data-timeline-row-kind="thinking"'); | |
| 1346 | expect(markup).toContain("Declined pnpm"); | |
| 1347 | expect(markup).toContain("Thinking"); | |
| 1348 | expect(markup).toContain("tool call failed"); | |
| 1375 | 1349 | }); |
| 1376 | 1350 | |
| 1377 | 1351 | it("renders review comment contexts as structured cards instead of raw tags", () => { |
apps/web/src/components/chat/MessagesTimeline.tsx+4-9| ... | ... | @@ -62,7 +62,6 @@ import { |
| 62 | 62 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 63 | 63 | import { |
| 64 | 64 | BotIcon, |
| 65 | BrainIcon, | |
| 66 | 65 | CheckIcon, |
| 67 | 66 | ChevronDownIcon, |
| 68 | 67 | ChevronRightIcon, |
| ... | ... | @@ -986,15 +985,14 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 986 | 985 | : "pb-0" |
| 987 | 986 | : isExpandedToolGroupHeader |
| 988 | 987 | ? "pb-0" |
| 989 | : row.kind === "turn-fold" || row.kind === "working" | |
| 988 | : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" | |
| 990 | 989 | ? "pb-1.5" |
| 991 | 990 | : (row.kind === "message" && |
| 992 | 991 | row.message.role === "assistant" && |
| 993 | 992 | !row.showAssistantMeta) || |
| 994 | 993 | row.kind === "work" || |
| 995 | 994 | row.kind === "work-live" || |
| 996 | row.kind === "work-toggle" || | |
| 997 | row.kind === "thinking" | |
| 995 | row.kind === "work-toggle" | |
| 998 | 996 | ? "pb-2" |
| 999 | 997 | : "pb-4", |
| 1000 | 998 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, |
| ... | ... | @@ -1366,7 +1364,7 @@ function ThinkingTimelineRow() { |
| 1366 | 1364 | // Reserve the activity row during setup so the handoff keeps the same height. |
| 1367 | 1365 | return ( |
| 1368 | 1366 | <div className="min-h-7"> |
| 1369 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />} | |
| 1367 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />} | |
| 1370 | 1368 | </div> |
| 1371 | 1369 | ); |
| 1372 | 1370 | } |
| ... | ... | @@ -2125,7 +2123,6 @@ function formatWorkingTimerNow(startIso: string): string { |
| 2125 | 2123 | |
| 2126 | 2124 | type WorkEntryIconName = |
| 2127 | 2125 | | "bot" |
| 2128 | | "brain" | |
| 2129 | 2126 | | "check" |
| 2130 | 2127 | | "circle-alert" |
| 2131 | 2128 | | "eye" |
| ... | ... | @@ -2143,8 +2140,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN |
| 2143 | 2140 | switch (name) { |
| 2144 | 2141 | case "bot": |
| 2145 | 2142 | return <BotIcon className={className} aria-hidden />; |
| 2146 | case "brain": | |
| 2147 | return <BrainIcon className={className} aria-hidden />; | |
| 2148 | 2143 | case "check": |
| 2149 | 2144 | return <CheckIcon className={className} aria-hidden />; |
| 2150 | 2145 | case "circle-alert": |
| ... | ... | @@ -2184,7 +2179,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { |
| 2184 | 2179 | } |
| 2185 | 2180 | if (tone === "thinking") { |
| 2186 | 2181 | return { |
| 2187 | iconName: "brain", | |
| 2182 | iconName: "bot", | |
| 2188 | 2183 | className: "text-foreground", |
| 2189 | 2184 | }; |
| 2190 | 2185 | } |