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";
3939import { SymbolView } from "../../components/AppSymbol";
4040import { AppText as Text } from "../../components/AppText";
4141import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
42import { ShimmeringWorkContent } from "./thread-work-log";
4243import { ComposerCommandPopover } from "./ComposerCommandPopover";
4344import { useComposerCommandMenu } from "./use-composer-command-menu";
4445import {
......@@ -380,7 +381,8 @@ export function NewTaskDraftScreen(props: {
380381 };
381382 }, [props.pendingTaskId, cancelEditingPendingTask]);
382383
383 const foregroundColor = useUniwindTheme()["--color-foreground"];
384 const theme = useUniwindTheme();
385 const foregroundColor = theme["--color-foreground"];
384386 const regularFontFamily = useFontFamily("regular");
385387 const bodyText = useScaledTextRole("body");
386388
......@@ -1110,31 +1112,50 @@ export function NewTaskDraftScreen(props: {
11101112
11111113 const workspaceControls = (
11121114 <View className="flex-row items-center gap-1 px-2">
1113 <ComposerInlineControl
1114 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
1115 accessibilityLabel={workspaceLabel}
1116 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1117 iconNode={
1118 <NewTaskWorkspaceIcon
1119 workspaceMode={flow.workspaceMode}
1120 worktreePath={flow.selectedWorktreePath}
1115 {flow.submitting && environmentConnected && flow.workspaceMode === "worktree" ? (
1116 <View
1117 accessible
1118 accessibilityLabel="Setting up worktree…"
1119 className="h-11 w-full max-w-[260px] flex-row items-center px-2"
1120 >
1121 <ShimmeringWorkContent
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}
11211146 />
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 <ComposerInlineControl
1130 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
1131 chevronDirection="right"
1132 disabled={isComposerInteractionLocked}
1133 icon="arrow.triangle.branch"
1134 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
1135 maxWidth={190}
1136 onPress={() => openContextPicker("NewTaskBranch")}
1137 />
1148 <ComposerInlineControl
1149 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
1150 chevronDirection="right"
1151 disabled={isComposerInteractionLocked}
1152 icon="arrow.triangle.branch"
1153 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
1154 maxWidth={190}
1155 onPress={() => openContextPicker("NewTaskBranch")}
1156 />
1157 </>
1158 )}
11381159 </View>
11391160 );
11401161
apps/mobile/src/features/threads/thread-work-log.tsx+1-1
......@@ -79,7 +79,7 @@ function ShimmerWorkContent(props: {
7979 );
8080}
8181
82function ShimmeringWorkContent(props: {
82export function ShimmeringWorkContent(props: {
8383 readonly icon: AppSymbolName;
8484 readonly iconSubtleColor: ColorValue;
8585 readonly label: string;
apps/mobile/src/lib/threadActivity.test.ts+77
......@@ -234,6 +234,83 @@ function makeThread(
234234}
235235
236236describe("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
237314 it("keeps older local feedback before newer messages returned by the server", () => {
238315 const submission = {
239316 id: MessageId.make("feedback-command-ordering"),
apps/mobile/src/lib/threadActivity.ts+2
......@@ -14,6 +14,7 @@ import type {
1414} from "@t3tools/contracts";
1515import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1616import {
17 isWorktreeSetupActivity,
1718 normalizeCompactToolLabel,
1819 omitSupersededLifecycleMarkers,
1920 summarizeToolGroup,
......@@ -343,6 +344,7 @@ function deriveWorkLogEntries(
343344 const ordered = Arr.sort(activities, activityOrder);
344345 const entries: DerivedWorkLogEntry[] = [];
345346 for (const activity of ordered) {
347 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
346348 if (activity.kind === "tool.started") continue;
347349 if (activity.kind === "task.started") continue;
348350 // 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", () => {
115115 expect(
116116 resolveDraftPromotionNavigationTarget({
117117 serverThreadRef: { environmentId, threadId },
118 serverThreadStarted: true,
118 serverThread: makeThread({ latestTurn: completedTurn }),
119119 backgroundSubmissionPending: true,
120120 }),
121121 ).toBeNull();
......@@ -316,6 +316,66 @@ const readySession = {
316316 updatedAt: "2026-03-29T00:00:10.000Z",
317317};
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
319379describe("buildLoadingThreadFromShell", () => {
320380 it("preserves shell metadata and supplies empty detail collections", () => {
321381 const shell = {
apps/web/src/components/ChatView.logic.ts+8-2
......@@ -110,13 +110,19 @@ export function resolveDraftHeroState(input: {
110110
111111export function resolveDraftPromotionNavigationTarget(input: {
112112 serverThreadRef: ScopedThreadRef | null;
113 serverThreadStarted: boolean;
113 serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined;
114114 backgroundSubmissionPending: boolean;
115115}): ScopedThreadRef | null {
116116 if (input.backgroundSubmissionPending) {
117117 return null;
118118 }
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;
120126}
121127
122128export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
apps/web/src/components/ChatView.tsx+9-6
......@@ -53,7 +53,6 @@ import {
5353 createModelSelection,
5454 resolvePromptInjectedEffort,
5555} from "@t3tools/shared/model";
56import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
5756import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
5857import { truncate } from "@t3tools/shared/String";
5958import {
......@@ -108,7 +107,11 @@ import {
108107 isLatestTurnSettled,
109108} from "../session-logic";
110109import { 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";
112115import {
113116 buildPendingUserInputAnswers,
114117 derivePendingUserInputProgress,
......@@ -4110,7 +4113,7 @@ function ChatViewContent(props: ChatViewProps) {
41104113 state,
41114114 anchorIndex,
41124115 composerOverlayHeight,
4113 anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
4116 anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
41144117 });
41154118 },
41164119 [composerOverlayHeight],
......@@ -4138,7 +4141,7 @@ function ChatViewContent(props: ChatViewProps) {
41384141 const realContentBottom = lastRowTop + Math.max(1, lastRowHeight);
41394142 const visibleScrollLength = Math.max(
41404143 0,
4141 (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET,
4144 (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET,
41424145 );
41434146 return realContentBottom > visibleScrollLength;
41444147 },
......@@ -4326,7 +4329,7 @@ function ChatViewContent(props: ChatViewProps) {
43264329 index: anchorIndex,
43274330 animated: true,
43284331 viewPosition: 0,
4329 viewOffset: CHAT_LIST_ANCHOR_OFFSET,
4332 viewOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
43304333 })
43314334 .then(() => {
43324335 if (positionedTimelineAnchorRef.current !== messageId) {
......@@ -6146,7 +6149,6 @@ function ChatViewContent(props: ChatViewProps) {
61466149 : {}),
61476150 }
61486151 : undefined;
6149 beginLocalDispatch({ preparingWorktree: false });
61506152 const backgroundThreadRef =
61516153 resolvedSubmissionIntent === "background"
61526154 ? scopeThreadRef(activeThread.environmentId, threadIdForSend)
......@@ -7225,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) {
72257227 onOpenAgents={addAgentsSurface}
72267228 key={activeThread.id}
72277229 isWorking={isWorking}
7230 isPreparingWorktree={isPreparingWorktree}
72287231 activeTurnStartedAt={activeWorkStartedAt}
72297232 listRef={legendListRef}
72307233 timelineEntries={timelineEntries}
apps/web/src/components/chat/ChatComposer.tsx-3
......@@ -537,9 +537,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
537537 compactDisabledReason={props.compactDisabledReason}
538538 />
539539 ) : null}
540 {props.isPreparingWorktree ? (
541 <span className="text-secondary-label text-xs">Preparing worktree...</span>
542 ) : null}
543540 <ComposerPrimaryActions
544541 compact={props.compact}
545542 pendingAction={props.pendingAction}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+52
......@@ -1,4 +1,5 @@
11import { describe, expect, it } from "vite-plus/test";
2import { MessageId, TurnId } from "@t3tools/contracts";
23import {
34 computeStableMessagesTimelineRows,
45 computeMessageDurationStart,
......@@ -828,6 +829,7 @@ describe("deriveMessagesTimelineRows", () => {
828829 ]);
829830 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
830831 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
832 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true });
831833 });
832834
833835 it("does not fold the active in-progress turn", () => {
......@@ -942,6 +944,7 @@ describe("deriveMessagesTimelineRows", () => {
942944 });
943945
944946 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
947 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false });
945948 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
946949 entry: { id: "running-command" },
947950 groupedEntries: [
......@@ -1375,6 +1378,7 @@ describe("deriveMessagesTimelineRows", () => {
13751378
13761379 expect(assistantRow?.showAssistantMeta).toBe(false);
13771380 expect(assistantRow?.showAssistantCopyButton).toBe(false);
1381 expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false });
13781382 });
13791383
13801384 it.each([
......@@ -1543,6 +1547,54 @@ describe("deriveMessagesTimelineRows", () => {
15431547});
15441548
15451549describe("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
15461598 it("returns the previous result when row order and content are unchanged", () => {
15471599 const firstUserMessage = {
15481600 id: "user-1" as never,
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-1
......@@ -520,7 +520,7 @@ describe("MessagesTimeline", () => {
520520 );
521521
522522 expect(markup).toContain('data-anchor-index="0"');
523 expect(markup).toContain('data-anchor-offset="16"');
523 expect(markup).toContain('data-anchor-offset="24"');
524524 expect(markup).toContain('data-anchor-on-ready="true"');
525525 expect(markup).not.toContain("data-anchor-max-size=");
526526 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
8484import { ProposedPlanCard } from "./ProposedPlanCard";
8585import { ChangedFilesCard } from "./ChangedFilesTree";
8686import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
87import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring";
87import {
88 CHAT_TIMELINE_ANCHOR_OFFSET,
89 keepTimelineEndVisibleAfterOverlayGrowth,
90} from "./timelineScrollAnchoring";
8891import { MessageCopyButton } from "./MessageCopyButton";
8992import {
9093 computeStableMessagesTimelineRows,
......@@ -169,6 +172,7 @@ interface TimelineRowSharedState {
169172
170173interface TimelineRowActivityState {
171174 isWorking: boolean;
175 isPreparingWorktree: boolean;
172176 isRevertingCheckpoint: boolean;
173177 latestTurnId: TurnId | null;
174178}
......@@ -225,6 +229,7 @@ interface MessagesTimelineProps {
225229 agentPanelModel?: AgentPanelModel;
226230 onOpenAgents?: () => void;
227231 isWorking: boolean;
232 isPreparingWorktree?: boolean;
228233 activeTurnStartedAt: string | null;
229234 listRef: React.RefObject<LegendListRef | null>;
230235 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
......@@ -270,6 +275,7 @@ interface MessagesTimelineProps {
270275
271276export const MessagesTimeline = memo(function MessagesTimeline({
272277 isWorking,
278 isPreparingWorktree = false,
273279 activeTurnStartedAt,
274280 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
275281 onOpenAgents = NOOP_OPEN_AGENTS,
......@@ -467,8 +473,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
467473 [anchorMessageId, onAnchorReady],
468474 );
469475 const anchoredEndSpace = useMemo(() => {
470 const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>
471 row.kind === "message" && row.message.role === "user" ? row.message.id : null,
476 const config = resolveChatListAnchoredEndSpace(
477 rows,
478 anchorMessageId,
479 (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null),
480 { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET },
472481 );
473482 return config ? { ...config, onReady: handleAnchorReady } : undefined;
474483 }, [anchorMessageId, handleAnchorReady, rows]);
......@@ -577,10 +586,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
577586 const activityState = useMemo<TimelineRowActivityState>(
578587 () => ({
579588 isWorking,
589 isPreparingWorktree,
580590 isRevertingCheckpoint,
581591 latestTurnId: latestTurn?.turnId ?? null,
582592 }),
583 [isRevertingCheckpoint, isWorking, latestTurn?.turnId],
593 [isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId],
584594 );
585595
586596 // Stable renderItem — no closure deps. Row components read shared state
......@@ -1318,12 +1328,21 @@ function ProposedPlanTimelineRow({
13181328}
13191329
13201330function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1331 const { isPreparingWorktree } = use(TimelineRowActivityCtx);
13211332 return (
13221333 <div>
13231334 <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">
1325 <span className="shrink-0 whitespace-nowrap">
1326 {row.createdAt ? (
1335 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1336 <span
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 ? (
13271346 <>
13281347 Working for <WorkingTimer createdAt={row.createdAt} />
13291348 </>
......@@ -1334,8 +1353,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
13341353 </div>
13351354 </div>
13361355 {row.showThinking ? (
1337 <div className="mt-1">
1338 <ThinkingActivityRow />
1356 // Reserve the activity row during setup so the handoff keeps the same height.
1357 <div className="mt-1 min-h-7">
1358 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
13391359 </div>
13401360 ) : null}
13411361 </div>
......@@ -1412,6 +1432,19 @@ const WorkGroupSection = memo(function WorkGroupSection({
14121432 );
14131433});
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
14151448function LiveActivityRow({
14161449 label,
14171450 iconName,
......@@ -1429,24 +1462,13 @@ function LiveActivityRow({
14291462 failed={failed}
14301463 announceFailure={failed}
14311464 />
1432 <div
1433 aria-hidden
1434 className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
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>
1465 <ActivityShimmerOverlay>
1466 <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
1467 </ActivityShimmerOverlay>
14421468 </div>
14431469 );
14441470}
14451471
1446function ThinkingActivityRow() {
1447 return <LiveActivityRow label="Thinking" />;
1448}
1449
14501472function LiveActivityContent({
14511473 label,
14521474 iconName,
......@@ -1463,7 +1485,7 @@ function LiveActivityContent({
14631485 const resolvedIconName = failed ? "circle-alert" : iconName;
14641486
14651487 return (
1466 <div
1488 <span
14671489 className={cn(
14681490 "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",
14691491 resolvedIconName ? "px-0.5" : "px-1",
......@@ -1486,7 +1508,7 @@ function LiveActivityContent({
14861508 </span>
14871509 ) : null}
14881510 <span className="min-w-0 flex-1 truncate">{label}</span>
1489 </div>
1511 </span>
14901512 );
14911513}
14921514
apps/web/src/components/chat/timelineScrollAnchoring.ts+3
......@@ -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
14export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling";
25
36export interface TimelineListMeasurementState {
apps/web/src/routes/_chat.draft.$draftId.tsx+2-6
......@@ -1,10 +1,7 @@
11import { createFileRoute, useNavigate } from "@tanstack/react-router";
22import { useEffect } from "react";
33import ChatView from "../components/ChatView";
4import {
5 resolveDraftPromotionNavigationTarget,
6 threadHasStarted,
7} from "../components/ChatView.logic";
4import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic";
85import {
96 DraftId,
107 markPromotedDraftThreadByRef,
......@@ -31,11 +28,10 @@ function DraftChatThreadRouteView() {
3128 : null;
3229 const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef;
3330 const serverThread = useThread(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
apps/web/src/session-logic.test.ts+83
......@@ -894,6 +894,89 @@ describe("deriveWorkLogEntries", () => {
894894 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
895895 });
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
897980 it("drops runtime warnings with no displayable content, keeps ones with a preview", () => {
898981 const activities: OrchestrationThreadActivity[] = [
899982 makeActivity({
apps/web/src/session-logic.ts+2
......@@ -2,6 +2,7 @@ import * as Option from "effect/Option";
22import * as Arr from "effect/Array";
33import * as Schema from "effect/Schema";
44import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
5import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation";
56import {
67 ApprovalRequestId,
78 isToolLifecycleItemType,
......@@ -809,6 +810,7 @@ export function deriveWorkLogEntries(
809810 const ordered = [...activities].toSorted(compareActivitiesByOrder);
810811 const entries: DerivedWorkLogEntry[] = [];
811812 for (const activity of ordered) {
813 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
812814 if (activity.kind === "tool.started") continue;
813815 // Agent task.started rows are CTA seeds: they carry the true spawn turn,
814816 // which is the batch key (completions of background subagents arrive
packages/client-runtime/src/work-log/presentation.ts+4
......@@ -1,5 +1,9 @@
11import { 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
37export interface WorkLogPresentationEntry {
48 readonly label: string;
59 readonly toolTitle?: string;