authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-12 19:26:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-12 16:26:27-07:00
log1e59b4c4004ce3c724d09ca0b140ed4523758d1e
treef3e2d3a1f8bdaa6c0bd715f1761a316c27dec93d
parent33f9705926c4557c845bdf972256f805f625b95b
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the typed prompt when a draft changes repo (#6393)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

4 files changed, 158 insertions(+), 0 deletions(-)

apps/web/src/components/chat/DraftHeroHeadline.tsx+3
......@@ -115,8 +115,11 @@ export function DraftHeroHeadline({
115115 return;
116116 }
117117 const project = entry.targetProject;
118 // Changing the repo of a draft moves the typed content along:
119 // the user started writing in the wrong project, not a new task.
118120 void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
119121 replace: true,
122 carryComposerContent: true,
120123 });
121124 }}
122125 >
apps/web/src/composerDraftStore.test.ts+56
......@@ -289,6 +289,62 @@ describe("composerDraftStore clearComposerContent", () => {
289289 });
290290});
291291
292describe("composerDraftStore moveComposerPromptAndImages", () => {
293 const sourceDraftId = DraftId.make("draft-move-source");
294 const destinationDraftId = DraftId.make("draft-move-destination");
295 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
296 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
297
298 beforeEach(() => {
299 resetComposerDraftStore();
300 originalRevokeObjectUrl = URL.revokeObjectURL;
301 revokeSpy = vi.fn();
302 URL.revokeObjectURL = revokeSpy;
303 });
304
305 afterEach(() => {
306 URL.revokeObjectURL = originalRevokeObjectUrl;
307 });
308
309 it("moves prompt and images to the destination without revoking preview URLs", () => {
310 const store = useComposerDraftStore.getState();
311 store.setPrompt(sourceDraftId, "fix the login redirect");
312 store.addImages(sourceDraftId, [makeImage({ id: "img-move", previewUrl: "blob:move" })]);
313
314 store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId);
315
316 expect(draftByKey(sourceDraftId)).toBeUndefined();
317 const destination = draftByKey(destinationDraftId);
318 expect(destination?.prompt).toBe("fix the login redirect");
319 expect(destination?.images.map((image) => image.id)).toEqual(["img-move"]);
320 expect(revokeSpy).not.toHaveBeenCalled();
321 });
322
323 it("keeps session-bound contexts on the source and strips their placeholders from the moved prompt", () => {
324 const sourceThreadId = ThreadId.make("thread-move-source");
325 const sourceThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, sourceThreadId);
326 const store = useComposerDraftStore.getState();
327 store.addTerminalContext(sourceThreadRef, makeTerminalContext({ id: "ctx-stay" }));
328 store.setPrompt(sourceThreadRef, `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} explain this error`);
329
330 store.moveComposerPromptAndImages(sourceThreadRef, destinationDraftId);
331
332 const source = draftFor(sourceThreadId, TEST_ENVIRONMENT_ID);
333 expect(source?.terminalContexts.map((context) => context.id)).toEqual(["ctx-stay"]);
334 expect(source?.prompt).toBe(INLINE_TERMINAL_CONTEXT_PLACEHOLDER);
335 expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error");
336 });
337
338 it("is a no-op when source and destination are the same target", () => {
339 const store = useComposerDraftStore.getState();
340 store.setPrompt(sourceDraftId, "keep me");
341
342 store.moveComposerPromptAndImages(sourceDraftId, sourceDraftId);
343
344 expect(draftByKey(sourceDraftId)?.prompt).toBe("keep me");
345 });
346});
347
292348describe("composerDraftStore syncPersistedAttachments", () => {
293349 const threadId = ThreadId.make("thread-sync-persisted");
294350 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
apps/web/src/composerDraftStore.ts+66
......@@ -38,6 +38,7 @@ import {
3838 type TerminalContextDraft,
3939 ensureInlineTerminalContextPlaceholders,
4040 normalizeTerminalContextText,
41 stripInlineTerminalContextPlaceholders,
4142} from "./lib/terminalContext";
4243import {
4344 type ElementContextDraft,
......@@ -527,6 +528,15 @@ interface ComposerDraftStoreState {
527528 * session-bound contexts would destroy state nothing can restore.
528529 */
529530 clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void;
531 /**
532 * Moves the prompt text and image attachments from one composer target to
533 * another. Used when a draft changes project: the new project gets its own
534 * draft session and the typed content follows it. Session-bound extras
535 * (terminal / element contexts, preview annotations, review comments) stay
536 * on the source — they reference sessions of the source thread that the
537 * destination cannot use.
538 */
539 moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void;
530540}
531541
532542export interface EffectiveComposerModelState {
......@@ -3474,6 +3484,62 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
34743484 return { draftsByThreadKey: nextDraftsByThreadKey };
34753485 });
34763486 },
3487 moveComposerPromptAndImages: (from, to) => {
3488 const fromKey = resolveComposerDraftKey(get(), from) ?? "";
3489 const toKey = resolveComposerDraftKey(get(), to) ?? "";
3490 if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) {
3491 return;
3492 }
3493 set((state) => {
3494 const source = state.draftsByThreadKey[fromKey];
3495 if (!source) {
3496 return state;
3497 }
3498 const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft();
3499 // Inline placeholders reference the source's terminal contexts,
3500 // which stay behind; re-anchor the moved prompt to whatever
3501 // contexts the destination already holds.
3502 const movedPrompt = ensureInlineTerminalContextPlaceholders(
3503 stripInlineTerminalContextPlaceholders(source.prompt),
3504 destination.terminalContexts.length,
3505 );
3506 const nextDestination: ComposerThreadDraftState = {
3507 ...destination,
3508 prompt: movedPrompt,
3509 images: [...destination.images, ...source.images],
3510 nonPersistedImageIds: [
3511 ...destination.nonPersistedImageIds,
3512 ...source.nonPersistedImageIds,
3513 ],
3514 persistedAttachments: [
3515 ...destination.persistedAttachments,
3516 ...source.persistedAttachments,
3517 ],
3518 };
3519 // Same clearing shape as clearComposerPromptAndImages, but the
3520 // preview URLs are NOT revoked: the images moved and their blobs
3521 // are still referenced from the destination.
3522 const nextSource: ComposerThreadDraftState = {
3523 ...source,
3524 prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length),
3525 images: [],
3526 nonPersistedImageIds: [],
3527 persistedAttachments: [],
3528 };
3529 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3530 if (shouldRemoveDraft(nextSource)) {
3531 delete nextDraftsByThreadKey[fromKey];
3532 } else {
3533 nextDraftsByThreadKey[fromKey] = nextSource;
3534 }
3535 if (shouldRemoveDraft(nextDestination)) {
3536 delete nextDraftsByThreadKey[toKey];
3537 } else {
3538 nextDraftsByThreadKey[toKey] = nextDestination;
3539 }
3540 return { draftsByThreadKey: nextDraftsByThreadKey };
3541 });
3542 },
34773543 };
34783544 },
34793545 {
apps/web/src/hooks/useHandleNewThread.ts+33
......@@ -74,6 +74,14 @@ export function useNewThreadHandler() {
7474 envMode?: DraftThreadEnvMode;
7575 startFromOrigin?: boolean;
7676 replace?: boolean;
77 /**
78 * Move the viewed draft's typed content (prompt + images) into the
79 * draft this request lands on. Set by the draft repo picker: the
80 * user started writing in the wrong project and the text should
81 * follow them. Explicit new-thread surfaces leave this unset and
82 * keep mint-fresh semantics.
83 */
84 carryComposerContent?: boolean;
7785 },
7886 // Which draft the thread ended up in, so a caller that has something to put in it — a
7987 // prepared checkout, a task to write — addresses that one rather than looking the project
......@@ -85,6 +93,7 @@ export function useNewThreadHandler() {
8593 getDraftSession,
8694 getDraftThread,
8795 applyStickyState,
96 moveComposerPromptAndImages,
8897 setDraftThreadContext,
8998 setLogicalProjectDraftThreadId,
9099 setModelSelection,
......@@ -126,6 +135,27 @@ export function useNewThreadHandler() {
126135 carrySourceShell?.interactionMode ??
127136 carrySourceDraft?.interactionMode ??
128137 null;
138 // Content only moves when the caller opted in and the user is looking
139 // at a draft. The content check happens at move time, not here: the
140 // paths below await, and text typed during those awaits must still
141 // come along.
142 const carryContentSourceDraftId =
143 options?.carryComposerContent === true && currentRouteTarget?.kind === "draft"
144 ? currentRouteTarget.draftId
145 : null;
146 const carryComposerContentTo = (destinationDraftId: DraftId) => {
147 if (
148 carryContentSourceDraftId &&
149 carryContentSourceDraftId !== destinationDraftId &&
150 // Never clobber a destination the user already invested in — the
151 // move overwrites the destination prompt, so a concurrent repo
152 // change that carried content first must win.
153 !composerDraftHasUserContent(getComposerDraft(destinationDraftId)) &&
154 composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId))
155 ) {
156 moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId);
157 }
158 };
129159 const project = projects.find(
130160 (candidate) =>
131161 candidate.id === projectRef.projectId &&
......@@ -267,6 +297,7 @@ export function useNewThreadHandler() {
267297 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
268298 },
269299 );
300 carryComposerContentTo(emptyStoredDraftThread.draftId);
270301 const opened = {
271302 draftId: emptyStoredDraftThread.draftId,
272303 threadId: emptyStoredDraftThread.threadId,
......@@ -354,6 +385,7 @@ export function useNewThreadHandler() {
354385 interactionMode: racedDraft.interactionMode,
355386 ...pickExplicitWorkspaceOptions(options),
356387 });
388 carryComposerContentTo(racedDraft.draftId);
357389 await router.navigate({
358390 to: "/draft/$draftId",
359391 params: { draftId: racedDraft.draftId },
......@@ -385,6 +417,7 @@ export function useNewThreadHandler() {
385417 // whatever sticky state just wrote".
386418 setModelSelection(draftId, carryModelSelection, { replaceOptions: true });
387419 }
420 carryComposerContentTo(draftId);
388421
389422 await router.navigate({
390423 to: "/draft/$draftId",