| author | |
| committer | |
| log | 1e59b4c4004ce3c724d09ca0b140ed4523758d1e |
| tree | f3e2d3a1f8bdaa6c0bd715f1761a316c27dec93d |
| parent | 33f9705926c4557c845bdf972256f805f625b95b |
| signature | Signed by PGP key B5690EEEBB952194 |
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({ | ... | @@ -115,8 +115,11 @@ export function DraftHeroHeadline({ |
| 115 | return; | 115 | return; |
| 116 | } | 116 | } |
| 117 | const project = entry.targetProject; | 117 | 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. | ||
| 118 | void handleNewThread(scopeProjectRef(project.environmentId, project.id), { | 120 | void handleNewThread(scopeProjectRef(project.environmentId, project.id), { |
| 119 | replace: true, | 121 | replace: true, |
| 122 | carryComposerContent: true, | ||
| 120 | }); | 123 | }); |
| 121 | }} | 124 | }} |
| 122 | > | 125 | > |
apps/web/src/composerDraftStore.test.ts+56| ... | @@ -289,6 +289,62 @@ describe("composerDraftStore clearComposerContent", () => { | ... | @@ -289,6 +289,62 @@ describe("composerDraftStore clearComposerContent", () => { |
| 289 | }); | 289 | }); |
| 290 | }); | 290 | }); |
| 291 | 291 | ||
| 292 | describe("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 | |||
| 292 | describe("composerDraftStore syncPersistedAttachments", () => { | 348 | describe("composerDraftStore syncPersistedAttachments", () => { |
| 293 | const threadId = ThreadId.make("thread-sync-persisted"); | 349 | const threadId = ThreadId.make("thread-sync-persisted"); |
| 294 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | 350 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); |
apps/web/src/composerDraftStore.ts+66| ... | @@ -38,6 +38,7 @@ import { | ... | @@ -38,6 +38,7 @@ import { |
| 38 | type TerminalContextDraft, | 38 | type TerminalContextDraft, |
| 39 | ensureInlineTerminalContextPlaceholders, | 39 | ensureInlineTerminalContextPlaceholders, |
| 40 | normalizeTerminalContextText, | 40 | normalizeTerminalContextText, |
| 41 | stripInlineTerminalContextPlaceholders, | ||
| 41 | } from "./lib/terminalContext"; | 42 | } from "./lib/terminalContext"; |
| 42 | import { | 43 | import { |
| 43 | type ElementContextDraft, | 44 | type ElementContextDraft, |
| ... | @@ -527,6 +528,15 @@ interface ComposerDraftStoreState { | ... | @@ -527,6 +528,15 @@ interface ComposerDraftStoreState { |
| 527 | * session-bound contexts would destroy state nothing can restore. | 528 | * session-bound contexts would destroy state nothing can restore. |
| 528 | */ | 529 | */ |
| 529 | clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; | 530 | 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; | ||
| 530 | } | 540 | } |
| 531 | 541 | ||
| 532 | export interface EffectiveComposerModelState { | 542 | export interface EffectiveComposerModelState { |
| ... | @@ -3474,6 +3484,62 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -3474,6 +3484,62 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3474 | return { draftsByThreadKey: nextDraftsByThreadKey }; | 3484 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 3475 | }); | 3485 | }); |
| 3476 | }, | 3486 | }, |
| 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 | }, | ||
| 3477 | }; | 3543 | }; |
| 3478 | }, | 3544 | }, |
| 3479 | { | 3545 | { |
apps/web/src/hooks/useHandleNewThread.ts+33| ... | @@ -74,6 +74,14 @@ export function useNewThreadHandler() { | ... | @@ -74,6 +74,14 @@ export function useNewThreadHandler() { |
| 74 | envMode?: DraftThreadEnvMode; | 74 | envMode?: DraftThreadEnvMode; |
| 75 | startFromOrigin?: boolean; | 75 | startFromOrigin?: boolean; |
| 76 | replace?: boolean; | 76 | 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; | ||
| 77 | }, | 85 | }, |
| 78 | // Which draft the thread ended up in, so a caller that has something to put in it — a | 86 | // Which draft the thread ended up in, so a caller that has something to put in it — a |
| 79 | // prepared checkout, a task to write — addresses that one rather than looking the project | 87 | // prepared checkout, a task to write — addresses that one rather than looking the project |
| ... | @@ -85,6 +93,7 @@ export function useNewThreadHandler() { | ... | @@ -85,6 +93,7 @@ export function useNewThreadHandler() { |
| 85 | getDraftSession, | 93 | getDraftSession, |
| 86 | getDraftThread, | 94 | getDraftThread, |
| 87 | applyStickyState, | 95 | applyStickyState, |
| 96 | moveComposerPromptAndImages, | ||
| 88 | setDraftThreadContext, | 97 | setDraftThreadContext, |
| 89 | setLogicalProjectDraftThreadId, | 98 | setLogicalProjectDraftThreadId, |
| 90 | setModelSelection, | 99 | setModelSelection, |
| ... | @@ -126,6 +135,27 @@ export function useNewThreadHandler() { | ... | @@ -126,6 +135,27 @@ export function useNewThreadHandler() { |
| 126 | carrySourceShell?.interactionMode ?? | 135 | carrySourceShell?.interactionMode ?? |
| 127 | carrySourceDraft?.interactionMode ?? | 136 | carrySourceDraft?.interactionMode ?? |
| 128 | null; | 137 | 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 | }; | ||
| 129 | const project = projects.find( | 159 | const project = projects.find( |
| 130 | (candidate) => | 160 | (candidate) => |
| 131 | candidate.id === projectRef.projectId && | 161 | candidate.id === projectRef.projectId && |
| ... | @@ -267,6 +297,7 @@ export function useNewThreadHandler() { | ... | @@ -267,6 +297,7 @@ export function useNewThreadHandler() { |
| 267 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | 297 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 268 | }, | 298 | }, |
| 269 | ); | 299 | ); |
| 300 | carryComposerContentTo(emptyStoredDraftThread.draftId); | ||
| 270 | const opened = { | 301 | const opened = { |
| 271 | draftId: emptyStoredDraftThread.draftId, | 302 | draftId: emptyStoredDraftThread.draftId, |
| 272 | threadId: emptyStoredDraftThread.threadId, | 303 | threadId: emptyStoredDraftThread.threadId, |
| ... | @@ -354,6 +385,7 @@ export function useNewThreadHandler() { | ... | @@ -354,6 +385,7 @@ export function useNewThreadHandler() { |
| 354 | interactionMode: racedDraft.interactionMode, | 385 | interactionMode: racedDraft.interactionMode, |
| 355 | ...pickExplicitWorkspaceOptions(options), | 386 | ...pickExplicitWorkspaceOptions(options), |
| 356 | }); | 387 | }); |
| 388 | carryComposerContentTo(racedDraft.draftId); | ||
| 357 | await router.navigate({ | 389 | await router.navigate({ |
| 358 | to: "/draft/$draftId", | 390 | to: "/draft/$draftId", |
| 359 | params: { draftId: racedDraft.draftId }, | 391 | params: { draftId: racedDraft.draftId }, |
| ... | @@ -385,6 +417,7 @@ export function useNewThreadHandler() { | ... | @@ -385,6 +417,7 @@ export function useNewThreadHandler() { |
| 385 | // whatever sticky state just wrote". | 417 | // whatever sticky state just wrote". |
| 386 | setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); | 418 | setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); |
| 387 | } | 419 | } |
| 420 | carryComposerContentTo(draftId); | ||
| 388 | 421 | ||
| 389 | await router.navigate({ | 422 | await router.navigate({ |
| 390 | to: "/draft/$draftId", | 423 | to: "/draft/$draftId", |