authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-01 16:56:09-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 20:56:09+00:00
log163d50846bc8989b5858802bb02253254dcee0dd
tree081095c340b415e68511fe7ceb76ec4d8283a7ef
parent03542836834d008a097450cf24d0d9c6f965b859
signature Signed by PGP key B5690EEEBB952194

Revert "fix(chat): reuse one row for live activity" (#9096)


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