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
68 changes: 36 additions & 32 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ import {
clearBackgroundDraftSubmissionByRef,
finalizePromotedDraftThreadByRef,
markPromotedDraftThreadByRef,
restoreFailedBackgroundDraftThread,
restoreFailedDraftThread,
composerDraftHasUserContent,
type ComposerFileAttachment,
type ComposerImageAttachment,
Expand Down Expand Up @@ -6824,6 +6824,11 @@ export default function ChatView(props: ChatViewProps) {
}),
);
}
const sentDraftThreadRef = isLocalDraftThread
? scopeThreadRef(environmentId, threadIdForSend)
: null;
// Keep new-thread flows from reusing this draft while attachments and worktree setup settle.
if (sentDraftThreadRef) markPromotedDraftThreadByRef(sentDraftThreadRef);
promptRef.current = "";
clearComposerDraftContent(composerDraftTarget);
composerRef.current?.resetCursorState();
Expand Down Expand Up @@ -6986,7 +6991,6 @@ export default function ChatView(props: ChatViewProps) {
},
});
if (backgroundThreadRef) {
markPromotedDraftThreadByRef(backgroundThreadRef);
try {
backgroundDraftOpened = Boolean(
await handleNewThread(
Expand Down Expand Up @@ -7020,12 +7024,15 @@ export default function ChatView(props: ChatViewProps) {
releaseDraftAttachments(composerAttachmentsSnapshot);
}
acknowledgeActiveThreadWoke();
if (backgroundThreadRef) {
// A draft still on screen finalizes once its thread route takes over.
if (sentDraftThreadRef) {
if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) {
finalizePromotedDraftThreadByRef(backgroundThreadRef);
finalizePromotedDraftThreadByRef(sentDraftThreadRef);
} else {
clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
clearBackgroundDraftSubmissionByRef(sentDraftThreadRef);
}
}
if (backgroundThreadRef) {
if (backgroundDraftOpened) {
toastManager.add(
stackedThreadToast({
Expand Down Expand Up @@ -7054,8 +7061,8 @@ export default function ChatView(props: ChatViewProps) {
}

if (failure !== null) {
if (submissionIntent === "background" && draftId && draftThread) {
restoreFailedBackgroundDraftThread(
if (isLocalDraftThread && draftId && draftThread) {
restoreFailedDraftThread(
draftId,
draftThread,
wasBootstrapThreadDeleted(squashAtomCommandFailure(failure))
Expand All @@ -7065,20 +7072,9 @@ export default function ChatView(props: ChatViewProps) {
clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend));
}
if (
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 &&
(useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.threadContexts
.length ?? 0) === 0
!composerDraftHasUserContent(
useComposerDraftStore.getState().getComposerDraft(composerDraftTarget),
)
) {
setOptimisticUserMessages((existing) => {
const removed = existing.filter((message) => message.id === messageIdForSend);
Expand All @@ -7088,35 +7084,43 @@ export default function ChatView(props: ChatViewProps) {
const next = existing.filter((message) => message.id !== messageIdForSend);
return next.length === existing.length ? existing : next;
});
promptRef.current = messageTextForSend;
const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry);
composerImagesRef.current = retryComposerImages;
composerFilesRef.current = composerFilesSnapshot;
composerTerminalContextsRef.current = composerTerminalContextsSnapshot;
setComposerDraftPrompt(composerDraftTarget, messageTextForSend);
addComposerDraftImages(composerDraftTarget, retryComposerImages);
addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot);
setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot);
setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot);
setComposerDraftThreadContexts(composerDraftTarget, composerThreadContextsSnapshot);
composerRef.current?.resetCursorState({
cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length),
prompt: messageTextForSend,
detectTrigger: true,
});
if (currentRouteThreadKeyRef.current === routeThreadKey) {
promptRef.current = messageTextForSend;
composerImagesRef.current = retryComposerImages;
composerFilesRef.current = composerFilesSnapshot;
composerTerminalContextsRef.current = composerTerminalContextsSnapshot;
composerRef.current?.resetCursorState({
cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length),
prompt: messageTextForSend,
detectTrigger: true,
});
}
}
if (!isAtomCommandInterrupted(failure)) {
const error = squashAtomCommandFailure(failure);
setThreadError(
threadIdForSend,
error instanceof Error ? error.message : "Failed to send message.",
);
if (backgroundDraftOpened && draftId) {
if (
draftId &&
(backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey)
) {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Background task failed",
title:
submissionIntent === "background"
? "Background task failed"
: "Could not start thread",
description: error instanceof Error ? error.message : "Failed to send message.",
actionProps: {
children: "Open draft",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/ThreadRouteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) {
draftId === null ? null : store.getDraftSession(draftId),
);
const threadRefs = useThreadRefs();
// Promotion can begin before the server creates the reserved thread.
const inferredThreadRef = draftSession
? (threadRefs.find(
(ref) =>
Expand All @@ -43,7 +44,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) {
) ?? null)
: null;
const serverThreadRef: ScopedThreadRef | null =
target.kind === "server" ? target.threadRef : (draftSession?.promotedTo ?? inferredThreadRef);
target.kind === "server" ? target.threadRef : inferredThreadRef;
const serverThread = useQuietThreadShell(serverThreadRef);
const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(
target.kind === "draft" ? serverThreadRef : null,
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import {
clearBackgroundDraftSubmissionByRef,
finalizePromotedDraftThreadByRef,
markPromotedDraftThreadByRef,
restoreFailedBackgroundDraftThread,
restoreFailedDraftThread,
type ComposerFileAttachment,
type ComposerImageAttachment,
composerFileNeedsReattach,
Expand Down Expand Up @@ -1643,20 +1643,22 @@ describe("composerDraftStore project draft thread mapping", () => {
expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull();
});

it("restores a failed background draft without changing the fresh draft", () => {
it("restores a failed draft without changing the fresh draft", () => {
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(projectRef, draftId, { threadId });
const sentDraft = useComposerDraftStore.getState().getDraftSession(draftId)!;
markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
const freshId = DraftId.make("fresh-background-draft");
store.setProjectDraftThreadId(projectRef, freshId, { threadId: ThreadId.make("fresh-thread") });
store.setPrompt(freshId, "new work");
restoreFailedBackgroundDraftThread(draftId, sentDraft, ThreadId.make("retry-thread"));
store.setDraftThreadContext(draftId, { branch: "retry-base" });
restoreFailedDraftThread(draftId, sentDraft, ThreadId.make("retry-thread"));
store.setPrompt(draftId, "retry work");
expect(useComposerDraftStore.getState().getDraftSession(draftId)?.promotedTo).toBeNull();
expect(useComposerDraftStore.getState().getDraftSession(draftId)?.threadId).toBe(
"retry-thread",
);
expect(useComposerDraftStore.getState().getDraftSession(draftId)?.branch).toBe("retry-base");
expect(draftByKey(freshId)?.prompt).toBe("new work");
expect(draftByKey(draftId)?.prompt).toBe("retry work");
});
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4270,7 +4270,7 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
}
}

export function restoreFailedBackgroundDraftThread(
export function restoreFailedDraftThread(
draftId: DraftId,
draftThread: DraftThreadState,
threadId: ThreadId,
Expand All @@ -4279,8 +4279,9 @@ export function restoreFailedBackgroundDraftThread(
draftThreadsByThreadKey: {
...state.draftThreadsByThreadKey,
[draftId]: {
...draftThread,
...(state.draftThreadsByThreadKey[draftId] ?? draftThread),
threadId,
...(threadId !== draftThread.threadId ? { createdAt: new Date().toISOString() } : {}),
promotedTo: null,
},
},
Expand Down
Loading