| author | |
| committer | |
| log | 3a7058da50c91906158c89fc3f845e6c04379414 |
| tree | 43713dc12cf8c1258dcdbababb23e486471155cd |
| parent | 22e9d35613305a04a28e96f83abc10c1bcf4aa8a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 628 | ).toBe(true); |
| 629 | 629 | }); |
| 630 | 630 | |
| 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 | 655 | it("waits for the matching running turn before acknowledging", () => { |
| 632 | 656 | const localDispatch = createLocalDispatchSnapshot( |
| 633 | 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 | 57 | import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 58 | 58 | import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; |
| 59 | 59 | import type { ReviewCommentContext } from "../reviewCommentContext"; |
| 60 | import type { TimelineEntry } from "../session-logic"; | |
| 60 | import { derivePhase, type TimelineEntry } from "../session-logic"; | |
| 61 | 61 | import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; |
| 62 | 62 | import type { DesktopPreviewOverlay } from "../previewStateStore"; |
| 63 | 63 | import type { RightPanelSurface } from "../rightPanelStore"; |
| ... | ... | @@ -1248,7 +1248,10 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 1248 | 1248 | if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { |
| 1249 | 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 | 1255 | return false; |
| 1253 | 1256 | } |
| 1254 | 1257 |
apps/web/src/components/ChatView.tsx+4-1| ... | ... | @@ -6396,6 +6396,9 @@ export default function ChatView(props: ChatViewProps) { |
| 6396 | 6396 | optimisticUserMessages, |
| 6397 | 6397 | ]); |
| 6398 | 6398 | |
| 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 | 6402 | useEffect(() => { |
| 6400 | 6403 | setOptimisticUserMessages((existing) => { |
| 6401 | 6404 | for (const message of existing) { |
| ... | ... | @@ -6405,7 +6408,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6405 | 6408 | }); |
| 6406 | 6409 | resetLocalDispatch(); |
| 6407 | 6410 | setExpandedImage(null); |
| 6408 | }, [draftId, resetLocalDispatch, threadId]); | |
| 6411 | }, [resetLocalDispatch, threadId]); | |
| 6409 | 6412 | |
| 6410 | 6413 | const closeExpandedImage = useCallback(() => { |
| 6411 | 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 | 4625 | expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]); |
| 4626 | 4626 | expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false }); |
| 4627 | 4627 | |
| 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 | 4644 | // A script that already finished has nothing left to show once the turn is live. |
| 4629 | 4645 | const finishedRows = deriveMessagesTimelineRows({ |
| 4630 | 4646 | timelineEntries: [userEntry], |
apps/web/src/components/chat/MessagesTimeline.logic.ts+12-6| ... | ... | @@ -1673,7 +1673,11 @@ export function deriveMessagesTimelineRows(input: { |
| 1673 | 1673 | input.worktreeSetup !== undefined && |
| 1674 | 1674 | worktreeSetupAgentStarted(input.worktreeSetup) && |
| 1675 | 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 | 1681 | if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) { |
| 1678 | 1682 | const setupRow = { |
| 1679 | 1683 | kind: "worktree-setup", |
| ... | ... | @@ -1688,7 +1692,9 @@ export function deriveMessagesTimelineRows(input: { |
| 1688 | 1692 | // While the setup runs, the working header leads the card in the same |
| 1689 | 1693 | // slot it keeps once the agent's own turn takes over. The main pass may |
| 1690 | 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 | 1698 | if (workingRowIndex >= 0) { |
| 1693 | 1699 | nextRows.splice(workingRowIndex + 1, 0, setupRow); |
| 1694 | 1700 | } else { |
| ... | ... | @@ -1696,7 +1702,7 @@ export function deriveMessagesTimelineRows(input: { |
| 1696 | 1702 | nextRows.splice( |
| 1697 | 1703 | insertAt, |
| 1698 | 1704 | 0, |
| 1699 | ...(setupRunning | |
| 1705 | ...(setupOwnsWorkingSlot | |
| 1700 | 1706 | ? [ |
| 1701 | 1707 | { |
| 1702 | 1708 | kind: "working", |
| ... | ... | @@ -1710,15 +1716,15 @@ export function deriveMessagesTimelineRows(input: { |
| 1710 | 1716 | } |
| 1711 | 1717 | } |
| 1712 | 1718 | |
| 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. | |
| 1715 | 1721 | const hasWorkingRow = nextRows.some((row) => row.kind === "working"); |
| 1716 | 1722 | if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) { |
| 1717 | 1723 | appendWorkingRow(); |
| 1718 | 1724 | } |
| 1719 | 1725 | if ( |
| 1720 | 1726 | input.isWorking && |
| 1721 | !setupRunning && | |
| 1727 | !setupOwnsWorkingSlot && | |
| 1722 | 1728 | !hasActiveCompaction && |
| 1723 | 1729 | (!hasActivityRow || latestToolFailed) |
| 1724 | 1730 | ) { |
apps/web/src/components/chat/WorktreeSetupCard.tsx+11-8| ... | ... | @@ -356,17 +356,20 @@ export function WorktreeSetupCard({ |
| 356 | 356 | const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); |
| 357 | 357 | const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending"; |
| 358 | 358 | 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"; | |
| 364 | 364 | // The tail box is part of the script row's footprint while the script runs |
| 365 | 365 | // (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. | |
| 368 | 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)); | |
| 370 | 373 | |
| 371 | 374 | return ( |
| 372 | 375 | <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}> |