History

feat(web): port working and thinking timeline rows to orchestration v2

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.

Julius Marminge committed 2 months ago

Commit 786a2581 parent 9d90e5b9 files · +456 −40Browse files

Unified · Split

9 files
  1. apps/web/src/components/ChatView.logic.test.ts +57 −0
  2. apps/web/src/components/ChatView.logic.ts +10 −2
  3. apps/web/src/components/ChatView.tsx +5 −0
  4. apps/web/src/components/chat/ChatComposer.tsx +0 −3
  5. apps/web/src/components/chat/ComposerActivityStatus.tsx +1 −1
  6. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +98 −0
  7. apps/web/src/components/chat/MessagesTimeline.logic.ts +165 −10
  8. apps/web/src/components/chat/MessagesTimeline.tsx +118 −18
  9. apps/web/src/routes/_chat.draft.$draftId.tsx +2 −6
apps/web/src/components/ChatView.logic.test.ts+57−0
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
3030 loadVideoPreviewUrl,
3131 reconcileMountedTerminalThreadIds,
3232 reconcileRetainedMountedThreadIds,
33+ resolveDraftPromotionNavigationTarget,
3334 resolveEffectiveInteractionMode,
3435 resolveThreadMetadataUpdateForNextTurn,
3536 resolveSendEnvMode,
@@ -308,6 +309,62 @@ const readySession = { · 272 unmodified lines
308309 updatedAt: "2026-03-29T00:00:10.000Z",
309310 };
310311
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+
311368 describe("resolveEffectiveInteractionMode", () => {
312369 it("forces build mode when legacy plan mode is disabled", () => {
313370 expect(
apps/web/src/components/ChatView.logic.ts+10−2
@@ -101,13 +101,21 @@ export function resolveDraftHeroState(input: { · 100 unmodified lines
101101
102102 export function resolveDraftPromotionNavigationTarget(input: {
103103 serverThreadRef: ScopedThreadRef | null;
104− serverThreadStarted: boolean;
104+ serverThread: Pick<Thread, "latestRun"> | null | undefined;
105105 backgroundSubmissionPending: boolean;
106106 }): ScopedThreadRef | null {
107107 if (input.backgroundSubmissionPending) {
108108 return null;
109109 }
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;
111119 }
112120
113121 export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
apps/web/src/components/ChatView.tsx+5−0
@@ -2677,6 +2677,9 @@ function ChatViewContent(props: ChatViewProps) { · 2676 unmodified lines
26772677 activeRuntime,
26782678 localDispatchStartedAt,
26792679 );
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";
26802683 useEffect(() => {
26812684 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
26822685 }, [attachmentPreviewHandoffByMessageId]);
@@ -7421,6 +7424,8 @@ function ChatViewContent(props: ChatViewProps) { · 4738 unmodified lines
74217424 key={activeThread.id}
74227425 isWorking={isWorking}
74237426 activeTurnInProgress={isWorking || !latestRunSettled}
7427+ activeTurnStartedAt={activeWorkStartedAt}
7428+ isPreparingWorktree={isPreparingWorktree || activeRunPreparing}
74247429 listRef={legendListRef}
74257430 timelineEntries={timelineEntries}
74267431 latestRun={activeActivityRun}
apps/web/src/components/chat/ChatComposer.tsx+0−3
@@ -541,9 +541,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 540 unmodified lines
541541 compactDisabledReason={props.compactDisabledReason}
542542 />
543543 ) : null}
544− {props.isPreparingWorktree ? (
545− <span className="text-secondary-label text-xs">Preparing worktree...</span>
546− ) : null}
547544 <ComposerPrimaryActions
548545 compact={props.compact}
549546 pendingAction={props.pendingAction}
apps/web/src/components/chat/ComposerActivityStatus.tsx+1−1
@@ -148,6 +148,6 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { · 147 unmodified lines
148148 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
149149 }
150150
151−function formatWorkingTimerNow(startIso: string): string {
151+export function formatWorkingTimerNow(startIso: string): string {
152152 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
153153 }
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+98−0
@@ -1579,9 +1579,53 @@ describe("deriveMessagesTimelineRows", () => { · 1578 unmodified lines
15791579 "turn-fold:turn-1",
15801580 "assistant-final-entry",
15811581 "user-followup-entry",
1582+ "working-indicator-row",
1583+ "thinking-indicator-row",
15821584 ]);
15831585 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
15841586 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" });
15851629 });
15861630
15871631 it("does not fold the active in-progress turn", () => {
@@ -1629,6 +1673,7 @@ describe("deriveMessagesTimelineRows", () => { · 41 unmodified lines
16291673
16301674 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
16311675 expect(rows.map((row) => row.id)).toEqual([
1676+ "working-indicator-row",
16321677 "assistant-thought-entry",
16331678 "work-live:work-entry-1",
16341679 ]);
@@ -1857,6 +1902,59 @@ describe("deriveMessagesTimelineRows", () => { · 222 unmodified lines
18571902 });
18581903
18591904 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+
18601958 it("returns the previous result when row order and content are unchanged", () => {
18611959 const firstUserMessage = {
18621960 id: "user-1" as never,
apps/web/src/components/chat/MessagesTimeline.logic.ts+165−10
@@ -43,6 +43,24 @@ import { · 42 unmodified lines
4343 type T3McpToolSummaryAction,
4444 } from "@t3tools/shared/t3McpToolPresentation";
4545
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+
4664 export function workEntryIsVisibleInGroup(
4765 entry: WorkLogEntry,
4866 expandedToolGroupEntry = false,
@@ -223,6 +241,17 @@ export type MessagesTimelineRow = · 174 unmodified lines
223241 groupedEntries: WorkLogEntry[];
224242 groupId: string;
225243 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;
226255 }
227256 | {
228257 kind: "work-toggle";
@@ -788,6 +817,7 @@ export function deriveMessagesTimelineRows(input: { · 559 unmodified lines
788817 expandedAttemptIds?: ReadonlySet<RunAttemptId>;
789818 expandedWorkGroupIds?: ReadonlySet<string>;
790819 isWorking: boolean;
820+ activeTurnStartedAt?: string | null;
791821 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
792822 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
793823 }): MessagesTimelineRow[] {
@@ -826,12 +856,121 @@ export function deriveMessagesTimelineRows(input: { · 32 unmodified lines
826856 entry.toolLifecycleStatus === "inProgress" &&
827857 entry.runId === unsettledRunId;
828858
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+
829960 for (let index = 0; index < input.timelineEntries.length; index += 1) {
830961 const timelineEntry = input.timelineEntries[index];
831962 if (!timelineEntry) {
832963 continue;
833964 }
834965
966+ if (input.isWorking && index === activeTurnHeaderIndex) {
967+ appendWorkingRow();
968+ }
969+
970+ if (timelineEntry.id === activeWorkPlacementEntryId) {
971+ appendActiveWorkRows();
972+ }
973+
835974 // The terminal interrupt result is the useful timeline marker. The
836975 // preceding request is transient bookkeeping and duplicates that marker.
837976 if (
@@ -874,6 +1013,10 @@ export function deriveMessagesTimelineRows(input: { · 36 unmodified lines
8741013 continue;
8751014 }
8761015
1016+ if (activeWorkEntryIds.has(timelineEntry.id)) {
1017+ continue;
1018+ }
1019+
8771020 if (timelineEntry.kind === "work") {
8781021 if (timelineEntry.entry.tone === "error") {
8791022 nextRows.push({
@@ -894,6 +1037,7 @@ export function deriveMessagesTimelineRows(input: { · 14 unmodified lines
8941037 !nextEntry ||
8951038 nextEntry.kind !== "work" ||
8961039 nextEntry.entry.tone === "error" ||
1040+ activeWorkEntryIds.has(nextEntry.id) ||
8971041 collapsedEntryIds.has(nextEntry.id) ||
8981042 collapsedSupersededEntryIds.has(nextEntry.id) ||
8991043 foldsByAnchorEntryId.has(nextEntry.id) ||
@@ -906,22 +1050,16 @@ export function deriveMessagesTimelineRows(input: { · 6 unmodified lines
9061050 groupedEntries.push(nextEntry.entry);
9071051 cursor += 1;
9081052 }
909− const isActiveTail =
910− input.isWorking &&
911− unsettledRunId !== null &&
912− timelineEntry.entry.runId === unsettledRunId &&
913− cursor === input.timelineEntries.length;
9141053 const visibleGroupedEntries = groupedEntries.filter((entry) =>
915− workEntryIsVisibleInGroup(entry, isActiveTail || workEntryIsInActiveRun(entry)),
1054+ workEntryIsVisibleInGroup(entry, workEntryIsInActiveRun(entry)),
9161055 );
9171056 if (visibleGroupedEntries.length > 0) {
9181057 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
919− if (isActiveTail || activeInProgressToolEntries.length > 0) {
1058+ if (activeInProgressToolEntries.length > 0) {
9201059 const groupId = workGroupId(timelineEntry.id);
9211060 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
922− const latestActiveToolEntry = (
923− isActiveTail ? visibleGroupedEntries : activeInProgressToolEntries
924− ).at(-1)!;
1061+ const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
1062+ hasLiveWorkRow = true;
9251063 nextRows.push({
9261064 kind: "work-live",
9271065 id: `work-live:${timelineEntry.id}`,
@@ -930,6 +1068,7 @@ export function deriveMessagesTimelineRows(input: { · 2 unmodified lines
9301068 groupedEntries: visibleGroupedEntries,
9311069 groupId,
9321070 expanded,
1071+ active: true,
9331072 });
9341073 if (expanded) {
9351074 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
@@ -1050,6 +1189,17 @@ export function deriveMessagesTimelineRows(input: { · 114 unmodified lines
10501189 });
10511190 }
10521191
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+
10531203 return nextRows;
10541204 }
10551205
@@ -1131,6 +1281,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 75 unmodified lines
11311281 if (a.kind !== b.kind || a.id !== b.id) return false;
11321282
11331283 switch (a.kind) {
1284+ case "working":
1285+ case "thinking":
1286+ return a.createdAt === (b as typeof a).createdAt;
1287+
11341288 case "turn-fold": {
11351289 const bf = b as typeof a;
11361290 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
11781332 a.createdAt === bw.createdAt &&
11791333 a.groupId === bw.groupId &&
11801334 a.expanded === bw.expanded &&
1335+ a.active === bw.active &&
11811336 Equal.equals(a.entry, bw.entry) &&
11821337 Equal.equals(a.groupedEntries, bw.groupedEntries)
11831338 );
apps/web/src/components/chat/MessagesTimeline.tsx+118−18
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
1414 import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
1515 import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
16+import { formatWorkingTimerNow } from "./ComposerActivityStatus";
1617 import {
1718 resolveViewedImageAsset,
1819 workEntryViewedImagePath,
@@ -197,6 +198,7 @@ interface TimelineRowActivityState { · 178 unmodified lines
197198 isWorking: boolean;
198199 isRevertingCheckpoint: boolean;
199200 activeTurnInProgress: boolean;
201+ isPreparingWorktree: boolean;
200202 latestRunId: RunId | null;
201203 }
202204
@@ -233,6 +235,8 @@ export interface MessagesTimelineHistoryControls { · 30 unmodified lines
233235 interface MessagesTimelineProps {
234236 isWorking: boolean;
235237 activeTurnInProgress: boolean;
238+ activeTurnStartedAt?: string | null;
239+ isPreparingWorktree?: boolean;
236240 listRef: React.RefObject<LegendListRef | null>;
237241 timelineEntries: ReadonlyArray<TimelineEntry>;
238242 latestRun: TimelineLatestRun | null;
@@ -292,6 +296,8 @@ interface MessagesTimelineProps { · 53 unmodified lines
292296 export const MessagesTimeline = memo(function MessagesTimeline({
293297 isWorking,
294298 activeTurnInProgress,
299+ activeTurnStartedAt = null,
300+ isPreparingWorktree = false,
295301 listRef,
296302 timelineEntries,
297303 latestRun,
@@ -468,6 +474,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 170 unmodified lines
468474 expandedAttemptIds,
469475 expandedWorkGroupIds,
470476 isWorking,
477+ activeTurnStartedAt,
471478 turnDiffSummaryByAssistantMessageId,
472479 revertTurnCountByUserMessageId,
473480 }),
@@ -478,6 +485,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 4 unmodified lines
478485 expandedAttemptIds,
479486 expandedWorkGroupIds,
480487 isWorking,
488+ activeTurnStartedAt,
481489 turnDiffSummaryByAssistantMessageId,
482490 revertTurnCountByUserMessageId,
483491 ],
@@ -644,9 +652,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 160 unmodified lines
644652 isWorking,
645653 isRevertingCheckpoint,
646654 activeTurnInProgress,
655+ isPreparingWorktree,
647656 latestRunId: latestRun?.runId ?? null,
648657 }),
649− [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestRun?.runId],
658+ [activeTurnInProgress, isPreparingWorktree, isRevertingCheckpoint, isWorking, latestRun?.runId],
650659 );
651660 const listHeader = useMemo(() => {
652661 const leadingContent =
@@ -1109,17 +1118,19 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 456 unmodified lines
11091118 ? "pb-0"
11101119 : row.kind === "turn-fold"
11111120 ? "pb-0"
1112− : (row.kind === "message" &&
1113− row.message.role === "assistant" &&
1114− !row.showAssistantMeta) ||
1115− row.kind === "work" ||
1116− row.kind === "work-live" ||
1117− row.kind === "work-toggle" ||
1118− row.kind === "turn-plan" ||
1119− row.kind === "event" ||
1120− row.kind === "attempt-fold"
1121− ? "pb-2"
1122− : "pb-4",
1121+ : row.kind === "working" || row.kind === "thinking"
1122+ ? "pb-1.5"
1123+ : (row.kind === "message" &&
1124+ row.message.role === "assistant" &&
1125+ !row.showAssistantMeta) ||
1126+ row.kind === "work" ||
1127+ row.kind === "work-live" ||
1128+ row.kind === "work-toggle" ||
1129+ row.kind === "turn-plan" ||
1130+ row.kind === "event" ||
1131+ row.kind === "attempt-fold"
1132+ ? "pb-2"
1133+ : "pb-4",
11231134 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
11241135 )}
11251136 data-timeline-row-id={row.id}
@@ -1142,6 +1153,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 16 unmodified lines
11421153 <AssistantTimelineRow row={row} />
11431154 ) : null}
11441155 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
1156+ {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
1157+ {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
11451158 {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}
11461159 {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null}
11471160 </div>
@@ -2012,6 +2025,78 @@ const WorkGroupSection = memo(function WorkGroupSection({ · 864 unmodified lines
20122025 );
20132026 });
20142027
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+
20152100 function LiveActivityRow({
20162101 label,
20172102 iconName,
@@ -2088,7 +2173,7 @@ function LiveActivityContent({ · 70 unmodified lines
20882173
20892174 function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
20902175 const ctx = use(TimelineRowCtx);
2091− const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);
2176+ const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
20922177 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
20932178
20942179 return (
@@ -2099,7 +2184,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " · 4 unmodified lines
20992184 aria-expanded={row.expanded}
21002185 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
21012186 >
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+ )}
21032199 </button>
21042200 );
21052201 }
@@ -2854,14 +2950,18 @@ const toolCallExpandedBodyClassName = · 748 unmodified lines
28542950 function liveWorkEntryLabel(
28552951 workEntry: TimelineWorkEntry,
28562952 workspaceRoot: string | undefined,
2953+ active: boolean,
28572954 ): string {
28582955 const command = workEntry.command?.trim();
28592956 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.
28622957 const program = commandProgramName(command);
2863− if (program) return `Running ${program}`;
2864− return "Running command";
2958+ const verb = active
2959+ ? "Running"
2960+ : workEntry.toolLifecycleStatus === "declined"
2961+ ? "Declined"
2962+ : "Ran";
2963+ if (program) return `${verb} ${program}`;
2964+ return `${verb} command`;
28652965 }
28662966
28672967 return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
apps/web/src/routes/_chat.draft.$draftId.tsx+2−6
@@ -1,10 +1,7 @@
11 import { createFileRoute, useNavigate } from "@tanstack/react-router";
22 import { useEffect } from "react";
33 import ChatView from "../components/ChatView";
4−import {
5− resolveDraftPromotionNavigationTarget,
6− threadHasStarted,
7−} from "../components/ChatView.logic";
4+import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic";
85 import {
96 DraftId,
107 markPromotedDraftThreadByRef,
@@ -31,11 +28,10 @@ function DraftChatThreadRouteView() { · 20 unmodified lines
3128 : null;
3229 const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef;
3330 const serverThread = useThreadShell(serverThreadRef);
34− const serverThreadStarted = threadHasStarted(serverThread);
3531 const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef);
3632 const canonicalThreadRef = resolveDraftPromotionNavigationTarget({
3733 serverThreadRef,
38− serverThreadStarted,
34+ serverThread,
3935 backgroundSubmissionPending,
4036 });
4137