| author | |
| committer | |
| log | 9ecfc07a8b3acadd1612665b6e24425f625480de |
| tree | 744b47b12cf1e6fac066c5e72ffa1e19f34e347b |
| parent | 9bc7a56848eb7c7546605e54ff02c6abea176f96 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 826 | "assistant-final-entry", |
| 827 | 827 | "user-followup-entry", |
| 828 | 828 | "working-indicator-row", |
| 829 | "thinking-indicator-row", | |
| 829 | 830 | ]); |
| 830 | 831 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); |
| 831 | 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 | }); |
| 834 | 835 | |
| 835 | 836 | it("does not fold the active in-progress turn", () => { |
| ... | ... | @@ -886,18 +887,18 @@ describe("deriveMessagesTimelineRows", () => { |
| 886 | 887 | const rows = deriveMessagesTimelineRows({ |
| 887 | 888 | timelineEntries: [ |
| 888 | 889 | { |
| 889 | id: "completed-command-entry", | |
| 890 | id: "running-command-entry", | |
| 890 | 891 | kind: "work", |
| 891 | 892 | createdAt: "2026-01-01T00:00:05Z", |
| 892 | 893 | entry: { |
| 893 | id: "completed-command", | |
| 894 | id: "running-command", | |
| 894 | 895 | createdAt: "2026-01-01T00:00:05Z", |
| 895 | 896 | turnId: "turn-1" as never, |
| 896 | label: "Ran rg", | |
| 897 | label: "Running rg", | |
| 897 | 898 | command: "rg toolCall", |
| 898 | 899 | requestKind: "command", |
| 899 | 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 | 917 | }, |
| 917 | 918 | }, |
| 918 | 919 | { |
| 919 | id: "running-command-entry", | |
| 920 | id: "completed-command-entry", | |
| 920 | 921 | kind: "work", |
| 921 | 922 | createdAt: "2026-01-01T00:00:07Z", |
| 922 | 923 | entry: { |
| 923 | id: "running-command", | |
| 924 | id: "completed-command", | |
| 924 | 925 | createdAt: "2026-01-01T00:00:07Z", |
| 925 | 926 | turnId: "turn-1" as never, |
| 926 | label: "Running tests", | |
| 927 | label: "Ran tests", | |
| 927 | 928 | command: "vp test run", |
| 928 | 929 | requestKind: "command", |
| 929 | 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 | 945 | }); |
| 945 | 946 | |
| 946 | 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 | 949 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ |
| 949 | 950 | entry: { id: "running-command" }, |
| 950 | 951 | groupedEntries: [ |
| 951 | { id: "completed-command" }, | |
| 952 | { id: "completed-edit" }, | |
| 953 | 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 | 1196 | ]); |
| 1196 | 1197 | }); |
| 1197 | 1198 | |
| 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 | 1200 | const rows = deriveMessagesTimelineRows({ |
| 1200 | 1201 | timelineEntries: [ |
| 1201 | 1202 | { |
| ... | ... | @@ -1226,11 +1227,9 @@ describe("deriveMessagesTimelineRows", () => { |
| 1226 | 1227 | revertTurnCountByUserMessageId: new Map(), |
| 1227 | 1228 | }); |
| 1228 | 1229 | |
| 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" }); | |
| 1234 | 1233 | }); |
| 1235 | 1234 | |
| 1236 | 1235 | it("does not fold the session's running turn when latestTurn regresses", () => { |
| ... | ... | @@ -1378,7 +1377,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1378 | 1377 | |
| 1379 | 1378 | expect(assistantRow?.showAssistantMeta).toBe(false); |
| 1380 | 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 | }); |
| 1383 | 1382 | |
| 1384 | 1383 | it.each([ |
| ... | ... | @@ -1547,7 +1546,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1547 | 1546 | }); |
| 1548 | 1547 | |
| 1549 | 1548 | describe("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 | 1550 | const startedAt = "2026-01-01T00:00:00Z"; |
| 1552 | 1551 | const turnId = TurnId.make("turn-1"); |
| 1553 | 1552 | const input = { |
| ... | ... | @@ -1588,11 +1587,11 @@ describe("computeStableMessagesTimelineRows", () => { |
| 1588 | 1587 | initial, |
| 1589 | 1588 | ); |
| 1590 | 1589 | |
| 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); | |
| 1596 | 1595 | }); |
| 1597 | 1596 | |
| 1598 | 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 | 193 | groupedEntries: WorkLogEntry[]; |
| 194 | 194 | groupId: string; |
| 195 | 195 | expanded: boolean; |
| 196 | active: boolean; | |
| 196 | 197 | } |
| 197 | 198 | | { |
| 198 | 199 | kind: "work-toggle"; |
| ... | ... | @@ -235,7 +236,11 @@ export type MessagesTimelineRow = |
| 235 | 236 | kind: "working"; |
| 236 | 237 | id: string; |
| 237 | 238 | createdAt: string | null; |
| 238 | showThinking: boolean; | |
| 239 | } | |
| 240 | | { | |
| 241 | kind: "thinking"; | |
| 242 | id: string; | |
| 243 | createdAt: string | null; | |
| 239 | 244 | }; |
| 240 | 245 | |
| 241 | 246 | export interface StableMessagesTimelineRowsState { |
| ... | ... | @@ -512,6 +517,14 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { |
| 512 | 517 | return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; |
| 513 | 518 | } |
| 514 | 519 | |
| 520 | function 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 | 529 | * Settled turns keep only their terminal assistant message visible. |
| 517 | 530 | * Everything before it folds behind a "Worked for ..." row anchored at the |
| ... | ... | @@ -702,24 +715,6 @@ export function deriveMessagesTimelineRows(input: { |
| 702 | 715 | unsettledTurnId !== null && |
| 703 | 716 | entry.toolLifecycleStatus === "inProgress" && |
| 704 | 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 | 718 | const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = []; |
| 724 | 719 | for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { |
| 725 | 720 | const entry = input.timelineEntries[index]!; |
| ... | ... | @@ -733,40 +728,48 @@ export function deriveMessagesTimelineRows(input: { |
| 733 | 728 | } |
| 734 | 729 | activeToolEntries.unshift(entry); |
| 735 | 730 | } |
| 736 | const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id)); | |
| 737 | 731 | const visibleActiveToolEntries = omitSupersededLifecycleMarkers( |
| 738 | 732 | activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)), |
| 739 | 733 | (entry) => entry.entry, |
| 740 | 734 | ); |
| 741 | 735 | 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; | |
| 744 | 742 | const activeWorkRow = |
| 745 | activeWorkAnchor && latestActiveToolEntry | |
| 743 | activeWorkAnchor && displayedToolEntry | |
| 746 | 744 | ? (() => { |
| 747 | 745 | const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); |
| 748 | 746 | return { |
| 749 | 747 | kind: "work-live" as const, |
| 750 | 748 | id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, |
| 751 | 749 | createdAt: activeWorkAnchor.createdAt, |
| 752 | entry: latestActiveToolEntry.entry, | |
| 750 | entry: displayedToolEntry.entry, | |
| 753 | 751 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), |
| 754 | 752 | groupId, |
| 755 | 753 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, |
| 754 | active: latestRunningToolEntry !== undefined, | |
| 756 | 755 | }; |
| 757 | 756 | })() |
| 758 | 757 | : null; |
| 758 | const activeWorkEntryIds = new Set( | |
| 759 | activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id), | |
| 760 | ); | |
| 759 | 761 | const appendWorkingRow = () => { |
| 760 | 762 | nextRows.push({ |
| 761 | 763 | kind: "working", |
| 762 | 764 | id: "working-indicator-row", |
| 763 | 765 | createdAt: input.activeTurnStartedAt, |
| 764 | showThinking: activeWorkRow === null && !activeTurnHasVisibleContent, | |
| 765 | 766 | }); |
| 766 | 767 | }; |
| 768 | let hasLiveWorkRow = false; | |
| 767 | 769 | const appendActiveWorkRows = () => { |
| 768 | 770 | if (activeWorkRow === null) return; |
| 769 | 771 | nextRows.push(activeWorkRow); |
| 772 | hasLiveWorkRow ||= activeWorkRow.active; | |
| 770 | 773 | if (!activeWorkRow.expanded) return; |
| 771 | 774 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { |
| 772 | 775 | nextRows.push({ |
| ... | ... | @@ -864,7 +867,9 @@ export function deriveMessagesTimelineRows(input: { |
| 864 | 867 | groupedEntries: visibleGroupedEntries, |
| 865 | 868 | groupId, |
| 866 | 869 | expanded, |
| 870 | active: true, | |
| 867 | 871 | }); |
| 872 | hasLiveWorkRow = true; | |
| 868 | 873 | if (expanded) { |
| 869 | 874 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
| 870 | 875 | nextRows.push({ |
| ... | ... | @@ -966,6 +971,13 @@ export function deriveMessagesTimelineRows(input: { |
| 966 | 971 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 967 | 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 | } | |
| 969 | 981 | |
| 970 | 982 | return nextRows; |
| 971 | 983 | } |
| ... | ... | @@ -996,9 +1008,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 996 | 1008 | |
| 997 | 1009 | switch (a.kind) { |
| 998 | 1010 | 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; | |
| 1002 | 1013 | |
| 1003 | 1014 | case "turn-fold": { |
| 1004 | 1015 | const bf = b as typeof a; |
| ... | ... | @@ -1023,6 +1034,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1023 | 1034 | a.createdAt === bw.createdAt && |
| 1024 | 1035 | a.groupId === bw.groupId && |
| 1025 | 1036 | a.expanded === bw.expanded && |
| 1037 | a.active === bw.active && | |
| 1026 | 1038 | Equal.equals(a.entry, bw.entry) && |
| 1027 | 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 | 1308 | expect(markup).not.toContain("tool call failed"); |
| 1309 | 1309 | }); |
| 1310 | 1310 | |
| 1311 | it("keeps terminal command copy live while the parent turn is active", () => { | |
| 1311 | it("keeps declined command copy visible while thinking continues", () => { | |
| 1312 | 1312 | const turnId = TurnId.make("turn-live"); |
| 1313 | 1313 | const markup = renderToStaticMarkup( |
| 1314 | 1314 | <MessagesTimeline |
| ... | ... | @@ -1324,26 +1324,27 @@ describe("MessagesTimeline", () => { |
| 1324 | 1324 | runningTurnId={turnId} |
| 1325 | 1325 | timelineEntries={[ |
| 1326 | 1326 | { |
| 1327 | id: "entry-failed", | |
| 1327 | id: "entry-declined", | |
| 1328 | 1328 | kind: "work", |
| 1329 | 1329 | createdAt: MESSAGE_CREATED_AT, |
| 1330 | 1330 | entry: { |
| 1331 | id: "work-failed", | |
| 1331 | id: "work-declined", | |
| 1332 | 1332 | createdAt: MESSAGE_CREATED_AT, |
| 1333 | 1333 | turnId, |
| 1334 | toolCallId: "call-failed", | |
| 1334 | toolCallId: "call-declined", | |
| 1335 | 1335 | label: "Run lint", |
| 1336 | 1336 | tone: "tool", |
| 1337 | 1337 | itemType: "command_execution", |
| 1338 | 1338 | command: "pnpm lint", |
| 1339 | toolLifecycleStatus: "failed", | |
| 1339 | toolLifecycleStatus: "declined", | |
| 1340 | 1340 | }, |
| 1341 | 1341 | }, |
| 1342 | 1342 | ]} |
| 1343 | 1343 | />, |
| 1344 | 1344 | ); |
| 1345 | 1345 | |
| 1346 | expect(markup).toContain("Running pnpm"); | |
| 1346 | expect(markup).toContain("Declined pnpm"); | |
| 1347 | expect(markup).toContain("Thinking"); | |
| 1347 | 1348 | expect(markup).toContain("tool call failed"); |
| 1348 | 1349 | }); |
| 1349 | 1350 |
apps/web/src/components/chat/MessagesTimeline.tsx+52-34| ... | ... | @@ -981,7 +981,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 981 | 981 | : "pb-0" |
| 982 | 982 | : isExpandedToolGroupHeader |
| 983 | 983 | ? "pb-0" |
| 984 | : row.kind === "turn-fold" || row.kind === "working" | |
| 984 | : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" | |
| 985 | 985 | ? "pb-1.5" |
| 986 | 986 | : (row.kind === "message" && |
| 987 | 987 | row.message.role === "assistant" && |
| ... | ... | @@ -1013,6 +1013,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1013 | 1013 | ) : null} |
| 1014 | 1014 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 1015 | 1015 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 1016 | {row.kind === "thinking" ? <ThinkingTimelineRow /> : null} | |
| 1016 | 1017 | </div> |
| 1017 | 1018 | ); |
| 1018 | 1019 | }); |
| ... | ... | @@ -1330,34 +1331,36 @@ function ProposedPlanTimelineRow({ |
| 1330 | 1331 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| 1331 | 1332 | const { isPreparingWorktree } = use(TimelineRowActivityCtx); |
| 1332 | 1333 | 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> | |
| 1354 | 1353 | </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 | ||
| 1358 | function 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" />} | |
| 1361 | 1364 | </div> |
| 1362 | 1365 | ); |
| 1363 | 1366 | } |
| ... | ... | @@ -1514,7 +1517,7 @@ function LiveActivityContent({ |
| 1514 | 1517 | |
| 1515 | 1518 | function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) { |
| 1516 | 1519 | const ctx = use(TimelineRowCtx); |
| 1517 | const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot); | |
| 1520 | const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); | |
| 1518 | 1521 | const failed = workEntryDisplayIndicatesToolFailure(row.entry); |
| 1519 | 1522 | |
| 1520 | 1523 | return ( |
| ... | ... | @@ -1525,7 +1528,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " |
| 1525 | 1528 | aria-expanded={row.expanded} |
| 1526 | 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 | 1543 | </button> |
| 1530 | 1544 | ); |
| 1531 | 1545 | } |
| ... | ... | @@ -2205,14 +2219,18 @@ function workEntryRawCommand( |
| 2205 | 2219 | function liveWorkEntryLabel( |
| 2206 | 2220 | workEntry: TimelineWorkEntry, |
| 2207 | 2221 | workspaceRoot: string | undefined, |
| 2222 | active: boolean, | |
| 2208 | 2223 | ): string { |
| 2209 | 2224 | const command = workEntry.command?.trim(); |
| 2210 | 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 | 2226 | 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`; | |
| 2216 | 2234 | } |
| 2217 | 2235 | |
| 2218 | 2236 | return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); |