Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(true);
});

it("holds a first send while the thread shell still reports a preparing run", () => {
// The draft had no run. The server thread's shell shows the new run before
// the detail projection behind `phase` loads.
const localDispatch = createLocalDispatchSnapshot(makeThread());
const preparingRun = {
...completedTurn,
status: "preparing" as const,
startedAt: null,
completedAt: null,
};

expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "disconnected",
latestRun: preparingRun,
runtime: { ...readySession, status: "preparing", activeRunId: preparingRun.runId },
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
}),
).toBe(false);
});

it("waits for the matching running turn before acknowledging", () => {
const localDispatch = createLocalDispatchSnapshot(
makeThread({ latestRun: completedTurn, runtime: readySession }),
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import { stripInlineContextReferences } from "~/lib/composerContextReferences";
import type { DraftThreadEnvMode } from "../composerDraftStore";
import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic";
import type { ReviewCommentContext } from "../reviewCommentContext";
import type { TimelineEntry } from "../session-logic";
import { derivePhase, type TimelineEntry } from "../session-logic";
import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore";
import type { DesktopPreviewOverlay } from "../previewStateStore";
import type { RightPanelSurface } from "../rightPanelStore";
Expand Down Expand Up @@ -1248,7 +1248,10 @@ export function hasServerAcknowledgedLocalDispatch(input: {
if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
return true;
}
if (input.phase === "connecting") {
// The thread shell can report a preparing or starting run before the detail
// projection behind `phase` loads, so either source still connecting holds
// the send.
if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") {
return false;
}

Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6396,6 +6396,9 @@ export default function ChatView(props: ChatViewProps) {
optimisticUserMessages,
]);

// Keyed on the thread, not the draft: a draft's promotion to its server
// route keeps this instance and drops `draftId`, and the send it is still
// dispatching must survive that swap.
useEffect(() => {
setOptimisticUserMessages((existing) => {
for (const message of existing) {
Expand All @@ -6405,7 +6408,7 @@ export default function ChatView(props: ChatViewProps) {
});
resetLocalDispatch();
setExpandedImage(null);
}, [draftId, resetLocalDispatch, threadId]);
}, [resetLocalDispatch, threadId]);

const closeExpandedImage = useCallback(() => {
setExpandedImage(null);
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4625,6 +4625,22 @@ it("keeps the working header in place across worktree setup handoff", () => {
expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);
expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false });

// A clean finish before the turn is live keeps that same layout, so the card
// does not jump above the header in the gap before the run starts.
const settledRows = deriveMessagesTimelineRows({
timelineEntries: [userEntry],
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaries: [],
supportsConversationRollback: false,
worktreeSetup: {
...snapshot,
phase: "done",
stages: [stage("setup-script", "done"), stage("agent", "done")],
},
});
expect(settledRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);

// A script that already finished has nothing left to show once the turn is live.
const finishedRows = deriveMessagesTimelineRows({
timelineEntries: [userEntry],
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1673,7 +1673,11 @@ export function deriveMessagesTimelineRows(input: {
input.worktreeSetup !== undefined &&
worktreeSetupAgentStarted(input.worktreeSetup) &&
input.latestRun?.startedAt != null;
const setupRunning = !setupHandedOff && input.worktreeSetup?.phase === "running";
// A finished setup keeps the slot until the turn is live, so the card does
// not jump above the working header in the gap before the run starts.
const setupOwnsWorkingSlot =
!setupHandedOff &&
(input.worktreeSetup?.phase === "running" || input.worktreeSetup?.phase === "done");
if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) {
const setupRow = {
kind: "worktree-setup",
Expand All @@ -1688,15 +1692,17 @@ export function deriveMessagesTimelineRows(input: {
// While the setup runs, the working header leads the card in the same
// slot it keeps once the agent's own turn takes over. The main pass may
// already have placed that header (a bootstrap counts as working).
const workingRowIndex = setupRunning ? nextRows.findIndex((row) => row.kind === "working") : -1;
const workingRowIndex = setupOwnsWorkingSlot
? nextRows.findIndex((row) => row.kind === "working")
: -1;
if (workingRowIndex >= 0) {
nextRows.splice(workingRowIndex + 1, 0, setupRow);
} else {
const insertAt = firstUserRowIndex >= 0 ? firstUserRowIndex + 1 : nextRows.length;
nextRows.splice(
insertAt,
0,
...(setupRunning
...(setupOwnsWorkingSlot
? [
{
kind: "working",
Expand All @@ -1710,15 +1716,15 @@ export function deriveMessagesTimelineRows(input: {
}
}

// A running setup owns the working slot above its card and shows no
// activity row of its own; every other state gets the usual tail.
// A setup that owns the working slot sits under it and shows no activity
// row of its own; every other state gets the usual tail.
const hasWorkingRow = nextRows.some((row) => row.kind === "working");
if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) {
appendWorkingRow();
}
if (
input.isWorking &&
!setupRunning &&
!setupOwnsWorkingSlot &&
!hasActiveCompaction &&
(!hasActivityRow || latestToolFailed)
) {
Expand Down
19 changes: 11 additions & 8 deletions apps/web/src/components/chat/WorktreeSetupCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,17 +356,20 @@ export function WorktreeSetupCard({
const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
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;
// While running, and after a clean finish until the agent's turn is live,
// the timeline's working row above the card carries the label (and keeps
// that slot when the agent takes over). The card only brings its own
// header for a failed or cancelled setup that has no working row above it.
const showHeader = !embedded && !running && snapshot.phase !== "done";
// 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.
// with the first output line at its full fixed height, so the card grows
// once instead of with each line. A script that ends before printing
// anything never flashes an empty box.
const showTail =
setupStage !== undefined && (setupStage.status === "running" || setupStage.status === "failed");
setupStage !== undefined &&
(setupStage.status === "failed" ||
(setupStage.status === "running" && setupStage.tail.length > 0));

return (
<section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>
Expand Down
Loading