authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-15 18:18:38+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 20:48:38+05:30
logae53072af0560c0606202b87b5fdb7e52d48ac5c
tree2b6c2ef82ca9c9c76ee5c25a4da1afeeefe5e583
parente33b710d5f1147993e52076a9e033d97ef4106ad
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the composer ready during background worktree setup (#11883)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

4 files changed, 168 insertions(+), 51 deletions(-)

apps/web/src/components/ChatView.tsx+87-51
......@@ -288,6 +288,7 @@ import {
288288 type DraftThreadEnvMode,
289289 finalizePromotedDraftThreadByRef,
290290 markPromotedDraftThreadByRef,
291 restoreFailedBackgroundDraftThread,
291292 useComposerDraftStore,
292293 DraftId,
293294} from "../composerDraftStore";
......@@ -7893,6 +7894,7 @@ export default function ChatView(props: ChatViewProps) {
78937894 }
78947895
78957896 let turnStartSucceeded = false;
7897 let backgroundDraftOpened = false;
78967898 if (failure === null && turnAttachmentsResult._tag === "Success") {
78977899 const bootstrap =
78987900 isLocalDraftThread || baseBranchForWorktree
......@@ -7931,7 +7933,7 @@ export default function ChatView(props: ChatViewProps) {
79317933 if (backgroundThreadRef) {
79327934 beginBackgroundDraftSubmissionByRef(backgroundThreadRef);
79337935 }
7934 const startResult = await startThreadTurn({
7936 const startPromise = startThreadTurn({
79357937 environmentId,
79367938 input: {
79377939 threadId: threadIdForSend,
......@@ -7975,10 +7977,32 @@ export default function ChatView(props: ChatViewProps) {
79757977 createdAt: messageCreatedAt,
79767978 },
79777979 });
7978 if (startResult._tag === "Failure") {
7979 if (backgroundThreadRef) {
7980 if (backgroundThreadRef) {
7981 markPromotedDraftThreadByRef(backgroundThreadRef);
7982 try {
7983 backgroundDraftOpened = Boolean(
7984 await handleNewThread(
7985 scopeProjectRef(activeProject.environmentId, activeProject.id),
7986 resolveBackgroundDraftWorkspaceOptions({
7987 envMode: sendEnvMode,
7988 branch: activeThreadBranch,
7989 startFromOrigin,
7990 }),
7991 ),
7992 );
7993 } catch (error) {
79807994 clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
7995 toastManager.add(
7996 stackedThreadToast({
7997 type: "warning",
7998 title: "Could not open a fresh composer",
7999 description: error instanceof Error ? error.message : undefined,
8000 }),
8001 );
79818002 }
8003 }
8004 const startResult = await startPromise;
8005 if (startResult._tag === "Failure") {
79828006 failure = startResult;
79838007 } else {
79848008 turnStartSucceeded = true;
......@@ -7991,48 +8015,26 @@ export default function ChatView(props: ChatViewProps) {
79918015 }
79928016 acknowledgeActiveThreadWoke();
79938017 if (backgroundThreadRef) {
7994 markPromotedDraftThreadByRef(backgroundThreadRef);
7995 try {
7996 const nextDraft = await handleNewThread(
7997 scopeProjectRef(activeProject.environmentId, activeProject.id),
7998 resolveBackgroundDraftWorkspaceOptions({
7999 envMode: sendEnvMode,
8000 branch: activeThreadBranch,
8001 startFromOrigin,
8002 }),
8003 );
8004 if (nextDraft) {
8005 finalizePromotedDraftThreadByRef(backgroundThreadRef);
8006 toastManager.add(
8007 stackedThreadToast({
8008 type: "success",
8009 title: "Started in background",
8010 timeout: 5_000,
8011 actionProps: {
8012 children: "Open",
8013 onClick: () => {
8014 void navigate({
8015 to: "/$environmentId/$threadId",
8016 params: buildThreadRouteParams(backgroundThreadRef),
8017 });
8018 },
8019 },
8020 }),
8021 );
8022 } else {
8023 clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
8024 }
8025 } catch (error) {
8018 if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) {
8019 finalizePromotedDraftThreadByRef(backgroundThreadRef);
8020 } else {
80268021 clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
8027 resetLocalDispatch();
8022 }
8023 if (backgroundDraftOpened) {
80288024 toastManager.add(
80298025 stackedThreadToast({
8030 type: "warning",
8031 title: "Task started in the background",
8032 description:
8033 error instanceof Error
8034 ? `Could not open a fresh composer: ${error.message}`
8035 : "Could not open a fresh composer.",
8026 type: "success",
8027 title: "Started in background",
8028 timeout: 5_000,
8029 actionProps: {
8030 children: "Open",
8031 onClick: () => {
8032 void navigate({
8033 to: "/$environmentId/$threadId",
8034 params: buildThreadRouteParams(backgroundThreadRef),
8035 });
8036 },
8037 },
80368038 }),
80378039 );
80388040 }
......@@ -8041,6 +8043,16 @@ export default function ChatView(props: ChatViewProps) {
80418043 }
80428044
80438045 if (failure !== null) {
8046 if (resolvedSubmissionIntent === "background" && draftId && draftThread) {
8047 restoreFailedBackgroundDraftThread(
8048 draftId,
8049 draftThread,
8050 wasBootstrapThreadDeleted(squashAtomCommandFailure(failure))
8051 ? newThreadId()
8052 : threadIdForSend,
8053 );
8054 clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend));
8055 }
80448056 if (queuedMessage) {
80458057 setOptimisticUserMessages((existing) => {
80468058 const removed = existing.filter((message) => message.id === messageIdForSend);
......@@ -8059,14 +8071,18 @@ export default function ChatView(props: ChatViewProps) {
80598071 });
80608072 }
80618073 } else if (
8062 promptRef.current.length === 0 &&
8063 composerImagesRef.current.length === 0 &&
8064 composerFilesRef.current.length === 0 &&
8065 composerTerminalContextsRef.current.length === 0 &&
8066 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations
8067 .length ?? 0) === 0 &&
8068 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments
8069 .length ?? 0) === 0
8074 backgroundDraftOpened
8075 ? !composerDraftHasUserContent(
8076 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget),
8077 )
8078 : promptRef.current.length === 0 &&
8079 composerImagesRef.current.length === 0 &&
8080 composerFilesRef.current.length === 0 &&
8081 composerTerminalContextsRef.current.length === 0 &&
8082 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)
8083 ?.previewAnnotations.length ?? 0) === 0 &&
8084 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments
8085 .length ?? 0) === 0
80708086 ) {
80718087 setOptimisticUserMessages((existing) => {
80728088 const removed = existing.filter((message) => message.id === messageIdForSend);
......@@ -8095,7 +8111,12 @@ export default function ChatView(props: ChatViewProps) {
80958111 }
80968112 if (!isAtomCommandInterrupted(failure)) {
80978113 const error = squashAtomCommandFailure(failure);
8098 if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) {
8114 if (
8115 resolvedSubmissionIntent !== "background" &&
8116 isLocalDraftThread &&
8117 draftId &&
8118 wasBootstrapThreadDeleted(error)
8119 ) {
80998120 const failedDraftSession = getDraftSession(draftId);
81008121 if (failedDraftSession?.threadId === threadIdForSend) {
81018122 setLogicalProjectDraftThreadId(
......@@ -8113,6 +8134,21 @@ export default function ChatView(props: ChatViewProps) {
81138134 threadIdForSend,
81148135 error instanceof Error ? error.message : "Failed to send message.",
81158136 );
8137 if (backgroundDraftOpened && draftId) {
8138 toastManager.add(
8139 stackedThreadToast({
8140 type: "error",
8141 title: "Background task failed",
8142 description: error instanceof Error ? error.message : "Failed to send message.",
8143 actionProps: {
8144 children: "Open draft",
8145 onClick: () => {
8146 void navigate({ to: "/draft/$draftId", params: { draftId } });
8147 },
8148 },
8149 }),
8150 );
8151 }
81168152 }
81178153 }
81188154 sendInFlightRef.current = false;
apps/web/src/composerDraftStore.test.ts+63
......@@ -66,10 +66,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
6666
6767import {
6868 COMPOSER_DRAFT_STORAGE_KEY,
69 beginBackgroundDraftSubmissionByRef,
6970 clearComposerDraftsEnvironment,
7071 composerDraftHasUserContent,
7172 finalizePromotedDraftThreadByRef,
7273 markPromotedDraftThreadByRef,
74 restoreFailedBackgroundDraftThread,
7375 type ComposerFileAttachment,
7476 type ComposerImageAttachment,
7577 composerFileNeedsReattach,
......@@ -1337,6 +1339,45 @@ describe("composerDraftStore project draft thread mapping", () => {
13371339 );
13381340 });
13391341
1342 it.each([false, true])(
1343 "restores a failed background draft without replacing the next draft (finalized: %s)",
1344 (finalized) => {
1345 const store = useComposerDraftStore.getState();
1346 const nextDraftId = DraftId.make("next-draft");
1347 const retryThreadId = ThreadId.make("retry-thread");
1348 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1349 store.setProjectDraftThreadId(projectRef, draftId, {
1350 threadId,
1351 branch: "main",
1352 envMode: "worktree",
1353 startFromOrigin: true,
1354 });
1355 const sentDraft = store.getDraftSession(draftId)!;
1356 markPromotedDraftThreadByRef(threadRef);
1357 store.setProjectDraftThreadId(projectRef, nextDraftId, {
1358 threadId: ThreadId.make("next-thread"),
1359 });
1360 store.setPrompt(nextDraftId, "My next task");
1361 const nextDraft = store.getDraftSession(nextDraftId);
1362 if (finalized) finalizePromotedDraftThreadByRef(threadRef);
1363
1364 restoreFailedBackgroundDraftThread(draftId, sentDraft, retryThreadId);
1365 store.setPrompt(draftId, "Retry the first task");
1366
1367 expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(nextDraftId);
1368 expect(store.getDraftSession(nextDraftId)).toBe(nextDraft);
1369 expect(store.getComposerDraft(nextDraftId)?.prompt).toBe("My next task");
1370 expect(store.getDraftSession(draftId)).toMatchObject({
1371 threadId: retryThreadId,
1372 promotedTo: null,
1373 branch: "main",
1374 envMode: "worktree",
1375 startFromOrigin: true,
1376 });
1377 expect(store.getComposerDraft(draftId)?.prompt).toBe("Retry the first task");
1378 },
1379 );
1380
13401381 it("clears only matching project draft mapping entries", () => {
13411382 const store = useComposerDraftStore.getState();
13421383 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
......@@ -1588,6 +1629,28 @@ describe("composerDraftStore project draft thread mapping", () => {
15881629 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup");
15891630 });
15901631
1632 it("cleans up a completed background draft without replacing the active draft", () => {
1633 const store = useComposerDraftStore.getState();
1634 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1635 const nextDraftId = DraftId.make("next-draft");
1636 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1637 beginBackgroundDraftSubmissionByRef(threadRef);
1638 markPromotedDraftThreadByRef(threadRef);
1639 store.setProjectDraftThreadId(projectRef, nextDraftId, {
1640 threadId: ThreadId.make("next-thread"),
1641 });
1642 store.setPrompt(nextDraftId, "Keep my next task");
1643
1644 finalizePromotedDraftThreadByRef(threadRef);
1645
1646 expect(store.getDraftSession(draftId)).toBeNull();
1647 expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(nextDraftId);
1648 expect(store.getComposerDraft(nextDraftId)?.prompt).toBe("Keep my next task");
1649 expect(
1650 useComposerDraftStore.getState().backgroundSubmissionThreadKeys[scopedThreadKey(threadRef)],
1651 ).toBeUndefined();
1652 });
1653
15911654 it("finalizes a matching materialized draft even when promotion was not pre-marked", () => {
15921655 const store = useComposerDraftStore.getState();
15931656 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
apps/web/src/composerDraftStore.ts+17
......@@ -4254,6 +4254,23 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
42544254 }
42554255}
42564256
4257export function restoreFailedBackgroundDraftThread(
4258 draftId: DraftId,
4259 draftThread: DraftThreadState,
4260 threadId: ThreadId,
4261): void {
4262 useComposerDraftStore.setState((state) => ({
4263 draftThreadsByThreadKey: {
4264 ...state.draftThreadsByThreadKey,
4265 [draftId]: {
4266 ...draftThread,
4267 threadId,
4268 promotedTo: null,
4269 },
4270 },
4271 }));
4272}
4273
42574274export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
42584275 const draftStore = useComposerDraftStore.getState();
42594276 for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {
apps/web/src/routes/_chat.draft.$draftId.tsx+1
......@@ -78,6 +78,7 @@ function DraftChatThreadRouteView() {
7878 return (
7979 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
8080 <ChatView
81 key={draftId}
8182 draftId={draftId}
8283 environmentId={draftSession.environmentId}
8384 threadId={draftSession.threadId}