authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-31 12:01:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 12:01:50-07:00
logef84bc9873a6c4565fbeb64dce3f552570e95a2d
treebf21c3fe3cb6f79195a2df3f694d686938c21244
parent4a9d2d0ced2a2b899dbee9e4a5162fd83f81edb8
signature Signed by PGP key B5690EEEBB952194

fix(chat): smooth worktree setup status (#8922)


16 files changed, 397 insertions(+), 69 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+45-24
...@@ -39,6 +39,7 @@ import { ProviderIcon } from "../../components/ProviderIcon";...@@ -39,6 +39,7 @@ import { ProviderIcon } from "../../components/ProviderIcon";
39import { SymbolView } from "../../components/AppSymbol";39import { SymbolView } from "../../components/AppSymbol";
40import { AppText as Text } from "../../components/AppText";40import { AppText as Text } from "../../components/AppText";
41import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";41import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
42import { ShimmeringWorkContent } from "./thread-work-log";
42import { ComposerCommandPopover } from "./ComposerCommandPopover";43import { ComposerCommandPopover } from "./ComposerCommandPopover";
43import { useComposerCommandMenu } from "./use-composer-command-menu";44import { useComposerCommandMenu } from "./use-composer-command-menu";
44import {45import {
...@@ -380,7 +381,8 @@ export function NewTaskDraftScreen(props: {...@@ -380,7 +381,8 @@ export function NewTaskDraftScreen(props: {
380 };381 };
381 }, [props.pendingTaskId, cancelEditingPendingTask]);382 }, [props.pendingTaskId, cancelEditingPendingTask]);
382383
383 const foregroundColor = useUniwindTheme()["--color-foreground"];384 const theme = useUniwindTheme();
385 const foregroundColor = theme["--color-foreground"];
384 const regularFontFamily = useFontFamily("regular");386 const regularFontFamily = useFontFamily("regular");
385 const bodyText = useScaledTextRole("body");387 const bodyText = useScaledTextRole("body");
386388
...@@ -1110,31 +1112,50 @@ export function NewTaskDraftScreen(props: {...@@ -1110,31 +1112,50 @@ export function NewTaskDraftScreen(props: {
11101112
1111 const workspaceControls = (1113 const workspaceControls = (
1112 <View className="flex-row items-center gap-1 px-2">1114 <View className="flex-row items-center gap-1 px-2">
1113 <ComposerInlineControl1115 {flow.submitting && environmentConnected && flow.workspaceMode === "worktree" ? (
1114 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}1116 <View
1115 accessibilityLabel={workspaceLabel}1117 accessible
1116 disabled={isComposerInteractionLocked || voiceInput.isBusy}1118 accessibilityLabel="Setting up worktree…"
1117 iconNode={1119 className="h-11 w-full max-w-[260px] flex-row items-center px-2"
1118 <NewTaskWorkspaceIcon1120 >
1119 workspaceMode={flow.workspaceMode}1121 <ShimmeringWorkContent
1120 worktreePath={flow.selectedWorktreePath}1122 icon="arrow.triangle.branch"
1123 iconSubtleColor={theme["--color-icon-subtle"]}
1124 label="Setting up worktree…"
1125 showIcon
1126 />
1127 </View>
1128 ) : (
1129 <>
1130 <ComposerInlineControl
1131 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
1132 accessibilityLabel={workspaceLabel}
1133 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1134 iconNode={
1135 <NewTaskWorkspaceIcon
1136 workspaceMode={flow.workspaceMode}
1137 worktreePath={flow.selectedWorktreePath}
1138 />
1139 }
1140 label={workspaceLabel}
1141 maxWidth={flow.workspaceMode === "local" ? 220 : 148}
1142 onPress={() =>
1143 flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")
1144 }
1145 showChevron={false}
1121 />1146 />
1122 }
1123 label={workspaceLabel}
1124 maxWidth={flow.workspaceMode === "local" ? 220 : 148}
1125 onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
1126 showChevron={false}
1127 />
11281147
1129 <ComposerInlineControl1148 <ComposerInlineControl
1130 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}1149 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
1131 chevronDirection="right"1150 chevronDirection="right"
1132 disabled={isComposerInteractionLocked}1151 disabled={isComposerInteractionLocked}
1133 icon="arrow.triangle.branch"1152 icon="arrow.triangle.branch"
1134 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}1153 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
1135 maxWidth={190}1154 maxWidth={190}
1136 onPress={() => openContextPicker("NewTaskBranch")}1155 onPress={() => openContextPicker("NewTaskBranch")}
1137 />1156 />
1157 </>
1158 )}
1138 </View>1159 </View>
1139 );1160 );
11401161
apps/mobile/src/features/threads/thread-work-log.tsx+1-1
...@@ -79,7 +79,7 @@ function ShimmerWorkContent(props: {...@@ -79,7 +79,7 @@ function ShimmerWorkContent(props: {
79 );79 );
80}80}
8181
82function ShimmeringWorkContent(props: {82export function ShimmeringWorkContent(props: {
83 readonly icon: AppSymbolName;83 readonly icon: AppSymbolName;
84 readonly iconSubtleColor: ColorValue;84 readonly iconSubtleColor: ColorValue;
85 readonly label: string;85 readonly label: string;
apps/mobile/src/lib/threadActivity.test.ts+77
...@@ -234,6 +234,83 @@ function makeThread(...@@ -234,6 +234,83 @@ function makeThread(
234}234}
235235
236describe("buildThreadFeed", () => {236describe("buildThreadFeed", () => {
237 it("keeps setup failures visible without routine setup notices before or after a turn", () => {
238 const thread = makeThread({
239 id: ThreadId.make("thread-worktree-setup"),
240 projectId: ProjectId.make("project-1"),
241 title: "Worktree setup",
242 activities: [
243 makeActivity({
244 id: EventId.make("setup-requested"),
245 kind: "setup-script.requested",
246 summary: "Starting setup script",
247 createdAt: "2026-08-30T00:00:00.000Z",
248 }),
249 makeActivity({
250 id: EventId.make("setup-started"),
251 kind: "setup-script.started",
252 summary: "Setup script started",
253 createdAt: "2026-08-30T00:00:01.000Z",
254 }),
255 makeActivity({
256 id: EventId.make("setup-failed"),
257 kind: "setup-script.failed",
258 summary: "Setup script failed to start",
259 createdAt: "2026-08-30T00:00:02.000Z",
260 tone: "error",
261 payload: { detail: "Setup command was not found" },
262 }),
263 ],
264 });
265 const latestTurn = {
266 turnId: TurnId.make("turn-after-setup"),
267 state: "running" as const,
268 requestedAt: "2026-08-30T00:00:03.000Z",
269 startedAt: "2026-08-30T00:00:04.000Z",
270 completedAt: null,
271 assistantMessageId: null,
272 };
273
274 for (const currentTurn of [null, latestTurn]) {
275 const feed = buildThreadFeed({ ...thread, latestTurn: currentTurn });
276 expect(feed).toMatchObject([
277 {
278 type: "activity-group",
279 activities: [{ id: "setup-failed", status: "failure" }],
280 },
281 ]);
282 const group = feed[0];
283 if (group?.type !== "activity-group") throw new Error("Expected the setup failure group");
284 expect(group.activities[0]?.getCopyText()).toContain("Setup command was not found");
285 }
286 });
287
288 it.each(["setup-script.requested", "setup-script.started"])(
289 "keeps error-toned %s notices visible",
290 (kind) => {
291 const feed = buildThreadFeed(
292 makeThread({
293 id: ThreadId.make("thread-setup-error"),
294 projectId: ProjectId.make("project-1"),
295 title: "Setup error",
296 activities: [
297 makeActivity({
298 id: EventId.make("setup-error"),
299 kind,
300 summary: "Setup failed",
301 createdAt: "2026-08-30T00:00:00.000Z",
302 tone: "error",
303 }),
304 ],
305 }),
306 );
307
308 expect(feed).toMatchObject([
309 { type: "activity-group", activities: [{ id: "setup-error", status: "failure" }] },
310 ]);
311 },
312 );
313
237 it("keeps older local feedback before newer messages returned by the server", () => {314 it("keeps older local feedback before newer messages returned by the server", () => {
238 const submission = {315 const submission = {
239 id: MessageId.make("feedback-command-ordering"),316 id: MessageId.make("feedback-command-ordering"),
apps/mobile/src/lib/threadActivity.ts+2
...@@ -14,6 +14,7 @@ import type {...@@ -14,6 +14,7 @@ import type {
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { formatDuration } from "@t3tools/shared/orchestrationTiming";15import { formatDuration } from "@t3tools/shared/orchestrationTiming";
16import {16import {
17 isWorktreeSetupActivity,
17 normalizeCompactToolLabel,18 normalizeCompactToolLabel,
18 omitSupersededLifecycleMarkers,19 omitSupersededLifecycleMarkers,
19 summarizeToolGroup,20 summarizeToolGroup,
...@@ -343,6 +344,7 @@ function deriveWorkLogEntries(...@@ -343,6 +344,7 @@ function deriveWorkLogEntries(
343 const ordered = Arr.sort(activities, activityOrder);344 const ordered = Arr.sort(activities, activityOrder);
344 const entries: DerivedWorkLogEntry[] = [];345 const entries: DerivedWorkLogEntry[] = [];
345 for (const activity of ordered) {346 for (const activity of ordered) {
347 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
346 if (activity.kind === "tool.started") continue;348 if (activity.kind === "tool.started") continue;
347 if (activity.kind === "task.started") continue;349 if (activity.kind === "task.started") continue;
348 // Terminal bypassed updates pass: Codex children's only terminal signal.350 // Terminal bypassed updates pass: Codex children's only terminal signal.
apps/web/src/components/ChatView.logic.test.ts+61-1
...@@ -115,7 +115,7 @@ describe("draft hero submission transition", () => {...@@ -115,7 +115,7 @@ describe("draft hero submission transition", () => {
115 expect(115 expect(
116 resolveDraftPromotionNavigationTarget({116 resolveDraftPromotionNavigationTarget({
117 serverThreadRef: { environmentId, threadId },117 serverThreadRef: { environmentId, threadId },
118 serverThreadStarted: true,118 serverThread: makeThread({ latestTurn: completedTurn }),
119 backgroundSubmissionPending: true,119 backgroundSubmissionPending: true,
120 }),120 }),
121 ).toBeNull();121 ).toBeNull();
...@@ -316,6 +316,66 @@ const readySession = {...@@ -316,6 +316,66 @@ const readySession = {
316 updatedAt: "2026-03-29T00:00:10.000Z",316 updatedAt: "2026-03-29T00:00:10.000Z",
317};317};
318318
319describe("draft promotion during worktree setup", () => {
320 const serverThreadRef = { environmentId, threadId };
321
322 it.each([null, "idle", "starting", "ready"] as const)(
323 "keeps the draft mounted while the first turn waits with session %s",
324 (status) => {
325 const serverThread = makeThread({
326 messages: [
327 {
328 id: MessageId.make("submitted-message"),
329 role: "user",
330 text: "Start in a new worktree",
331 turnId: null,
332 createdAt: now,
333 updatedAt: now,
334 streaming: false,
335 },
336 ],
337 session: status ? { ...readySession, status } : null,
338 });
339
340 expect(
341 resolveDraftPromotionNavigationTarget({
342 serverThreadRef,
343 serverThread,
344 backgroundSubmissionPending: false,
345 }),
346 ).toBeNull();
347 },
348 );
349
350 it("promotes when the provider starts the first turn", () => {
351 const latestTurn = { ...completedTurn, state: "running" as const, completedAt: null };
352
353 expect(
354 resolveDraftPromotionNavigationTarget({
355 serverThreadRef,
356 serverThread: makeThread({
357 latestTurn,
358 session: { ...readySession, status: "running", activeTurnId: latestTurn.turnId },
359 }),
360 backgroundSubmissionPending: false,
361 }),
362 ).toEqual(serverThreadRef);
363 });
364
365 it.each(["error", "stopped", "interrupted"] as const)(
366 "promotes a startup that ends as %s before a turn starts",
367 (status) => {
368 expect(
369 resolveDraftPromotionNavigationTarget({
370 serverThreadRef,
371 serverThread: makeThread({ session: { ...readySession, status } }),
372 backgroundSubmissionPending: false,
373 }),
374 ).toEqual(serverThreadRef);
375 },
376 );
377});
378
319describe("buildLoadingThreadFromShell", () => {379describe("buildLoadingThreadFromShell", () => {
320 it("preserves shell metadata and supplies empty detail collections", () => {380 it("preserves shell metadata and supplies empty detail collections", () => {
321 const shell = {381 const shell = {
apps/web/src/components/ChatView.logic.ts+8-2
...@@ -110,13 +110,19 @@ export function resolveDraftHeroState(input: {...@@ -110,13 +110,19 @@ export function resolveDraftHeroState(input: {
110110
111export function resolveDraftPromotionNavigationTarget(input: {111export function resolveDraftPromotionNavigationTarget(input: {
112 serverThreadRef: ScopedThreadRef | null;112 serverThreadRef: ScopedThreadRef | null;
113 serverThreadStarted: boolean;113 serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined;
114 backgroundSubmissionPending: boolean;114 backgroundSubmissionPending: boolean;
115}): ScopedThreadRef | null {115}): ScopedThreadRef | null {
116 if (input.backgroundSubmissionPending) {116 if (input.backgroundSubmissionPending) {
117 return null;117 return null;
118 }118 }
119 return input.serverThreadStarted ? input.serverThreadRef : null;119 const sessionStatus = input.serverThread?.session?.status;
120 const turnStarted = input.serverThread?.latestTurn?.startedAt != null;
121 const startupStopped =
122 sessionStatus === "error" || sessionStatus === "stopped" || sessionStatus === "interrupted";
123 // Keep local preparation feedback mounted until the server can render the
124 // running turn or its startup error on the canonical thread route.
125 return turnStarted || startupStopped ? input.serverThreadRef : null;
120}126}
121127
122export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {128export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
apps/web/src/components/ChatView.tsx+9-6
...@@ -53,7 +53,6 @@ import {...@@ -53,7 +53,6 @@ import {
53 createModelSelection,53 createModelSelection,
54 resolvePromptInjectedEffort,54 resolvePromptInjectedEffort,
55} from "@t3tools/shared/model";55} from "@t3tools/shared/model";
56import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
57import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";56import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
58import { truncate } from "@t3tools/shared/String";57import { truncate } from "@t3tools/shared/String";
59import {58import {
...@@ -108,7 +107,11 @@ import {...@@ -108,7 +107,11 @@ import {
108 isLatestTurnSettled,107 isLatestTurnSettled,
109} from "../session-logic";108} from "../session-logic";
110import { type LegendListRef } from "@legendapp/list/react";109import { type LegendListRef } from "@legendapp/list/react";
111import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring";110import {
111 CHAT_TIMELINE_ANCHOR_OFFSET,
112 getAnchoredTurnMetrics,
113 type TimelineScrollMode,
114} from "./chat/timelineScrollAnchoring";
112import {115import {
113 buildPendingUserInputAnswers,116 buildPendingUserInputAnswers,
114 derivePendingUserInputProgress,117 derivePendingUserInputProgress,
...@@ -4110,7 +4113,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4110,7 +4113,7 @@ function ChatViewContent(props: ChatViewProps) {
4110 state,4113 state,
4111 anchorIndex,4114 anchorIndex,
4112 composerOverlayHeight,4115 composerOverlayHeight,
4113 anchorOffset: CHAT_LIST_ANCHOR_OFFSET,4116 anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
4114 });4117 });
4115 },4118 },
4116 [composerOverlayHeight],4119 [composerOverlayHeight],
...@@ -4138,7 +4141,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4138,7 +4141,7 @@ function ChatViewContent(props: ChatViewProps) {
4138 const realContentBottom = lastRowTop + Math.max(1, lastRowHeight);4141 const realContentBottom = lastRowTop + Math.max(1, lastRowHeight);
4139 const visibleScrollLength = Math.max(4142 const visibleScrollLength = Math.max(
4140 0,4143 0,
4141 (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET,4144 (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET,
4142 );4145 );
4143 return realContentBottom > visibleScrollLength;4146 return realContentBottom > visibleScrollLength;
4144 },4147 },
...@@ -4326,7 +4329,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4326,7 +4329,7 @@ function ChatViewContent(props: ChatViewProps) {
4326 index: anchorIndex,4329 index: anchorIndex,
4327 animated: true,4330 animated: true,
4328 viewPosition: 0,4331 viewPosition: 0,
4329 viewOffset: CHAT_LIST_ANCHOR_OFFSET,4332 viewOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
4330 })4333 })
4331 .then(() => {4334 .then(() => {
4332 if (positionedTimelineAnchorRef.current !== messageId) {4335 if (positionedTimelineAnchorRef.current !== messageId) {
...@@ -6146,7 +6149,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6146,7 +6149,6 @@ function ChatViewContent(props: ChatViewProps) {
6146 : {}),6149 : {}),
6147 }6150 }
6148 : undefined;6151 : undefined;
6149 beginLocalDispatch({ preparingWorktree: false });
6150 const backgroundThreadRef =6152 const backgroundThreadRef =
6151 resolvedSubmissionIntent === "background"6153 resolvedSubmissionIntent === "background"
6152 ? scopeThreadRef(activeThread.environmentId, threadIdForSend)6154 ? scopeThreadRef(activeThread.environmentId, threadIdForSend)
...@@ -7225,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7225,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) {
7225 onOpenAgents={addAgentsSurface}7227 onOpenAgents={addAgentsSurface}
7226 key={activeThread.id}7228 key={activeThread.id}
7227 isWorking={isWorking}7229 isWorking={isWorking}
7230 isPreparingWorktree={isPreparingWorktree}
7228 activeTurnStartedAt={activeWorkStartedAt}7231 activeTurnStartedAt={activeWorkStartedAt}
7229 listRef={legendListRef}7232 listRef={legendListRef}
7230 timelineEntries={timelineEntries}7233 timelineEntries={timelineEntries}
apps/web/src/components/chat/ChatComposer.tsx-3
...@@ -537,9 +537,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -537,9 +537,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
537 compactDisabledReason={props.compactDisabledReason}537 compactDisabledReason={props.compactDisabledReason}
538 />538 />
539 ) : null}539 ) : null}
540 {props.isPreparingWorktree ? (
541 <span className="text-secondary-label text-xs">Preparing worktree...</span>
542 ) : null}
543 <ComposerPrimaryActions540 <ComposerPrimaryActions
544 compact={props.compact}541 compact={props.compact}
545 pendingAction={props.pendingAction}542 pendingAction={props.pendingAction}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+52
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { MessageId, TurnId } from "@t3tools/contracts";
2import {3import {
3 computeStableMessagesTimelineRows,4 computeStableMessagesTimelineRows,
4 computeMessageDurationStart,5 computeMessageDurationStart,
...@@ -828,6 +829,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -828,6 +829,7 @@ describe("deriveMessagesTimelineRows", () => {
828 ]);829 ]);
829 const finalRow = rows.find((row) => row.id === "assistant-final-entry");830 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
830 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);831 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
832 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true });
831 });833 });
832834
833 it("does not fold the active in-progress turn", () => {835 it("does not fold the active in-progress turn", () => {
...@@ -942,6 +944,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -942,6 +944,7 @@ describe("deriveMessagesTimelineRows", () => {
942 });944 });
943945
944 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);946 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
947 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false });
945 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({948 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
946 entry: { id: "running-command" },949 entry: { id: "running-command" },
947 groupedEntries: [950 groupedEntries: [
...@@ -1375,6 +1378,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1375,6 +1378,7 @@ describe("deriveMessagesTimelineRows", () => {
13751378
1376 expect(assistantRow?.showAssistantMeta).toBe(false);1379 expect(assistantRow?.showAssistantMeta).toBe(false);
1377 expect(assistantRow?.showAssistantCopyButton).toBe(false);1380 expect(assistantRow?.showAssistantCopyButton).toBe(false);
1381 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false });
1378 });1382 });
13791383
1380 it.each([1384 it.each([
...@@ -1543,6 +1547,54 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1543,6 +1547,54 @@ describe("deriveMessagesTimelineRows", () => {
1543});1547});
15441548
1545describe("computeStableMessagesTimelineRows", () => {1549describe("computeStableMessagesTimelineRows", () => {
1550 it.each(["", " \n"])("replaces Thinking when assistant content grows from %j", (text) => {
1551 const startedAt = "2026-01-01T00:00:00Z";
1552 const turnId = TurnId.make("turn-1");
1553 const input = {
1554 runningTurnId: turnId,
1555 isWorking: true,
1556 activeTurnStartedAt: startedAt,
1557 turnDiffSummaryByAssistantMessageId: new Map(),
1558 revertTurnCountByUserMessageId: new Map(),
1559 };
1560 const assistantEntry = {
1561 id: "assistant-entry",
1562 kind: "message" as const,
1563 createdAt: startedAt,
1564 message: {
1565 id: MessageId.make("assistant-1"),
1566 role: "assistant" as const,
1567 text,
1568 turnId,
1569 createdAt: startedAt,
1570 updatedAt: startedAt,
1571 streaming: true,
1572 },
1573 };
1574 const initial = computeStableMessagesTimelineRows(
1575 deriveMessagesTimelineRows({ ...input, timelineEntries: [assistantEntry] }),
1576 { byId: new Map(), result: [] },
1577 );
1578 const updated = computeStableMessagesTimelineRows(
1579 deriveMessagesTimelineRows({
1580 ...input,
1581 timelineEntries: [
1582 {
1583 ...assistantEntry,
1584 message: { ...assistantEntry.message, text: "I will inspect the repository." },
1585 },
1586 ],
1587 }),
1588 initial,
1589 );
1590
1591 const initialWorking = initial.byId.get("working-indicator-row");
1592 const updatedWorking = updated.byId.get("working-indicator-row");
1593 expect(initialWorking).toMatchObject({ showThinking: true });
1594 expect(updatedWorking).toMatchObject({ showThinking: false });
1595 expect(updatedWorking).not.toBe(initialWorking);
1596 });
1597
1546 it("returns the previous result when row order and content are unchanged", () => {1598 it("returns the previous result when row order and content are unchanged", () => {
1547 const firstUserMessage = {1599 const firstUserMessage = {
1548 id: "user-1" as never,1600 id: "user-1" as never,
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-1
...@@ -520,7 +520,7 @@ describe("MessagesTimeline", () => {...@@ -520,7 +520,7 @@ describe("MessagesTimeline", () => {
520 );520 );
521521
522 expect(markup).toContain('data-anchor-index="0"');522 expect(markup).toContain('data-anchor-index="0"');
523 expect(markup).toContain('data-anchor-offset="16"');523 expect(markup).toContain('data-anchor-offset="24"');
524 expect(markup).toContain('data-anchor-on-ready="true"');524 expect(markup).toContain('data-anchor-on-ready="true"');
525 expect(markup).not.toContain("data-anchor-max-size=");525 expect(markup).not.toContain("data-anchor-max-size=");
526 expect(markup).toContain('data-content-inset-end="144"');526 expect(markup).toContain('data-content-inset-end="144"');
apps/web/src/components/chat/MessagesTimeline.tsx+47-25
...@@ -84,7 +84,10 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage...@@ -84,7 +84,10 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage
84import { ProposedPlanCard } from "./ProposedPlanCard";84import { ProposedPlanCard } from "./ProposedPlanCard";
85import { ChangedFilesCard } from "./ChangedFilesTree";85import { ChangedFilesCard } from "./ChangedFilesTree";
86import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";86import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
87import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring";87import {
88 CHAT_TIMELINE_ANCHOR_OFFSET,
89 keepTimelineEndVisibleAfterOverlayGrowth,
90} from "./timelineScrollAnchoring";
88import { MessageCopyButton } from "./MessageCopyButton";91import { MessageCopyButton } from "./MessageCopyButton";
89import {92import {
90 computeStableMessagesTimelineRows,93 computeStableMessagesTimelineRows,
...@@ -169,6 +172,7 @@ interface TimelineRowSharedState {...@@ -169,6 +172,7 @@ interface TimelineRowSharedState {
169172
170interface TimelineRowActivityState {173interface TimelineRowActivityState {
171 isWorking: boolean;174 isWorking: boolean;
175 isPreparingWorktree: boolean;
172 isRevertingCheckpoint: boolean;176 isRevertingCheckpoint: boolean;
173 latestTurnId: TurnId | null;177 latestTurnId: TurnId | null;
174}178}
...@@ -225,6 +229,7 @@ interface MessagesTimelineProps {...@@ -225,6 +229,7 @@ interface MessagesTimelineProps {
225 agentPanelModel?: AgentPanelModel;229 agentPanelModel?: AgentPanelModel;
226 onOpenAgents?: () => void;230 onOpenAgents?: () => void;
227 isWorking: boolean;231 isWorking: boolean;
232 isPreparingWorktree?: boolean;
228 activeTurnStartedAt: string | null;233 activeTurnStartedAt: string | null;
229 listRef: React.RefObject<LegendListRef | null>;234 listRef: React.RefObject<LegendListRef | null>;
230 timelineEntries: ReturnType<typeof deriveTimelineEntries>;235 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
...@@ -270,6 +275,7 @@ interface MessagesTimelineProps {...@@ -270,6 +275,7 @@ interface MessagesTimelineProps {
270275
271export const MessagesTimeline = memo(function MessagesTimeline({276export const MessagesTimeline = memo(function MessagesTimeline({
272 isWorking,277 isWorking,
278 isPreparingWorktree = false,
273 activeTurnStartedAt,279 activeTurnStartedAt,
274 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,280 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
275 onOpenAgents = NOOP_OPEN_AGENTS,281 onOpenAgents = NOOP_OPEN_AGENTS,
...@@ -467,8 +473,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -467,8 +473,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
467 [anchorMessageId, onAnchorReady],473 [anchorMessageId, onAnchorReady],
468 );474 );
469 const anchoredEndSpace = useMemo(() => {475 const anchoredEndSpace = useMemo(() => {
470 const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>476 const config = resolveChatListAnchoredEndSpace(
471 row.kind === "message" && row.message.role === "user" ? row.message.id : null,477 rows,
478 anchorMessageId,
479 (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null),
480 { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET },
472 );481 );
473 return config ? { ...config, onReady: handleAnchorReady } : undefined;482 return config ? { ...config, onReady: handleAnchorReady } : undefined;
474 }, [anchorMessageId, handleAnchorReady, rows]);483 }, [anchorMessageId, handleAnchorReady, rows]);
...@@ -577,10 +586,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -577,10 +586,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
577 const activityState = useMemo<TimelineRowActivityState>(586 const activityState = useMemo<TimelineRowActivityState>(
578 () => ({587 () => ({
579 isWorking,588 isWorking,
589 isPreparingWorktree,
580 isRevertingCheckpoint,590 isRevertingCheckpoint,
581 latestTurnId: latestTurn?.turnId ?? null,591 latestTurnId: latestTurn?.turnId ?? null,
582 }),592 }),
583 [isRevertingCheckpoint, isWorking, latestTurn?.turnId],593 [isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId],
584 );594 );
585595
586 // Stable renderItem — no closure deps. Row components read shared state596 // Stable renderItem — no closure deps. Row components read shared state
...@@ -1318,12 +1328,21 @@ function ProposedPlanTimelineRow({...@@ -1318,12 +1328,21 @@ function ProposedPlanTimelineRow({
1318}1328}
13191329
1320function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {1330function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1331 const { isPreparingWorktree } = use(TimelineRowActivityCtx);
1321 return (1332 return (
1322 <div>1333 <div>
1323 <div className="border-b border-border/60 pb-2 pt-1">1334 <div className="border-b border-border/60 pb-2 pt-1">
1324 <div className="flex min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">1335 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1325 <span className="shrink-0 whitespace-nowrap">1336 <span
1326 {row.createdAt ? (1337 key={isPreparingWorktree ? "setup" : "working"}
1338 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
1339 >
1340 {isPreparingWorktree ? (
1341 <>
1342 Setting up worktree…
1343 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
1344 </>
1345 ) : row.createdAt ? (
1327 <>1346 <>
1328 Working for <WorkingTimer createdAt={row.createdAt} />1347 Working for <WorkingTimer createdAt={row.createdAt} />
1329 </>1348 </>
...@@ -1334,8 +1353,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -1334,8 +1353,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
1334 </div>1353 </div>
1335 </div>1354 </div>
1336 {row.showThinking ? (1355 {row.showThinking ? (
1337 <div className="mt-1">1356 // Reserve the activity row during setup so the handoff keeps the same height.
1338 <ThinkingActivityRow />1357 <div className="mt-1 min-h-7">
1358 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
1339 </div>1359 </div>
1340 ) : null}1360 ) : null}
1341 </div>1361 </div>
...@@ -1412,6 +1432,19 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1412,6 +1432,19 @@ const WorkGroupSection = memo(function WorkGroupSection({
1412 );1432 );
1413});1433});
14141434
1435function ActivityShimmerOverlay({ children }: { children: ReactNode }) {
1436 return (
1437 <span
1438 aria-hidden
1439 className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
1440 >
1441 <span className="live-activity-focus-counter block">
1442 <span className="live-activity-focus-aligned block text-foreground">{children}</span>
1443 </span>
1444 </span>
1445 );
1446}
1447
1415function LiveActivityRow({1448function LiveActivityRow({
1416 label,1449 label,
1417 iconName,1450 iconName,
...@@ -1429,24 +1462,13 @@ function LiveActivityRow({...@@ -1429,24 +1462,13 @@ function LiveActivityRow({
1429 failed={failed}1462 failed={failed}
1430 announceFailure={failed}1463 announceFailure={failed}
1431 />1464 />
1432 <div1465 <ActivityShimmerOverlay>
1433 aria-hidden1466 <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
1434 className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"1467 </ActivityShimmerOverlay>
1435 >
1436 <div className="live-activity-focus-counter">
1437 <div className="live-activity-focus-aligned">
1438 <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
1439 </div>
1440 </div>
1441 </div>
1442 </div>1468 </div>
1443 );1469 );
1444}1470}
14451471
1446function ThinkingActivityRow() {
1447 return <LiveActivityRow label="Thinking" />;
1448}
1449
1450function LiveActivityContent({1472function LiveActivityContent({
1451 label,1473 label,
1452 iconName,1474 iconName,
...@@ -1463,7 +1485,7 @@ function LiveActivityContent({...@@ -1463,7 +1485,7 @@ function LiveActivityContent({
1463 const resolvedIconName = failed ? "circle-alert" : iconName;1485 const resolvedIconName = failed ? "circle-alert" : iconName;
14641486
1465 return (1487 return (
1466 <div1488 <span
1467 className={cn(1489 className={cn(
1468 "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",1490 "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",
1469 resolvedIconName ? "px-0.5" : "px-1",1491 resolvedIconName ? "px-0.5" : "px-1",
...@@ -1486,7 +1508,7 @@ function LiveActivityContent({...@@ -1486,7 +1508,7 @@ function LiveActivityContent({
1486 </span>1508 </span>
1487 ) : null}1509 ) : null}
1488 <span className="min-w-0 flex-1 truncate">{label}</span>1510 <span className="min-w-0 flex-1 truncate">{label}</span>
1489 </div>1511 </span>
1490 );1512 );
1491}1513}
14921514
apps/web/src/components/chat/timelineScrollAnchoring.ts+3
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1// Match the titlebar fade inset so draft promotion preserves the first row's position.
2export const CHAT_TIMELINE_ANCHOR_OFFSET = 24;
3
1export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling";4export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling";
25
3export interface TimelineListMeasurementState {6export interface TimelineListMeasurementState {
apps/web/src/routes/_chat.draft.$draftId.tsx+2-6
...@@ -1,10 +1,7 @@...@@ -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() {...@@ -31,11 +28,10 @@ function DraftChatThreadRouteView() {
31 : null;28 : null;
32 const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef;29 const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef;
33 const serverThread = useThread(serverThreadRef);30 const serverThread = useThread(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 });
4137
apps/web/src/session-logic.test.ts+83
...@@ -894,6 +894,89 @@ describe("deriveWorkLogEntries", () => {...@@ -894,6 +894,89 @@ describe("deriveWorkLogEntries", () => {
894 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);894 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
895 });895 });
896896
897 it("omits routine setup updates before work starts and after later turn activity", () => {
898 const setupActivities = [
899 makeActivity({
900 id: "setup-requested",
901 kind: "setup-script.requested",
902 summary: "Preparing setup script",
903 tone: "info",
904 sequence: 1,
905 }),
906 makeActivity({
907 id: "setup-started",
908 kind: "setup-script.started",
909 summary: "Setup script started",
910 tone: "info",
911 sequence: 2,
912 }),
913 ];
914
915 expect(deriveWorkLogEntries(setupActivities)).toEqual([]);
916 expect(
917 deriveWorkLogEntries([
918 ...setupActivities,
919 makeActivity({
920 id: "first-turn-tool",
921 kind: "tool.completed",
922 summary: "Read project files",
923 turnId: "turn-1",
924 sequence: 3,
925 }),
926 makeActivity({
927 id: "later-turn-tool",
928 kind: "tool.completed",
929 summary: "Ran tests",
930 turnId: "turn-2",
931 sequence: 4,
932 }),
933 ]).map((entry) => entry.id),
934 ).toEqual(["first-turn-tool", "later-turn-tool"]);
935 });
936
937 it("preserves setup failures and unrelated info without a turn id", () => {
938 const entries = deriveWorkLogEntries([
939 makeActivity({
940 id: "setup-requested",
941 kind: "setup-script.requested",
942 summary: "Preparing setup script",
943 tone: "info",
944 sequence: 1,
945 }),
946 makeActivity({
947 id: "setup-failed",
948 kind: "setup-script.failed",
949 summary: "Setup script failed to start",
950 tone: "error",
951 payload: { detail: "Could not start the setup terminal" },
952 sequence: 2,
953 }),
954 makeActivity({
955 id: "runtime-notice",
956 kind: "runtime.warning",
957 summary: "Reconnecting to provider",
958 tone: "info",
959 sequence: 3,
960 }),
961 ]);
962
963 expect(entries).toMatchObject([
964 {
965 id: "setup-failed",
966 label: "Setup script failed to start",
967 tone: "error",
968 detail: "Could not start the setup terminal",
969 turnId: null,
970 },
971 {
972 id: "runtime-notice",
973 label: "Reconnecting to provider",
974 tone: "info",
975 turnId: null,
976 },
977 ]);
978 });
979
897 it("drops runtime warnings with no displayable content, keeps ones with a preview", () => {980 it("drops runtime warnings with no displayable content, keeps ones with a preview", () => {
898 const activities: OrchestrationThreadActivity[] = [981 const activities: OrchestrationThreadActivity[] = [
899 makeActivity({982 makeActivity({
apps/web/src/session-logic.ts+2
...@@ -2,6 +2,7 @@ import * as Option from "effect/Option";...@@ -2,6 +2,7 @@ import * as Option from "effect/Option";
2import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";4import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
5import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation";
5import {6import {
6 ApprovalRequestId,7 ApprovalRequestId,
7 isToolLifecycleItemType,8 isToolLifecycleItemType,
...@@ -809,6 +810,7 @@ export function deriveWorkLogEntries(...@@ -809,6 +810,7 @@ export function deriveWorkLogEntries(
809 const ordered = [...activities].toSorted(compareActivitiesByOrder);810 const ordered = [...activities].toSorted(compareActivitiesByOrder);
810 const entries: DerivedWorkLogEntry[] = [];811 const entries: DerivedWorkLogEntry[] = [];
811 for (const activity of ordered) {812 for (const activity of ordered) {
813 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
812 if (activity.kind === "tool.started") continue;814 if (activity.kind === "tool.started") continue;
813 // Agent task.started rows are CTA seeds: they carry the true spawn turn,815 // Agent task.started rows are CTA seeds: they carry the true spawn turn,
814 // which is the batch key (completions of background subagents arrive816 // which is the batch key (completions of background subagents arrive
packages/client-runtime/src/work-log/presentation.ts+4
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts";1import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts";
22
3export function isWorktreeSetupActivity(kind: string): boolean {
4 return kind === "setup-script.requested" || kind === "setup-script.started";
5}
6
3export interface WorkLogPresentationEntry {7export interface WorkLogPresentationEntry {
4 readonly label: string;8 readonly label: string;
5 readonly toolTitle?: string;9 readonly toolTitle?: string;