| 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,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 | }); |
| 834 | 835 | ||
| 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 | }); |
| 945 | 946 | ||
| 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 | }); |
| 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 | 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 | }); |
| 1228 | 1229 | ||
| 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 | }); |
| 1235 | 1234 | ||
| 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", () => { |
| 1378 | 1377 | ||
| 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 | }); |
| 1383 | 1382 | ||
| 1384 | it.each([ | 1383 | it.each([ |
| ... | @@ -1547,7 +1546,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1547,7 +1546,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1547 | }); | 1546 | }); |
| 1548 | 1547 | ||
| 1549 | describe("computeStableMessagesTimelineRows", () => { | 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 | 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 | ); |
| 1590 | 1589 | ||
| 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 | }); |
| 1597 | 1596 | ||
| 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 | }; |
| 240 | 245 | ||
| 241 | export interface StableMessagesTimelineRowsState { | 246 | export 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 | } |
| 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 | * 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 the | 530 | * 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 && latestActiveToolEntry | 743 | 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 | } | ||
| 969 | 981 | ||
| 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 |
| 996 | 1008 | ||
| 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).showThinking | 1012 | return a.createdAt === (b as typeof a).createdAt; |
| 1001 | ); | ||
| 1002 | 1013 | ||
| 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 | }); |
| 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 | const turnId = TurnId.make("turn-live"); | 1312 | const turnId = TurnId.make("turn-live"); |
| 1313 | const markup = renderToStaticMarkup( | 1313 | const markup = renderToStaticMarkup( |
| 1314 | <MessagesTimeline | 1314 | <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 | ); |
| 1345 | 1345 | ||
| 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 | }); |
| 1349 | 1350 |
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 | : isExpandedToolGroupHeader | 982 | : 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({ |
| 1330 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { | 1331 | function 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 | <span | 1337 | 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> | 1358 | function 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({ |
| 1514 | 1517 | ||
| 1515 | function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) { | 1518 | function 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); |
| 1519 | 1522 | ||
| 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( |
| 2205 | function liveWorkEntryLabel( | 2219 | function 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 | } |
| 2217 | 2235 | ||
| 2218 | return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); | 2236 | return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); |