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", () => {
826826 "assistant-final-entry",
827827 "user-followup-entry",
828828 "working-indicator-row",
829 "live-activity-row",
829 "thinking-indicator-row",
830830 ]);
831831 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
832832 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
......@@ -879,11 +879,11 @@ describe("deriveMessagesTimelineRows", () => {
879879 expect(rows.map((row) => row.id)).toEqual([
880880 "working-indicator-row",
881881 "assistant-thought-entry",
882 "live-activity-row",
882 "work-live:work-entry-1",
883883 ]);
884884 });
885885
886 it("keeps an actually running tool in the shared activity row", () => {
886 it("keeps adjacent active tool calls in one replacing row", () => {
887887 const rows = deriveMessagesTimelineRows({
888888 timelineEntries: [
889889 {
......@@ -948,7 +948,6 @@ describe("deriveMessagesTimelineRows", () => {
948948 expect(rows.some((row) => row.kind === "thinking")).toBe(false);
949949 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
950950 entry: { id: "running-command" },
951 active: true,
952951 groupedEntries: [
953952 { id: "running-command" },
954953 { id: "completed-edit" },
......@@ -1197,54 +1196,40 @@ describe("deriveMessagesTimelineRows", () => {
11971196 ]);
11981197 });
11991198
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 },
12281216 },
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 });
12341229
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" });
12481233 });
12491234
12501235 it("does not fold the session's running turn when latestTurn regresses", () => {
......@@ -1305,7 +1290,7 @@ describe("deriveMessagesTimelineRows", () => {
13051290 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
13061291 "turn-1",
13071292 ]);
1308 expect(rows.map((row) => row.id)).toContain("live-activity-row");
1293 expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry");
13091294 });
13101295
13111296 it("only shows assistant metadata on the terminal assistant message", () => {
......@@ -1602,8 +1587,8 @@ describe("computeStableMessagesTimelineRows", () => {
16021587 initial,
16031588 );
16041589
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");
16071592 expect(initialThinking).toMatchObject({ kind: "thinking" });
16081593 expect(updatedThinking).toBe(initialThinking);
16091594 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<
188188 "turnId" | "state" | "startedAt" | "completedAt"
189189>;
190190
191const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
192
193191export type MessagesTimelineRow =
194192 | {
195193 kind: "work";
......@@ -600,16 +598,17 @@ export function deriveMessagesTimelineRows(input: {
600598 const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) =>
601599 workEntryIsActiveTurnActivity(entry.entry),
602600 );
601 const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry;
603602 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
604603 const activeWorkRow =
605 activeWorkAnchor && latestVisibleToolEntry
604 activeWorkAnchor && displayedToolEntry
606605 ? (() => {
607606 const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry);
608607 return {
609608 kind: "work-live" as const,
610 id: LIVE_ACTIVITY_ROW_ID,
609 id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`,
611610 createdAt: activeWorkAnchor.createdAt,
612 entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry,
611 entry: displayedToolEntry.entry,
613612 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
614613 groupId,
615614 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
......@@ -627,11 +626,11 @@ export function deriveMessagesTimelineRows(input: {
627626 createdAt: input.activeTurnStartedAt,
628627 });
629628 };
630 let hasActivityRow = false;
629 let hasLiveWorkRow = false;
631630 const appendActiveWorkRows = () => {
632631 if (activeWorkRow === null) return;
633632 nextRows.push(activeWorkRow);
634 hasActivityRow = true;
633 hasLiveWorkRow ||= activeWorkRow.active;
635634 if (!activeWorkRow.expanded) return;
636635 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
637636 nextRows.push({
......@@ -731,7 +730,7 @@ export function deriveMessagesTimelineRows(input: {
731730 expanded,
732731 active: true,
733732 });
734 hasActivityRow = true;
733 hasLiveWorkRow = true;
735734 if (expanded) {
736735 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
737736 nextRows.push({
......@@ -833,10 +832,10 @@ export function deriveMessagesTimelineRows(input: {
833832 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
834833 appendWorkingRow();
835834 }
836 if (input.isWorking && !hasActivityRow) {
835 if (input.isWorking && !hasLiveWorkRow) {
837836 nextRows.push({
838837 kind: "thinking",
839 id: LIVE_ACTIVITY_ROW_ID,
838 id: "thinking-indicator-row",
840839 createdAt: input.activeTurnStartedAt,
841840 });
842841 }
apps/web/src/components/chat/MessagesTimeline.test.tsx+8-34
......@@ -1308,30 +1308,7 @@ describe("MessagesTimeline", () => {
13081308 expect(markup).not.toContain("tool call failed");
13091309 });
13101310
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", () => {
13351312 const turnId = TurnId.make("turn-live");
13361313 const markup = renderToStaticMarkup(
13371314 <MessagesTimeline
......@@ -1347,31 +1324,28 @@ describe("MessagesTimeline", () => {
13471324 runningTurnId={turnId}
13481325 timelineEntries={[
13491326 {
1350 id: "entry-completed",
1327 id: "entry-declined",
13511328 kind: "work",
13521329 createdAt: MESSAGE_CREATED_AT,
13531330 entry: {
1354 id: "work-completed",
1331 id: "work-declined",
13551332 createdAt: MESSAGE_CREATED_AT,
13561333 turnId,
1357 toolCallId: "call-completed",
1334 toolCallId: "call-declined",
13581335 label: "Run lint",
13591336 tone: "tool",
13601337 itemType: "command_execution",
13611338 command: "pnpm lint",
1362 toolLifecycleStatus: "completed",
1339 toolLifecycleStatus: "declined",
13631340 },
13641341 },
13651342 ]}
13661343 />,
13671344 );
13681345
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");
13751349 });
13761350
13771351 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 {
6262import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
6363import {
6464 BotIcon,
65 BrainIcon,
6665 CheckIcon,
6766 ChevronDownIcon,
6867 ChevronRightIcon,
......@@ -986,15 +985,14 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
986985 : "pb-0"
987986 : isExpandedToolGroupHeader
988987 ? "pb-0"
989 : row.kind === "turn-fold" || row.kind === "working"
988 : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking"
990989 ? "pb-1.5"
991990 : (row.kind === "message" &&
992991 row.message.role === "assistant" &&
993992 !row.showAssistantMeta) ||
994993 row.kind === "work" ||
995994 row.kind === "work-live" ||
996 row.kind === "work-toggle" ||
997 row.kind === "thinking"
995 row.kind === "work-toggle"
998996 ? "pb-2"
999997 : "pb-4",
1000998 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
......@@ -1366,7 +1364,7 @@ function ThinkingTimelineRow() {
13661364 // Reserve the activity row during setup so the handoff keeps the same height.
13671365 return (
13681366 <div className="min-h-7">
1369 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />}
1367 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
13701368 </div>
13711369 );
13721370}
......@@ -2125,7 +2123,6 @@ function formatWorkingTimerNow(startIso: string): string {
21252123
21262124type WorkEntryIconName =
21272125 | "bot"
2128 | "brain"
21292126 | "check"
21302127 | "circle-alert"
21312128 | "eye"
......@@ -2143,8 +2140,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
21432140 switch (name) {
21442141 case "bot":
21452142 return <BotIcon className={className} aria-hidden />;
2146 case "brain":
2147 return <BrainIcon className={className} aria-hidden />;
21482143 case "check":
21492144 return <CheckIcon className={className} aria-hidden />;
21502145 case "circle-alert":
......@@ -2184,7 +2179,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
21842179 }
21852180 if (tone === "thinking") {
21862181 return {
2187 iconName: "brain",
2182 iconName: "bot",
21882183 className: "text-foreground",
21892184 };
21902185 }