authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-02 21:37:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 21:37:18-07:00
log3a7058da50c91906158c89fc3f845e6c04379414
tree43713dc12cf8c1258dcdbababb23e486471155cd
parent22e9d35613305a04a28e96f83abc10c1bcf4aa8a
signature Signed by PGP key B5690EEEBB952194

fix(web): new worktree threads no longer flicker while starting (#15019)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 72 insertions(+), 17 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+24
...@@ -628,6 +628,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -628,6 +628,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
628 ).toBe(true);628 ).toBe(true);
629 });629 });
630630
631 it("holds a first send while the thread shell still reports a preparing run", () => {
632 // The draft had no run. The server thread's shell shows the new run before
633 // the detail projection behind `phase` loads.
634 const localDispatch = createLocalDispatchSnapshot(makeThread());
635 const preparingRun = {
636 ...completedTurn,
637 status: "preparing" as const,
638 startedAt: null,
639 completedAt: null,
640 };
641
642 expect(
643 hasServerAcknowledgedLocalDispatch({
644 localDispatch,
645 phase: "disconnected",
646 latestRun: preparingRun,
647 runtime: { ...readySession, status: "preparing", activeRunId: preparingRun.runId },
648 hasPendingApproval: false,
649 hasPendingUserInput: false,
650 threadError: null,
651 }),
652 ).toBe(false);
653 });
654
631 it("waits for the matching running turn before acknowledging", () => {655 it("waits for the matching running turn before acknowledging", () => {
632 const localDispatch = createLocalDispatchSnapshot(656 const localDispatch = createLocalDispatchSnapshot(
633 makeThread({ latestRun: completedTurn, runtime: readySession }),657 makeThread({ latestRun: completedTurn, runtime: readySession }),
apps/web/src/components/ChatView.logic.ts+5-2
...@@ -57,7 +57,7 @@ import { stripInlineContextReferences } from "~/lib/composerContextReferences";...@@ -57,7 +57,7 @@ import { stripInlineContextReferences } from "~/lib/composerContextReferences";
57import type { DraftThreadEnvMode } from "../composerDraftStore";57import type { DraftThreadEnvMode } from "../composerDraftStore";
58import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic";58import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic";
59import type { ReviewCommentContext } from "../reviewCommentContext";59import type { ReviewCommentContext } from "../reviewCommentContext";
60import type { TimelineEntry } from "../session-logic";60import { derivePhase, type TimelineEntry } from "../session-logic";
61import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore";61import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore";
62import type { DesktopPreviewOverlay } from "../previewStateStore";62import type { DesktopPreviewOverlay } from "../previewStateStore";
63import type { RightPanelSurface } from "../rightPanelStore";63import type { RightPanelSurface } from "../rightPanelStore";
...@@ -1248,7 +1248,10 @@ export function hasServerAcknowledgedLocalDispatch(input: {...@@ -1248,7 +1248,10 @@ export function hasServerAcknowledgedLocalDispatch(input: {
1248 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {1248 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
1249 return true;1249 return true;
1250 }1250 }
1251 if (input.phase === "connecting") {1251 // The thread shell can report a preparing or starting run before the detail
1252 // projection behind `phase` loads, so either source still connecting holds
1253 // the send.
1254 if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") {
1252 return false;1255 return false;
1253 }1256 }
12541257
apps/web/src/components/ChatView.tsx+4-1
...@@ -6396,6 +6396,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -6396,6 +6396,9 @@ export default function ChatView(props: ChatViewProps) {
6396 optimisticUserMessages,6396 optimisticUserMessages,
6397 ]);6397 ]);
63986398
6399 // Keyed on the thread, not the draft: a draft's promotion to its server
6400 // route keeps this instance and drops `draftId`, and the send it is still
6401 // dispatching must survive that swap.
6399 useEffect(() => {6402 useEffect(() => {
6400 setOptimisticUserMessages((existing) => {6403 setOptimisticUserMessages((existing) => {
6401 for (const message of existing) {6404 for (const message of existing) {
...@@ -6405,7 +6408,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6405,7 +6408,7 @@ export default function ChatView(props: ChatViewProps) {
6405 });6408 });
6406 resetLocalDispatch();6409 resetLocalDispatch();
6407 setExpandedImage(null);6410 setExpandedImage(null);
6408 }, [draftId, resetLocalDispatch, threadId]);6411 }, [resetLocalDispatch, threadId]);
64096412
6410 const closeExpandedImage = useCallback(() => {6413 const closeExpandedImage = useCallback(() => {
6411 setExpandedImage(null);6414 setExpandedImage(null);
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+16
...@@ -4625,6 +4625,22 @@ it("keeps the working header in place across worktree setup handoff", () => {...@@ -4625,6 +4625,22 @@ it("keeps the working header in place across worktree setup handoff", () => {
4625 expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);4625 expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);
4626 expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false });4626 expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false });
46274627
4628 // A clean finish before the turn is live keeps that same layout, so the card
4629 // does not jump above the header in the gap before the run starts.
4630 const settledRows = deriveMessagesTimelineRows({
4631 timelineEntries: [userEntry],
4632 isWorking: true,
4633 activeTurnStartedAt: "2026-01-01T00:00:00Z",
4634 turnDiffSummaries: [],
4635 supportsConversationRollback: false,
4636 worktreeSetup: {
4637 ...snapshot,
4638 phase: "done",
4639 stages: [stage("setup-script", "done"), stage("agent", "done")],
4640 },
4641 });
4642 expect(settledRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);
4643
4628 // A script that already finished has nothing left to show once the turn is live.4644 // A script that already finished has nothing left to show once the turn is live.
4629 const finishedRows = deriveMessagesTimelineRows({4645 const finishedRows = deriveMessagesTimelineRows({
4630 timelineEntries: [userEntry],4646 timelineEntries: [userEntry],
apps/web/src/components/chat/MessagesTimeline.logic.ts+12-6
...@@ -1673,7 +1673,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -1673,7 +1673,11 @@ export function deriveMessagesTimelineRows(input: {
1673 input.worktreeSetup !== undefined &&1673 input.worktreeSetup !== undefined &&
1674 worktreeSetupAgentStarted(input.worktreeSetup) &&1674 worktreeSetupAgentStarted(input.worktreeSetup) &&
1675 input.latestRun?.startedAt != null;1675 input.latestRun?.startedAt != null;
1676 const setupRunning = !setupHandedOff && input.worktreeSetup?.phase === "running";1676 // A finished setup keeps the slot until the turn is live, so the card does
1677 // not jump above the working header in the gap before the run starts.
1678 const setupOwnsWorkingSlot =
1679 !setupHandedOff &&
1680 (input.worktreeSetup?.phase === "running" || input.worktreeSetup?.phase === "done");
1677 if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) {1681 if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) {
1678 const setupRow = {1682 const setupRow = {
1679 kind: "worktree-setup",1683 kind: "worktree-setup",
...@@ -1688,7 +1692,9 @@ export function deriveMessagesTimelineRows(input: {...@@ -1688,7 +1692,9 @@ export function deriveMessagesTimelineRows(input: {
1688 // While the setup runs, the working header leads the card in the same1692 // While the setup runs, the working header leads the card in the same
1689 // slot it keeps once the agent's own turn takes over. The main pass may1693 // slot it keeps once the agent's own turn takes over. The main pass may
1690 // already have placed that header (a bootstrap counts as working).1694 // already have placed that header (a bootstrap counts as working).
1691 const workingRowIndex = setupRunning ? nextRows.findIndex((row) => row.kind === "working") : -1;1695 const workingRowIndex = setupOwnsWorkingSlot
1696 ? nextRows.findIndex((row) => row.kind === "working")
1697 : -1;
1692 if (workingRowIndex >= 0) {1698 if (workingRowIndex >= 0) {
1693 nextRows.splice(workingRowIndex + 1, 0, setupRow);1699 nextRows.splice(workingRowIndex + 1, 0, setupRow);
1694 } else {1700 } else {
...@@ -1696,7 +1702,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -1696,7 +1702,7 @@ export function deriveMessagesTimelineRows(input: {
1696 nextRows.splice(1702 nextRows.splice(
1697 insertAt,1703 insertAt,
1698 0,1704 0,
1699 ...(setupRunning1705 ...(setupOwnsWorkingSlot
1700 ? [1706 ? [
1701 {1707 {
1702 kind: "working",1708 kind: "working",
...@@ -1710,15 +1716,15 @@ export function deriveMessagesTimelineRows(input: {...@@ -1710,15 +1716,15 @@ export function deriveMessagesTimelineRows(input: {
1710 }1716 }
1711 }1717 }
17121718
1713 // A running setup owns the working slot above its card and shows no1719 // A setup that owns the working slot sits under it and shows no activity
1714 // activity row of its own; every other state gets the usual tail.1720 // row of its own; every other state gets the usual tail.
1715 const hasWorkingRow = nextRows.some((row) => row.kind === "working");1721 const hasWorkingRow = nextRows.some((row) => row.kind === "working");
1716 if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) {1722 if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) {
1717 appendWorkingRow();1723 appendWorkingRow();
1718 }1724 }
1719 if (1725 if (
1720 input.isWorking &&1726 input.isWorking &&
1721 !setupRunning &&1727 !setupOwnsWorkingSlot &&
1722 !hasActiveCompaction &&1728 !hasActiveCompaction &&
1723 (!hasActivityRow || latestToolFailed)1729 (!hasActivityRow || latestToolFailed)
1724 ) {1730 ) {
apps/web/src/components/chat/WorktreeSetupCard.tsx+11-8
...@@ -356,17 +356,20 @@ export function WorktreeSetupCard({...@@ -356,17 +356,20 @@ export function WorktreeSetupCard({
356 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");356 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
357 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";357 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
358 const collapsed = embedded && !running;358 const collapsed = embedded && !running;
359 // While running, the timeline's working row above the card carries the359 // While running, and after a clean finish until the agent's turn is live,
360 // "Setting up worktree…" label (and keeps that slot when the agent takes360 // the timeline's working row above the card carries the label (and keeps
361 // over). The card only brings its own header for a settled outcome that361 // that slot when the agent takes over). The card only brings its own
362 // has no working row to sit under.362 // header for a failed or cancelled setup that has no working row above it.
363 const showHeader = !embedded && !running;363 const showHeader = !embedded && !running && snapshot.phase !== "done";
364 // The tail box is part of the script row's footprint while the script runs364 // The tail box is part of the script row's footprint while the script runs
365 // (and after it failed, so the last lines explain the failure). It mounts365 // (and after it failed, so the last lines explain the failure). It mounts
366 // as soon as the script is running, empty lines and all, so the card takes366 // with the first output line at its full fixed height, so the card grows
367 // its final height once instead of growing with each output line.367 // once instead of with each line. A script that ends before printing
368 // anything never flashes an empty box.
368 const showTail =369 const showTail =
369 setupStage !== undefined && (setupStage.status === "running" || setupStage.status === "failed");370 setupStage !== undefined &&
371 (setupStage.status === "failed" ||
372 (setupStage.status === "running" && setupStage.tail.length > 0));
370373
371 return (374 return (
372 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>375 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>