The v2 equivalents of main's #8984 and #8922: a "Working for ..." header anchors the active run, the trailing live tool row survives between actions in past tense instead of vanishing, and a shimmering Thinking row marks reasoning gaps. During workspace preparation the header shows "Setting up worktree..." (driven by the local dispatch flag or the v2 run's preparing status, so remote viewers see it too), the composer footer span is gone, and draft promotion waits until the run starts or startup fails instead of navigating mid-preparation.
Unified · Split
| @@ -30,6 +30,7 @@ import { · 29 unmodified lines | |||
| 30 | loadVideoPreviewUrl, | 30 | loadVideoPreviewUrl, |
| 31 | reconcileMountedTerminalThreadIds, | 31 | reconcileMountedTerminalThreadIds, |
| 32 | reconcileRetainedMountedThreadIds, | 32 | reconcileRetainedMountedThreadIds, |
| 33 | resolveDraftPromotionNavigationTarget, | ||
| 33 | resolveEffectiveInteractionMode, | 34 | resolveEffectiveInteractionMode, |
| 34 | resolveThreadMetadataUpdateForNextTurn, | 35 | resolveThreadMetadataUpdateForNextTurn, |
| 35 | resolveSendEnvMode, | 36 | resolveSendEnvMode, |
| @@ -308,6 +309,62 @@ const readySession = { · 272 unmodified lines | |||
| 308 | updatedAt: "2026-03-29T00:00:10.000Z", | 309 | updatedAt: "2026-03-29T00:00:10.000Z", |
| 309 | }; | 310 | }; |
| 310 | 311 | ||
| 312 | describe("resolveDraftPromotionNavigationTarget", () => { | ||
| 313 | const serverThreadRef = { environmentId, threadId }; | ||
| 314 | const preparingRun = { | ||
| 315 | ...completedTurn, | ||
| 316 | status: "preparing" as const, | ||
| 317 | startedAt: null, | ||
| 318 | completedAt: null, | ||
| 319 | }; | ||
| 320 | |||
| 321 | it("stays on the draft while the workspace is still preparing", () => { | ||
| 322 | expect( | ||
| 323 | resolveDraftPromotionNavigationTarget({ | ||
| 324 | serverThreadRef, | ||
| 325 | serverThread: makeThread({ latestRun: preparingRun }), | ||
| 326 | backgroundSubmissionPending: false, | ||
| 327 | }), | ||
| 328 | ).toBeNull(); | ||
| 329 | expect( | ||
| 330 | resolveDraftPromotionNavigationTarget({ | ||
| 331 | serverThreadRef, | ||
| 332 | serverThread: makeThread(), | ||
| 333 | backgroundSubmissionPending: false, | ||
| 334 | }), | ||
| 335 | ).toBeNull(); | ||
| 336 | }); | ||
| 337 | |||
| 338 | it("navigates once the run starts or startup stops", () => { | ||
| 339 | expect( | ||
| 340 | resolveDraftPromotionNavigationTarget({ | ||
| 341 | serverThreadRef, | ||
| 342 | serverThread: makeThread({ latestRun: completedTurn }), | ||
| 343 | backgroundSubmissionPending: false, | ||
| 344 | }), | ||
| 345 | ).toBe(serverThreadRef); | ||
| 346 | expect( | ||
| 347 | resolveDraftPromotionNavigationTarget({ | ||
| 348 | serverThreadRef, | ||
| 349 | serverThread: makeThread({ | ||
| 350 | latestRun: { ...preparingRun, status: "failed" as const }, | ||
| 351 | }), | ||
| 352 | backgroundSubmissionPending: false, | ||
| 353 | }), | ||
| 354 | ).toBe(serverThreadRef); | ||
| 355 | }); | ||
| 356 | |||
| 357 | it("defers while a background submission is pending", () => { | ||
| 358 | expect( | ||
| 359 | resolveDraftPromotionNavigationTarget({ | ||
| 360 | serverThreadRef, | ||
| 361 | serverThread: makeThread({ latestRun: completedTurn }), | ||
| 362 | backgroundSubmissionPending: true, | ||
| 363 | }), | ||
| 364 | ).toBeNull(); | ||
| 365 | }); | ||
| 366 | }); | ||
| 367 | |||
| 311 | describe("resolveEffectiveInteractionMode", () => { | 368 | describe("resolveEffectiveInteractionMode", () => { |
| 312 | it("forces build mode when legacy plan mode is disabled", () => { | 369 | it("forces build mode when legacy plan mode is disabled", () => { |
| 313 | expect( | 370 | expect( |
| @@ -101,13 +101,21 @@ export function resolveDraftHeroState(input: { · 100 unmodified lines | |||
| 101 | 101 | ||
| 102 | export function resolveDraftPromotionNavigationTarget(input: { | 102 | export function resolveDraftPromotionNavigationTarget(input: { |
| 103 | serverThreadRef: ScopedThreadRef | null; | 103 | serverThreadRef: ScopedThreadRef | null; |
| 104 | serverThreadStarted: boolean; | 104 | serverThread: Pick<Thread, "latestRun"> | null | undefined; |
| 105 | backgroundSubmissionPending: boolean; | 105 | backgroundSubmissionPending: boolean; |
| 106 | }): ScopedThreadRef | null { | 106 | }): ScopedThreadRef | null { |
| 107 | if (input.backgroundSubmissionPending) { | 107 | if (input.backgroundSubmissionPending) { |
| 108 | return null; | 108 | return null; |
| 109 | } | 109 | } |
| 110 | return input.serverThreadStarted ? input.serverThreadRef : null; | 110 | const latestRun = input.serverThread?.latestRun ?? null; |
| 111 | const runStarted = latestRun?.startedAt != null; | ||
| 112 | const startupStopped = | ||
| 113 | latestRun?.status === "failed" || | ||
| 114 | latestRun?.status === "interrupted" || | ||
| 115 | latestRun?.status === "cancelled"; | ||
| 116 | // Keep local preparation feedback mounted until the server can render the | ||
| 117 | // running turn or its startup error on the canonical thread route. | ||
| 118 | return runStarted || startupStopped ? input.serverThreadRef : null; | ||
| 111 | } | 119 | } |
| 112 | 120 | ||
| 113 | export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { | 121 | export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { |
| @@ -2677,6 +2677,9 @@ function ChatViewContent(props: ChatViewProps) { · 2676 unmodified lines | |||
| 2677 | activeRuntime, | 2677 | activeRuntime, |
| 2678 | localDispatchStartedAt, | 2678 | localDispatchStartedAt, |
| 2679 | ); | 2679 | ); |
| 2680 | // Server-side workspace preparation: unlike the local-dispatch flag this | ||
| 2681 | // survives reloads and shows on remote viewers of the same thread. | ||
| 2682 | const activeRunPreparing = activeActivityRun?.status === "preparing"; | ||
| 2680 | useEffect(() => { | 2683 | useEffect(() => { |
| 2681 | attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; | 2684 | attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; |
| 2682 | }, [attachmentPreviewHandoffByMessageId]); | 2685 | }, [attachmentPreviewHandoffByMessageId]); |
| @@ -7421,6 +7424,8 @@ function ChatViewContent(props: ChatViewProps) { · 4738 unmodified lines | |||
| 7421 | key={activeThread.id} | 7424 | key={activeThread.id} |
| 7422 | isWorking={isWorking} | 7425 | isWorking={isWorking} |
| 7423 | activeTurnInProgress={isWorking || !latestRunSettled} | 7426 | activeTurnInProgress={isWorking || !latestRunSettled} |
| 7427 | activeTurnStartedAt={activeWorkStartedAt} | ||
| 7428 | isPreparingWorktree={isPreparingWorktree || activeRunPreparing} | ||
| 7424 | listRef={legendListRef} | 7429 | listRef={legendListRef} |
| 7425 | timelineEntries={timelineEntries} | 7430 | timelineEntries={timelineEntries} |
| 7426 | latestRun={activeActivityRun} | 7431 | latestRun={activeActivityRun} |
| @@ -541,9 +541,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 540 unmodified lines | |||
| 541 | compactDisabledReason={props.compactDisabledReason} | 541 | compactDisabledReason={props.compactDisabledReason} |
| 542 | /> | 542 | /> |
| 543 | ) : null} | 543 | ) : null} |
| 544 | {props.isPreparingWorktree ? ( | ||
| 545 | <span className="text-secondary-label text-xs">Preparing worktree...</span> | ||
| 546 | ) : null} | ||
| 547 | <ComposerPrimaryActions | 544 | <ComposerPrimaryActions |
| 548 | compact={props.compact} | 545 | compact={props.compact} |
| 549 | pendingAction={props.pendingAction} | 546 | pendingAction={props.pendingAction} |
| @@ -148,6 +148,6 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { · 147 unmodified lines | |||
| 148 | return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; | 148 | return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; |
| 149 | } | 149 | } |
| 150 | 150 | ||
| 151 | function formatWorkingTimerNow(startIso: string): string { | 151 | export function formatWorkingTimerNow(startIso: string): string { |
| 152 | return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; | 152 | return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; |
| 153 | } | 153 | } |
| @@ -1579,9 +1579,53 @@ describe("deriveMessagesTimelineRows", () => { · 1578 unmodified lines | |||
| 1579 | "turn-fold:turn-1", | 1579 | "turn-fold:turn-1", |
| 1580 | "assistant-final-entry", | 1580 | "assistant-final-entry", |
| 1581 | "user-followup-entry", | 1581 | "user-followup-entry", |
| 1582 | "working-indicator-row", | ||
| 1583 | "thinking-indicator-row", | ||
| 1582 | ]); | 1584 | ]); |
| 1583 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); | 1585 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); |
| 1584 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); | 1586 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); |
| 1587 | expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); | ||
| 1588 | }); | ||
| 1589 | |||
| 1590 | it("keeps the settled tool visible and shows thinking between actions", () => { | ||
| 1591 | const rows = deriveMessagesTimelineRows({ | ||
| 1592 | timelineEntries: [ | ||
| 1593 | { | ||
| 1594 | id: "work-entry-1", | ||
| 1595 | kind: "work", | ||
| 1596 | createdAt: "2026-01-01T00:00:08Z", | ||
| 1597 | entry: { | ||
| 1598 | id: "work-1", | ||
| 1599 | createdAt: "2026-01-01T00:00:08Z", | ||
| 1600 | runId: "turn-1" as never, | ||
| 1601 | label: "Ran command", | ||
| 1602 | command: "vp test run", | ||
| 1603 | tone: "tool" as const, | ||
| 1604 | itemType: "command_execution" as const, | ||
| 1605 | toolLifecycleStatus: "completed" as const, | ||
| 1606 | }, | ||
| 1607 | }, | ||
| 1608 | ], | ||
| 1609 | latestRun: { | ||
| 1610 | runId: "turn-1" as never, | ||
| 1611 | status: "running", | ||
| 1612 | startedAt: "2026-01-01T00:00:00Z", | ||
| 1613 | completedAt: null, | ||
| 1614 | }, | ||
| 1615 | isWorking: true, | ||
| 1616 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | ||
| 1617 | turnDiffSummaryByAssistantMessageId: new Map(), | ||
| 1618 | revertTurnCountByUserMessageId: new Map(), | ||
| 1619 | }); | ||
| 1620 | |||
| 1621 | // The settled tool stays visible in past tense instead of vanishing, and | ||
| 1622 | // the thinking indicator marks that the turn itself is still running. | ||
| 1623 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); | ||
| 1624 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ | ||
| 1625 | active: false, | ||
| 1626 | entry: { id: "work-1" }, | ||
| 1627 | }); | ||
| 1628 | expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); | ||
| 1585 | }); | 1629 | }); |
| 1586 | 1630 | ||
| 1587 | it("does not fold the active in-progress turn", () => { | 1631 | it("does not fold the active in-progress turn", () => { |
| @@ -1629,6 +1673,7 @@ describe("deriveMessagesTimelineRows", () => { · 41 unmodified lines | |||
| 1629 | 1673 | ||
| 1630 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); | 1674 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); |
| 1631 | expect(rows.map((row) => row.id)).toEqual([ | 1675 | expect(rows.map((row) => row.id)).toEqual([ |
| 1676 | "working-indicator-row", | ||
| 1632 | "assistant-thought-entry", | 1677 | "assistant-thought-entry", |
| 1633 | "work-live:work-entry-1", | 1678 | "work-live:work-entry-1", |
| 1634 | ]); | 1679 | ]); |
| @@ -1857,6 +1902,59 @@ describe("deriveMessagesTimelineRows", () => { · 222 unmodified lines | |||
| 1857 | }); | 1902 | }); |
| 1858 | 1903 | ||
| 1859 | describe("computeStableMessagesTimelineRows", () => { | 1904 | describe("computeStableMessagesTimelineRows", () => { |
| 1905 | it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => { | ||
| 1906 | const startedAt = "2026-01-01T00:00:00Z"; | ||
| 1907 | const runId = RunId.make("run-1"); | ||
| 1908 | const input = { | ||
| 1909 | latestRun: { | ||
| 1910 | runId, | ||
| 1911 | status: "running" as const, | ||
| 1912 | startedAt, | ||
| 1913 | completedAt: null, | ||
| 1914 | }, | ||
| 1915 | isWorking: true, | ||
| 1916 | activeTurnStartedAt: startedAt, | ||
| 1917 | turnDiffSummaryByAssistantMessageId: new Map(), | ||
| 1918 | revertTurnCountByUserMessageId: new Map(), | ||
| 1919 | }; | ||
| 1920 | const assistantEntry = { | ||
| 1921 | id: "assistant-entry", | ||
| 1922 | kind: "message" as const, | ||
| 1923 | createdAt: startedAt, | ||
| 1924 | message: { | ||
| 1925 | id: "assistant-1" as never, | ||
| 1926 | role: "assistant" as const, | ||
| 1927 | text, | ||
| 1928 | runId, | ||
| 1929 | createdAt: startedAt, | ||
| 1930 | updatedAt: startedAt, | ||
| 1931 | streaming: true, | ||
| 1932 | }, | ||
| 1933 | }; | ||
| 1934 | const initial = computeStableMessagesTimelineRows( | ||
| 1935 | deriveMessagesTimelineRows({ ...input, timelineEntries: [assistantEntry] }), | ||
| 1936 | { byId: new Map(), result: [] }, | ||
| 1937 | ); | ||
| 1938 | const updated = computeStableMessagesTimelineRows( | ||
| 1939 | deriveMessagesTimelineRows({ | ||
| 1940 | ...input, | ||
| 1941 | timelineEntries: [ | ||
| 1942 | { | ||
| 1943 | ...assistantEntry, | ||
| 1944 | message: { ...assistantEntry.message, text: "I will inspect the repository." }, | ||
| 1945 | }, | ||
| 1946 | ], | ||
| 1947 | }), | ||
| 1948 | initial, | ||
| 1949 | ); | ||
| 1950 | |||
| 1951 | const initialThinking = initial.byId.get("thinking-indicator-row"); | ||
| 1952 | const updatedThinking = updated.byId.get("thinking-indicator-row"); | ||
| 1953 | expect(initialThinking).toMatchObject({ kind: "thinking" }); | ||
| 1954 | expect(updatedThinking).toBe(initialThinking); | ||
| 1955 | expect(updated.result.at(-1)).toBe(updatedThinking); | ||
| 1956 | }); | ||
| 1957 | |||
| 1860 | it("returns the previous result when row order and content are unchanged", () => { | 1958 | it("returns the previous result when row order and content are unchanged", () => { |
| 1861 | const firstUserMessage = { | 1959 | const firstUserMessage = { |
| 1862 | id: "user-1" as never, | 1960 | id: "user-1" as never, |
| @@ -43,6 +43,24 @@ import { · 42 unmodified lines | |||
| 43 | type T3McpToolSummaryAction, | 43 | type T3McpToolSummaryAction, |
| 44 | } from "@t3tools/shared/t3McpToolPresentation"; | 44 | } from "@t3tools/shared/t3McpToolPresentation"; |
| 45 | 45 | ||
| 46 | function timelineEntryRunId(entry: TimelineEntry): RunId | null { | ||
| 47 | if (entry.kind === "message") { | ||
| 48 | return entry.message.role === "assistant" ? (entry.message.runId ?? null) : null; | ||
| 49 | } | ||
| 50 | if (entry.kind === "proposed-plan") { | ||
| 51 | return entry.proposedPlan.runId; | ||
| 52 | } | ||
| 53 | return entry.kind === "work" ? (entry.entry.runId ?? null) : null; | ||
| 54 | } | ||
| 55 | |||
| 56 | /** Whether the entry still represents live activity, not a settled result. */ | ||
| 57 | function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { | ||
| 58 | return ( | ||
| 59 | entry.toolLifecycleStatus === "inProgress" || | ||
| 60 | (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry)) | ||
| 61 | ); | ||
| 62 | } | ||
| 63 | |||
| 46 | export function workEntryIsVisibleInGroup( | 64 | export function workEntryIsVisibleInGroup( |
| 47 | entry: WorkLogEntry, | 65 | entry: WorkLogEntry, |
| 48 | expandedToolGroupEntry = false, | 66 | expandedToolGroupEntry = false, |
| @@ -223,6 +241,17 @@ export type MessagesTimelineRow = · 174 unmodified lines | |||
| 223 | groupedEntries: WorkLogEntry[]; | 241 | groupedEntries: WorkLogEntry[]; |
| 224 | groupId: string; | 242 | groupId: string; |
| 225 | expanded: boolean; | 243 | expanded: boolean; |
| 244 | active: boolean; | ||
| 245 | } | ||
| 246 | | { | ||
| 247 | kind: "working"; | ||
| 248 | id: string; | ||
| 249 | createdAt: string | null; | ||
| 250 | } | ||
| 251 | | { | ||
| 252 | kind: "thinking"; | ||
| 253 | id: string; | ||
| 254 | createdAt: string | null; | ||
| 226 | } | 255 | } |
| 227 | | { | 256 | | { |
| 228 | kind: "work-toggle"; | 257 | kind: "work-toggle"; |
| @@ -788,6 +817,7 @@ export function deriveMessagesTimelineRows(input: { · 559 unmodified lines | |||
| 788 | expandedAttemptIds?: ReadonlySet<RunAttemptId>; | 817 | expandedAttemptIds?: ReadonlySet<RunAttemptId>; |
| 789 | expandedWorkGroupIds?: ReadonlySet<string>; | 818 | expandedWorkGroupIds?: ReadonlySet<string>; |
| 790 | isWorking: boolean; | 819 | isWorking: boolean; |
| 820 | activeTurnStartedAt?: string | null; | ||
| 791 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; | 821 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; |
| 792 | revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>; | 822 | revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>; |
| 793 | }): MessagesTimelineRow[] { | 823 | }): MessagesTimelineRow[] { |
| @@ -826,12 +856,121 @@ export function deriveMessagesTimelineRows(input: { · 32 unmodified lines | |||
| 826 | entry.toolLifecycleStatus === "inProgress" && | 856 | entry.toolLifecycleStatus === "inProgress" && |
| 827 | entry.runId === unsettledRunId; | 857 | entry.runId === unsettledRunId; |
| 828 | 858 | ||
| 859 | // The active run's header row ("Working for ...") anchors right after the | ||
| 860 | // latest user message, or at the run's first owned work entry when one | ||
| 861 | // already rendered above it. | ||
| 862 | let activeTurnHeaderIndex = input.timelineEntries.length; | ||
| 863 | if (input.isWorking) { | ||
| 864 | const latestUserMessageIndex = input.timelineEntries.findLastIndex( | ||
| 865 | (entry) => entry.kind === "message" && entry.message.role === "user", | ||
| 866 | ); | ||
| 867 | const firstOwnedAfterUser = | ||
| 868 | unsettledRunId === null | ||
| 869 | ? -1 | ||
| 870 | : input.timelineEntries.findIndex( | ||
| 871 | (entry, index) => | ||
| 872 | index > latestUserMessageIndex && timelineEntryRunId(entry) === unsettledRunId, | ||
| 873 | ); | ||
| 874 | activeTurnHeaderIndex = | ||
| 875 | firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1; | ||
| 876 | } | ||
| 877 | |||
| 878 | // Contiguous trailing work entries of the active run collapse into one live | ||
| 879 | // row that survives between actions: while a tool runs it shows that tool, | ||
| 880 | // and once everything settles it keeps the latest tool in past tense | ||
| 881 | // instead of vanishing (#8984). | ||
| 882 | const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = []; | ||
| 883 | if (input.isWorking && unsettledRunId !== null) { | ||
| 884 | let tailAttemptId: string | null | undefined; | ||
| 885 | for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { | ||
| 886 | const entry = input.timelineEntries[index]!; | ||
| 887 | if ( | ||
| 888 | entry.kind !== "work" || | ||
| 889 | entry.entry.tone === "error" || | ||
| 890 | (entry.entry.runId ?? null) !== unsettledRunId || | ||
| 891 | collapsedEntryIds.has(entry.id) || | ||
| 892 | collapsedSupersededEntryIds.has(entry.id) || | ||
| 893 | foldsByAnchorEntryId.has(entry.id) || | ||
| 894 | supersededFoldsByAnchorEntryId.has(entry.id) | ||
| 895 | ) { | ||
| 896 | break; | ||
| 897 | } | ||
| 898 | if (tailAttemptId === undefined) { | ||
| 899 | tailAttemptId = entry.attempt?.id ?? null; | ||
| 900 | } else if ((entry.attempt?.id ?? null) !== tailAttemptId) { | ||
| 901 | break; | ||
| 902 | } | ||
| 903 | activeToolEntries.unshift(entry); | ||
| 904 | } | ||
| 905 | } | ||
| 906 | const visibleActiveToolEntries = activeToolEntries.filter((entry) => | ||
| 907 | workEntryIsVisibleInGroup(entry.entry, true), | ||
| 908 | ); | ||
| 909 | const activeWorkAnchor = activeToolEntries[0]; | ||
| 910 | const latestVisibleToolEntry = visibleActiveToolEntries.at(-1); | ||
| 911 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => | ||
| 912 | workEntryIsActiveTurnActivity(entry.entry), | ||
| 913 | ); | ||
| 914 | const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; | ||
| 915 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; | ||
| 916 | const activeWorkRow = | ||
| 917 | activeWorkAnchor && displayedToolEntry | ||
| 918 | ? (() => { | ||
| 919 | const groupId = workGroupId(activeWorkAnchor.id); | ||
| 920 | return { | ||
| 921 | kind: "work-live" as const, | ||
| 922 | id: `work-live:${activeWorkAnchor.id}`, | ||
| 923 | createdAt: activeWorkAnchor.createdAt, | ||
| 924 | entry: displayedToolEntry.entry, | ||
| 925 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), | ||
| 926 | groupId, | ||
| 927 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, | ||
| 928 | active: latestRunningToolEntry !== undefined, | ||
| 929 | }; | ||
| 930 | })() | ||
| 931 | : null; | ||
| 932 | const activeWorkEntryIds = new Set( | ||
| 933 | activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id), | ||
| 934 | ); | ||
| 935 | const appendWorkingRow = () => { | ||
| 936 | nextRows.push({ | ||
| 937 | kind: "working", | ||
| 938 | id: "working-indicator-row", | ||
| 939 | createdAt: input.activeTurnStartedAt ?? null, | ||
| 940 | }); | ||
| 941 | }; | ||
| 942 | let hasLiveWorkRow = false; | ||
| 943 | const appendActiveWorkRows = () => { | ||
| 944 | if (activeWorkRow === null) return; | ||
| 945 | nextRows.push(activeWorkRow); | ||
| 946 | hasLiveWorkRow ||= activeWorkRow.active; | ||
| 947 | if (!activeWorkRow.expanded) return; | ||
| 948 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { | ||
| 949 | nextRows.push({ | ||
| 950 | kind: "work", | ||
| 951 | id: workEntry.id, | ||
| 952 | createdAt: workEntry.createdAt, | ||
| 953 | groupedEntries: [workEntry], | ||
| 954 | isExpandedToolGroupEntry: true, | ||
| 955 | isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, | ||
| 956 | }); | ||
| 957 | } | ||
| 958 | }; | ||
| 959 | |||
| 829 | for (let index = 0; index < input.timelineEntries.length; index += 1) { | 960 | for (let index = 0; index < input.timelineEntries.length; index += 1) { |
| 830 | const timelineEntry = input.timelineEntries[index]; | 961 | const timelineEntry = input.timelineEntries[index]; |
| 831 | if (!timelineEntry) { | 962 | if (!timelineEntry) { |
| 832 | continue; | 963 | continue; |
| 833 | } | 964 | } |
| 834 | 965 | ||
| 966 | if (input.isWorking && index === activeTurnHeaderIndex) { | ||
| 967 | appendWorkingRow(); | ||
| 968 | } | ||
| 969 | |||
| 970 | if (timelineEntry.id === activeWorkPlacementEntryId) { | ||
| 971 | appendActiveWorkRows(); | ||
| 972 | } | ||
| 973 | |||
| 835 | // The terminal interrupt result is the useful timeline marker. The | 974 | // The terminal interrupt result is the useful timeline marker. The |
| 836 | // preceding request is transient bookkeeping and duplicates that marker. | 975 | // preceding request is transient bookkeeping and duplicates that marker. |
| 837 | if ( | 976 | if ( |
| @@ -874,6 +1013,10 @@ export function deriveMessagesTimelineRows(input: { · 36 unmodified lines | |||
| 874 | continue; | 1013 | continue; |
| 875 | } | 1014 | } |
| 876 | 1015 | ||
| 1016 | if (activeWorkEntryIds.has(timelineEntry.id)) { | ||
| 1017 | continue; | ||
| 1018 | } | ||
| 1019 | |||
| 877 | if (timelineEntry.kind === "work") { | 1020 | if (timelineEntry.kind === "work") { |
| 878 | if (timelineEntry.entry.tone === "error") { | 1021 | if (timelineEntry.entry.tone === "error") { |
| 879 | nextRows.push({ | 1022 | nextRows.push({ |
| @@ -894,6 +1037,7 @@ export function deriveMessagesTimelineRows(input: { · 14 unmodified lines | |||
| 894 | !nextEntry || | 1037 | !nextEntry || |
| 895 | nextEntry.kind !== "work" || | 1038 | nextEntry.kind !== "work" || |
| 896 | nextEntry.entry.tone === "error" || | 1039 | nextEntry.entry.tone === "error" || |
| 1040 | activeWorkEntryIds.has(nextEntry.id) || | ||
| 897 | collapsedEntryIds.has(nextEntry.id) || | 1041 | collapsedEntryIds.has(nextEntry.id) || |
| 898 | collapsedSupersededEntryIds.has(nextEntry.id) || | 1042 | collapsedSupersededEntryIds.has(nextEntry.id) || |
| 899 | foldsByAnchorEntryId.has(nextEntry.id) || | 1043 | foldsByAnchorEntryId.has(nextEntry.id) || |
| @@ -906,22 +1050,16 @@ export function deriveMessagesTimelineRows(input: { · 6 unmodified lines | |||
| 906 | groupedEntries.push(nextEntry.entry); | 1050 | groupedEntries.push(nextEntry.entry); |
| 907 | cursor += 1; | 1051 | cursor += 1; |
| 908 | } | 1052 | } |
| 909 | const isActiveTail = | ||
| 910 | input.isWorking && | ||
| 911 | unsettledRunId !== null && | ||
| 912 | timelineEntry.entry.runId === unsettledRunId && | ||
| 913 | cursor === input.timelineEntries.length; | ||
| 914 | const visibleGroupedEntries = groupedEntries.filter((entry) => | 1053 | const visibleGroupedEntries = groupedEntries.filter((entry) => |
| 915 | workEntryIsVisibleInGroup(entry, isActiveTail || workEntryIsInActiveRun(entry)), | 1054 | workEntryIsVisibleInGroup(entry, workEntryIsInActiveRun(entry)), |
| 916 | ); | 1055 | ); |
| 917 | if (visibleGroupedEntries.length > 0) { | 1056 | if (visibleGroupedEntries.length > 0) { |
| 918 | const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun); | 1057 | const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun); |
| 919 | if (isActiveTail || activeInProgressToolEntries.length > 0) { | 1058 | if (activeInProgressToolEntries.length > 0) { |
| 920 | const groupId = workGroupId(timelineEntry.id); | 1059 | const groupId = workGroupId(timelineEntry.id); |
| 921 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; | 1060 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 922 | const latestActiveToolEntry = ( | 1061 | const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; |
| 923 | isActiveTail ? visibleGroupedEntries : activeInProgressToolEntries | 1062 | hasLiveWorkRow = true; |
| 924 | ).at(-1)!; | ||
| 925 | nextRows.push({ | 1063 | nextRows.push({ |
| 926 | kind: "work-live", | 1064 | kind: "work-live", |
| 927 | id: `work-live:${timelineEntry.id}`, | 1065 | id: `work-live:${timelineEntry.id}`, |
| @@ -930,6 +1068,7 @@ export function deriveMessagesTimelineRows(input: { · 2 unmodified lines | |||
| 930 | groupedEntries: visibleGroupedEntries, | 1068 | groupedEntries: visibleGroupedEntries, |
| 931 | groupId, | 1069 | groupId, |
| 932 | expanded, | 1070 | expanded, |
| 1071 | active: true, | ||
| 933 | }); | 1072 | }); |
| 934 | if (expanded) { | 1073 | if (expanded) { |
| 935 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | 1074 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
| @@ -1050,6 +1189,17 @@ export function deriveMessagesTimelineRows(input: { · 114 unmodified lines | |||
| 1050 | }); | 1189 | }); |
| 1051 | } | 1190 | } |
| 1052 | 1191 | ||
| 1192 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { | ||
| 1193 | appendWorkingRow(); | ||
| 1194 | } | ||
| 1195 | if (input.isWorking && !hasLiveWorkRow) { | ||
| 1196 | nextRows.push({ | ||
| 1197 | kind: "thinking", | ||
| 1198 | id: "thinking-indicator-row", | ||
| 1199 | createdAt: input.activeTurnStartedAt ?? null, | ||
| 1200 | }); | ||
| 1201 | } | ||
| 1202 | |||
| 1053 | return nextRows; | 1203 | return nextRows; |
| 1054 | } | 1204 | } |
| 1055 | 1205 | ||
| @@ -1131,6 +1281,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 75 unmodified lines | |||
| 1131 | if (a.kind !== b.kind || a.id !== b.id) return false; | 1281 | if (a.kind !== b.kind || a.id !== b.id) return false; |
| 1132 | 1282 | ||
| 1133 | switch (a.kind) { | 1283 | switch (a.kind) { |
| 1284 | case "working": | ||
| 1285 | case "thinking": | ||
| 1286 | return a.createdAt === (b as typeof a).createdAt; | ||
| 1287 | |||
| 1134 | case "turn-fold": { | 1288 | case "turn-fold": { |
| 1135 | const bf = b as typeof a; | 1289 | const bf = b as typeof a; |
| 1136 | return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; | 1290 | return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; |
| @@ -1178,6 +1332,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 41 unmodified lines | |||
| 1178 | a.createdAt === bw.createdAt && | 1332 | a.createdAt === bw.createdAt && |
| 1179 | a.groupId === bw.groupId && | 1333 | a.groupId === bw.groupId && |
| 1180 | a.expanded === bw.expanded && | 1334 | a.expanded === bw.expanded && |
| 1335 | a.active === bw.active && | ||
| 1181 | Equal.equals(a.entry, bw.entry) && | 1336 | Equal.equals(a.entry, bw.entry) && |
| 1182 | Equal.equals(a.groupedEntries, bw.groupedEntries) | 1337 | Equal.equals(a.groupedEntries, bw.groupedEntries) |
| 1183 | ); | 1338 | ); |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 13 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 14 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; | 14 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 15 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; | 15 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; |
| 16 | import { formatWorkingTimerNow } from "./ComposerActivityStatus"; | ||
| 16 | import { | 17 | import { |
| 17 | resolveViewedImageAsset, | 18 | resolveViewedImageAsset, |
| 18 | workEntryViewedImagePath, | 19 | workEntryViewedImagePath, |
| @@ -197,6 +198,7 @@ interface TimelineRowActivityState { · 178 unmodified lines | |||
| 197 | isWorking: boolean; | 198 | isWorking: boolean; |
| 198 | isRevertingCheckpoint: boolean; | 199 | isRevertingCheckpoint: boolean; |
| 199 | activeTurnInProgress: boolean; | 200 | activeTurnInProgress: boolean; |
| 201 | isPreparingWorktree: boolean; | ||
| 200 | latestRunId: RunId | null; | 202 | latestRunId: RunId | null; |
| 201 | } | 203 | } |
| 202 | 204 | ||
| @@ -233,6 +235,8 @@ export interface MessagesTimelineHistoryControls { · 30 unmodified lines | |||
| 233 | interface MessagesTimelineProps { | 235 | interface MessagesTimelineProps { |
| 234 | isWorking: boolean; | 236 | isWorking: boolean; |
| 235 | activeTurnInProgress: boolean; | 237 | activeTurnInProgress: boolean; |
| 238 | activeTurnStartedAt?: string | null; | ||
| 239 | isPreparingWorktree?: boolean; | ||
| 236 | listRef: React.RefObject<LegendListRef | null>; | 240 | listRef: React.RefObject<LegendListRef | null>; |
| 237 | timelineEntries: ReadonlyArray<TimelineEntry>; | 241 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 238 | latestRun: TimelineLatestRun | null; | 242 | latestRun: TimelineLatestRun | null; |
| @@ -292,6 +296,8 @@ interface MessagesTimelineProps { · 53 unmodified lines | |||
| 292 | export const MessagesTimeline = memo(function MessagesTimeline({ | 296 | export const MessagesTimeline = memo(function MessagesTimeline({ |
| 293 | isWorking, | 297 | isWorking, |
| 294 | activeTurnInProgress, | 298 | activeTurnInProgress, |
| 299 | activeTurnStartedAt = null, | ||
| 300 | isPreparingWorktree = false, | ||
| 295 | listRef, | 301 | listRef, |
| 296 | timelineEntries, | 302 | timelineEntries, |
| 297 | latestRun, | 303 | latestRun, |
| @@ -468,6 +474,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 170 unmodified lines | |||
| 468 | expandedAttemptIds, | 474 | expandedAttemptIds, |
| 469 | expandedWorkGroupIds, | 475 | expandedWorkGroupIds, |
| 470 | isWorking, | 476 | isWorking, |
| 477 | activeTurnStartedAt, | ||
| 471 | turnDiffSummaryByAssistantMessageId, | 478 | turnDiffSummaryByAssistantMessageId, |
| 472 | revertTurnCountByUserMessageId, | 479 | revertTurnCountByUserMessageId, |
| 473 | }), | 480 | }), |
| @@ -478,6 +485,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 4 unmodified lines | |||
| 478 | expandedAttemptIds, | 485 | expandedAttemptIds, |
| 479 | expandedWorkGroupIds, | 486 | expandedWorkGroupIds, |
| 480 | isWorking, | 487 | isWorking, |
| 488 | activeTurnStartedAt, | ||
| 481 | turnDiffSummaryByAssistantMessageId, | 489 | turnDiffSummaryByAssistantMessageId, |
| 482 | revertTurnCountByUserMessageId, | 490 | revertTurnCountByUserMessageId, |
| 483 | ], | 491 | ], |
| @@ -644,9 +652,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 160 unmodified lines | |||
| 644 | isWorking, | 652 | isWorking, |
| 645 | isRevertingCheckpoint, | 653 | isRevertingCheckpoint, |
| 646 | activeTurnInProgress, | 654 | activeTurnInProgress, |
| 655 | isPreparingWorktree, | ||
| 647 | latestRunId: latestRun?.runId ?? null, | 656 | latestRunId: latestRun?.runId ?? null, |
| 648 | }), | 657 | }), |
| 649 | [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestRun?.runId], | 658 | [activeTurnInProgress, isPreparingWorktree, isRevertingCheckpoint, isWorking, latestRun?.runId], |
| 650 | ); | 659 | ); |
| 651 | const listHeader = useMemo(() => { | 660 | const listHeader = useMemo(() => { |
| 652 | const leadingContent = | 661 | const leadingContent = |
| @@ -1109,17 +1118,19 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 456 unmodified lines | |||
| 1109 | ? "pb-0" | 1118 | ? "pb-0" |
| 1110 | : row.kind === "turn-fold" | 1119 | : row.kind === "turn-fold" |
| 1111 | ? "pb-0" | 1120 | ? "pb-0" |
| 1112 | : (row.kind === "message" && | 1121 | : row.kind === "working" || row.kind === "thinking" |
| 1113 | row.message.role === "assistant" && | 1122 | ? "pb-1.5" |
| 1114 | !row.showAssistantMeta) || | 1123 | : (row.kind === "message" && |
| 1115 | row.kind === "work" || | 1124 | row.message.role === "assistant" && |
| 1116 | row.kind === "work-live" || | 1125 | !row.showAssistantMeta) || |
| 1117 | row.kind === "work-toggle" || | 1126 | row.kind === "work" || |
| 1118 | row.kind === "turn-plan" || | 1127 | row.kind === "work-live" || |
| 1119 | row.kind === "event" || | 1128 | row.kind === "work-toggle" || |
| 1120 | row.kind === "attempt-fold" | 1129 | row.kind === "turn-plan" || |
| 1121 | ? "pb-2" | 1130 | row.kind === "event" || |
| 1122 | : "pb-4", | 1131 | row.kind === "attempt-fold" |
| 1132 | ? "pb-2" | ||
| 1133 | : "pb-4", | ||
| 1123 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, | 1134 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, |
| 1124 | )} | 1135 | )} |
| 1125 | data-timeline-row-id={row.id} | 1136 | data-timeline-row-id={row.id} |
| @@ -1142,6 +1153,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 16 unmodified lines | |||
| 1142 | <AssistantTimelineRow row={row} /> | 1153 | <AssistantTimelineRow row={row} /> |
| 1143 | ) : null} | 1154 | ) : null} |
| 1144 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} | 1155 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 1156 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} | ||
| 1157 | {row.kind === "thinking" ? <ThinkingTimelineRow /> : null} | ||
| 1145 | {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null} | 1158 | {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null} |
| 1146 | {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null} | 1159 | {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null} |
| 1147 | </div> | 1160 | </div> |
| @@ -2012,6 +2025,78 @@ const WorkGroupSection = memo(function WorkGroupSection({ · 864 unmodified lines | |||
| 2012 | ); | 2025 | ); |
| 2013 | }); | 2026 | }); |
| 2014 | 2027 | ||
| 2028 | function WorkingTimer({ createdAt }: { createdAt: string }) { | ||
| 2029 | const textRef = useRef<HTMLSpanElement>(null); | ||
| 2030 | const initialText = formatWorkingTimerNow(createdAt); | ||
| 2031 | |||
| 2032 | useEffect(() => { | ||
| 2033 | const updateText = () => { | ||
| 2034 | if (textRef.current) { | ||
| 2035 | textRef.current.textContent = formatWorkingTimerNow(createdAt); | ||
| 2036 | } | ||
| 2037 | }; | ||
| 2038 | updateText(); | ||
| 2039 | const id = setInterval(updateText, 1000); | ||
| 2040 | return () => clearInterval(id); | ||
| 2041 | }, [createdAt]); | ||
| 2042 | |||
| 2043 | return ( | ||
| 2044 | <span ref={textRef} className="tabular-nums"> | ||
| 2045 | {initialText} | ||
| 2046 | </span> | ||
| 2047 | ); | ||
| 2048 | } | ||
| 2049 | |||
| 2050 | function ActivityShimmerOverlay({ children }: { children: ReactNode }) { | ||
| 2051 | return ( | ||
| 2052 | <span | ||
| 2053 | aria-hidden | ||
| 2054 | className="live-activity-focus pointer-events-none absolute inset-y-0 select-none" | ||
| 2055 | > | ||
| 2056 | <span className="live-activity-focus-counter block"> | ||
| 2057 | <span className="live-activity-focus-aligned block text-foreground">{children}</span> | ||
| 2058 | </span> | ||
| 2059 | </span> | ||
| 2060 | ); | ||
| 2061 | } | ||
| 2062 | |||
| 2063 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { | ||
| 2064 | const { isPreparingWorktree } = use(TimelineRowActivityCtx); | ||
| 2065 | return ( | ||
| 2066 | <div className="border-b border-border/60 pb-2 pt-1"> | ||
| 2067 | <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> | ||
| 2068 | <span | ||
| 2069 | key={isPreparingWorktree ? "setup" : "working"} | ||
| 2070 | className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none" | ||
| 2071 | > | ||
| 2072 | {isPreparingWorktree ? ( | ||
| 2073 | <> | ||
| 2074 | Setting up worktree… | ||
| 2075 | <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay> | ||
| 2076 | </> | ||
| 2077 | ) : row.createdAt ? ( | ||
| 2078 | <> | ||
| 2079 | Working for <WorkingTimer createdAt={row.createdAt} /> | ||
| 2080 | </> | ||
| 2081 | ) : ( | ||
| 2082 | "Working..." | ||
| 2083 | )} | ||
| 2084 | </span> | ||
| 2085 | </div> | ||
| 2086 | </div> | ||
| 2087 | ); | ||
| 2088 | } | ||
| 2089 | |||
| 2090 | function ThinkingTimelineRow() { | ||
| 2091 | const { isPreparingWorktree } = use(TimelineRowActivityCtx); | ||
| 2092 | // Reserve the activity row during setup so the handoff keeps the same height. | ||
| 2093 | return ( | ||
| 2094 | <div className="min-h-7"> | ||
| 2095 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />} | ||
| 2096 | </div> | ||
| 2097 | ); | ||
| 2098 | } | ||
| 2099 | |||
| 2015 | function LiveActivityRow({ | 2100 | function LiveActivityRow({ |
| 2016 | label, | 2101 | label, |
| 2017 | iconName, | 2102 | iconName, |
| @@ -2088,7 +2173,7 @@ function LiveActivityContent({ · 70 unmodified lines | |||
| 2088 | 2173 | ||
| 2089 | function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) { | 2174 | function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) { |
| 2090 | const ctx = use(TimelineRowCtx); | 2175 | const ctx = use(TimelineRowCtx); |
| 2091 | const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot); | 2176 | const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); |
| 2092 | const failed = workEntryDisplayIndicatesToolFailure(row.entry); | 2177 | const failed = workEntryDisplayIndicatesToolFailure(row.entry); |
| 2093 | 2178 | ||
| 2094 | return ( | 2179 | return ( |
| @@ -2099,7 +2184,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " · 4 unmodified lines | |||
| 2099 | aria-expanded={row.expanded} | 2184 | aria-expanded={row.expanded} |
| 2100 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} | 2185 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} |
| 2101 | > | 2186 | > |
| 2102 | <LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} /> | 2187 | {row.active ? ( |
| 2188 | <LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} /> | ||
| 2189 | ) : ( | ||
| 2190 | <div className="min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed"> | ||
| 2191 | <LiveActivityContent | ||
| 2192 | label={label} | ||
| 2193 | iconName={workEntryIconName(row.entry)} | ||
| 2194 | failed={failed} | ||
| 2195 | announceFailure={failed} | ||
| 2196 | /> | ||
| 2197 | </div> | ||
| 2198 | )} | ||
| 2103 | </button> | 2199 | </button> |
| 2104 | ); | 2200 | ); |
| 2105 | } | 2201 | } |
| @@ -2854,14 +2950,18 @@ const toolCallExpandedBodyClassName = · 748 unmodified lines | |||
| 2854 | function liveWorkEntryLabel( | 2950 | function liveWorkEntryLabel( |
| 2855 | workEntry: TimelineWorkEntry, | 2951 | workEntry: TimelineWorkEntry, |
| 2856 | workspaceRoot: string | undefined, | 2952 | workspaceRoot: string | undefined, |
| 2953 | active: boolean, | ||
| 2857 | ): string { | 2954 | ): string { |
| 2858 | const command = workEntry.command?.trim(); | 2955 | const command = workEntry.command?.trim(); |
| 2859 | if (command) { | 2956 | if (command) { |
| 2860 | // This row describes the active tool run, not the command lifecycle. | ||
| 2861 | // Keep its live "Running" copy until the contiguous tool run settles. | ||
| 2862 | const program = commandProgramName(command); | 2957 | const program = commandProgramName(command); |
| 2863 | if (program) return `Running ${program}`; | 2958 | const verb = active |
| 2864 | return "Running command"; | 2959 | ? "Running" |
| 2960 | : workEntry.toolLifecycleStatus === "declined" | ||
| 2961 | ? "Declined" | ||
| 2962 | : "Ran"; | ||
| 2963 | if (program) return `${verb} ${program}`; | ||
| 2964 | return `${verb} command`; | ||
| 2865 | } | 2965 | } |
| 2866 | 2966 | ||
| 2867 | return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); | 2967 | return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); |
| @@ -1,10 +1,7 @@ | |||
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { useEffect } from "react"; | 2 | import { useEffect } from "react"; |
| 3 | import ChatView from "../components/ChatView"; | 3 | import ChatView from "../components/ChatView"; |
| 4 | import { | 4 | import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic"; |
| 5 | resolveDraftPromotionNavigationTarget, | ||
| 6 | threadHasStarted, | ||
| 7 | } from "../components/ChatView.logic"; | ||
| 8 | import { | 5 | import { |
| 9 | DraftId, | 6 | DraftId, |
| 10 | markPromotedDraftThreadByRef, | 7 | markPromotedDraftThreadByRef, |
| @@ -31,11 +28,10 @@ function DraftChatThreadRouteView() { · 20 unmodified lines | |||
| 31 | : null; | 28 | : null; |
| 32 | const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; | 29 | const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; |
| 33 | const serverThread = useThreadShell(serverThreadRef); | 30 | const serverThread = useThreadShell(serverThreadRef); |
| 34 | const serverThreadStarted = threadHasStarted(serverThread); | ||
| 35 | const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); | 31 | const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); |
| 36 | const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ | 32 | const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ |
| 37 | serverThreadRef, | 33 | serverThreadRef, |
| 38 | serverThreadStarted, | 34 | serverThread, |
| 39 | backgroundSubmissionPending, | 35 | backgroundSubmissionPending, |
| 40 | }); | 36 | }); |
| 41 | 37 | ||