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 {...@@ -288,6 +288,7 @@ import {
288 type DraftThreadEnvMode,288 type DraftThreadEnvMode,
289 finalizePromotedDraftThreadByRef,289 finalizePromotedDraftThreadByRef,
290 markPromotedDraftThreadByRef,290 markPromotedDraftThreadByRef,
291 restoreFailedBackgroundDraftThread,
291 useComposerDraftStore,292 useComposerDraftStore,
292 DraftId,293 DraftId,
293} from "../composerDraftStore";294} from "../composerDraftStore";
...@@ -7893,6 +7894,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7893,6 +7894,7 @@ export default function ChatView(props: ChatViewProps) {
7893 }7894 }
78947895
7895 let turnStartSucceeded = false;7896 let turnStartSucceeded = false;
7897 let backgroundDraftOpened = false;
7896 if (failure === null && turnAttachmentsResult._tag === "Success") {7898 if (failure === null && turnAttachmentsResult._tag === "Success") {
7897 const bootstrap =7899 const bootstrap =
7898 isLocalDraftThread || baseBranchForWorktree7900 isLocalDraftThread || baseBranchForWorktree
...@@ -7931,7 +7933,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7931,7 +7933,7 @@ export default function ChatView(props: ChatViewProps) {
7931 if (backgroundThreadRef) {7933 if (backgroundThreadRef) {
7932 beginBackgroundDraftSubmissionByRef(backgroundThreadRef);7934 beginBackgroundDraftSubmissionByRef(backgroundThreadRef);
7933 }7935 }
7934 const startResult = await startThreadTurn({7936 const startPromise = startThreadTurn({
7935 environmentId,7937 environmentId,
7936 input: {7938 input: {
7937 threadId: threadIdForSend,7939 threadId: threadIdForSend,
...@@ -7975,10 +7977,32 @@ export default function ChatView(props: ChatViewProps) {...@@ -7975,10 +7977,32 @@ export default function ChatView(props: ChatViewProps) {
7975 createdAt: messageCreatedAt,7977 createdAt: messageCreatedAt,
7976 },7978 },
7977 });7979 });
7978 if (startResult._tag === "Failure") {7980 if (backgroundThreadRef) {
7979 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) {
7980 clearBackgroundDraftSubmissionByRef(backgroundThreadRef);7994 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 );
7981 }8002 }
8003 }
8004 const startResult = await startPromise;
8005 if (startResult._tag === "Failure") {
7982 failure = startResult;8006 failure = startResult;
7983 } else {8007 } else {
7984 turnStartSucceeded = true;8008 turnStartSucceeded = true;
...@@ -7991,48 +8015,26 @@ export default function ChatView(props: ChatViewProps) {...@@ -7991,48 +8015,26 @@ export default function ChatView(props: ChatViewProps) {
7991 }8015 }
7992 acknowledgeActiveThreadWoke();8016 acknowledgeActiveThreadWoke();
7993 if (backgroundThreadRef) {8017 if (backgroundThreadRef) {
7994 markPromotedDraftThreadByRef(backgroundThreadRef);8018 if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) {
7995 try {8019 finalizePromotedDraftThreadByRef(backgroundThreadRef);
7996 const nextDraft = await handleNewThread(8020 } else {
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) {
8026 clearBackgroundDraftSubmissionByRef(backgroundThreadRef);8021 clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
8027 resetLocalDispatch();8022 }
8023 if (backgroundDraftOpened) {
8028 toastManager.add(8024 toastManager.add(
8029 stackedThreadToast({8025 stackedThreadToast({
8030 type: "warning",8026 type: "success",
8031 title: "Task started in the background",8027 title: "Started in background",
8032 description:8028 timeout: 5_000,
8033 error instanceof Error8029 actionProps: {
8034 ? `Could not open a fresh composer: ${error.message}`8030 children: "Open",
8035 : "Could not open a fresh composer.",8031 onClick: () => {
8032 void navigate({
8033 to: "/$environmentId/$threadId",
8034 params: buildThreadRouteParams(backgroundThreadRef),
8035 });
8036 },
8037 },
8036 }),8038 }),
8037 );8039 );
8038 }8040 }
...@@ -8041,6 +8043,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -8041,6 +8043,16 @@ export default function ChatView(props: ChatViewProps) {
8041 }8043 }
80428044
8043 if (failure !== null) {8045 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 }
8044 if (queuedMessage) {8056 if (queuedMessage) {
8045 setOptimisticUserMessages((existing) => {8057 setOptimisticUserMessages((existing) => {
8046 const removed = existing.filter((message) => message.id === messageIdForSend);8058 const removed = existing.filter((message) => message.id === messageIdForSend);
...@@ -8059,14 +8071,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -8059,14 +8071,18 @@ export default function ChatView(props: ChatViewProps) {
8059 });8071 });
8060 }8072 }
8061 } else if (8073 } else if (
8062 promptRef.current.length === 0 &&8074 backgroundDraftOpened
8063 composerImagesRef.current.length === 0 &&8075 ? !composerDraftHasUserContent(
8064 composerFilesRef.current.length === 0 &&8076 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget),
8065 composerTerminalContextsRef.current.length === 0 &&8077 )
8066 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations8078 : promptRef.current.length === 0 &&
8067 .length ?? 0) === 0 &&8079 composerImagesRef.current.length === 0 &&
8068 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments8080 composerFilesRef.current.length === 0 &&
8069 .length ?? 0) === 08081 composerTerminalContextsRef.current.length === 0 &&
8082 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)
8083 ?.previewAnnotations.length ?? 0) === 0 &&
8084 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments
8085 .length ?? 0) === 0
8070 ) {8086 ) {
8071 setOptimisticUserMessages((existing) => {8087 setOptimisticUserMessages((existing) => {
8072 const removed = existing.filter((message) => message.id === messageIdForSend);8088 const removed = existing.filter((message) => message.id === messageIdForSend);
...@@ -8095,7 +8111,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -8095,7 +8111,12 @@ export default function ChatView(props: ChatViewProps) {
8095 }8111 }
8096 if (!isAtomCommandInterrupted(failure)) {8112 if (!isAtomCommandInterrupted(failure)) {
8097 const error = squashAtomCommandFailure(failure);8113 const error = squashAtomCommandFailure(failure);
8098 if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) {8114 if (
8115 resolvedSubmissionIntent !== "background" &&
8116 isLocalDraftThread &&
8117 draftId &&
8118 wasBootstrapThreadDeleted(error)
8119 ) {
8099 const failedDraftSession = getDraftSession(draftId);8120 const failedDraftSession = getDraftSession(draftId);
8100 if (failedDraftSession?.threadId === threadIdForSend) {8121 if (failedDraftSession?.threadId === threadIdForSend) {
8101 setLogicalProjectDraftThreadId(8122 setLogicalProjectDraftThreadId(
...@@ -8113,6 +8134,21 @@ export default function ChatView(props: ChatViewProps) {...@@ -8113,6 +8134,21 @@ export default function ChatView(props: ChatViewProps) {
8113 threadIdForSend,8134 threadIdForSend,
8114 error instanceof Error ? error.message : "Failed to send message.",8135 error instanceof Error ? error.message : "Failed to send message.",
8115 );8136 );
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 }
8116 }8152 }
8117 }8153 }
8118 sendInFlightRef.current = false;8154 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"...@@ -66,10 +66,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
6666
67import {67import {
68 COMPOSER_DRAFT_STORAGE_KEY,68 COMPOSER_DRAFT_STORAGE_KEY,
69 beginBackgroundDraftSubmissionByRef,
69 clearComposerDraftsEnvironment,70 clearComposerDraftsEnvironment,
70 composerDraftHasUserContent,71 composerDraftHasUserContent,
71 finalizePromotedDraftThreadByRef,72 finalizePromotedDraftThreadByRef,
72 markPromotedDraftThreadByRef,73 markPromotedDraftThreadByRef,
74 restoreFailedBackgroundDraftThread,
73 type ComposerFileAttachment,75 type ComposerFileAttachment,
74 type ComposerImageAttachment,76 type ComposerImageAttachment,
75 composerFileNeedsReattach,77 composerFileNeedsReattach,
...@@ -1337,6 +1339,45 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1337,6 +1339,45 @@ describe("composerDraftStore project draft thread mapping", () => {
1337 );1339 );
1338 });1340 });
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
1340 it("clears only matching project draft mapping entries", () => {1381 it("clears only matching project draft mapping entries", () => {
1341 const store = useComposerDraftStore.getState();1382 const store = useComposerDraftStore.getState();
1342 store.setProjectDraftThreadId(projectRef, draftId, { threadId });1383 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
...@@ -1588,6 +1629,28 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1588,6 +1629,28 @@ describe("composerDraftStore project draft thread mapping", () => {
1588 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup");1629 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup");
1589 });1630 });
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
1591 it("finalizes a matching materialized draft even when promotion was not pre-marked", () => {1654 it("finalizes a matching materialized draft even when promotion was not pre-marked", () => {
1592 const store = useComposerDraftStore.getState();1655 const store = useComposerDraftStore.getState();
1593 store.setProjectDraftThreadId(projectRef, draftId, { threadId });1656 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
apps/web/src/composerDraftStore.ts+17
...@@ -4254,6 +4254,23 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {...@@ -4254,6 +4254,23 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
4254 }4254 }
4255}4255}
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
4257export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {4274export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
4258 const draftStore = useComposerDraftStore.getState();4275 const draftStore = useComposerDraftStore.getState();
4259 for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {4276 for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {
apps/web/src/routes/_chat.draft.$draftId.tsx+1
...@@ -78,6 +78,7 @@ function DraftChatThreadRouteView() {...@@ -78,6 +78,7 @@ function DraftChatThreadRouteView() {
78 return (78 return (
79 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">79 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
80 <ChatView80 <ChatView
81 key={draftId}
81 draftId={draftId}82 draftId={draftId}
82 environmentId={draftSession.environmentId}83 environmentId={draftSession.environmentId}
83 threadId={draftSession.threadId}84 threadId={draftSession.threadId}