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
138 changes: 87 additions & 51 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ import {
type DraftThreadEnvMode,
finalizePromotedDraftThreadByRef,
markPromotedDraftThreadByRef,
restoreFailedBackgroundDraftThread,
useComposerDraftStore,
DraftId,
} from "../composerDraftStore";
Expand Down Expand Up @@ -7893,6 +7894,7 @@ export default function ChatView(props: ChatViewProps) {
}

let turnStartSucceeded = false;
let backgroundDraftOpened = false;
if (failure === null && turnAttachmentsResult._tag === "Success") {
const bootstrap =
isLocalDraftThread || baseBranchForWorktree
Expand Down Expand Up @@ -7931,7 +7933,7 @@ export default function ChatView(props: ChatViewProps) {
if (backgroundThreadRef) {
beginBackgroundDraftSubmissionByRef(backgroundThreadRef);
}
const startResult = await startThreadTurn({
const startPromise = startThreadTurn({
environmentId,
input: {
threadId: threadIdForSend,
Expand Down Expand Up @@ -7975,10 +7977,32 @@ export default function ChatView(props: ChatViewProps) {
createdAt: messageCreatedAt,
},
});
if (startResult._tag === "Failure") {
if (backgroundThreadRef) {
if (backgroundThreadRef) {
markPromotedDraftThreadByRef(backgroundThreadRef);
try {
backgroundDraftOpened = Boolean(
await handleNewThread(
scopeProjectRef(activeProject.environmentId, activeProject.id),
resolveBackgroundDraftWorkspaceOptions({
envMode: sendEnvMode,
branch: activeThreadBranch,
startFromOrigin,
}),
),
);
} catch (error) {
clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
toastManager.add(
stackedThreadToast({
type: "warning",
title: "Could not open a fresh composer",
description: error instanceof Error ? error.message : undefined,
}),
);
}
}
const startResult = await startPromise;
if (startResult._tag === "Failure") {
failure = startResult;
} else {
turnStartSucceeded = true;
Expand All @@ -7991,48 +8015,26 @@ export default function ChatView(props: ChatViewProps) {
}
acknowledgeActiveThreadWoke();
if (backgroundThreadRef) {
markPromotedDraftThreadByRef(backgroundThreadRef);
try {
const nextDraft = await handleNewThread(
scopeProjectRef(activeProject.environmentId, activeProject.id),
resolveBackgroundDraftWorkspaceOptions({
envMode: sendEnvMode,
branch: activeThreadBranch,
startFromOrigin,
}),
);
if (nextDraft) {
finalizePromotedDraftThreadByRef(backgroundThreadRef);
toastManager.add(
stackedThreadToast({
type: "success",
title: "Started in background",
timeout: 5_000,
actionProps: {
children: "Open",
onClick: () => {
void navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(backgroundThreadRef),
});
},
},
}),
);
} else {
clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
}
} catch (error) {
if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) {
finalizePromotedDraftThreadByRef(backgroundThreadRef);
} else {
clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
resetLocalDispatch();
}
if (backgroundDraftOpened) {
toastManager.add(
stackedThreadToast({
type: "warning",
title: "Task started in the background",
description:
error instanceof Error
? `Could not open a fresh composer: ${error.message}`
: "Could not open a fresh composer.",
type: "success",
title: "Started in background",
timeout: 5_000,
actionProps: {
children: "Open",
onClick: () => {
void navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(backgroundThreadRef),
});
},
},
}),
);
}
Expand All @@ -8041,6 +8043,16 @@ export default function ChatView(props: ChatViewProps) {
}

if (failure !== null) {
if (resolvedSubmissionIntent === "background" && draftId && draftThread) {
restoreFailedBackgroundDraftThread(
draftId,
draftThread,
wasBootstrapThreadDeleted(squashAtomCommandFailure(failure))
? newThreadId()
: threadIdForSend,
);
clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend));
}
if (queuedMessage) {
setOptimisticUserMessages((existing) => {
const removed = existing.filter((message) => message.id === messageIdForSend);
Expand All @@ -8059,14 +8071,18 @@ export default function ChatView(props: ChatViewProps) {
});
}
} else if (
promptRef.current.length === 0 &&
composerImagesRef.current.length === 0 &&
composerFilesRef.current.length === 0 &&
composerTerminalContextsRef.current.length === 0 &&
(useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations
.length ?? 0) === 0 &&
(useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments
.length ?? 0) === 0
backgroundDraftOpened
? !composerDraftHasUserContent(
useComposerDraftStore.getState().getComposerDraft(composerDraftTarget),
)
: promptRef.current.length === 0 &&
composerImagesRef.current.length === 0 &&
composerFilesRef.current.length === 0 &&
composerTerminalContextsRef.current.length === 0 &&
(useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)
?.previewAnnotations.length ?? 0) === 0 &&
(useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments
.length ?? 0) === 0
) {
setOptimisticUserMessages((existing) => {
const removed = existing.filter((message) => message.id === messageIdForSend);
Expand Down Expand Up @@ -8095,7 +8111,12 @@ export default function ChatView(props: ChatViewProps) {
}
if (!isAtomCommandInterrupted(failure)) {
const error = squashAtomCommandFailure(failure);
if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) {
if (
resolvedSubmissionIntent !== "background" &&
isLocalDraftThread &&
draftId &&
wasBootstrapThreadDeleted(error)
) {
const failedDraftSession = getDraftSession(draftId);
if (failedDraftSession?.threadId === threadIdForSend) {
setLogicalProjectDraftThreadId(
Expand All @@ -8113,6 +8134,21 @@ export default function ChatView(props: ChatViewProps) {
threadIdForSend,
error instanceof Error ? error.message : "Failed to send message.",
);
if (backgroundDraftOpened && draftId) {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Background task failed",
description: error instanceof Error ? error.message : "Failed to send message.",
actionProps: {
children: "Open draft",
onClick: () => {
void navigate({ to: "/draft/$draftId", params: { draftId } });
},
},
}),
);
}
}
}
sendInFlightRef.current = false;
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"

import {
COMPOSER_DRAFT_STORAGE_KEY,
beginBackgroundDraftSubmissionByRef,
clearComposerDraftsEnvironment,
composerDraftHasUserContent,
finalizePromotedDraftThreadByRef,
markPromotedDraftThreadByRef,
restoreFailedBackgroundDraftThread,
type ComposerFileAttachment,
type ComposerImageAttachment,
composerFileNeedsReattach,
Expand Down Expand Up @@ -1337,6 +1339,45 @@ describe("composerDraftStore project draft thread mapping", () => {
);
});

it.each([false, true])(
"restores a failed background draft without replacing the next draft (finalized: %s)",
(finalized) => {
const store = useComposerDraftStore.getState();
const nextDraftId = DraftId.make("next-draft");
const retryThreadId = ThreadId.make("retry-thread");
const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
store.setProjectDraftThreadId(projectRef, draftId, {
threadId,
branch: "main",
envMode: "worktree",
startFromOrigin: true,
});
const sentDraft = store.getDraftSession(draftId)!;
markPromotedDraftThreadByRef(threadRef);
store.setProjectDraftThreadId(projectRef, nextDraftId, {
threadId: ThreadId.make("next-thread"),
});
store.setPrompt(nextDraftId, "My next task");
const nextDraft = store.getDraftSession(nextDraftId);
if (finalized) finalizePromotedDraftThreadByRef(threadRef);

restoreFailedBackgroundDraftThread(draftId, sentDraft, retryThreadId);
store.setPrompt(draftId, "Retry the first task");

expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(nextDraftId);
expect(store.getDraftSession(nextDraftId)).toBe(nextDraft);
expect(store.getComposerDraft(nextDraftId)?.prompt).toBe("My next task");
expect(store.getDraftSession(draftId)).toMatchObject({
threadId: retryThreadId,
promotedTo: null,
branch: "main",
envMode: "worktree",
startFromOrigin: true,
});
expect(store.getComposerDraft(draftId)?.prompt).toBe("Retry the first task");
},
);

it("clears only matching project draft mapping entries", () => {
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(projectRef, draftId, { threadId });
Expand Down Expand Up @@ -1588,6 +1629,28 @@ describe("composerDraftStore project draft thread mapping", () => {
expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup");
});

it("cleans up a completed background draft without replacing the active draft", () => {
const store = useComposerDraftStore.getState();
const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
const nextDraftId = DraftId.make("next-draft");
store.setProjectDraftThreadId(projectRef, draftId, { threadId });
beginBackgroundDraftSubmissionByRef(threadRef);
markPromotedDraftThreadByRef(threadRef);
store.setProjectDraftThreadId(projectRef, nextDraftId, {
threadId: ThreadId.make("next-thread"),
});
store.setPrompt(nextDraftId, "Keep my next task");

finalizePromotedDraftThreadByRef(threadRef);

expect(store.getDraftSession(draftId)).toBeNull();
expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(nextDraftId);
expect(store.getComposerDraft(nextDraftId)?.prompt).toBe("Keep my next task");
expect(
useComposerDraftStore.getState().backgroundSubmissionThreadKeys[scopedThreadKey(threadRef)],
).toBeUndefined();
});

it("finalizes a matching materialized draft even when promotion was not pre-marked", () => {
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(projectRef, draftId, { threadId });
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4254,6 +4254,23 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
}
}

export function restoreFailedBackgroundDraftThread(
draftId: DraftId,
draftThread: DraftThreadState,
threadId: ThreadId,
): void {
useComposerDraftStore.setState((state) => ({
draftThreadsByThreadKey: {
...state.draftThreadsByThreadKey,
[draftId]: {
...draftThread,
threadId,
promotedTo: null,
},
},
}));
}

export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
const draftStore = useComposerDraftStore.getState();
for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/routes/_chat.draft.$draftId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function DraftChatThreadRouteView() {
return (
<SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
<ChatView
key={draftId}
draftId={draftId}
environmentId={draftSession.environmentId}
threadId={draftSession.threadId}
Expand Down
Loading