Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/DraftHeroHeadline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}}
>
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn<(url: string) => 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);
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type TerminalContextDraft,
ensureInlineTerminalContextPlaceholders,
normalizeTerminalContextText,
stripInlineTerminalContextPlaceholders,
} from "./lib/terminalContext";
import {
type ElementContextDraft,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -3474,6 +3484,62 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
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 };
});
},
};
},
{
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -85,6 +93,7 @@ export function useNewThreadHandler() {
getDraftSession,
getDraftThread,
applyStickyState,
moveComposerPromptAndImages,
setDraftThreadContext,
setLogicalProjectDraftThreadId,
setModelSelection,
Expand Down Expand Up @@ -126,6 +135,21 @@ export function useNewThreadHandler() {
carrySourceShell?.interactionMode ??
carrySourceDraft?.interactionMode ??
null;
// Content only moves when the caller opted in, the user is looking at
// a draft, and that draft has real typed content to move. The move
// reads the store at call time, so text typed during the awaits below
// still comes along.
const carryContentSourceDraftId =
options?.carryComposerContent === true &&
currentRouteTarget?.kind === "draft" &&
composerDraftHasUserContent(carrySourceComposer)
? currentRouteTarget.draftId
: null;
const carryComposerContentTo = (destinationDraftId: DraftId) => {
if (carryContentSourceDraftId && carryContentSourceDraftId !== destinationDraftId) {
moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId);
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
};
const project = projects.find(
(candidate) =>
candidate.id === projectRef.projectId &&
Expand Down Expand Up @@ -267,6 +291,7 @@ export function useNewThreadHandler() {
...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
},
);
carryComposerContentTo(emptyStoredDraftThread.draftId);
const opened = {
draftId: emptyStoredDraftThread.draftId,
threadId: emptyStoredDraftThread.threadId,
Expand Down Expand Up @@ -354,6 +379,7 @@ export function useNewThreadHandler() {
interactionMode: racedDraft.interactionMode,
...pickExplicitWorkspaceOptions(options),
});
carryComposerContentTo(racedDraft.draftId);
await router.navigate({
to: "/draft/$draftId",
params: { draftId: racedDraft.draftId },
Expand Down Expand Up @@ -385,6 +411,7 @@ export function useNewThreadHandler() {
// whatever sticky state just wrote".
setModelSelection(draftId, carryModelSelection, { replaceOptions: true });
}
carryComposerContentTo(draftId);

await router.navigate({
to: "/draft/$draftId",
Expand Down
Loading