History

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

Julius Marminge committed 2 months ago

Commit ef84bc91 parent 4a9d2d016 files · +397 −69Browse files

Unified · Split

16 files
  1. apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +45 −24
  2. apps/mobile/src/features/threads/thread-work-log.tsx +1 −1
  3. apps/mobile/src/lib/threadActivity.test.ts +77 −0
  4. apps/mobile/src/lib/threadActivity.ts +2 −0
  5. apps/web/src/components/ChatView.logic.test.ts +61 −1
  6. apps/web/src/components/ChatView.logic.ts +8 −2
  7. apps/web/src/components/ChatView.tsx +9 −6
  8. apps/web/src/components/chat/ChatComposer.tsx +0 −3
  9. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +52 −0
  10. apps/web/src/components/chat/MessagesTimeline.test.tsx +1 −1
  11. apps/web/src/components/chat/MessagesTimeline.tsx +47 −25
  12. apps/web/src/components/chat/timelineScrollAnchoring.ts +3 −0
  13. apps/web/src/routes/_chat.draft.$draftId.tsx +2 −6
  14. apps/web/src/session-logic.test.ts +83 −0
  15. apps/web/src/session-logic.ts +2 −0
  16. packages/client-runtime/src/work-log/presentation.ts +4 −0
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+45−24
@@ -39,6 +39,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; · 38 unmodified lines
3939 import { SymbolView } from "../../components/AppSymbol";
4040 import { AppText as Text } from "../../components/AppText";
4141 import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
42+import { ShimmeringWorkContent } from "./thread-work-log";
4243 import { ComposerCommandPopover } from "./ComposerCommandPopover";
4344 import { useComposerCommandMenu } from "./use-composer-command-menu";
4445 import {
@@ -380,7 +381,8 @@ export function NewTaskDraftScreen(props: { · 335 unmodified lines
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: { · 723 unmodified lines
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: { · 78 unmodified lines
7979 );
8080 }
8181
82−function ShimmeringWorkContent(props: {
82+export function ShimmeringWorkContent(props: {
8383 readonly icon: AppSymbolName;
8484 readonly iconSubtleColor: ColorValue;
8585 readonly label: string;
apps/mobile/src/lib/threadActivity.test.ts+77−0
@@ -234,6 +234,83 @@ function makeThread( · 233 unmodified lines
234234 }
235235
236236 describe("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−0
@@ -14,6 +14,7 @@ import type { · 13 unmodified lines
1414 } from "@t3tools/contracts";
1515 import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1616 import {
17+ isWorktreeSetupActivity,
1718 normalizeCompactToolLabel,
1819 omitSupersededLifecycleMarkers,
1920 summarizeToolGroup,
@@ -343,6 +344,7 @@ function deriveWorkLogEntries( · 323 unmodified lines
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", () => { · 114 unmodified lines
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 = { · 194 unmodified lines
316316 updatedAt: "2026-03-29T00:00:10.000Z",
317317 };
318318
319+describe("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+
319379 describe("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: { · 109 unmodified lines
110110
111111 export 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
122128 export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
apps/web/src/components/ChatView.tsx+9−6
@@ -53,7 +53,6 @@ import { · 52 unmodified lines
5353 createModelSelection,
5454 resolvePromptInjectedEffort,
5555 } from "@t3tools/shared/model";
56−import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
5756 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
5857 import { truncate } from "@t3tools/shared/String";
5958 import {
@@ -108,7 +107,11 @@ import { · 48 unmodified lines
108107 isLatestTurnSettled,
109108 } from "../session-logic";
110109 import { type LegendListRef } from "@legendapp/list/react";
111−import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring";
110+import {
111+ CHAT_TIMELINE_ANCHOR_OFFSET,
112+ getAnchoredTurnMetrics,
113+ type TimelineScrollMode,
114+} from "./chat/timelineScrollAnchoring";
112115 import {
113116 buildPendingUserInputAnswers,
114117 derivePendingUserInputProgress,
@@ -4110,7 +4113,7 @@ function ChatViewContent(props: ChatViewProps) { · 3995 unmodified lines
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) { · 21 unmodified lines
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) { · 181 unmodified lines
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) { · 1813 unmodified lines
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) { · 1072 unmodified lines
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+0−3
@@ -537,9 +537,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 536 unmodified lines
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−0
@@ -1,4 +1,5 @@
11 import { describe, expect, it } from "vite-plus/test";
2+import { MessageId, TurnId } from "@t3tools/contracts";
23 import {
34 computeStableMessagesTimelineRows,
45 computeMessageDurationStart,
@@ -828,6 +829,7 @@ describe("deriveMessagesTimelineRows", () => { · 823 unmodified lines
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", () => { · 108 unmodified lines
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", () => { · 427 unmodified lines
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", () => { · 162 unmodified lines
15431547 });
15441548
15451549 describe("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", () => { · 519 unmodified lines
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 · 83 unmodified lines
8484 import { ProposedPlanCard } from "./ProposedPlanCard";
8585 import { ChangedFilesCard } from "./ChangedFilesTree";
8686 import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
87−import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring";
87+import {
88+ CHAT_TIMELINE_ANCHOR_OFFSET,
89+ keepTimelineEndVisibleAfterOverlayGrowth,
90+} from "./timelineScrollAnchoring";
8891 import { MessageCopyButton } from "./MessageCopyButton";
8992 import {
9093 computeStableMessagesTimelineRows,
@@ -169,6 +172,7 @@ interface TimelineRowSharedState { · 78 unmodified lines
169172
170173 interface TimelineRowActivityState {
171174 isWorking: boolean;
175+ isPreparingWorktree: boolean;
172176 isRevertingCheckpoint: boolean;
173177 latestTurnId: TurnId | null;
174178 }
@@ -225,6 +229,7 @@ interface MessagesTimelineProps { · 50 unmodified lines
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 { · 39 unmodified lines
270275
271276 export 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({ · 191 unmodified lines
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({ · 102 unmodified lines
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({ · 731 unmodified lines
13181328 }
13191329
13201330 function 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 · 4 unmodified lines
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({ · 70 unmodified lines
14121432 );
14131433 });
14141434
1435+function 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+
14151448 function LiveActivityRow({
14161449 label,
14171450 iconName,
@@ -1429,24 +1462,13 @@ function LiveActivityRow({ · 11 unmodified lines
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
1446−function ThinkingActivityRow() {
1447− return <LiveActivityRow label="Thinking" />;
1448−}
1449−
14501472 function LiveActivityContent({
14511473 label,
14521474 iconName,
@@ -1463,7 +1485,7 @@ function LiveActivityContent({ · 10 unmodified lines
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({ · 16 unmodified lines
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−0
@@ -1,3 +1,6 @@
1+// Match the titlebar fade inset so draft promotion preserves the first row's position.
2+export const CHAT_TIMELINE_ANCHOR_OFFSET = 24;
3+
14 export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling";
25
36 export interface TimelineListMeasurementState {
apps/web/src/routes/_chat.draft.$draftId.tsx+2−6
@@ -1,10 +1,7 @@
11 import { createFileRoute, useNavigate } from "@tanstack/react-router";
22 import { useEffect } from "react";
33 import ChatView from "../components/ChatView";
4−import {
5− resolveDraftPromotionNavigationTarget,
6− threadHasStarted,
7−} from "../components/ChatView.logic";
4+import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic";
85 import {
96 DraftId,
107 markPromotedDraftThreadByRef,
@@ -31,11 +28,10 @@ function DraftChatThreadRouteView() { · 20 unmodified lines
3128 : null;
3229 const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef;
3330 const serverThread = 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−0
@@ -894,6 +894,89 @@ describe("deriveWorkLogEntries", () => { · 893 unmodified lines
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−0
@@ -2,6 +2,7 @@ import * as Option from "effect/Option"; · 1 unmodified line
22 import * as Arr from "effect/Array";
33 import * as Schema from "effect/Schema";
44 import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
5+import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation";
56 import {
67 ApprovalRequestId,
78 isToolLifecycleItemType,
@@ -809,6 +810,7 @@ export function deriveWorkLogEntries( · 801 unmodified lines
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−0
@@ -1,5 +1,9 @@
11 import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts";
22
3+export function isWorktreeSetupActivity(kind: string): boolean {
4+ return kind === "setup-script.requested" || kind === "setup-script.started";
5+}
6+
37 export interface WorkLogPresentationEntry {
48 readonly label: string;
59 readonly toolTitle?: string;

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t