authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-01 18:06:21-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:06:21-04:00
log590a579f2e9292ce314c69e459e19620004578fe
treebfd334c0383510f569cb97d4442f03ff84327eb5
parent0222aa255d11babd242dbe5ed0947e5fc5eaefee
signature Signed by PGP key B5690EEEBB952194

fix(chat): keep latest command live between messages (#9098)

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

4 files changed, 118 insertions(+), 60 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.test.ts+57-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,58 @@ 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" | "declined" | 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 declinedRows = deriveRows("declined");
1239 const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row");
1240 const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row");
1241 const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row");
1242
1243 expect(initialActivityRow).toMatchObject({ kind: "thinking" });
1244 expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true });
1245 expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true });
1246 expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false });
1247 expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
1248 expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
1249 expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
1250 expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
1251 expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
12331252 });
12341253
12351254 it("does not fold the session's running turn when latestTurn regresses", () => {
......@@ -1290,7 +1309,7 @@ describe("deriveMessagesTimelineRows", () => {
12901309 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
12911310 "turn-1",
12921311 ]);
1293 expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry");
1312 expect(rows.map((row) => row.id)).toContain("live-activity-row");
12941313 });
12951314
12961315 it("only shows assistant metadata on the terminal assistant message", () => {
......@@ -1587,8 +1606,8 @@ describe("computeStableMessagesTimelineRows", () => {
15871606 initial,
15881607 );
15891608
1590 const initialThinking = initial.byId.get("thinking-indicator-row");
1591 const updatedThinking = updated.byId.get("thinking-indicator-row");
1609 const initialThinking = initial.byId.get("live-activity-row");
1610 const updatedThinking = updated.byId.get("live-activity-row");
15921611 expect(initialThinking).toMatchObject({ kind: "thinking" });
15931612 expect(updatedThinking).toBe(initialThinking);
15941613 expect(updated.result.at(-1)).toBe(updatedThinking);
apps/web/src/components/chat/MessagesTimeline.logic.ts+18-10
......@@ -15,6 +15,7 @@ export {
1515import {
1616 formatDuration,
1717 workEntryDisplayIndicatesToolFailure,
18 workEntryIndicatesToolSuccess,
1819 workEntryIndicatesToolNeutralStatus,
1920 workLogEntryIsToolLike,
2021 type TimelineEntry,
......@@ -188,6 +189,8 @@ export type TimelineLatestTurn = Pick<
188189 "turnId" | "state" | "startedAt" | "completedAt"
189190>;
190191
192const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
193
191194export type MessagesTimelineRow =
192195 | {
193196 kind: "work";
......@@ -598,21 +601,26 @@ export function deriveMessagesTimelineRows(input: {
598601 const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) =>
599602 workEntryIsActiveTurnActivity(entry.entry),
600603 );
601 const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry;
604 const latestToolKeepsActivityLive =
605 latestRunningToolEntry !== undefined ||
606 (latestVisibleToolEntry !== undefined &&
607 workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry));
602608 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
603609 const activeWorkRow =
604 activeWorkAnchor && displayedToolEntry
610 activeWorkAnchor && latestVisibleToolEntry
605611 ? (() => {
606612 const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry);
607613 return {
608614 kind: "work-live" as const,
609 id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`,
615 id: latestToolKeepsActivityLive
616 ? LIVE_ACTIVITY_ROW_ID
617 : `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`,
610618 createdAt: activeWorkAnchor.createdAt,
611 entry: displayedToolEntry.entry,
619 entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry,
612620 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
613621 groupId,
614622 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
615 active: latestRunningToolEntry !== undefined,
623 active: latestToolKeepsActivityLive,
616624 };
617625 })()
618626 : null;
......@@ -626,11 +634,11 @@ export function deriveMessagesTimelineRows(input: {
626634 createdAt: input.activeTurnStartedAt,
627635 });
628636 };
629 let hasLiveWorkRow = false;
637 let hasActivityRow = false;
630638 const appendActiveWorkRows = () => {
631639 if (activeWorkRow === null) return;
632640 nextRows.push(activeWorkRow);
633 hasLiveWorkRow ||= activeWorkRow.active;
641 hasActivityRow ||= activeWorkRow.active;
634642 if (!activeWorkRow.expanded) return;
635643 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
636644 nextRows.push({
......@@ -730,7 +738,7 @@ export function deriveMessagesTimelineRows(input: {
730738 expanded,
731739 active: true,
732740 });
733 hasLiveWorkRow = true;
741 hasActivityRow = true;
734742 if (expanded) {
735743 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
736744 nextRows.push({
......@@ -832,10 +840,10 @@ export function deriveMessagesTimelineRows(input: {
832840 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
833841 appendWorkingRow();
834842 }
835 if (input.isWorking && !hasLiveWorkRow) {
843 if (input.isWorking && !hasActivityRow) {
836844 nextRows.push({
837845 kind: "thinking",
838 id: "thinking-indicator-row",
846 id: LIVE_ACTIVITY_ROW_ID,
839847 createdAt: input.activeTurnStartedAt,
840848 });
841849 }
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("Running pnpm");
1370 expect(markup).toContain("lucide-terminal");
1371 expect(markup).toContain("live-activity-focus");
1372 expect(markup).not.toContain("Ran 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 }