| author | |
| committer | |
| log | 0310cbf9f46ca94e9df0231a3440bca08ac44709 |
| tree | cdbad762a5725026a9d34615f51ec1fed94d0281 |
| parent | 5623089aea68ca62811f51321686c259fa4c810f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>4 files changed, 166 insertions(+), 20 deletions(-)
apps/server/src/server.test.ts+42-9| ... | @@ -10836,17 +10836,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -10836,17 +10836,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 10836 | ), | 10836 | ), |
| 10837 | ); | 10837 | ); |
| 10838 | 10838 | ||
| 10839 | assert.equal(response.sequence, 5); | 10839 | assert.equal(response.sequence, 6); |
| 10840 | assert.deepEqual( | 10840 | assert.deepEqual( |
| 10841 | dispatchedCommands.map((command) => command.type), | 10841 | dispatchedCommands.map((command) => command.type), |
| 10842 | [ | 10842 | [ |
| 10843 | "thread.create", | 10843 | "thread.create", |
| 10844 | "thread.session.set", | ||
| 10844 | "thread.meta.update", | 10845 | "thread.meta.update", |
| 10845 | "thread.activity.append", | 10846 | "thread.activity.append", |
| 10846 | "thread.activity.append", | 10847 | "thread.activity.append", |
| 10847 | "thread.turn.start", | 10848 | "thread.turn.start", |
| 10848 | ], | 10849 | ], |
| 10849 | ); | 10850 | ); |
| 10851 | // The checkout can take minutes, so the thread reads as working from | ||
| 10852 | // the moment setup starts rather than only once the turn is dispatched. | ||
| 10853 | const preparingCommand = dispatchedCommands[1]; | ||
| 10854 | assertTrue(preparingCommand?.type === "thread.session.set"); | ||
| 10855 | if (preparingCommand?.type === "thread.session.set") { | ||
| 10856 | assert.equal(preparingCommand.session.status, "starting"); | ||
| 10857 | assert.equal(preparingCommand.session.activeTurnId, null); | ||
| 10858 | assert.equal( | ||
| 10859 | preparingCommand.session.providerInstanceId, | ||
| 10860 | defaultModelSelection.instanceId, | ||
| 10861 | ); | ||
| 10862 | } | ||
| 10850 | assert.deepEqual(createWorktree.mock.calls[0]?.[0], { | 10863 | assert.deepEqual(createWorktree.mock.calls[0]?.[0], { |
| 10851 | cwd: "/tmp/project", | 10864 | cwd: "/tmp/project", |
| 10852 | refName: fetchedOriginCommit, | 10865 | refName: fetchedOriginCommit, |
| ... | @@ -10902,7 +10915,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -10902,7 +10915,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 10902 | setupActivities.map((command) => command.activity.kind), | 10915 | setupActivities.map((command) => command.activity.kind), |
| 10903 | ["setup-script.requested", "setup-script.started"], | 10916 | ["setup-script.requested", "setup-script.started"], |
| 10904 | ); | 10917 | ); |
| 10905 | const finalCommand = dispatchedCommands[4]; | 10918 | const finalCommand = dispatchedCommands[5]; |
| 10906 | assertTrue(finalCommand?.type === "thread.turn.start"); | 10919 | assertTrue(finalCommand?.type === "thread.turn.start"); |
| 10907 | if (finalCommand?.type === "thread.turn.start") { | 10920 | if (finalCommand?.type === "thread.turn.start") { |
| 10908 | assert.equal(finalCommand.bootstrap, undefined); | 10921 | assert.equal(finalCommand.bootstrap, undefined); |
| ... | @@ -11284,10 +11297,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -11284,10 +11297,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11284 | ), | 11297 | ), |
| 11285 | ); | 11298 | ); |
| 11286 | 11299 | ||
| 11287 | assert.equal(response.sequence, 4); | 11300 | assert.equal(response.sequence, 5); |
| 11288 | assert.deepEqual( | 11301 | assert.deepEqual( |
| 11289 | dispatchedCommands.map((command) => command.type), | 11302 | dispatchedCommands.map((command) => command.type), |
| 11290 | ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], | 11303 | [ |
| 11304 | "thread.create", | ||
| 11305 | "thread.session.set", | ||
| 11306 | "thread.meta.update", | ||
| 11307 | "thread.activity.append", | ||
| 11308 | "thread.turn.start", | ||
| 11309 | ], | ||
| 11291 | ); | 11310 | ); |
| 11292 | const setupFailureActivity = dispatchedCommands.find( | 11311 | const setupFailureActivity = dispatchedCommands.find( |
| 11293 | (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> => | 11312 | (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> => |
| ... | @@ -11411,10 +11430,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -11411,10 +11430,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11411 | ), | 11430 | ), |
| 11412 | ); | 11431 | ); |
| 11413 | 11432 | ||
| 11414 | assert.equal(response.sequence, 4); | 11433 | assert.equal(response.sequence, 5); |
| 11415 | assert.deepEqual( | 11434 | assert.deepEqual( |
| 11416 | dispatchedCommands.map((command) => command.type), | 11435 | dispatchedCommands.map((command) => command.type), |
| 11417 | ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], | 11436 | [ |
| 11437 | "thread.create", | ||
| 11438 | "thread.session.set", | ||
| 11439 | "thread.meta.update", | ||
| 11440 | "thread.activity.append", | ||
| 11441 | "thread.turn.start", | ||
| 11442 | ], | ||
| 11418 | ); | 11443 | ); |
| 11419 | const setupActivities = dispatchedCommands.filter( | 11444 | const setupActivities = dispatchedCommands.filter( |
| 11420 | (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> => | 11445 | (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> => |
| ... | @@ -11676,7 +11701,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -11676,7 +11701,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11676 | assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); | 11701 | assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); |
| 11677 | assert.deepEqual( | 11702 | assert.deepEqual( |
| 11678 | dispatchedCommands.map((command) => command.type), | 11703 | dispatchedCommands.map((command) => command.type), |
| 11679 | ["thread.create", "thread.delete"], | 11704 | ["thread.create", "thread.session.set", "thread.delete"], |
| 11680 | ); | 11705 | ); |
| 11681 | assert.isDefined(pendingAttachmentId); | 11706 | assert.isDefined(pendingAttachmentId); |
| 11682 | assert.isTrue( | 11707 | assert.isTrue( |
| ... | @@ -11874,8 +11899,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -11874,8 +11899,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11874 | assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined); | 11899 | assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined); |
| 11875 | assert.deepEqual( | 11900 | assert.deepEqual( |
| 11876 | dispatchedCommands.map((command) => command.type), | 11901 | dispatchedCommands.map((command) => command.type), |
| 11877 | ["thread.create", "thread.delete"], | 11902 | ["thread.create", "thread.session.set", "thread.delete", "thread.session.set"], |
| 11878 | ); | 11903 | ); |
| 11904 | // The surviving thread must not keep its preparing session, or it would | ||
| 11905 | // read as working forever. | ||
| 11906 | const failedSession = dispatchedCommands[3]; | ||
| 11907 | assertTrue(failedSession?.type === "thread.session.set"); | ||
| 11908 | if (failedSession?.type === "thread.session.set") { | ||
| 11909 | assert.equal(failedSession.session.status, "error"); | ||
| 11910 | assert.include(failedSession.session.lastError ?? "", "worktree exploded"); | ||
| 11911 | } | ||
| 11879 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 11912 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 11880 | ); | 11913 | ); |
| 11881 | 11914 |
apps/server/src/ws.ts+63-1| ... | @@ -1017,6 +1017,30 @@ const makeWsRpcLayer = ( | ... | @@ -1017,6 +1017,30 @@ const makeWsRpcLayer = ( |
| 1017 | // one so terminals the user opened meanwhile survive. | 1017 | // one so terminals the user opened meanwhile survive. |
| 1018 | let setupTerminalId: string | null = null; | 1018 | let setupTerminalId: string | null = null; |
| 1019 | 1019 | ||
| 1020 | // Set once the checkout starts; see the session.set below. | ||
| 1021 | let preparingSessionSet = false; | ||
| 1022 | const markPreparingSessionFailed = (detail: string) => | ||
| 1023 | Effect.gen(function* () { | ||
| 1024 | const failedAt = yield* nowIso; | ||
| 1025 | yield* dispatchFromClient({ | ||
| 1026 | type: "thread.session.set", | ||
| 1027 | commandId: yield* serverCommandId("bootstrap-thread-preparing-failed"), | ||
| 1028 | threadId, | ||
| 1029 | session: { | ||
| 1030 | threadId, | ||
| 1031 | status: "error", | ||
| 1032 | providerName: null, | ||
| 1033 | providerInstanceId: | ||
| 1034 | bootstrap?.createThread?.modelSelection.instanceId ?? | ||
| 1035 | command.modelSelection?.instanceId, | ||
| 1036 | runtimeMode: command.runtimeMode, | ||
| 1037 | activeTurnId: null, | ||
| 1038 | lastError: detail.trim().length > 0 ? detail : "Worktree setup failed.", | ||
| 1039 | updatedAt: failedAt, | ||
| 1040 | }, | ||
| 1041 | createdAt: failedAt, | ||
| 1042 | }); | ||
| 1043 | }); | ||
| 1020 | const cleanupCreatedThread = () => | 1044 | const cleanupCreatedThread = () => |
| 1021 | createdThread | 1045 | createdThread |
| 1022 | ? serverCommandId("bootstrap-thread-delete").pipe( | 1046 | ? serverCommandId("bootstrap-thread-delete").pipe( |
| ... | @@ -1340,6 +1364,32 @@ const makeWsRpcLayer = ( | ... | @@ -1340,6 +1364,32 @@ const makeWsRpcLayer = ( |
| 1340 | } | 1364 | } |
| 1341 | 1365 | ||
| 1342 | if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { | 1366 | if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { |
| 1367 | if (bootstrap?.createThread && createdThread) { | ||
| 1368 | // The checkout and setup script can run for minutes before the | ||
| 1369 | // turn starts, and the created thread carries no message or | ||
| 1370 | // turn until then. Project a starting session now so every | ||
| 1371 | // client lists the thread as working and a reopened thread | ||
| 1372 | // knows to follow the setup stream. A failed or cancelled setup | ||
| 1373 | // deletes the thread, so nothing lingers. | ||
| 1374 | const preparingAt = yield* nowIso; | ||
| 1375 | yield* dispatchFromClient({ | ||
| 1376 | type: "thread.session.set", | ||
| 1377 | commandId: yield* serverCommandId("bootstrap-thread-preparing"), | ||
| 1378 | threadId, | ||
| 1379 | session: { | ||
| 1380 | threadId, | ||
| 1381 | status: "starting", | ||
| 1382 | providerName: null, | ||
| 1383 | providerInstanceId: bootstrap.createThread.modelSelection.instanceId, | ||
| 1384 | runtimeMode: command.runtimeMode, | ||
| 1385 | activeTurnId: null, | ||
| 1386 | lastError: null, | ||
| 1387 | updatedAt: preparingAt, | ||
| 1388 | }, | ||
| 1389 | createdAt: preparingAt, | ||
| 1390 | }); | ||
| 1391 | preparingSessionSet = true; | ||
| 1392 | } | ||
| 1343 | yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); | 1393 | yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); |
| 1344 | let checkoutTotal: number | null = null; | 1394 | let checkoutTotal: number | null = null; |
| 1345 | const worktree = yield* gitWorkflow.createWorktree( | 1395 | const worktree = yield* gitWorkflow.createWorktree( |
| ... | @@ -1483,7 +1533,19 @@ const makeWsRpcLayer = ( | ... | @@ -1483,7 +1533,19 @@ const makeWsRpcLayer = ( |
| 1483 | Effect.logWarning("bootstrap thread cleanup failed", { | 1533 | Effect.logWarning("bootstrap thread cleanup failed", { |
| 1484 | threadId, | 1534 | threadId, |
| 1485 | detail: Cause.pretty(cleanupCause), | 1535 | detail: Cause.pretty(cleanupCause), |
| 1486 | }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), | 1536 | }).pipe( |
| 1537 | // The thread outlived its setup. Its preparing session | ||
| 1538 | // must not read as working forever, so record the failure | ||
| 1539 | // on it instead. | ||
| 1540 | Effect.andThen( | ||
| 1541 | preparingSessionSet | ||
| 1542 | ? markPreparingSessionFailed(dispatchError.message).pipe( | ||
| 1543 | Effect.ignoreCause({ log: true }), | ||
| 1544 | ) | ||
| 1545 | : Effect.void, | ||
| 1546 | ), | ||
| 1547 | Effect.flatMap(() => Effect.fail(dispatchError)), | ||
| 1548 | ), | ||
| 1487 | onSuccess: (threadDeleted) => | 1549 | onSuccess: (threadDeleted) => |
| 1488 | Effect.fail( | 1550 | Effect.fail( |
| 1489 | threadDeleted | 1551 | threadDeleted |
apps/web/src/components/ChatView.tsx+60-9| ... | @@ -349,7 +349,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; | ... | @@ -349,7 +349,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 349 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | 349 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 350 | import { MessagesTimeline } from "./chat/MessagesTimeline"; | 350 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 351 | import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; | 351 | import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; |
| 352 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; | 352 | import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic"; |
| 353 | import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; | 353 | import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; |
| 354 | import { ChatHeader } from "./chat/ChatHeader"; | 354 | import { ChatHeader } from "./chat/ChatHeader"; |
| 355 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; | 355 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| ... | @@ -3493,14 +3493,47 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3493,14 +3493,47 @@ export default function ChatView(props: ChatViewProps) { |
| 3493 | const worktreeSetupOwnerKey = draftId ?? routeThreadKey; | 3493 | const worktreeSetupOwnerKey = draftId ?? routeThreadKey; |
| 3494 | const worktreeSetupActive = | 3494 | const worktreeSetupActive = |
| 3495 | worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; | 3495 | worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; |
| 3496 | // A thread reopened mid-setup has no dispatch ref: this view remounted. | ||
| 3497 | // The server projects a starting session before any message or turn exists | ||
| 3498 | // for exactly that window, so follow the setup stream from the thread's own | ||
| 3499 | // state. The ref is adopted below so the card then follows the same | ||
| 3500 | // lifecycle as the original send, including an async setup script that | ||
| 3501 | // keeps running after the agent's turn lands. | ||
| 3502 | const resumedWorktreeSetupRef = | ||
| 3503 | !worktreeSetupActive && | ||
| 3504 | isServerThread && | ||
| 3505 | activeThreadRef !== null && | ||
| 3506 | activeThreadShell?.session?.status === "starting" && | ||
| 3507 | activeThreadShell.latestTurn === null && | ||
| 3508 | activeThreadShell.latestUserMessageAt === null | ||
| 3509 | ? activeThreadRef | ||
| 3510 | : null; | ||
| 3511 | useEffect(() => { | ||
| 3512 | if (!resumedWorktreeSetupRef) return; | ||
| 3513 | // Only a ref owned by this route survives adoption. This component is | ||
| 3514 | // reused across routes, so a ref left by another thread's send is stale | ||
| 3515 | // here and would leave the resumed setup with no target after handoff. | ||
| 3516 | setWorktreeSetupRef((current) => | ||
| 3517 | current?.ownerKey === worktreeSetupOwnerKey | ||
| 3518 | ? current | ||
| 3519 | : { ...resumedWorktreeSetupRef, ownerKey: worktreeSetupOwnerKey }, | ||
| 3520 | ); | ||
| 3521 | }, [resumedWorktreeSetupRef, worktreeSetupOwnerKey]); | ||
| 3496 | // The setup runs on the environment that received the dispatch, so both | 3522 | // The setup runs on the environment that received the dispatch, so both |
| 3497 | // the subscription and cancel target that one even if the draft's machine | 3523 | // the subscription and cancel target that one even if the draft's machine |
| 3498 | // picker changes underneath. | 3524 | // picker changes underneath. |
| 3525 | const worktreeSetupTarget = useMemo( | ||
| 3526 | () => | ||
| 3527 | worktreeSetupActive | ||
| 3528 | ? { environmentId: worktreeSetupRef.environmentId, threadId: worktreeSetupRef.threadId } | ||
| 3529 | : resumedWorktreeSetupRef, | ||
| 3530 | [resumedWorktreeSetupRef, worktreeSetupActive, worktreeSetupRef], | ||
| 3531 | ); | ||
| 3499 | const worktreeSetupQuery = useEnvironmentQuery( | 3532 | const worktreeSetupQuery = useEnvironmentQuery( |
| 3500 | worktreeSetupActive | 3533 | worktreeSetupTarget |
| 3501 | ? vcsEnvironment.worktreeSetup({ | 3534 | ? vcsEnvironment.worktreeSetup({ |
| 3502 | environmentId: worktreeSetupRef.environmentId, | 3535 | environmentId: worktreeSetupTarget.environmentId, |
| 3503 | input: { threadId: worktreeSetupRef.threadId }, | 3536 | input: { threadId: worktreeSetupTarget.threadId }, |
| 3504 | }) | 3537 | }) |
| 3505 | : null, | 3538 | : null, |
| 3506 | ); | 3539 | ); |
| ... | @@ -3511,7 +3544,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3511,7 +3544,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3511 | if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); | 3544 | if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); |
| 3512 | }, [latestWorktreeSetup]); | 3545 | }, [latestWorktreeSetup]); |
| 3513 | const worktreeSetup = | 3546 | const worktreeSetup = |
| 3514 | worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId | 3547 | worktreeSetupTarget !== null && heldWorktreeSetup?.threadId === worktreeSetupTarget.threadId |
| 3515 | ? heldWorktreeSetup | 3548 | ? heldWorktreeSetup |
| 3516 | : null; | 3549 | : null; |
| 3517 | // A finished card is dropped once the agent's turn shows in the timeline: | 3550 | // A finished card is dropped once the agent's turn shows in the timeline: |
| ... | @@ -3537,16 +3570,32 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3537,16 +3570,32 @@ export default function ChatView(props: ChatViewProps) { |
| 3537 | useEffect(() => { | 3570 | useEffect(() => { |
| 3538 | if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey); | 3571 | if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey); |
| 3539 | }, [worktreeSetupSettledKey]); | 3572 | }, [worktreeSetupSettledKey]); |
| 3573 | // Sends wait for the agent handoff, not for the setup script: an async | ||
| 3574 | // script keeps the snapshot running while the agent already works, and a | ||
| 3575 | // follow-up must not be held behind a slow install. A resumed setup has no | ||
| 3576 | // snapshot until its first stream event, so that gap blocks as well. The | ||
| 3577 | // gap is judged by the shell, not by the resumed ref, since adopting the | ||
| 3578 | // ref clears it before the query has delivered anything. | ||
| 3579 | const worktreeSetupAwaitingFirstSnapshot = | ||
| 3580 | worktreeSetup === null && | ||
| 3581 | worktreeSetupTarget !== null && | ||
| 3582 | isServerThread && | ||
| 3583 | activeThreadShell?.session?.status === "starting" && | ||
| 3584 | activeThreadShell.latestTurn === null; | ||
| 3585 | const worktreeSetupBlocksSend = | ||
| 3586 | worktreeSetup !== null | ||
| 3587 | ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup) | ||
| 3588 | : worktreeSetupAwaitingFirstSnapshot; | ||
| 3540 | const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { | 3589 | const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { |
| 3541 | reportFailure: false, | 3590 | reportFailure: false, |
| 3542 | }); | 3591 | }); |
| 3543 | const onCancelWorktreeSetup = useCallback(() => { | 3592 | const onCancelWorktreeSetup = useCallback(() => { |
| 3544 | if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return; | 3593 | if (!worktreeSetup || !worktreeSetupTarget || worktreeSetup.phase !== "running") return; |
| 3545 | void cancelWorktreeSetup({ | 3594 | void cancelWorktreeSetup({ |
| 3546 | environmentId: worktreeSetupRef.environmentId, | 3595 | environmentId: worktreeSetupTarget.environmentId, |
| 3547 | input: { threadId: worktreeSetup.threadId }, | 3596 | input: { threadId: worktreeSetup.threadId }, |
| 3548 | }); | 3597 | }); |
| 3549 | }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]); | 3598 | }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupTarget]); |
| 3550 | // The setup terminal belongs to the thread that was set up. A failed | 3599 | // The setup terminal belongs to the thread that was set up. A failed |
| 3551 | // bootstrap deletes that thread and closes its terminals, so only offer the | 3600 | // bootstrap deletes that thread and closes its terminals, so only offer the |
| 3552 | // terminal while the setup thread is still the active one. | 3601 | // terminal while the setup thread is still the active one. |
| ... | @@ -9313,7 +9362,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -9313,7 +9362,9 @@ export default function ChatView(props: ChatViewProps) { |
| 9313 | ? "Sending feedback" | 9362 | ? "Sending feedback" |
| 9314 | : threadDetailLoading | 9363 | : threadDetailLoading |
| 9315 | ? "Messages loading" | 9364 | ? "Messages loading" |
| 9316 | : projectCloneSendBlockReason | 9365 | : worktreeSetupBlocksSend |
| 9366 | ? "Preparing worktree" | ||
| 9367 | : projectCloneSendBlockReason | ||
| 9317 | } | 9368 | } |
| 9318 | isPreparingWorktree={isPreparingWorktree} | 9369 | isPreparingWorktree={isPreparingWorktree} |
| 9319 | bannerItems={composerBannerItems} | 9370 | bannerItems={composerBannerItems} |
apps/web/src/components/chat/MessagesTimeline.logic.ts+1-1| ... | @@ -1336,7 +1336,7 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -1336,7 +1336,7 @@ export function deriveMessagesTimelineRows(input: { |
| 1336 | export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; | 1336 | export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; |
| 1337 | 1337 | ||
| 1338 | /** True once the bootstrap handed off to the agent (async setup script may still run). */ | 1338 | /** True once the bootstrap handed off to the agent (async setup script may still run). */ |
| 1339 | function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { | 1339 | export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { |
| 1340 | return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); | 1340 | return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); |
| 1341 | } | 1341 | } |
| 1342 | 1342 |