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", () => {...@@ -826,10 +826,11 @@ 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 "thinking-indicator-row",
829 ]);830 ]);
830 const finalRow = rows.find((row) => row.id === "assistant-final-entry");831 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
831 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);832 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" });
833 });834 });
834835
835 it("does not fold the active in-progress turn", () => {836 it("does not fold the active in-progress turn", () => {
...@@ -886,18 +887,18 @@ describe("deriveMessagesTimelineRows", () => {...@@ -886,18 +887,18 @@ describe("deriveMessagesTimelineRows", () => {
886 const rows = deriveMessagesTimelineRows({887 const rows = deriveMessagesTimelineRows({
887 timelineEntries: [888 timelineEntries: [
888 {889 {
889 id: "completed-command-entry",890 id: "running-command-entry",
890 kind: "work",891 kind: "work",
891 createdAt: "2026-01-01T00:00:05Z",892 createdAt: "2026-01-01T00:00:05Z",
892 entry: {893 entry: {
893 id: "completed-command",894 id: "running-command",
894 createdAt: "2026-01-01T00:00:05Z",895 createdAt: "2026-01-01T00:00:05Z",
895 turnId: "turn-1" as never,896 turnId: "turn-1" as never,
896 label: "Ran rg",897 label: "Running rg",
897 command: "rg toolCall",898 command: "rg toolCall",
898 requestKind: "command",899 requestKind: "command",
899 tone: "tool" as const,900 tone: "tool" as const,
900 toolLifecycleStatus: "completed" as const,901 toolLifecycleStatus: "inProgress" as const,
901 },902 },
902 },903 },
903 {904 {
...@@ -916,18 +917,18 @@ describe("deriveMessagesTimelineRows", () => {...@@ -916,18 +917,18 @@ describe("deriveMessagesTimelineRows", () => {
916 },917 },
917 },918 },
918 {919 {
919 id: "running-command-entry",920 id: "completed-command-entry",
920 kind: "work",921 kind: "work",
921 createdAt: "2026-01-01T00:00:07Z",922 createdAt: "2026-01-01T00:00:07Z",
922 entry: {923 entry: {
923 id: "running-command",924 id: "completed-command",
924 createdAt: "2026-01-01T00:00:07Z",925 createdAt: "2026-01-01T00:00:07Z",
925 turnId: "turn-1" as never,926 turnId: "turn-1" as never,
926 label: "Running tests",927 label: "Ran tests",
927 command: "vp test run",928 command: "vp test run",
928 requestKind: "command",929 requestKind: "command",
929 tone: "tool" as const,930 tone: "tool" as const,
930 toolLifecycleStatus: "inProgress" as const,931 toolLifecycleStatus: "completed" as const,
931 },932 },
932 },933 },
933 ],934 ],
...@@ -944,13 +945,13 @@ describe("deriveMessagesTimelineRows", () => {...@@ -944,13 +945,13 @@ describe("deriveMessagesTimelineRows", () => {
944 });945 });
945946
946 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);947 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);
948 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({949 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
949 entry: { id: "running-command" },950 entry: { id: "running-command" },
950 groupedEntries: [951 groupedEntries: [
951 { id: "completed-command" },
952 { id: "completed-edit" },
953 { id: "running-command" },952 { id: "running-command" },
953 { id: "completed-edit" },
954 { id: "completed-command" },
954 ],955 ],
955 });956 });
956 });957 });
...@@ -1195,7 +1196,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1195,7 +1196,7 @@ describe("deriveMessagesTimelineRows", () => {
1195 ]);1196 ]);
1196 });1197 });
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", () => {
1199 const rows = deriveMessagesTimelineRows({1200 const rows = deriveMessagesTimelineRows({
1200 timelineEntries: [1201 timelineEntries: [
1201 {1202 {
...@@ -1226,11 +1227,9 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1226,11 +1227,9 @@ describe("deriveMessagesTimelineRows", () => {
1226 revertTurnCountByUserMessageId: new Map(),1227 revertTurnCountByUserMessageId: new Map(),
1227 });1228 });
12281229
1229 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);1230 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]);
1230 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({1231 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false });
1231 entry: { id: "latest-command" },1232 expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
1232 groupedEntries: [{ id: "latest-command" }],
1233 });
1234 });1233 });
12351234
1236 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", () => {
...@@ -1378,7 +1377,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1378,7 +1377,7 @@ describe("deriveMessagesTimelineRows", () => {
13781377
1379 expect(assistantRow?.showAssistantMeta).toBe(false);1378 expect(assistantRow?.showAssistantMeta).toBe(false);
1380 expect(assistantRow?.showAssistantCopyButton).toBe(false);1379 expect(assistantRow?.showAssistantCopyButton).toBe(false);
1381 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false });1380 expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
1382 });1381 });
13831382
1384 it.each([1383 it.each([
...@@ -1547,7 +1546,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1547,7 +1546,7 @@ describe("deriveMessagesTimelineRows", () => {
1547});1546});
15481547
1549describe("computeStableMessagesTimelineRows", () => {1548describe("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) => {
1551 const startedAt = "2026-01-01T00:00:00Z";1550 const startedAt = "2026-01-01T00:00:00Z";
1552 const turnId = TurnId.make("turn-1");1551 const turnId = TurnId.make("turn-1");
1553 const input = {1552 const input = {
...@@ -1588,11 +1587,11 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -1588,11 +1587,11 @@ describe("computeStableMessagesTimelineRows", () => {
1588 initial,1587 initial,
1589 );1588 );
15901589
1591 const initialWorking = initial.byId.get("working-indicator-row");1590 const initialThinking = initial.byId.get("thinking-indicator-row");
1592 const updatedWorking = updated.byId.get("working-indicator-row");1591 const updatedThinking = updated.byId.get("thinking-indicator-row");
1593 expect(initialWorking).toMatchObject({ showThinking: true });1592 expect(initialThinking).toMatchObject({ kind: "thinking" });
1594 expect(updatedWorking).toMatchObject({ showThinking: false });1593 expect(updatedThinking).toBe(initialThinking);
1595 expect(updatedWorking).not.toBe(initialWorking);1594 expect(updated.result.at(-1)).toBe(updatedThinking);
1596 });1595 });
15971596
1598 it("returns the previous result when row order and content are unchanged", () => {1597 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 =...@@ -193,6 +193,7 @@ export type MessagesTimelineRow =
193 groupedEntries: WorkLogEntry[];193 groupedEntries: WorkLogEntry[];
194 groupId: string;194 groupId: string;
195 expanded: boolean;195 expanded: boolean;
196 active: boolean;
196 }197 }
197 | {198 | {
198 kind: "work-toggle";199 kind: "work-toggle";
...@@ -235,7 +236,11 @@ export type MessagesTimelineRow =...@@ -235,7 +236,11 @@ export type MessagesTimelineRow =
235 kind: "working";236 kind: "working";
236 id: string;237 id: string;
237 createdAt: string | null;238 createdAt: string | null;
238 showThinking: boolean;239 }
240 | {
241 kind: "thinking";
242 id: string;
243 createdAt: string | null;
239 };244 };
240245
241export interface StableMessagesTimelineRowsState {246export interface StableMessagesTimelineRowsState {
...@@ -512,6 +517,14 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {...@@ -512,6 +517,14 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
512 return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;517 return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;
513}518}
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
515/**528/**
516 * Settled turns keep only their terminal assistant message visible.529 * Settled turns keep only their terminal assistant message visible.
517 * Everything before it folds behind a "Worked for ..." row anchored at the530 * Everything before it folds behind a "Worked for ..." row anchored at the
...@@ -702,24 +715,6 @@ export function deriveMessagesTimelineRows(input: {...@@ -702,24 +715,6 @@ export function deriveMessagesTimelineRows(input: {
702 unsettledTurnId !== null &&715 unsettledTurnId !== null &&
703 entry.toolLifecycleStatus === "inProgress" &&716 entry.toolLifecycleStatus === "inProgress" &&
704 entry.turnId === unsettledTurnId;717 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
723 const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];718 const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];
724 for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {719 for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
725 const entry = input.timelineEntries[index]!;720 const entry = input.timelineEntries[index]!;
...@@ -733,40 +728,48 @@ export function deriveMessagesTimelineRows(input: {...@@ -733,40 +728,48 @@ export function deriveMessagesTimelineRows(input: {
733 }728 }
734 activeToolEntries.unshift(entry);729 activeToolEntries.unshift(entry);
735 }730 }
736 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
737 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(731 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
738 activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),732 activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),
739 (entry) => entry.entry,733 (entry) => entry.entry,
740 );734 );
741 const activeWorkAnchor = activeToolEntries[0];735 const activeWorkAnchor = activeToolEntries[0];
742 const latestActiveToolEntry = visibleActiveToolEntries.at(-1);736 const latestVisibleToolEntry = visibleActiveToolEntries.at(-1);
743 const activeWorkPlacementEntryId = latestActiveToolEntry?.id;737 const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) =>
738 workEntryIsActiveTurnActivity(entry.entry),
739 );
740 const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry;
741 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
744 const activeWorkRow =742 const activeWorkRow =
745 activeWorkAnchor && latestActiveToolEntry743 activeWorkAnchor && displayedToolEntry
746 ? (() => {744 ? (() => {
747 const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry);745 const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry);
748 return {746 return {
749 kind: "work-live" as const,747 kind: "work-live" as const,
750 id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`,748 id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`,
751 createdAt: activeWorkAnchor.createdAt,749 createdAt: activeWorkAnchor.createdAt,
752 entry: latestActiveToolEntry.entry,750 entry: displayedToolEntry.entry,
753 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),751 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
754 groupId,752 groupId,
755 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,753 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
754 active: latestRunningToolEntry !== undefined,
756 };755 };
757 })()756 })()
758 : null;757 : null;
758 const activeWorkEntryIds = new Set(
759 activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id),
760 );
759 const appendWorkingRow = () => {761 const appendWorkingRow = () => {
760 nextRows.push({762 nextRows.push({
761 kind: "working",763 kind: "working",
762 id: "working-indicator-row",764 id: "working-indicator-row",
763 createdAt: input.activeTurnStartedAt,765 createdAt: input.activeTurnStartedAt,
764 showThinking: activeWorkRow === null && !activeTurnHasVisibleContent,
765 });766 });
766 };767 };
768 let hasLiveWorkRow = false;
767 const appendActiveWorkRows = () => {769 const appendActiveWorkRows = () => {
768 if (activeWorkRow === null) return;770 if (activeWorkRow === null) return;
769 nextRows.push(activeWorkRow);771 nextRows.push(activeWorkRow);
772 hasLiveWorkRow ||= activeWorkRow.active;
770 if (!activeWorkRow.expanded) return;773 if (!activeWorkRow.expanded) return;
771 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {774 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
772 nextRows.push({775 nextRows.push({
...@@ -864,7 +867,9 @@ export function deriveMessagesTimelineRows(input: {...@@ -864,7 +867,9 @@ export function deriveMessagesTimelineRows(input: {
864 groupedEntries: visibleGroupedEntries,867 groupedEntries: visibleGroupedEntries,
865 groupId,868 groupId,
866 expanded,869 expanded,
870 active: true,
867 });871 });
872 hasLiveWorkRow = true;
868 if (expanded) {873 if (expanded) {
869 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {874 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
870 nextRows.push({875 nextRows.push({
...@@ -966,6 +971,13 @@ export function deriveMessagesTimelineRows(input: {...@@ -966,6 +971,13 @@ export function deriveMessagesTimelineRows(input: {
966 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {971 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
967 appendWorkingRow();972 appendWorkingRow();
968 }973 }
974 if (input.isWorking && !hasLiveWorkRow) {
975 nextRows.push({
976 kind: "thinking",
977 id: "thinking-indicator-row",
978 createdAt: input.activeTurnStartedAt,
979 });
980 }
969981
970 return nextRows;982 return nextRows;
971}983}
...@@ -996,9 +1008,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -996,9 +1008,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
9961008
997 switch (a.kind) {1009 switch (a.kind) {
998 case "working":1010 case "working":
999 return (1011 case "thinking":
1000 a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking1012 return a.createdAt === (b as typeof a).createdAt;
1001 );
10021013
1003 case "turn-fold": {1014 case "turn-fold": {
1004 const bf = b as typeof a;1015 const bf = b as typeof a;
...@@ -1023,6 +1034,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1023,6 +1034,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1023 a.createdAt === bw.createdAt &&1034 a.createdAt === bw.createdAt &&
1024 a.groupId === bw.groupId &&1035 a.groupId === bw.groupId &&
1025 a.expanded === bw.expanded &&1036 a.expanded === bw.expanded &&
1037 a.active === bw.active &&
1026 Equal.equals(a.entry, bw.entry) &&1038 Equal.equals(a.entry, bw.entry) &&
1027 Equal.equals(a.groupedEntries, bw.groupedEntries)1039 Equal.equals(a.groupedEntries, bw.groupedEntries)
1028 );1040 );
apps/web/src/components/chat/MessagesTimeline.test.tsx+7-6
...@@ -1308,7 +1308,7 @@ describe("MessagesTimeline", () => {...@@ -1308,7 +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("keeps terminal command copy live while the parent turn is active", () => {1311 it("keeps declined command copy visible while thinking continues", () => {
1312 const turnId = TurnId.make("turn-live");1312 const turnId = TurnId.make("turn-live");
1313 const markup = renderToStaticMarkup(1313 const markup = renderToStaticMarkup(
1314 <MessagesTimeline1314 <MessagesTimeline
...@@ -1324,26 +1324,27 @@ describe("MessagesTimeline", () => {...@@ -1324,26 +1324,27 @@ describe("MessagesTimeline", () => {
1324 runningTurnId={turnId}1324 runningTurnId={turnId}
1325 timelineEntries={[1325 timelineEntries={[
1326 {1326 {
1327 id: "entry-failed",1327 id: "entry-declined",
1328 kind: "work",1328 kind: "work",
1329 createdAt: MESSAGE_CREATED_AT,1329 createdAt: MESSAGE_CREATED_AT,
1330 entry: {1330 entry: {
1331 id: "work-failed",1331 id: "work-declined",
1332 createdAt: MESSAGE_CREATED_AT,1332 createdAt: MESSAGE_CREATED_AT,
1333 turnId,1333 turnId,
1334 toolCallId: "call-failed",1334 toolCallId: "call-declined",
1335 label: "Run lint",1335 label: "Run lint",
1336 tone: "tool",1336 tone: "tool",
1337 itemType: "command_execution",1337 itemType: "command_execution",
1338 command: "pnpm lint",1338 command: "pnpm lint",
1339 toolLifecycleStatus: "failed",1339 toolLifecycleStatus: "declined",
1340 },1340 },
1341 },1341 },
1342 ]}1342 ]}
1343 />,1343 />,
1344 );1344 );
13451345
1346 expect(markup).toContain("Running pnpm");1346 expect(markup).toContain("Declined pnpm");
1347 expect(markup).toContain("Thinking");
1347 expect(markup).toContain("tool call failed");1348 expect(markup).toContain("tool call failed");
1348 });1349 });
13491350
apps/web/src/components/chat/MessagesTimeline.tsx+52-34
...@@ -981,7 +981,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -981,7 +981,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
981 : "pb-0"981 : "pb-0"
982 : isExpandedToolGroupHeader982 : isExpandedToolGroupHeader
983 ? "pb-0"983 ? "pb-0"
984 : row.kind === "turn-fold" || row.kind === "working"984 : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking"
985 ? "pb-1.5"985 ? "pb-1.5"
986 : (row.kind === "message" &&986 : (row.kind === "message" &&
987 row.message.role === "assistant" &&987 row.message.role === "assistant" &&
...@@ -1013,6 +1013,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1013,6 +1013,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1013 ) : null}1013 ) : null}
1014 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}1014 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
1015 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}1015 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
1016 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
1016 </div>1017 </div>
1017 );1018 );
1018});1019});
...@@ -1330,34 +1331,36 @@ function ProposedPlanTimelineRow({...@@ -1330,34 +1331,36 @@ function ProposedPlanTimelineRow({
1330function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {1331function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1331 const { isPreparingWorktree } = use(TimelineRowActivityCtx);1332 const { isPreparingWorktree } = use(TimelineRowActivityCtx);
1332 return (1333 return (
1333 <div>1334 <div className="border-b border-border/60 pb-2 pt-1">
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">
1335 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">1336 <span
1336 <span1337 key={isPreparingWorktree ? "setup" : "working"}
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"
1338 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"1339 >
1339 >1340 {isPreparingWorktree ? (
1340 {isPreparingWorktree ? (1341 <>
1341 <>1342 Setting up worktree…
1342 Setting up worktree…1343 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
1343 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>1344 </>
1344 </>1345 ) : row.createdAt ? (
1345 ) : row.createdAt ? (1346 <>
1346 <>1347 Working for <WorkingTimer createdAt={row.createdAt} />
1347 Working for <WorkingTimer createdAt={row.createdAt} />1348 </>
1348 </>1349 ) : (
1349 ) : (1350 "Working..."
1350 "Working..."1351 )}
1351 )}1352 </span>
1352 </span>
1353 </div>
1354 </div>1353 </div>
1355 {row.showThinking ? (1354 </div>
1356 // Reserve the activity row during setup so the handoff keeps the same height.1355 );
1357 <div className="mt-1 min-h-7">1356}
1358 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}1357
1359 </div>1358function ThinkingTimelineRow() {
1360 ) : null}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" />}
1361 </div>1364 </div>
1362 );1365 );
1363}1366}
...@@ -1514,7 +1517,7 @@ function LiveActivityContent({...@@ -1514,7 +1517,7 @@ function LiveActivityContent({
15141517
1515function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {1518function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
1516 const ctx = use(TimelineRowCtx);1519 const ctx = use(TimelineRowCtx);
1517 const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);1520 const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
1518 const failed = workEntryDisplayIndicatesToolFailure(row.entry);1521 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
15191522
1520 return (1523 return (
...@@ -1525,7 +1528,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "...@@ -1525,7 +1528,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
1525 aria-expanded={row.expanded}1528 aria-expanded={row.expanded}
1526 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}1529 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1527 >1530 >
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 )}
1529 </button>1543 </button>
1530 );1544 );
1531}1545}
...@@ -2205,14 +2219,18 @@ function workEntryRawCommand(...@@ -2205,14 +2219,18 @@ function workEntryRawCommand(
2205function liveWorkEntryLabel(2219function liveWorkEntryLabel(
2206 workEntry: TimelineWorkEntry,2220 workEntry: TimelineWorkEntry,
2207 workspaceRoot: string | undefined,2221 workspaceRoot: string | undefined,
2222 active: boolean,
2208): string {2223): string {
2209 const command = workEntry.command?.trim();2224 const command = workEntry.command?.trim();
2210 if (command) {2225 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.
2213 const program = commandProgramName(command);2226 const program = commandProgramName(command);
2214 if (program) return `Running ${program}`;2227 const verb = active
2215 return "Running command";2228 ? "Running"
2229 : workEntry.toolLifecycleStatus === "declined"
2230 ? "Declined"
2231 : "Ran";
2232 if (program) return `${verb} ${program}`;
2233 return `${verb} command`;
2216 }2234 }
22172235
2218 return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);2236 return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);