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
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
312describe("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
311describe("resolveEffectiveInteractionMode", () => {368describe("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(
apps/web/src/components/ChatView.logic.ts+10−2
@@ -101,13 +101,21 @@ export function resolveDraftHeroState(input: { · 100 unmodified lines
101 101
102export function resolveDraftPromotionNavigationTarget(input: {102export 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
113export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {121export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
apps/web/src/components/ChatView.tsx+5−0
@@ -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}
apps/web/src/components/chat/ChatComposer.tsx+0−3
@@ -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 <ComposerPrimaryActions544 <ComposerPrimaryActions
548 compact={props.compact}545 compact={props.compact}
549 pendingAction={props.pendingAction}546 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
148 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;148 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
149}149}
150 150
151function formatWorkingTimerNow(startIso: string): string {151export function formatWorkingTimerNow(startIso: string): string {
152 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";152 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
153}153}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+98−0
@@ -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
1859describe("computeStableMessagesTimelineRows", () => {1904describe("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,
apps/web/src/components/chat/MessagesTimeline.logic.ts+165−10
@@ -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
46function 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. */
57function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
58 return (
59 entry.toolLifecycleStatus === "inProgress" ||
60 (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry))
61 );
62}
63
46export function workEntryIsVisibleInGroup(64export 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. The974 // 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 : activeInProgressToolEntries1062 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 );
apps/web/src/components/chat/MessagesTimeline.tsx+118−18
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";13import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
14import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";14import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
15import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";15import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
16import { formatWorkingTimerNow } from "./ComposerActivityStatus";
16import {17import {
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
233interface MessagesTimelineProps {235interface 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
292export const MessagesTimeline = memo(function MessagesTimeline({296export 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
2028function 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
2050function 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
2063function 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
2090function 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
2015function LiveActivityRow({2100function LiveActivityRow({
2016 label,2101 label,
2017 iconName,2102 iconName,
@@ -2088,7 +2173,7 @@ function LiveActivityContent({ · 70 unmodified lines
2088 2173
2089function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {2174function 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
2854function liveWorkEntryLabel(2950function 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);
apps/web/src/routes/_chat.draft.$draftId.tsx+2−6
@@ -1,10 +1,7 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";2import { useEffect } from "react";
3import ChatView from "../components/ChatView";3import ChatView from "../components/ChatView";
4import {4import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic";
5 resolveDraftPromotionNavigationTarget,
6 threadHasStarted,
7} from "../components/ChatView.logic";
8import {5import {
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