authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-01 16:48:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 16:48:27-04:00
loga924fbe08e681ef56d8cfb8b99e1e803d52aa463
tree5c79508e318cfec9c1e946bd6e8db63772e98204
parentcb007469161ff0db2bc2dc8123c4b30e186aae50
signature Signed by PGP key B5690EEEBB952194

fix(chat): reuse one row for live activity (#9062)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

4 files changed, 106 insertions(+), 59 deletions(-)

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