diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 0fb187a5462..9377dfa229a 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -115,8 +115,11 @@ export function DraftHeroHeadline({ return; } const project = entry.targetProject; + // Changing the repo of a draft moves the typed content along: + // the user started writing in the wrong project, not a new task. void handleNewThread(scopeProjectRef(project.environmentId, project.id), { replace: true, + carryComposerContent: true, }); }} > diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index c127dfba175..3e4106c583f 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -289,6 +289,62 @@ describe("composerDraftStore clearComposerContent", () => { }); }); +describe("composerDraftStore moveComposerPromptAndImages", () => { + const sourceDraftId = DraftId.make("draft-move-source"); + const destinationDraftId = DraftId.make("draft-move-destination"); + let originalRevokeObjectUrl: typeof URL.revokeObjectURL; + let revokeSpy: ReturnType void>>; + + beforeEach(() => { + resetComposerDraftStore(); + originalRevokeObjectUrl = URL.revokeObjectURL; + revokeSpy = vi.fn(); + URL.revokeObjectURL = revokeSpy; + }); + + afterEach(() => { + URL.revokeObjectURL = originalRevokeObjectUrl; + }); + + it("moves prompt and images to the destination without revoking preview URLs", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(sourceDraftId, "fix the login redirect"); + store.addImages(sourceDraftId, [makeImage({ id: "img-move", previewUrl: "blob:move" })]); + + store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId); + + expect(draftByKey(sourceDraftId)).toBeUndefined(); + const destination = draftByKey(destinationDraftId); + expect(destination?.prompt).toBe("fix the login redirect"); + expect(destination?.images.map((image) => image.id)).toEqual(["img-move"]); + expect(revokeSpy).not.toHaveBeenCalled(); + }); + + it("keeps session-bound contexts on the source and strips their placeholders from the moved prompt", () => { + const sourceThreadId = ThreadId.make("thread-move-source"); + const sourceThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, sourceThreadId); + const store = useComposerDraftStore.getState(); + store.addTerminalContext(sourceThreadRef, makeTerminalContext({ id: "ctx-stay" })); + store.setPrompt(sourceThreadRef, `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} explain this error`); + + store.moveComposerPromptAndImages(sourceThreadRef, destinationDraftId); + + const source = draftFor(sourceThreadId, TEST_ENVIRONMENT_ID); + expect(source?.terminalContexts.map((context) => context.id)).toEqual(["ctx-stay"]); + expect(source?.prompt).toBe(INLINE_TERMINAL_CONTEXT_PLACEHOLDER); + expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error"); + }); + + it("is a no-op when source and destination are the same target", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(sourceDraftId, "keep me"); + + store.moveComposerPromptAndImages(sourceDraftId, sourceDraftId); + + expect(draftByKey(sourceDraftId)?.prompt).toBe("keep me"); + }); +}); + describe("composerDraftStore syncPersistedAttachments", () => { const threadId = ThreadId.make("thread-sync-persisted"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ebafd3b04d2..3fe6681e09e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -38,6 +38,7 @@ import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, + stripInlineTerminalContextPlaceholders, } from "./lib/terminalContext"; import { type ElementContextDraft, @@ -527,6 +528,15 @@ interface ComposerDraftStoreState { * session-bound contexts would destroy state nothing can restore. */ clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; + /** + * Moves the prompt text and image attachments from one composer target to + * another. Used when a draft changes project: the new project gets its own + * draft session and the typed content follows it. Session-bound extras + * (terminal / element contexts, preview annotations, review comments) stay + * on the source — they reference sessions of the source thread that the + * destination cannot use. + */ + moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -3474,6 +3484,62 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + moveComposerPromptAndImages: (from, to) => { + const fromKey = resolveComposerDraftKey(get(), from) ?? ""; + const toKey = resolveComposerDraftKey(get(), to) ?? ""; + if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) { + return; + } + set((state) => { + const source = state.draftsByThreadKey[fromKey]; + if (!source) { + return state; + } + const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft(); + // Inline placeholders reference the source's terminal contexts, + // which stay behind; re-anchor the moved prompt to whatever + // contexts the destination already holds. + const movedPrompt = ensureInlineTerminalContextPlaceholders( + stripInlineTerminalContextPlaceholders(source.prompt), + destination.terminalContexts.length, + ); + const nextDestination: ComposerThreadDraftState = { + ...destination, + prompt: movedPrompt, + images: [...destination.images, ...source.images], + nonPersistedImageIds: [ + ...destination.nonPersistedImageIds, + ...source.nonPersistedImageIds, + ], + persistedAttachments: [ + ...destination.persistedAttachments, + ...source.persistedAttachments, + ], + }; + // Same clearing shape as clearComposerPromptAndImages, but the + // preview URLs are NOT revoked: the images moved and their blobs + // are still referenced from the destination. + const nextSource: ComposerThreadDraftState = { + ...source, + prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length), + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextSource)) { + delete nextDraftsByThreadKey[fromKey]; + } else { + nextDraftsByThreadKey[fromKey] = nextSource; + } + if (shouldRemoveDraft(nextDestination)) { + delete nextDraftsByThreadKey[toKey]; + } else { + nextDraftsByThreadKey[toKey] = nextDestination; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, }; }, { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ef362362162..64176c0873a 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -74,6 +74,14 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; + /** + * Move the viewed draft's typed content (prompt + images) into the + * draft this request lands on. Set by the draft repo picker: the + * user started writing in the wrong project and the text should + * follow them. Explicit new-thread surfaces leave this unset and + * keep mint-fresh semantics. + */ + carryComposerContent?: boolean; }, // Which draft the thread ended up in, so a caller that has something to put in it — a // prepared checkout, a task to write — addresses that one rather than looking the project @@ -85,6 +93,7 @@ export function useNewThreadHandler() { getDraftSession, getDraftThread, applyStickyState, + moveComposerPromptAndImages, setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, @@ -126,6 +135,27 @@ export function useNewThreadHandler() { carrySourceShell?.interactionMode ?? carrySourceDraft?.interactionMode ?? null; + // Content only moves when the caller opted in and the user is looking + // at a draft. The content check happens at move time, not here: the + // paths below await, and text typed during those awaits must still + // come along. + const carryContentSourceDraftId = + options?.carryComposerContent === true && currentRouteTarget?.kind === "draft" + ? currentRouteTarget.draftId + : null; + const carryComposerContentTo = (destinationDraftId: DraftId) => { + if ( + carryContentSourceDraftId && + carryContentSourceDraftId !== destinationDraftId && + // Never clobber a destination the user already invested in — the + // move overwrites the destination prompt, so a concurrent repo + // change that carried content first must win. + !composerDraftHasUserContent(getComposerDraft(destinationDraftId)) && + composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId)) + ) { + moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId); + } + }; const project = projects.find( (candidate) => candidate.id === projectRef.projectId && @@ -267,6 +297,7 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); + carryComposerContentTo(emptyStoredDraftThread.draftId); const opened = { draftId: emptyStoredDraftThread.draftId, threadId: emptyStoredDraftThread.threadId, @@ -354,6 +385,7 @@ export function useNewThreadHandler() { interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), }); + carryComposerContentTo(racedDraft.draftId); await router.navigate({ to: "/draft/$draftId", params: { draftId: racedDraft.draftId }, @@ -385,6 +417,7 @@ export function useNewThreadHandler() { // whatever sticky state just wrote". setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); } + carryComposerContentTo(draftId); await router.navigate({ to: "/draft/$draftId",