authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-31 22:46:21-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 22:46:21-04:00
log9ecfc07a8b3acadd1612665b6e24425f625480de
tree744b47b12cf1e6fac066c5e72ffa1e19f34e347b
parent9bc7a56848eb7c7546605e54ff02c6abea176f96
signature Signed by PGP key B5690EEEBB952194

fix(chat): keep agent activity visible between actions (#8984)

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

4 files changed, 123 insertions(+), 93 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.test.ts+24-25
......@@ -826,10 +826,11 @@ describe("deriveMessagesTimelineRows", () => {
826826 "assistant-final-entry",
827827 "user-followup-entry",
828828 "working-indicator-row",
829 "thinking-indicator-row",
829830 ]);
830831 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
831832 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
832 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true });
833 expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
833834 });
834835
835836 it("does not fold the active in-progress turn", () => {
......@@ -886,18 +887,18 @@ describe("deriveMessagesTimelineRows", () => {
886887 const rows = deriveMessagesTimelineRows({
887888 timelineEntries: [
888889 {
889 id: "completed-command-entry",
890 id: "running-command-entry",
890891 kind: "work",
891892 createdAt: "2026-01-01T00:00:05Z",
892893 entry: {
893 id: "completed-command",
894 id: "running-command",
894895 createdAt: "2026-01-01T00:00:05Z",
895896 turnId: "turn-1" as never,
896 label: "Ran rg",
897 label: "Running rg",
897898 command: "rg toolCall",
898899 requestKind: "command",
899900 tone: "tool" as const,
900 toolLifecycleStatus: "completed" as const,
901 toolLifecycleStatus: "inProgress" as const,
901902 },
902903 },
903904 {
......@@ -916,18 +917,18 @@ describe("deriveMessagesTimelineRows", () => {
916917 },
917918 },
918919 {
919 id: "running-command-entry",
920 id: "completed-command-entry",
920921 kind: "work",
921922 createdAt: "2026-01-01T00:00:07Z",
922923 entry: {
923 id: "running-command",
924 id: "completed-command",
924925 createdAt: "2026-01-01T00:00:07Z",
925926 turnId: "turn-1" as never,
926 label: "Running tests",
927 label: "Ran tests",
927928 command: "vp test run",
928929 requestKind: "command",
929930 tone: "tool" as const,
930 toolLifecycleStatus: "inProgress" as const,
931 toolLifecycleStatus: "completed" as const,
931932 },
932933 },
933934 ],
......@@ -944,13 +945,13 @@ describe("deriveMessagesTimelineRows", () => {
944945 });
945946
946947 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
947 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false });
948 expect(rows.some((row) => row.kind === "thinking")).toBe(false);
948949 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
949950 entry: { id: "running-command" },
950951 groupedEntries: [
951 { id: "completed-command" },
952 { id: "completed-edit" },
953952 { id: "running-command" },
953 { id: "completed-edit" },
954 { id: "completed-command" },
954955 ],
955956 });
956957 });
......@@ -1195,7 +1196,7 @@ describe("deriveMessagesTimelineRows", () => {
11951196 ]);
11961197 });
11971198
1198 it("keeps the latest completed tool call live while the turn is running", () => {
1199 it("shows thinking after the latest tool call completes while the turn is running", () => {
11991200 const rows = deriveMessagesTimelineRows({
12001201 timelineEntries: [
12011202 {
......@@ -1226,11 +1227,9 @@ describe("deriveMessagesTimelineRows", () => {
12261227 revertTurnCountByUserMessageId: new Map(),
12271228 });
12281229
1229 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
1230 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
1231 entry: { id: "latest-command" },
1232 groupedEntries: [{ id: "latest-command" }],
1233 });
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" });
12341233 });
12351234
12361235 it("does not fold the session's running turn when latestTurn regresses", () => {
......@@ -1378,7 +1377,7 @@ describe("deriveMessagesTimelineRows", () => {
13781377
13791378 expect(assistantRow?.showAssistantMeta).toBe(false);
13801379 expect(assistantRow?.showAssistantCopyButton).toBe(false);
1381 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false });
1380 expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
13821381 });
13831382
13841383 it.each([
......@@ -1547,7 +1546,7 @@ describe("deriveMessagesTimelineRows", () => {
15471546});
15481547
15491548describe("computeStableMessagesTimelineRows", () => {
1550 it.each(["", " \n"])("replaces Thinking when assistant content grows from %j", (text) => {
1549 it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => {
15511550 const startedAt = "2026-01-01T00:00:00Z";
15521551 const turnId = TurnId.make("turn-1");
15531552 const input = {
......@@ -1588,11 +1587,11 @@ describe("computeStableMessagesTimelineRows", () => {
15881587 initial,
15891588 );
15901589
1591 const initialWorking = initial.byId.get("working-indicator-row");
1592 const updatedWorking = updated.byId.get("working-indicator-row");
1593 expect(initialWorking).toMatchObject({ showThinking: true });
1594 expect(updatedWorking).toMatchObject({ showThinking: false });
1595 expect(updatedWorking).not.toBe(initialWorking);
1590 const initialThinking = initial.byId.get("thinking-indicator-row");
1591 const updatedThinking = updated.byId.get("thinking-indicator-row");
1592 expect(initialThinking).toMatchObject({ kind: "thinking" });
1593 expect(updatedThinking).toBe(initialThinking);
1594 expect(updated.result.at(-1)).toBe(updatedThinking);
15961595 });
15971596
15981597 it("returns the previous result when row order and content are unchanged", () => {
apps/web/src/components/chat/MessagesTimeline.logic.ts+40-28
......@@ -193,6 +193,7 @@ export type MessagesTimelineRow =
193193 groupedEntries: WorkLogEntry[];
194194 groupId: string;
195195 expanded: boolean;
196 active: boolean;
196197 }
197198 | {
198199 kind: "work-toggle";
......@@ -235,7 +236,11 @@ export type MessagesTimelineRow =
235236 kind: "working";
236237 id: string;
237238 createdAt: string | null;
238 showThinking: boolean;
239 }
240 | {
241 kind: "thinking";
242 id: string;
243 createdAt: string | null;
239244 };
240245
241246export interface StableMessagesTimelineRowsState {
......@@ -512,6 +517,14 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
512517 return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;
513518}
514519
520function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
521 return (
522 entry.toolLifecycleStatus === "inProgress" ||
523 entry.sourceActivityKind === "task.progress" ||
524 (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry))
525 );
526}
527
515528/**
516529 * Settled turns keep only their terminal assistant message visible.
517530 * Everything before it folds behind a "Worked for ..." row anchored at the
......@@ -702,24 +715,6 @@ export function deriveMessagesTimelineRows(input: {
702715 unsettledTurnId !== null &&
703716 entry.toolLifecycleStatus === "inProgress" &&
704717 entry.turnId === unsettledTurnId;
705 const activeEntries = input.isWorking
706 ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
707 : [];
708 const activeTurnHasVisibleContent = activeEntries.some((entry) => {
709 if (entry.kind === "message") {
710 return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
711 }
712 if (entry.kind === "work") {
713 return (
714 entry.entry.agentSpawn === undefined &&
715 workLogEntryIsToolLike(entry.entry) &&
716 entry.entry.toolLifecycleStatus === "inProgress"
717 );
718 }
719 if (entry.kind === "proposed-plan") return true;
720 return false;
721 });
722
723718 const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];
724719 for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
725720 const entry = input.timelineEntries[index]!;
......@@ -733,40 +728,48 @@ export function deriveMessagesTimelineRows(input: {
733728 }
734729 activeToolEntries.unshift(entry);
735730 }
736 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
737731 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
738732 activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),
739733 (entry) => entry.entry,
740734 );
741735 const activeWorkAnchor = activeToolEntries[0];
742 const latestActiveToolEntry = visibleActiveToolEntries.at(-1);
743 const activeWorkPlacementEntryId = latestActiveToolEntry?.id;
736 const latestVisibleToolEntry = visibleActiveToolEntries.at(-1);
737 const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) =>
738 workEntryIsActiveTurnActivity(entry.entry),
739 );
740 const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry;
741 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
744742 const activeWorkRow =
745 activeWorkAnchor && latestActiveToolEntry
743 activeWorkAnchor && displayedToolEntry
746744 ? (() => {
747745 const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry);
748746 return {
749747 kind: "work-live" as const,
750748 id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`,
751749 createdAt: activeWorkAnchor.createdAt,
752 entry: latestActiveToolEntry.entry,
750 entry: displayedToolEntry.entry,
753751 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
754752 groupId,
755753 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
754 active: latestRunningToolEntry !== undefined,
756755 };
757756 })()
758757 : null;
758 const activeWorkEntryIds = new Set(
759 activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id),
760 );
759761 const appendWorkingRow = () => {
760762 nextRows.push({
761763 kind: "working",
762764 id: "working-indicator-row",
763765 createdAt: input.activeTurnStartedAt,
764 showThinking: activeWorkRow === null && !activeTurnHasVisibleContent,
765766 });
766767 };
768 let hasLiveWorkRow = false;
767769 const appendActiveWorkRows = () => {
768770 if (activeWorkRow === null) return;
769771 nextRows.push(activeWorkRow);
772 hasLiveWorkRow ||= activeWorkRow.active;
770773 if (!activeWorkRow.expanded) return;
771774 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
772775 nextRows.push({
......@@ -864,7 +867,9 @@ export function deriveMessagesTimelineRows(input: {
864867 groupedEntries: visibleGroupedEntries,
865868 groupId,
866869 expanded,
870 active: true,
867871 });
872 hasLiveWorkRow = true;
868873 if (expanded) {
869874 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
870875 nextRows.push({
......@@ -966,6 +971,13 @@ export function deriveMessagesTimelineRows(input: {
966971 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
967972 appendWorkingRow();
968973 }
974 if (input.isWorking && !hasLiveWorkRow) {
975 nextRows.push({
976 kind: "thinking",
977 id: "thinking-indicator-row",
978 createdAt: input.activeTurnStartedAt,
979 });
980 }
969981
970982 return nextRows;
971983}
......@@ -996,9 +1008,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
9961008
9971009 switch (a.kind) {
9981010 case "working":
999 return (
1000 a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking
1001 );
1011 case "thinking":
1012 return a.createdAt === (b as typeof a).createdAt;
10021013
10031014 case "turn-fold": {
10041015 const bf = b as typeof a;
......@@ -1023,6 +1034,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
10231034 a.createdAt === bw.createdAt &&
10241035 a.groupId === bw.groupId &&
10251036 a.expanded === bw.expanded &&
1037 a.active === bw.active &&
10261038 Equal.equals(a.entry, bw.entry) &&
10271039 Equal.equals(a.groupedEntries, bw.groupedEntries)
10281040 );
apps/web/src/components/chat/MessagesTimeline.test.tsx+7-6
......@@ -1308,7 +1308,7 @@ describe("MessagesTimeline", () => {
13081308 expect(markup).not.toContain("tool call failed");
13091309 });
13101310
1311 it("keeps terminal command copy live while the parent turn is active", () => {
1311 it("keeps declined command copy visible while thinking continues", () => {
13121312 const turnId = TurnId.make("turn-live");
13131313 const markup = renderToStaticMarkup(
13141314 <MessagesTimeline
......@@ -1324,26 +1324,27 @@ describe("MessagesTimeline", () => {
13241324 runningTurnId={turnId}
13251325 timelineEntries={[
13261326 {
1327 id: "entry-failed",
1327 id: "entry-declined",
13281328 kind: "work",
13291329 createdAt: MESSAGE_CREATED_AT,
13301330 entry: {
1331 id: "work-failed",
1331 id: "work-declined",
13321332 createdAt: MESSAGE_CREATED_AT,
13331333 turnId,
1334 toolCallId: "call-failed",
1334 toolCallId: "call-declined",
13351335 label: "Run lint",
13361336 tone: "tool",
13371337 itemType: "command_execution",
13381338 command: "pnpm lint",
1339 toolLifecycleStatus: "failed",
1339 toolLifecycleStatus: "declined",
13401340 },
13411341 },
13421342 ]}
13431343 />,
13441344 );
13451345
1346 expect(markup).toContain("Running pnpm");
1346 expect(markup).toContain("Declined pnpm");
1347 expect(markup).toContain("Thinking");
13471348 expect(markup).toContain("tool call failed");
13481349 });
13491350
apps/web/src/components/chat/MessagesTimeline.tsx+52-34
......@@ -981,7 +981,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
981981 : "pb-0"
982982 : isExpandedToolGroupHeader
983983 ? "pb-0"
984 : row.kind === "turn-fold" || row.kind === "working"
984 : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking"
985985 ? "pb-1.5"
986986 : (row.kind === "message" &&
987987 row.message.role === "assistant" &&
......@@ -1013,6 +1013,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
10131013 ) : null}
10141014 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
10151015 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
1016 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
10161017 </div>
10171018 );
10181019});
......@@ -1330,34 +1331,36 @@ function ProposedPlanTimelineRow({
13301331function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
13311332 const { isPreparingWorktree } = use(TimelineRowActivityCtx);
13321333 return (
1333 <div>
1334 <div className="border-b border-border/60 pb-2 pt-1">
1335 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1336 <span
1337 key={isPreparingWorktree ? "setup" : "working"}
1338 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
1339 >
1340 {isPreparingWorktree ? (
1341 <>
1342 Setting up worktree…
1343 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
1344 </>
1345 ) : row.createdAt ? (
1346 <>
1347 Working for <WorkingTimer createdAt={row.createdAt} />
1348 </>
1349 ) : (
1350 "Working..."
1351 )}
1352 </span>
1353 </div>
1334 <div className="border-b border-border/60 pb-2 pt-1">
1335 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1336 <span
1337 key={isPreparingWorktree ? "setup" : "working"}
1338 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
1339 >
1340 {isPreparingWorktree ? (
1341 <>
1342 Setting up worktree…
1343 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
1344 </>
1345 ) : row.createdAt ? (
1346 <>
1347 Working for <WorkingTimer createdAt={row.createdAt} />
1348 </>
1349 ) : (
1350 "Working..."
1351 )}
1352 </span>
13541353 </div>
1355 {row.showThinking ? (
1356 // Reserve the activity row during setup so the handoff keeps the same height.
1357 <div className="mt-1 min-h-7">
1358 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
1359 </div>
1360 ) : null}
1354 </div>
1355 );
1356}
1357
1358function ThinkingTimelineRow() {
1359 const { isPreparingWorktree } = use(TimelineRowActivityCtx);
1360 // Reserve the activity row during setup so the handoff keeps the same height.
1361 return (
1362 <div className="min-h-7">
1363 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
13611364 </div>
13621365 );
13631366}
......@@ -1514,7 +1517,7 @@ function LiveActivityContent({
15141517
15151518function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
15161519 const ctx = use(TimelineRowCtx);
1517 const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);
1520 const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
15181521 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
15191522
15201523 return (
......@@ -1525,7 +1528,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
15251528 aria-expanded={row.expanded}
15261529 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
15271530 >
1528 <LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} />
1531 {row.active ? (
1532 <LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} />
1533 ) : (
1534 <div className="min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
1535 <LiveActivityContent
1536 label={label}
1537 iconName={workEntryIconName(row.entry)}
1538 failed={failed}
1539 announceFailure={failed}
1540 />
1541 </div>
1542 )}
15291543 </button>
15301544 );
15311545}
......@@ -2205,14 +2219,18 @@ function workEntryRawCommand(
22052219function liveWorkEntryLabel(
22062220 workEntry: TimelineWorkEntry,
22072221 workspaceRoot: string | undefined,
2222 active: boolean,
22082223): string {
22092224 const command = workEntry.command?.trim();
22102225 if (command) {
2211 // This row describes the active parent turn, not the command lifecycle.
2212 // Keep its live "Running" copy until the turn or contiguous tool run settles.
22132226 const program = commandProgramName(command);
2214 if (program) return `Running ${program}`;
2215 return "Running command";
2227 const verb = active
2228 ? "Running"
2229 : workEntry.toolLifecycleStatus === "declined"
2230 ? "Declined"
2231 : "Ran";
2232 if (program) return `${verb} ${program}`;
2233 return `${verb} command`;
22162234 }
22172235
22182236 return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);