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", () => {
628628 ).toBe(true);
629629 });
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
631655 it("waits for the matching running turn before acknowledging", () => {
632656 const localDispatch = createLocalDispatchSnapshot(
633657 makeThread({ latestRun: completedTurn, runtime: readySession }),
apps/web/src/components/ChatView.logic.ts+5-2
......@@ -57,7 +57,7 @@ import { stripInlineContextReferences } from "~/lib/composerContextReferences";
5757import type { DraftThreadEnvMode } from "../composerDraftStore";
5858import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic";
5959import type { ReviewCommentContext } from "../reviewCommentContext";
60import type { TimelineEntry } from "../session-logic";
60import { derivePhase, type TimelineEntry } from "../session-logic";
6161import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore";
6262import type { DesktopPreviewOverlay } from "../previewStateStore";
6363import type { RightPanelSurface } from "../rightPanelStore";
......@@ -1248,7 +1248,10 @@ export function hasServerAcknowledgedLocalDispatch(input: {
12481248 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
12491249 return true;
12501250 }
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") {
12521255 return false;
12531256 }
12541257
apps/web/src/components/ChatView.tsx+4-1
......@@ -6396,6 +6396,9 @@ export default function ChatView(props: ChatViewProps) {
63966396 optimisticUserMessages,
63976397 ]);
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.
63996402 useEffect(() => {
64006403 setOptimisticUserMessages((existing) => {
64016404 for (const message of existing) {
......@@ -6405,7 +6408,7 @@ export default function ChatView(props: ChatViewProps) {
64056408 });
64066409 resetLocalDispatch();
64076410 setExpandedImage(null);
6408 }, [draftId, resetLocalDispatch, threadId]);
6411 }, [resetLocalDispatch, threadId]);
64096412
64106413 const closeExpandedImage = useCallback(() => {
64116414 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", () => {
46254625 expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);
46264626 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
46284644 // A script that already finished has nothing left to show once the turn is live.
46294645 const finishedRows = deriveMessagesTimelineRows({
46304646 timelineEntries: [userEntry],
apps/web/src/components/chat/MessagesTimeline.logic.ts+12-6
......@@ -1673,7 +1673,11 @@ export function deriveMessagesTimelineRows(input: {
16731673 input.worktreeSetup !== undefined &&
16741674 worktreeSetupAgentStarted(input.worktreeSetup) &&
16751675 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");
16771681 if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) {
16781682 const setupRow = {
16791683 kind: "worktree-setup",
......@@ -1688,7 +1692,9 @@ export function deriveMessagesTimelineRows(input: {
16881692 // While the setup runs, the working header leads the card in the same
16891693 // slot it keeps once the agent's own turn takes over. The main pass may
16901694 // 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;
16921698 if (workingRowIndex >= 0) {
16931699 nextRows.splice(workingRowIndex + 1, 0, setupRow);
16941700 } else {
......@@ -1696,7 +1702,7 @@ export function deriveMessagesTimelineRows(input: {
16961702 nextRows.splice(
16971703 insertAt,
16981704 0,
1699 ...(setupRunning
1705 ...(setupOwnsWorkingSlot
17001706 ? [
17011707 {
17021708 kind: "working",
......@@ -1710,15 +1716,15 @@ export function deriveMessagesTimelineRows(input: {
17101716 }
17111717 }
17121718
1713 // A running setup owns the working slot above its card and shows no
1714 // activity row of its own; every other state gets the usual tail.
1719 // A setup that owns the working slot sits under it and shows no activity
1720 // row of its own; every other state gets the usual tail.
17151721 const hasWorkingRow = nextRows.some((row) => row.kind === "working");
17161722 if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) {
17171723 appendWorkingRow();
17181724 }
17191725 if (
17201726 input.isWorking &&
1721 !setupRunning &&
1727 !setupOwnsWorkingSlot &&
17221728 !hasActiveCompaction &&
17231729 (!hasActivityRow || latestToolFailed)
17241730 ) {
apps/web/src/components/chat/WorktreeSetupCard.tsx+11-8
......@@ -356,17 +356,20 @@ export function WorktreeSetupCard({
356356 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
357357 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
358358 const collapsed = embedded && !running;
359 // While running, the timeline's working row above the card carries the
360 // "Setting up worktree…" label (and keeps that slot when the agent takes
361 // over). The card only brings its own header for a settled outcome that
362 // has no working row to sit under.
363 const showHeader = !embedded && !running;
359 // While running, and after a clean finish until the agent's turn is live,
360 // the timeline's working row above the card carries the label (and keeps
361 // that slot when the agent takes over). The card only brings its own
362 // header for a failed or cancelled setup that has no working row above it.
363 const showHeader = !embedded && !running && snapshot.phase !== "done";
364364 // The tail box is part of the script row's footprint while the script runs
365365 // (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 takes
367 // its final height once instead of growing with each output line.
366 // with the first output line at its full fixed height, so the card grows
367 // once instead of with each line. A script that ends before printing
368 // anything never flashes an empty box.
368369 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
371374 return (
372375 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>