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", () => {
826826 "assistant-final-entry",
827827 "user-followup-entry",
828828 "working-indicator-row",
829 "thinking-indicator-row",
829 "live-activity-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 "work-live:work-entry-1",
882 "live-activity-row",
883883 ]);
884884 });
885885
886 it("keeps adjacent active tool calls in one replacing row", () => {
886 it("keeps an actually running tool in the shared activity row", () => {
887887 const rows = deriveMessagesTimelineRows({
888888 timelineEntries: [
889889 {
......@@ -948,6 +948,7 @@ 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,
951952 groupedEntries: [
952953 { id: "running-command" },
953954 { id: "completed-edit" },
......@@ -1196,40 +1197,54 @@ describe("deriveMessagesTimelineRows", () => {
11961197 ]);
11971198 });
11981199
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" | 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,
12161228 },
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 });
12291234
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 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);
12331248 });
12341249
12351250 it("does not fold the session's running turn when latestTurn regresses", () => {
......@@ -1290,7 +1305,7 @@ describe("deriveMessagesTimelineRows", () => {
12901305 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
12911306 "turn-1",
12921307 ]);
1293 expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry");
1308 expect(rows.map((row) => row.id)).toContain("live-activity-row");
12941309 });
12951310
12961311 it("only shows assistant metadata on the terminal assistant message", () => {
......@@ -1587,8 +1602,8 @@ describe("computeStableMessagesTimelineRows", () => {
15871602 initial,
15881603 );
15891604
1590 const initialThinking = initial.byId.get("thinking-indicator-row");
1591 const updatedThinking = updated.byId.get("thinking-indicator-row");
1605 const initialThinking = initial.byId.get("live-activity-row");
1606 const updatedThinking = updated.byId.get("live-activity-row");
15921607 expect(initialThinking).toMatchObject({ kind: "thinking" });
15931608 expect(updatedThinking).toBe(initialThinking);
15941609 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<
188188 "turnId" | "state" | "startedAt" | "completedAt"
189189>;
190190
191const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
192
191193export type MessagesTimelineRow =
192194 | {
193195 kind: "work";
......@@ -598,17 +600,16 @@ export function deriveMessagesTimelineRows(input: {
598600 const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) =>
599601 workEntryIsActiveTurnActivity(entry.entry),
600602 );
601 const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry;
602603 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
603604 const activeWorkRow =
604 activeWorkAnchor && displayedToolEntry
605 activeWorkAnchor && latestVisibleToolEntry
605606 ? (() => {
606607 const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry);
607608 return {
608609 kind: "work-live" as const,
609 id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`,
610 id: LIVE_ACTIVITY_ROW_ID,
610611 createdAt: activeWorkAnchor.createdAt,
611 entry: displayedToolEntry.entry,
612 entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry,
612613 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
613614 groupId,
614615 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
......@@ -626,11 +627,11 @@ export function deriveMessagesTimelineRows(input: {
626627 createdAt: input.activeTurnStartedAt,
627628 });
628629 };
629 let hasLiveWorkRow = false;
630 let hasActivityRow = false;
630631 const appendActiveWorkRows = () => {
631632 if (activeWorkRow === null) return;
632633 nextRows.push(activeWorkRow);
633 hasLiveWorkRow ||= activeWorkRow.active;
634 hasActivityRow = true;
634635 if (!activeWorkRow.expanded) return;
635636 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
636637 nextRows.push({
......@@ -730,7 +731,7 @@ export function deriveMessagesTimelineRows(input: {
730731 expanded,
731732 active: true,
732733 });
733 hasLiveWorkRow = true;
734 hasActivityRow = true;
734735 if (expanded) {
735736 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
736737 nextRows.push({
......@@ -832,10 +833,10 @@ export function deriveMessagesTimelineRows(input: {
832833 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
833834 appendWorkingRow();
834835 }
835 if (input.isWorking && !hasLiveWorkRow) {
836 if (input.isWorking && !hasActivityRow) {
836837 nextRows.push({
837838 kind: "thinking",
838 id: "thinking-indicator-row",
839 id: LIVE_ACTIVITY_ROW_ID,
839840 createdAt: input.activeTurnStartedAt,
840841 });
841842 }
apps/web/src/components/chat/MessagesTimeline.test.tsx+34-8
......@@ -1308,7 +1308,30 @@ describe("MessagesTimeline", () => {
13081308 expect(markup).not.toContain("tool call failed");
13091309 });
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", () => {
13121335 const turnId = TurnId.make("turn-live");
13131336 const markup = renderToStaticMarkup(
13141337 <MessagesTimeline
......@@ -1324,28 +1347,31 @@ describe("MessagesTimeline", () => {
13241347 runningTurnId={turnId}
13251348 timelineEntries={[
13261349 {
1327 id: "entry-declined",
1350 id: "entry-completed",
13281351 kind: "work",
13291352 createdAt: MESSAGE_CREATED_AT,
13301353 entry: {
1331 id: "work-declined",
1354 id: "work-completed",
13321355 createdAt: MESSAGE_CREATED_AT,
13331356 turnId,
1334 toolCallId: "call-declined",
1357 toolCallId: "call-completed",
13351358 label: "Run lint",
13361359 tone: "tool",
13371360 itemType: "command_execution",
13381361 command: "pnpm lint",
1339 toolLifecycleStatus: "declined",
1362 toolLifecycleStatus: "completed",
13401363 },
13411364 },
13421365 ]}
13431366 />,
13441367 );
13451368
1346 expect(markup).toContain("Declined pnpm");
1347 expect(markup).toContain("Thinking");
1348 expect(markup).toContain("tool call failed");
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"');
13491375 });
13501376
13511377 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 {
6262import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
6363import {
6464 BotIcon,
65 BrainIcon,
6566 CheckIcon,
6667 ChevronDownIcon,
6768 ChevronRightIcon,
......@@ -985,14 +986,15 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
985986 : "pb-0"
986987 : isExpandedToolGroupHeader
987988 ? "pb-0"
988 : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking"
989 : row.kind === "turn-fold" || row.kind === "working"
989990 ? "pb-1.5"
990991 : (row.kind === "message" &&
991992 row.message.role === "assistant" &&
992993 !row.showAssistantMeta) ||
993994 row.kind === "work" ||
994995 row.kind === "work-live" ||
995 row.kind === "work-toggle"
996 row.kind === "work-toggle" ||
997 row.kind === "thinking"
996998 ? "pb-2"
997999 : "pb-4",
9981000 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
......@@ -1364,7 +1366,7 @@ function ThinkingTimelineRow() {
13641366 // Reserve the activity row during setup so the handoff keeps the same height.
13651367 return (
13661368 <div className="min-h-7">
1367 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
1369 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />}
13681370 </div>
13691371 );
13701372}
......@@ -2123,6 +2125,7 @@ function formatWorkingTimerNow(startIso: string): string {
21232125
21242126type WorkEntryIconName =
21252127 | "bot"
2128 | "brain"
21262129 | "check"
21272130 | "circle-alert"
21282131 | "eye"
......@@ -2140,6 +2143,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
21402143 switch (name) {
21412144 case "bot":
21422145 return <BotIcon className={className} aria-hidden />;
2146 case "brain":
2147 return <BrainIcon className={className} aria-hidden />;
21432148 case "check":
21442149 return <CheckIcon className={className} aria-hidden />;
21452150 case "circle-alert":
......@@ -2179,7 +2184,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
21792184 }
21802185 if (tone === "thinking") {
21812186 return {
2182 iconName: "bot",
2187 iconName: "brain",
21832188 className: "text-foreground",
21842189 };
21852190 }