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
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
33 changes: 33 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,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))
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
) {
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 +297,7 @@ export function useNewThreadHandler() {
...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
},
);
carryComposerContentTo(emptyStoredDraftThread.draftId);
const opened = {
draftId: emptyStoredDraftThread.draftId,
threadId: emptyStoredDraftThread.threadId,
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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",
Expand Down
Loading