-
+
- {isPreparingWorktree ? (
- <>
- Setting up worktree…
- Setting up worktree…
- >
- ) : isCompacting ? (
- <>
-
-
-
-
- >
- ) : row.createdAt ? (
- <>
- Working for
- >
- ) : (
- "Working..."
- )}
+ {label}
+ {shimmer ? {label} : null}
+ {backgroundWorktreeSetup ? (
+
+ ) : null}
);
}
+/**
+ * Trailing chip in the working header while a setup script still runs after
+ * the agent started. Opens the stage list and live output in a popover; the
+ * chip leaves with the script, so nothing lingers in the timeline.
+ */
+function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
+ const ctx = use(TimelineRowCtx);
+ const terminalId = snapshot.setupScript?.terminalId ?? null;
+ const openTerminal = ctx.onOpenWorktreeSetupTerminal;
+ const onOpenTerminal = useMemo(
+ () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null),
+ [openTerminal, terminalId],
+ );
+ const scriptName = snapshot.setupScript?.name ?? "Setup script";
+ return (
+
+
+ }
+ >
+
+ {scriptName}
+
+
+
+
+
+ );
+}
+
function ThinkingTimelineRow() {
const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
// Reserve the activity row during setup so the handoff keeps the same height.
diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx
index 6fe54269cd10..6fbe7a2b9b5e 100644
--- a/apps/web/src/components/chat/WorktreeSetupCard.tsx
+++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx
@@ -222,18 +222,34 @@ function StageRow({
);
}
+/** The server keeps this many trailing lines; the box is sized for exactly that. */
+const OUTPUT_TAIL_LINES = 4;
+const OUTPUT_TAIL_SLOTS = Array.from({ length: OUTPUT_TAIL_LINES }, (_, slot) => slot);
+
+/**
+ * Fixed-height window onto the script's last lines. Rows never wrap and the
+ * box never grows or shrinks, so streaming output cannot push the timeline
+ * around while the script runs.
+ */
function OutputTail({ lines, failed }: { lines: ReadonlyArray
; failed: boolean }) {
- if (lines.length === 0) return null;
+ const rows = OUTPUT_TAIL_SLOTS.map((slot) => ({
+ slot,
+ line: lines[lines.length - OUTPUT_TAIL_LINES + slot] ?? "",
+ }));
return (
- {lines.join("\n")}
+ {rows.map(({ slot, line }) => (
+
+ {line.length === 0 ? "\u00a0" : line}
+
+ ))}
);
}
@@ -269,6 +285,47 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
);
}
+/**
+ * One-line summary of a settled setup under a live turn. A clean finish is
+ * removed from the timeline altogether, so this only renders the outcomes
+ * worth keeping: a failed script, a failed setup, or a cancelled one.
+ */
+function CollapsedSummaryRow({
+ snapshot,
+ totalElapsed,
+}: {
+ snapshot: WorktreeSetupSnapshot;
+ totalElapsed: number | null;
+}) {
+ const status: WorktreeSetupStage["status"] =
+ snapshot.phase === "failed" || snapshot.phase === "cancelled"
+ ? "failed"
+ : snapshot.stages.some((stage) => stage.id === "setup-script" && stage.status === "failed")
+ ? "failed"
+ : "done";
+ const label = headerLabel(snapshot);
+ return (
+
+
+
+
+ {label}
+ {totalElapsed !== null ? (
+
+ {formatDuration(totalElapsed)}
+
+ ) : null}
+
+ );
+}
+
export function WorktreeSetupCard({
snapshot,
onCancel,
@@ -277,8 +334,9 @@ export function WorktreeSetupCard({
embedded = false,
}: WorktreeSetupCardProps & {
/**
- * The agent already started (async setup script), so the turn owns the
- * "Working for" header and only the script's row sits among the worklog.
+ * The agent's turn is live and owns the "Working for" header. The stage
+ * list stays exactly where it was so the handoff never moves anything; a
+ * failed script that outlives the handoff collapses to a single row.
*/
embedded?: boolean;
}) {
@@ -292,24 +350,40 @@ export function WorktreeSetupCard({
})();
const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
- const stages = embedded
- ? snapshot.stages.filter((stage) => stage.id === "setup-script")
- : snapshot.stages;
+ const collapsed = embedded && !running;
+ // While running, the timeline's working row above the card carries the
+ // "Setting up worktree…" label (and keeps that slot when the agent takes
+ // over). The card only brings its own header for a settled outcome that
+ // has no working row to sit under.
+ const showHeader = !embedded && !running;
+ // The tail box is part of the script row's footprint while the script runs
+ // (and after it failed, so the last lines explain the failure). It mounts
+ // as soon as the script is running, empty lines and all, so the card takes
+ // its final height once instead of growing with each output line.
+ const showTail =
+ setupStage !== undefined && (setupStage.status === "running" || setupStage.status === "failed");
return (
- {embedded ? null : }
-
- {stages.map((stage) => (
-
-
- {stage.id === "setup-script" &&
- (stage.status === "running" || stage.status === "failed") ? (
-
- ) : null}
-
- ))}
-
+ {showHeader ? : null}
+ {collapsed ? (
+
+ ) : (
+
+ {snapshot.stages.map((stage) => (
+
+
+ {stage.id === "setup-script" && showTail ? (
+
+ ) : null}
+
+ ))}
+
+ )}
{snapshot.phase === "failed" && snapshot.error ? (
{snapshot.error}
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index a5e333f218d6..ce2c51fe5247 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -498,6 +498,8 @@ interface ComposerDraftStoreState {
getDraftSession: (draftId: DraftId) => DraftSessionState | null;
/** Resolves a server-thread ref back to a matching draft session when one exists. */
getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null;
+ /** The draft id that reserved a server-thread ref, while its draft record still exists. */
+ getDraftIdByRef: (threadRef: ScopedThreadRef) => DraftId | null;
getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null;
getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null;
listDraftThreadKeys: () => string[];
@@ -2583,6 +2585,17 @@ const composerDraftStore = create()(
}
return null;
},
+ getDraftIdByRef: (threadRef) => {
+ for (const [draftId, draftSession] of Object.entries(get().draftThreadsByThreadKey)) {
+ if (
+ draftSession.environmentId === threadRef.environmentId &&
+ draftSession.threadId === threadRef.threadId
+ ) {
+ return DraftId.make(draftId);
+ }
+ }
+ return null;
+ },
getDraftThread: (threadRef) => {
if (typeof threadRef === "string") {
return get().getDraftSession(DraftId.make(threadRef));
diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx
index 16c0c4dfcc6d..0a1bd024f3d6 100644
--- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx
+++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx
@@ -1,105 +1,7 @@
-import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { useEffect } from "react";
-
-import ChatView from "../components/ChatView";
-import { threadHasStarted } from "../components/ChatView.logic";
-import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
-import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";
-import { resolveThreadSyncPhase } from "../threadSync";
-import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
-import { SidebarInset } from "~/components/ui/sidebar";
-import {
- useEnvironmentThreadRefs,
- useThreadDetail,
- useThreadShell,
- useThreadStatus,
-} from "../state/entities";
-import { useEnvironmentQuery } from "../state/query";
-import { environmentShell } from "../state/shell";
-
-function ChatThreadRouteView() {
- const navigate = useNavigate();
- const threadRef = Route.useParams({
- select: (params) => resolveThreadRouteRef(params),
- });
- const shell = useEnvironmentQuery(
- threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId),
- );
- const serverThreadShell = useThreadShell(threadRef);
- const serverThreadDetail = useThreadDetail(threadRef);
- const serverThreadStatus = useThreadStatus(threadRef);
- const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null);
- const bootstrapComplete = shell.data?.snapshot._tag === "Some";
- const environmentHasServerThreads = environmentThreadRefs.length > 0;
- const draftThreadExists = useComposerDraftStore((store) =>
- threadRef ? store.getDraftThreadByRef(threadRef) !== null : false,
- );
- const draftThread = useComposerDraftStore((store) =>
- threadRef ? store.getDraftThreadByRef(threadRef) : null,
- );
- const environmentHasDraftThreads = useComposerDraftStore((store) => {
- if (!threadRef) {
- return false;
- }
- return store.hasDraftThreadsInEnvironment(threadRef.environmentId);
- });
- const renderState = resolveThreadRouteRenderState({
- bootstrapComplete,
- serverThreadShellExists: serverThreadShell !== null,
- serverThreadDetailExists: serverThreadDetail !== null,
- serverThreadDetailDeleted: serverThreadStatus === "deleted",
- draftThreadExists,
- });
- const threadSyncPhase = resolveThreadSyncPhase({
- detailExists: serverThreadDetail !== null,
- shellExists: serverThreadShell !== null,
- status: serverThreadStatus,
- });
- const serverThreadStarted = threadHasStarted(serverThreadDetail);
- const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
-
- useEffect(() => {
- if (!threadRef || !bootstrapComplete) {
- return;
- }
-
- // Navigation already resolved onto this path, so a drop aimed here
- // passed its landing check; once the thread reads as missing it can
- // never be attached, release it even when there is nowhere to redirect.
- if (renderState === "missing") {
- const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState();
- clearPendingFileDropsForThread(threadRef);
- if (environmentHasAnyThreads) {
- void navigate({ to: "/", replace: true });
- }
- }
- }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]);
-
- useEffect(() => {
- if (!threadRef || !serverThreadStarted || !draftThread) {
- return;
- }
- finalizePromotedDraftThreadByRef(threadRef);
- }, [draftThread, serverThreadStarted, threadRef]);
-
- if (!threadRef) {
- return null;
- }
-
- return (
-
- {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? (
-
- ) : null}
-
- );
-}
+import { createFileRoute } from "@tanstack/react-router";
+// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's
+// promotion onto this route keeps the same ChatView mounted.
export const Route = createFileRoute("/_chat/$environmentId/$threadId")({
- component: ChatThreadRouteView,
+ component: () => null,
});
diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx
index 9d393f27e0bd..5a7f58cfbc66 100644
--- a/apps/web/src/routes/_chat.draft.$draftId.tsx
+++ b/apps/web/src/routes/_chat.draft.$draftId.tsx
@@ -1,94 +1,7 @@
-import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { useEffect } from "react";
-import ChatView from "../components/ChatView";
-import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic";
-import {
- DraftId,
- markPromotedDraftThreadByRef,
- useBackgroundDraftSubmissionPending,
- useComposerDraftStore,
-} from "../composerDraftStore";
-import { SidebarInset } from "../components/ui/sidebar";
-import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition";
-import { buildThreadRouteParams } from "../threadRoutes";
-import { useThread, useThreadRefs } from "../state/entities";
-
-function DraftChatThreadRouteView() {
- const navigate = useNavigate();
- const { draftId: rawDraftId } = Route.useParams();
- const draftId = DraftId.make(rawDraftId);
- const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId));
- const threadRefs = useThreadRefs();
- const inferredThreadRef = draftSession
- ? (threadRefs.find(
- (ref) =>
- ref.environmentId === draftSession.environmentId &&
- ref.threadId === draftSession.threadId,
- ) ?? null)
- : null;
- const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef;
- const serverThread = useThread(serverThreadRef);
- const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef);
- const canonicalThreadRef = resolveDraftPromotionNavigationTarget({
- serverThreadRef,
- serverThread,
- backgroundSubmissionPending,
- });
-
- useEffect(() => {
- if (!inferredThreadRef || draftSession?.promotedTo) {
- return;
- }
- markPromotedDraftThreadByRef(inferredThreadRef);
- }, [draftSession?.promotedTo, inferredThreadRef]);
-
- useEffect(() => {
- if (!canonicalThreadRef) {
- return;
- }
-
- let cancelled = false;
- void waitForDraftHeroTransition().then(() => {
- if (cancelled) {
- return;
- }
- void navigate({
- to: "/$environmentId/$threadId",
- params: buildThreadRouteParams(canonicalThreadRef),
- replace: true,
- });
- });
-
- return () => {
- cancelled = true;
- };
- }, [canonicalThreadRef, navigate]);
-
- useEffect(() => {
- if (draftSession || canonicalThreadRef) {
- return;
- }
- void navigate({ to: "/", replace: true });
- }, [canonicalThreadRef, draftSession, navigate]);
-
- if (!draftSession) {
- return null;
- }
-
- return (
-
-
-
- );
-}
+import { createFileRoute } from "@tanstack/react-router";
+// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's
+// promotion to `/$environmentId/$threadId` keeps the same ChatView mounted.
export const Route = createFileRoute("/_chat/draft/$draftId")({
- component: DraftChatThreadRouteView,
+ component: () => null,
});
diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx
index e084e22c2cbb..980177004daa 100644
--- a/apps/web/src/routes/_chat.tsx
+++ b/apps/web/src/routes/_chat.tsx
@@ -1,8 +1,10 @@
-import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
+import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router";
import { useAtomValue } from "@effect/atom-react";
import { useEffect, useMemo } from "react";
import { isCommandPaletteOpen } from "../commandPaletteBus";
+import { ThreadRouteView } from "../components/ThreadRouteView";
+import { resolveThreadRouteTarget } from "../threadRoutes";
import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
import { openCommandPalette } from "../commandPaletteBus";
import { useProjects } from "../state/entities";
@@ -175,10 +177,16 @@ function ChatRouteGlobalShortcuts() {
}
function ChatRouteLayout() {
+ // Both thread routes render here, not in their own leaf components, so the
+ // draft-to-thread promotion keeps one ChatView mounted across the swap.
+ const threadTarget = useParams({
+ strict: false,
+ select: (params) => resolveThreadRouteTarget(params),
+ });
return (
<>
-
+ {threadTarget ? : }
>
);
}