authorgravatar for 118054752+extoci@users.noreply.github.comExotic <118054752+extoci@users.noreply.github.com> 2026-09-02 01:00:51+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:00:51-04:00
log3b3465f2a9dbc541a8806e39d2889abb77b93f5d
tree7614b8a1c830631900e7f1d47dd7d04ae0666605
parent692eb1a5792b9930959b19805acf2bf2611318c9
signature Signed by PGP key B5690EEEBB952194

fix(web): changing projects no longer creates a draft (#9097)


8 files changed, 175 insertions(+), 511 deletions(-)

apps/web/src/components/ChatView.tsx+1
......@@ -7367,6 +7367,7 @@ function ChatViewContent(props: ChatViewProps) {
73677367 }
73687368 >
73697369 <DraftHeroHeadline
7370 draftId={draftId}
73707371 activeProjectRef={activeProjectRef}
73717372 activeProjectTitle={activeProject?.title ?? null}
73727373 />
apps/web/src/components/chat/DraftHeroHeadline.tsx+31-8
......@@ -1,11 +1,13 @@
1import type { DraftId } from "~/composerDraftStore";
2import { useComposerDraftStore } from "~/composerDraftStore";
13import type { ScopedProjectRef } from "@t3tools/contracts";
24import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
35import { FolderPlusIcon } from "lucide-react";
46import { useCallback, useMemo } from "react";
57
68import { openCommandPalette } from "~/commandPaletteBus";
7import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
89import { useClientSettings } from "~/hooks/useSettings";
10import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions";
911import { selectProjectGroupingSettings } from "~/logicalProject";
1012import {
1113 buildSidebarProjectPickerEntries,
......@@ -26,11 +28,13 @@ import {
2628import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2729
2830interface DraftHeroHeadlineProps {
31 readonly draftId: DraftId | null;
2932 readonly activeProjectRef: ScopedProjectRef | null;
3033 readonly activeProjectTitle: string | null;
3134}
3235
3336export function DraftHeroHeadline({
37 draftId,
3438 activeProjectRef,
3539 activeProjectTitle,
3640}: DraftHeroHeadlineProps) {
......@@ -40,7 +44,12 @@ export function DraftHeroHeadline({
4044 const primaryEnvironmentId = usePrimaryEnvironmentId();
4145 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
4246 const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder);
43 const handleNewThread = useNewThreadHandler();
47 const setLogicalProjectDraftThreadId = useComposerDraftStore(
48 (store) => store.setLogicalProjectDraftThreadId,
49 );
50 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
51 const applyStickyState = useComposerDraftStore((store) => store.applyStickyState);
52 const setModelSelection = useComposerDraftStore((store) => store.setModelSelection);
4453 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
4554
4655 const environmentLabelById = useMemo(
......@@ -126,12 +135,26 @@ export function DraftHeroHeadline({
126135 return;
127136 }
128137 const project = entry.targetProject;
129 // Changing the repo of a draft moves the typed content along:
130 // the user started writing in the wrong project, not a new task.
131 void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
132 replace: true,
133 carryComposerContent: true,
134 });
138 if (!draftId) {
139 return;
140 }
141 // Project selection changes the target of the open draft in
142 // place. The prompt stays in the same composer session, so the
143 // sidebar only gets a draft row if the user later navigates away.
144 const currentDraft = getComposerDraft(draftId);
145 setLogicalProjectDraftThreadId(
146 entry.group.projectKey,
147 scopeProjectRef(project.environmentId, project.id),
148 draftId,
149 );
150 if (!hasExplicitComposerModelSelection(currentDraft)) {
151 applyStickyState(draftId);
152 if (project.defaultModelSelection) {
153 setModelSelection(draftId, project.defaultModelSelection, {
154 replaceOptions: true,
155 });
156 }
157 }
135158 }}
136159 >
137160 {projectPickerEntries.map(({ group }) => {
apps/web/src/composerDraftStore.test.ts+36-161
......@@ -636,167 +636,6 @@ describe("composerDraftStore file attachments", () => {
636636 });
637637});
638638
639describe("composerDraftStore moveComposerPromptAndImages", () => {
640 const sourceDraftId = DraftId.make("draft-move-source");
641 const destinationDraftId = DraftId.make("draft-move-destination");
642 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
643 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
644
645 beforeEach(() => {
646 resetComposerDraftStore();
647 originalRevokeObjectUrl = URL.revokeObjectURL;
648 revokeSpy = vi.fn();
649 URL.revokeObjectURL = revokeSpy;
650 });
651
652 afterEach(() => {
653 URL.revokeObjectURL = originalRevokeObjectUrl;
654 });
655
656 it("moves prompt and images to the destination without revoking preview URLs", () => {
657 const store = useComposerDraftStore.getState();
658 store.setPrompt(sourceDraftId, "fix the login redirect");
659 store.addImages(sourceDraftId, [makeImage({ id: "img-move", previewUrl: "blob:move" })]);
660
661 store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId);
662
663 expect(draftByKey(sourceDraftId)).toBeUndefined();
664 const destination = draftByKey(destinationDraftId);
665 expect(destination?.prompt).toBe("fix the login redirect");
666 expect(destination?.images.map((image) => image.id)).toEqual(["img-move"]);
667 expect(revokeSpy).not.toHaveBeenCalled();
668 });
669
670 it("keeps session-bound contexts on the source and strips their placeholders from the moved prompt", () => {
671 const sourceThreadId = ThreadId.make("thread-move-source");
672 const sourceThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, sourceThreadId);
673 const store = useComposerDraftStore.getState();
674 store.addTerminalContext(sourceThreadRef, makeTerminalContext({ id: "ctx-stay" }));
675 store.setPrompt(sourceThreadRef, `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} explain this error`);
676
677 store.moveComposerPromptAndImages(sourceThreadRef, destinationDraftId);
678
679 const source = draftFor(sourceThreadId, TEST_ENVIRONMENT_ID);
680 expect(source?.terminalContexts.map((context) => context.id)).toEqual(["ctx-stay"]);
681 expect(source?.prompt).toBe(INLINE_TERMINAL_CONTEXT_PLACEHOLDER);
682 expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error");
683 });
684
685 it("keeps hydrated file references on their original environment", () => {
686 const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source"));
687 const destinationRef = scopeThreadRef(
688 OTHER_TEST_ENVIRONMENT_ID,
689 ThreadId.make("thread-file-destination"),
690 );
691 const store = useComposerDraftStore.getState();
692 store.setPrompt(sourceRef, "review the report");
693 store.addFiles(sourceRef, [
694 {
695 ...makeFile("file-hydrated"),
696 file: null,
697 uploadedAttachmentId: "pending-report-pdf",
698 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
699 },
700 ]);
701
702 store.moveComposerPromptAndImages(sourceRef, destinationRef);
703
704 expect(store.getComposerDraft(sourceRef)?.files.map((file) => file.id)).toEqual([
705 "file-hydrated",
706 ]);
707 expect(store.getComposerDraft(destinationRef)?.files).toEqual([]);
708 expect(store.getComposerDraft(destinationRef)?.prompt).toBe("review the report");
709 });
710
711 it("moves files across environments when the original browser file remains available", () => {
712 const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source"));
713 const destinationRef = scopeThreadRef(
714 OTHER_TEST_ENVIRONMENT_ID,
715 ThreadId.make("thread-file-destination"),
716 );
717 const store = useComposerDraftStore.getState();
718 store.addFiles(sourceRef, [
719 {
720 ...makeFile("file-local"),
721 uploadedAttachmentId: "pending-source-env",
722 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
723 },
724 ]);
725
726 store.moveComposerPromptAndImages(sourceRef, destinationRef);
727
728 expect(store.getComposerDraft(sourceRef)).toBeNull();
729 const moved = store.getComposerDraft(destinationRef)?.files;
730 expect(moved?.map((file) => file.id)).toEqual(["file-local"]);
731 // The source-environment upload is unreachable from the destination; the
732 // move drops it so the destination upload can mint its own.
733 expect(moved?.[0]?.uploadedAttachmentId).toBeUndefined();
734 expect(moved?.[0]?.uploadEnvironmentId).toBeUndefined();
735 });
736
737 it("does not duplicate a file the destination already holds", () => {
738 const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-dup-source"));
739 const destinationRef = scopeThreadRef(
740 TEST_ENVIRONMENT_ID,
741 ThreadId.make("thread-dup-destination"),
742 );
743 const store = useComposerDraftStore.getState();
744 // Same metadata key on both sides; the ids differ.
745 store.addFiles(sourceRef, [makeFile("file-copy-a")]);
746 store.addFiles(destinationRef, [makeFile("file-copy-b")]);
747
748 store.moveComposerPromptAndImages(sourceRef, destinationRef);
749
750 expect(store.getComposerDraft(destinationRef)?.files.map((file) => file.id)).toEqual([
751 "file-copy-b",
752 ]);
753 expect(store.getComposerDraft(sourceRef)?.files.map((file) => file.id)).toEqual([
754 "file-copy-a",
755 ]);
756 });
757
758 it("keeps overflow attachments on the source when the destination is nearly full", () => {
759 const store = useComposerDraftStore.getState();
760 store.addImages(
761 destinationDraftId,
762 Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) =>
763 makeImage({
764 id: `destination-${index}`,
765 name: `destination-${index}.png`,
766 previewUrl: `blob:destination-${index}`,
767 }),
768 ),
769 );
770 store.addImages(sourceDraftId, [
771 makeImage({ id: "source-first", name: "first.png", previewUrl: "blob:first" }),
772 makeImage({ id: "source-second", name: "second.png", previewUrl: "blob:second" }),
773 ]);
774 store.addFiles(sourceDraftId, [makeFile("source-file")]);
775
776 store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId);
777
778 expect(store.getComposerDraft(destinationDraftId)?.images).toHaveLength(
779 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
780 );
781 expect(store.getComposerDraft(destinationDraftId)?.files).toEqual([]);
782 expect(store.getComposerDraft(sourceDraftId)?.images.map((image) => image.id)).toEqual([
783 "source-second",
784 ]);
785 expect(store.getComposerDraft(sourceDraftId)?.files.map((file) => file.id)).toEqual([
786 "source-file",
787 ]);
788 });
789
790 it("is a no-op when source and destination are the same target", () => {
791 const store = useComposerDraftStore.getState();
792 store.setPrompt(sourceDraftId, "keep me");
793
794 store.moveComposerPromptAndImages(sourceDraftId, sourceDraftId);
795
796 expect(draftByKey(sourceDraftId)?.prompt).toBe("keep me");
797 });
798});
799
800639describe("composerDraftStore syncPersistedAttachments", () => {
801640 const threadId = ThreadId.make("thread-sync-persisted");
802641 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
......@@ -1275,6 +1114,18 @@ describe("composerDraftStore project draft thread mapping", () => {
12751114 });
12761115 });
12771116
1117 it("removes a draft's previous project mapping when retargeted in place", () => {
1118 const store = useComposerDraftStore.getState();
1119 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1120 store.setPrompt(draftId, "keep this prompt");
1121
1122 store.setProjectDraftThreadId(otherProjectRef, draftId, { threadId });
1123
1124 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
1125 expect(store.getDraftThreadByProjectRef(otherProjectRef)?.draftId).toBe(draftId);
1126 expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt");
1127 });
1128
12781129 it("rotates a failed bootstrap thread id without losing its draft", () => {
12791130 const store = useComposerDraftStore.getState();
12801131 const retryThreadId = ThreadId.make("thread-retry");
......@@ -1704,6 +1555,30 @@ describe("composerDraftStore project draft thread mapping", () => {
17041555 });
17051556 });
17061557
1558 it("clears stale upload metadata when retargeting a draft to another environment", () => {
1559 const store = useComposerDraftStore.getState();
1560 const hydratedFile: ComposerFileAttachment = {
1561 ...makeFile("file-cross-environment"),
1562 file: null,
1563 uploadedAttachmentId: "local-environment-upload",
1564 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
1565 };
1566
1567 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1568 store.addFiles(draftId, [hydratedFile]);
1569
1570 store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId });
1571
1572 const file = store.getComposerDraft(draftId)?.files[0];
1573 expect(file).toMatchObject({
1574 id: hydratedFile.id,
1575 file: null,
1576 });
1577 expect(file?.uploadedAttachmentId).toBeUndefined();
1578 expect(file?.uploadEnvironmentId).toBeUndefined();
1579 expect(file && composerFileNeedsReattach(file)).toBe(true);
1580 });
1581
17071582 it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => {
17081583 const store = useComposerDraftStore.getState();
17091584 store.setProjectDraftThreadId(projectRef, draftId, {
apps/web/src/composerDraftStore.ts+69-126
......@@ -45,7 +45,6 @@ import {
4545 type TerminalContextDraft,
4646 ensureInlineTerminalContextPlaceholders,
4747 normalizeTerminalContextText,
48 stripInlineTerminalContextPlaceholders,
4948} from "./lib/terminalContext";
5049import {
5150 type ElementContextDraft,
......@@ -116,6 +115,29 @@ export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean
116115 return file.file === null && file.uploadedAttachmentId === undefined;
117116}
118117
118function clearStaleFileUploadMetadata(
119 draft: ComposerThreadDraftState,
120 environmentId: EnvironmentId,
121): ComposerThreadDraftState {
122 let changed = false;
123 const files = draft.files.map((file) => {
124 if (
125 (file.uploadedAttachmentId === undefined && file.uploadEnvironmentId === undefined) ||
126 file.uploadEnvironmentId === environmentId
127 ) {
128 return file;
129 }
130
131 changed = true;
132 const nextFile = { ...file };
133 delete nextFile.uploadedAttachmentId;
134 delete nextFile.uploadEnvironmentId;
135 return nextFile;
136 });
137
138 return changed ? { ...draft, files } : draft;
139}
140
119141export const PersistedComposerFileAttachment = Schema.Struct({
120142 id: Schema.String,
121143 name: Schema.String,
......@@ -435,7 +457,11 @@ interface ComposerDraftStoreState {
435457 getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null;
436458 listDraftThreadKeys: () => string[];
437459 hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean;
438 /** Creates or updates the draft session tracked for a logical project. */
460 /**
461 * Creates or updates the draft session tracked for a logical project.
462 * Reassigning an existing draft removes its previous logical-project
463 * mapping so one session cannot resolve from two projects.
464 */
439465 setLogicalProjectDraftThreadId: (
440466 logicalProjectKey: string,
441467 projectRef: ScopedProjectRef,
......@@ -606,13 +632,6 @@ interface ComposerDraftStoreState {
606632 * prompt stash. Session-bound context stays in the source draft.
607633 */
608634 clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void;
609 /**
610 * Moves prompt text and transferable attachments into another composer.
611 * Attachments over the destination limit and uploaded files that belong to
612 * another environment stay in the source draft. Terminal and element
613 * context, preview annotations, and review comments also stay in the source.
614 */
615 moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void;
616635}
617636
618637export interface EffectiveComposerModelState {
......@@ -2515,18 +2534,53 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
25152534 options,
25162535 );
25172536 const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId;
2518 if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) {
2537 const hasNoStaleMappingsForDraft = Object.entries(
2538 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
2539 ).every(
2540 ([logicalKey, mappedDraftId]) =>
2541 mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey,
2542 );
2543 if (
2544 hasSameLogicalMapping &&
2545 hasNoStaleMappingsForDraft &&
2546 draftThreadsEqual(existingThread, nextDraftThread)
2547 ) {
25192548 return state;
25202549 }
2521 const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = {
2522 ...state.logicalProjectDraftThreadKeyByLogicalProjectKey,
2523 [normalizedLogicalProjectKey]: draftId,
2524 };
2550 // A draft session belongs to one logical project at a time. When
2551 // an open draft is retargeted in place, remove any old mapping
2552 // for that same draft so the previous project cannot resolve it.
2553 const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> =
2554 Object.fromEntries(
2555 Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter(
2556 ([logicalKey, mappedDraftId]) =>
2557 mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey,
2558 ),
2559 );
2560 nextLogicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey] =
2561 draftId;
25252562 const nextDraftThreadsByThreadKey: Record<string, DraftThreadState> = {
25262563 ...state.draftThreadsByThreadKey,
25272564 [draftId]: nextDraftThread,
25282565 };
2566 const existingDraft = state.draftsByThreadKey[draftId];
25292567 let nextDraftsByThreadKey = state.draftsByThreadKey;
2568 if (
2569 existingThread &&
2570 existingThread.environmentId !== projectRef.environmentId &&
2571 existingDraft !== undefined
2572 ) {
2573 const nextDraft = clearStaleFileUploadMetadata(
2574 existingDraft,
2575 projectRef.environmentId,
2576 );
2577 if (nextDraft !== existingDraft) {
2578 nextDraftsByThreadKey = {
2579 ...state.draftsByThreadKey,
2580 [draftId]: nextDraft,
2581 };
2582 }
2583 }
25302584 const previousDraftThread =
25312585 previousThreadKeyForLogicalProject === undefined
25322586 ? undefined
......@@ -2550,7 +2604,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
25502604 ) {
25512605 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
25522606 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {
2553 nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2607 nextDraftsByThreadKey = { ...nextDraftsByThreadKey };
25542608 delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject];
25552609 }
25562610 }
......@@ -3805,117 +3859,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
38053859 return { draftsByThreadKey: nextDraftsByThreadKey };
38063860 });
38073861 },
3808 moveComposerPromptAndImages: (from, to) => {
3809 const fromKey = resolveComposerDraftKey(get(), from) ?? "";
3810 const toKey = resolveComposerDraftKey(get(), to) ?? "";
3811 if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) {
3812 return;
3813 }
3814 set((state) => {
3815 const source = state.draftsByThreadKey[fromKey];
3816 if (!source) {
3817 return state;
3818 }
3819 const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft();
3820 const destinationEnvironmentId =
3821 typeof to === "string"
3822 ? (state.draftThreadsByThreadKey[toKey]?.environmentId ??
3823 parseScopedThreadKey(toKey)?.environmentId ??
3824 null)
3825 : to.environmentId;
3826 // A file the destination already holds (same id, or same
3827 // metadata key) stays behind instead of duplicating there.
3828 const destinationFileIds = new Set(destination.files.map((file) => file.id));
3829 const destinationFileKeys = new Set(destination.files.map(composerFileDedupKey));
3830 const transferableFiles = source.files.filter(
3831 (file) =>
3832 (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) &&
3833 !destinationFileIds.has(file.id) &&
3834 !destinationFileKeys.has(composerFileDedupKey(file)),
3835 );
3836 const remainingAttachmentSlots = Math.max(
3837 0,
3838 PROVIDER_SEND_TURN_MAX_ATTACHMENTS -
3839 destination.images.length -
3840 destination.files.length,
3841 );
3842 const movedImages = source.images.slice(0, remainingAttachmentSlots);
3843 const movedImageIds = new Set(movedImages.map((image) => image.id));
3844 const retainedImages = source.images.filter((image) => !movedImageIds.has(image.id));
3845 const movedFiles = transferableFiles
3846 .slice(0, remainingAttachmentSlots - movedImages.length)
3847 .map((file) => {
3848 // A byte-backed file moving across environments re-uploads at
3849 // the destination. Keeping the source-environment upload id
3850 // would mark it uploaded after a reload with no local bytes
3851 // and no valid upload anywhere the destination can reach. The
3852 // upload queue keeps the source upload as fallback, then
3853 // deletes it after the destination upload succeeds. Abandoned
3854 // uploads still expire through the server sweep.
3855 if (
3856 file.file === null ||
3857 file.uploadEnvironmentId === undefined ||
3858 file.uploadEnvironmentId === destinationEnvironmentId
3859 ) {
3860 return file;
3861 }
3862 const { uploadedAttachmentId: _a, uploadEnvironmentId: _e, ...rest } = file;
3863 return rest;
3864 });
3865 const movedFileIds = new Set(movedFiles.map((file) => file.id));
3866 const retainedFiles = source.files.filter((file) => !movedFileIds.has(file.id));
3867 // Inline placeholders reference the source's terminal contexts,
3868 // which stay behind; re-anchor the moved prompt to whatever
3869 // contexts the destination already holds.
3870 const movedPrompt = ensureInlineTerminalContextPlaceholders(
3871 stripInlineTerminalContextPlaceholders(source.prompt),
3872 destination.terminalContexts.length,
3873 );
3874 const nextDestination: ComposerThreadDraftState = {
3875 ...destination,
3876 prompt: movedPrompt,
3877 images: [...destination.images, ...movedImages],
3878 files: [...destination.files, ...movedFiles],
3879 nonPersistedImageIds: [
3880 ...destination.nonPersistedImageIds,
3881 ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)),
3882 ],
3883 persistedAttachments: [
3884 ...destination.persistedAttachments,
3885 ...source.persistedAttachments.filter((attachment) =>
3886 movedImageIds.has(attachment.id),
3887 ),
3888 ],
3889 };
3890 // Same clearing shape as clearComposerPromptAndImages, but the
3891 // preview URLs are NOT revoked: the images moved and their blobs
3892 // are still referenced from the destination.
3893 const nextSource: ComposerThreadDraftState = {
3894 ...source,
3895 prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length),
3896 images: retainedImages,
3897 files: retainedFiles,
3898 nonPersistedImageIds: source.nonPersistedImageIds.filter(
3899 (imageId) => !movedImageIds.has(imageId),
3900 ),
3901 persistedAttachments: source.persistedAttachments.filter(
3902 (attachment) => !movedImageIds.has(attachment.id),
3903 ),
3904 };
3905 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3906 if (shouldRemoveDraft(nextSource)) {
3907 delete nextDraftsByThreadKey[fromKey];
3908 } else {
3909 nextDraftsByThreadKey[fromKey] = nextSource;
3910 }
3911 if (shouldRemoveDraft(nextDestination)) {
3912 delete nextDraftsByThreadKey[toKey];
3913 } else {
3914 nextDraftsByThreadKey[toKey] = nextDestination;
3915 }
3916 return { draftsByThreadKey: nextDraftsByThreadKey };
3917 });
3918 },
39193862 };
39203863 },
39213864 {
apps/web/src/hooks/useHandleNewThread.ts+2-52
......@@ -25,6 +25,7 @@ import {
2525import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
2626import { readThreadShell, useProjects, useThread } from "../state/entities";
2727import {
28 hasExplicitComposerModelSelection,
2829 resolveNewDraftStartFromOrigin,
2930 resolveNewThreadModelSelectionOverride,
3031} from "../lib/chatThreadActions";
......@@ -33,7 +34,6 @@ import { primaryServerSettingsAtom } from "../state/server";
3334import { resolveThreadRouteTarget } from "../threadRoutes";
3435import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
3536import { useClientSettings } from "./useSettings";
36import { toastManager } from "../components/ui/toast";
3737
3838interface NewThreadWorkspaceOptions {
3939 branch?: string | null;
......@@ -78,14 +78,6 @@ export function useNewThreadHandler() {
7878 envMode?: DraftThreadEnvMode;
7979 startFromOrigin?: boolean;
8080 replace?: boolean;
81 /**
82 * Move the viewed draft's typed content and transferable attachments into the
83 * draft this request lands on. Set by the draft repo picker: the
84 * user started writing in the wrong project and the text should
85 * follow them. Explicit new-thread surfaces leave this unset and
86 * keep mint-fresh semantics.
87 */
88 carryComposerContent?: boolean;
8981 },
9082 // Which draft the thread ended up in, so a caller that has something to put in it — a
9183 // prepared checkout, a task to write — addresses that one rather than looking the project
......@@ -97,7 +89,6 @@ export function useNewThreadHandler() {
9789 getDraftSession,
9890 getDraftThread,
9991 applyStickyState,
100 moveComposerPromptAndImages,
10192 setDraftThreadContext,
10293 setLogicalProjectDraftThreadId,
10394 setModelSelection,
......@@ -138,39 +129,6 @@ export function useNewThreadHandler() {
138129 carrySourceShell?.interactionMode ??
139130 carrySourceDraft?.interactionMode ??
140131 null;
141 // Content only moves when the caller opted in and the user is looking
142 // at a draft. The content check happens at move time, not here: the
143 // paths below await, and text typed during those awaits must still
144 // come along.
145 const carryContentSourceDraftId =
146 options?.carryComposerContent === true && currentRouteTarget?.kind === "draft"
147 ? currentRouteTarget.draftId
148 : null;
149 const carryComposerContentTo = (destinationDraftId: DraftId) => {
150 if (
151 carryContentSourceDraftId &&
152 carryContentSourceDraftId !== destinationDraftId &&
153 // Never clobber a destination the user already invested in — the
154 // move overwrites the destination prompt, so a concurrent repo
155 // change that carried content first must win.
156 !composerDraftHasUserContent(getComposerDraft(destinationDraftId)) &&
157 composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId))
158 ) {
159 moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId);
160 // The move caps at the destination's free slots and skips
161 // duplicates, so images and files can both stay behind.
162 const remainingDraft = getComposerDraft(carryContentSourceDraftId);
163 const remainingCount =
164 (remainingDraft?.files.length ?? 0) + (remainingDraft?.images.length ?? 0);
165 if (remainingCount > 0) {
166 toastManager.add({
167 type: "warning",
168 title: `${remainingCount} attachment${remainingCount === 1 ? " stayed" : "s stayed"} in the original draft`,
169 description: "Return to the original draft or attach the files again.",
170 });
171 }
172 }
173 };
174132 const project = projects.find(
175133 (candidate) =>
176134 candidate.id === projectRef.projectId &&
......@@ -310,11 +268,7 @@ export function useNewThreadHandler() {
310268 // is looking at, because explicit picks are the only thing the
311269 // flag protects.
312270 const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId);
313 const storedActiveSelection = storedDraft?.activeProvider
314 ? storedDraft.modelSelectionByProvider[storedDraft.activeProvider]
315 : undefined;
316 const storedDraftHasExplicitModelPick =
317 Boolean(storedActiveSelection) && storedDraft?.modelSelectionExplicit === true;
271 const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft);
318272 if (!storedDraftHasExplicitModelPick) {
319273 applyStickyState(emptyStoredDraftThread.draftId);
320274 const modelSelectionOverride = resolveModelSelectionOverride(
......@@ -343,7 +297,6 @@ export function useNewThreadHandler() {
343297 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
344298 },
345299 );
346 carryComposerContentTo(emptyStoredDraftThread.draftId);
347300 const opened = {
348301 draftId: emptyStoredDraftThread.draftId,
349302 threadId: emptyStoredDraftThread.threadId,
......@@ -431,7 +384,6 @@ export function useNewThreadHandler() {
431384 interactionMode: racedDraft.interactionMode,
432385 ...pickExplicitWorkspaceOptions(options),
433386 });
434 carryComposerContentTo(racedDraft.draftId);
435387 await router.navigate({
436388 to: "/draft/$draftId",
437389 params: { draftId: racedDraft.draftId },
......@@ -461,8 +413,6 @@ export function useNewThreadHandler() {
461413 // state. The project default wins when both are present.
462414 setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true });
463415 }
464 carryComposerContentTo(draftId);
465
466416 await router.navigate({
467417 to: "/draft/$draftId",
468418 params: { draftId },
apps/web/src/lib/attachmentUploadQueue.test.ts+1-163
......@@ -1,5 +1,4 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1import { EnvironmentId } from "@t3tools/contracts";
32import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
43
54import {
......@@ -326,83 +325,6 @@ describe("attachmentUploadQueue", () => {
326325 }
327326 });
328327
329 it("persists a pending upload on the same-environment draft that receives its file", async () => {
330 const source = scopeThreadRef(firstEnvironment, ThreadId.make("thread-background-move-source"));
331 const destination = scopeThreadRef(
332 firstEnvironment,
333 ThreadId.make("thread-background-move-destination"),
334 );
335 const file = makeFile("background-move");
336 const store = useComposerDraftStore.getState();
337 store.addFiles(source, [file]);
338
339 try {
340 startAttachmentUpload({
341 environmentId: firstEnvironment,
342 image: file,
343 draftTarget: source,
344 });
345 await Promise.resolve();
346 store.moveComposerPromptAndImages(source, destination);
347 const sourceAfterMove = store.getComposerDraft(source);
348
349 // The destination never starts the existing job again. Its completion
350 // must find the file in current store state instead of the captured row.
351 const settled = awaitAttachmentUploads([file.id]);
352 TestXmlHttpRequest.requests[0]!.complete();
353 await settled;
354
355 expect(store.getComposerDraft(source)).toEqual(sourceAfterMove);
356 expect(store.getComposerDraft(destination)?.files).toMatchObject([
357 {
358 id: file.id,
359 uploadedAttachmentId: "pending-environment-1-background-move.pdf",
360 uploadEnvironmentId: firstEnvironment,
361 },
362 ]);
363 } finally {
364 store.clearComposerContent(source);
365 store.clearComposerContent(destination);
366 }
367 });
368
369 it("does not stamp an old-environment upload after its file moves environments", async () => {
370 const source = scopeThreadRef(
371 firstEnvironment,
372 ThreadId.make("thread-cross-environment-source"),
373 );
374 const destination = scopeThreadRef(
375 secondEnvironment,
376 ThreadId.make("thread-cross-environment-destination"),
377 );
378 const file = makeFile("cross-environment-move");
379 const store = useComposerDraftStore.getState();
380 store.addFiles(source, [file]);
381
382 try {
383 startAttachmentUpload({
384 environmentId: firstEnvironment,
385 image: file,
386 draftTarget: source,
387 });
388 await Promise.resolve();
389 store.moveComposerPromptAndImages(source, destination);
390
391 const settled = awaitAttachmentUploads([file.id]);
392 TestXmlHttpRequest.requests[0]!.complete();
393 await settled;
394
395 expect(store.getComposerDraft(source)).toBeNull();
396 const movedFile = store.getComposerDraft(destination)?.files[0];
397 expect(movedFile?.id).toBe(file.id);
398 expect(movedFile?.uploadedAttachmentId).toBeUndefined();
399 expect(movedFile?.uploadEnvironmentId).toBeUndefined();
400 } finally {
401 store.clearComposerContent(source);
402 store.clearComposerContent(destination);
403 }
404 });
405
406328 it("verifies an uploaded file reference before restoring it", async () => {
407329 const file: ComposerFileAttachment = {
408330 ...makeFile("restored"),
......@@ -849,90 +771,6 @@ describe("attachmentUploadQueue", () => {
849771 });
850772 });
851773
852 it("releases a moved file's source upload only after its destination upload succeeds", async () => {
853 const source = scopeThreadRef(firstEnvironment, ThreadId.make("thread-file-move-source"));
854 const destination = scopeThreadRef(
855 secondEnvironment,
856 ThreadId.make("thread-file-move-destination"),
857 );
858 const file = makeFile("moved-report");
859 const store = useComposerDraftStore.getState();
860 store.addFiles(source, [file]);
861
862 try {
863 startAttachmentUpload({
864 environmentId: firstEnvironment,
865 image: file,
866 draftTarget: source,
867 });
868 await Promise.resolve();
869 let settled = awaitAttachmentUploads([file.id]);
870 TestXmlHttpRequest.requests[0]!.complete();
871 await settled;
872
873 const sourceAttachmentId = store.getComposerDraft(source)?.files[0]?.uploadedAttachmentId;
874 expect(sourceAttachmentId).toBe("pending-environment-1-moved-report.pdf");
875
876 store.moveComposerPromptAndImages(source, destination);
877 const movedFile = store.getComposerDraft(destination)?.files[0];
878 expect(movedFile).toMatchObject({
879 id: file.id,
880 file: file.file,
881 });
882 expect(movedFile?.uploadedAttachmentId).toBeUndefined();
883 expect(movedFile?.uploadEnvironmentId).toBeUndefined();
884
885 startAttachmentUpload({
886 environmentId: secondEnvironment,
887 image: movedFile!,
888 draftTarget: destination,
889 });
890 await Promise.resolve();
891
892 const sourceDeletesBeforeDestinationUpload = mocks.runAtomCommand.mock.calls.filter(
893 ([, command, target]) =>
894 command === mocks.removeUpload &&
895 (
896 target as {
897 readonly environmentId: EnvironmentId;
898 readonly input: { readonly attachmentId: string };
899 }
900 ).environmentId === firstEnvironment &&
901 (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId ===
902 sourceAttachmentId,
903 );
904 expect(sourceDeletesBeforeDestinationUpload).toEqual([]);
905
906 settled = awaitAttachmentUploads([file.id]);
907 TestXmlHttpRequest.requests[1]!.complete();
908 await settled;
909
910 const sourceDeletesAfterDestinationUpload = mocks.runAtomCommand.mock.calls.filter(
911 ([, command, target]) =>
912 command === mocks.removeUpload &&
913 (
914 target as {
915 readonly environmentId: EnvironmentId;
916 readonly input: { readonly attachmentId: string };
917 }
918 ).environmentId === firstEnvironment &&
919 (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId ===
920 sourceAttachmentId,
921 );
922 expect(sourceDeletesAfterDestinationUpload).toHaveLength(1);
923 expect(store.getComposerDraft(destination)?.files).toMatchObject([
924 {
925 id: file.id,
926 uploadedAttachmentId: "pending-environment-2-moved-report.pdf",
927 uploadEnvironmentId: secondEnvironment,
928 },
929 ]);
930 } finally {
931 store.clearComposerContent(source);
932 store.clearComposerContent(destination);
933 }
934 });
935
936774 it("does not let stalled uploads block another environment", async () => {
937775 const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage);
938776 for (const image of images) {
apps/web/src/lib/chatThreadActions.test.ts+17
......@@ -8,6 +8,7 @@ import {
88import { describe, expect, it, vi } from "vite-plus/test";
99import {
1010 resolveThreadActionProjectRef,
11 hasExplicitComposerModelSelection,
1112 resolveNewDraftStartFromOrigin,
1213 resolveNewThreadModelSelectionOverride,
1314 startNewThreadFromContext,
......@@ -37,6 +38,22 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh
3738}
3839
3940describe("chatThreadActions", () => {
41 it("only treats an active stored selection marked explicit as an explicit pick", () => {
42 const draft = {
43 activeProvider: PROJECT_DEFAULT_SELECTION.instanceId,
44 modelSelectionByProvider: {
45 [PROJECT_DEFAULT_SELECTION.instanceId]: PROJECT_DEFAULT_SELECTION,
46 },
47 modelSelectionExplicit: true,
48 };
49
50 expect(hasExplicitComposerModelSelection(draft)).toBe(true);
51 expect(hasExplicitComposerModelSelection({ ...draft, modelSelectionExplicit: false })).toBe(
52 false,
53 );
54 expect(hasExplicitComposerModelSelection({ ...draft, activeProvider: null })).toBe(false);
55 });
56
4057 it("does not carry a non-explicit model from the destination draft back into itself", () => {
4158 expect(
4259 resolveNewThreadModelSelectionOverride({
apps/web/src/lib/chatThreadActions.ts+18-1
......@@ -5,7 +5,12 @@ import type {
55 ProjectId,
66 ScopedProjectRef,
77} from "@t3tools/contracts";
8import type { DraftThreadEnvMode } from "../composerDraftStore";
8import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore";
9
10type ComposerModelSelectionState = Pick<
11 ComposerThreadDraftState,
12 "activeProvider" | "modelSelectionByProvider" | "modelSelectionExplicit"
13>;
914
1015interface ThreadContextLike {
1116 environmentId: EnvironmentId;
......@@ -51,6 +56,18 @@ export function resolveNewThreadModelSelectionOverride(input: {
5156 );
5257}
5358
59export function hasExplicitComposerModelSelection(
60 draft: ComposerModelSelectionState | null | undefined,
61): boolean {
62 const activeProvider = draft?.activeProvider;
63 return (
64 draft?.modelSelectionExplicit === true &&
65 activeProvider !== null &&
66 activeProvider !== undefined &&
67 draft.modelSelectionByProvider[activeProvider] !== undefined
68 );
69}
70
5471export function resolveThreadActionProjectRef(
5572 context: ChatThreadActionContext,
5673): ScopedProjectRef | null {