| author | |
| committer | |
| log | 3b3465f2a9dbc541a8806e39d2889abb77b93f5d |
| tree | 7614b8a1c830631900e7f1d47dd7d04ae0666605 |
| parent | 692eb1a5792b9930959b19805acf2bf2611318c9 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 175 insertions(+), 511 deletions(-)
apps/web/src/components/ChatView.tsx+1| ... | @@ -7367,6 +7367,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7367,6 +7367,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7367 | } | 7367 | } |
| 7368 | > | 7368 | > |
| 7369 | <DraftHeroHeadline | 7369 | <DraftHeroHeadline |
| 7370 | draftId={draftId} | ||
| 7370 | activeProjectRef={activeProjectRef} | 7371 | activeProjectRef={activeProjectRef} |
| 7371 | activeProjectTitle={activeProject?.title ?? null} | 7372 | activeProjectTitle={activeProject?.title ?? null} |
| 7372 | /> | 7373 | /> |
apps/web/src/components/chat/DraftHeroHeadline.tsx+31-8| ... | @@ -1,11 +1,13 @@ | ... | @@ -1,11 +1,13 @@ |
| 1 | import type { DraftId } from "~/composerDraftStore"; | ||
| 2 | import { useComposerDraftStore } from "~/composerDraftStore"; | ||
| 1 | import type { ScopedProjectRef } from "@t3tools/contracts"; | 3 | import type { ScopedProjectRef } from "@t3tools/contracts"; |
| 2 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; | 4 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { FolderPlusIcon } from "lucide-react"; | 5 | import { FolderPlusIcon } from "lucide-react"; |
| 4 | import { useCallback, useMemo } from "react"; | 6 | import { useCallback, useMemo } from "react"; |
| 5 | 7 | ||
| 6 | import { openCommandPalette } from "~/commandPaletteBus"; | 8 | import { openCommandPalette } from "~/commandPaletteBus"; |
| 7 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; | ||
| 8 | import { useClientSettings } from "~/hooks/useSettings"; | 9 | import { useClientSettings } from "~/hooks/useSettings"; |
| 10 | import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; | ||
| 9 | import { selectProjectGroupingSettings } from "~/logicalProject"; | 11 | import { selectProjectGroupingSettings } from "~/logicalProject"; |
| 10 | import { | 12 | import { |
| 11 | buildSidebarProjectPickerEntries, | 13 | buildSidebarProjectPickerEntries, |
| ... | @@ -26,11 +28,13 @@ import { | ... | @@ -26,11 +28,13 @@ import { |
| 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 28 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 27 | 29 | ||
| 28 | interface DraftHeroHeadlineProps { | 30 | interface DraftHeroHeadlineProps { |
| 31 | readonly draftId: DraftId | null; | ||
| 29 | readonly activeProjectRef: ScopedProjectRef | null; | 32 | readonly activeProjectRef: ScopedProjectRef | null; |
| 30 | readonly activeProjectTitle: string | null; | 33 | readonly activeProjectTitle: string | null; |
| 31 | } | 34 | } |
| 32 | 35 | ||
| 33 | export function DraftHeroHeadline({ | 36 | export function DraftHeroHeadline({ |
| 37 | draftId, | ||
| 34 | activeProjectRef, | 38 | activeProjectRef, |
| 35 | activeProjectTitle, | 39 | activeProjectTitle, |
| 36 | }: DraftHeroHeadlineProps) { | 40 | }: DraftHeroHeadlineProps) { |
| ... | @@ -40,7 +44,12 @@ export function DraftHeroHeadline({ | ... | @@ -40,7 +44,12 @@ export function DraftHeroHeadline({ |
| 40 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 44 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 41 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | 45 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 42 | const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); | 46 | 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); | ||
| 44 | const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); | 53 | const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); |
| 45 | 54 | ||
| 46 | const environmentLabelById = useMemo( | 55 | const environmentLabelById = useMemo( |
| ... | @@ -126,12 +135,26 @@ export function DraftHeroHeadline({ | ... | @@ -126,12 +135,26 @@ export function DraftHeroHeadline({ |
| 126 | return; | 135 | return; |
| 127 | } | 136 | } |
| 128 | const project = entry.targetProject; | 137 | const project = entry.targetProject; |
| 129 | // Changing the repo of a draft moves the typed content along: | 138 | if (!draftId) { |
| 130 | // the user started writing in the wrong project, not a new task. | 139 | return; |
| 131 | void handleNewThread(scopeProjectRef(project.environmentId, project.id), { | 140 | } |
| 132 | replace: true, | 141 | // Project selection changes the target of the open draft in |
| 133 | carryComposerContent: true, | 142 | // place. The prompt stays in the same composer session, so the |
| 134 | }); | 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 | } | ||
| 135 | }} | 158 | }} |
| 136 | > | 159 | > |
| 137 | {projectPickerEntries.map(({ group }) => { | 160 | {projectPickerEntries.map(({ group }) => { |
apps/web/src/composerDraftStore.test.ts+36-161| ... | @@ -636,167 +636,6 @@ describe("composerDraftStore file attachments", () => { | ... | @@ -636,167 +636,6 @@ describe("composerDraftStore file attachments", () => { |
| 636 | }); | 636 | }); |
| 637 | }); | 637 | }); |
| 638 | 638 | ||
| 639 | describe("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 | |||
| 800 | describe("composerDraftStore syncPersistedAttachments", () => { | 639 | describe("composerDraftStore syncPersistedAttachments", () => { |
| 801 | const threadId = ThreadId.make("thread-sync-persisted"); | 640 | const threadId = ThreadId.make("thread-sync-persisted"); |
| 802 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | 641 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); |
| ... | @@ -1275,6 +1114,18 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -1275,6 +1114,18 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 1275 | }); | 1114 | }); |
| 1276 | }); | 1115 | }); |
| 1277 | 1116 | ||
| 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 | |||
| 1278 | it("rotates a failed bootstrap thread id without losing its draft", () => { | 1129 | it("rotates a failed bootstrap thread id without losing its draft", () => { |
| 1279 | const store = useComposerDraftStore.getState(); | 1130 | const store = useComposerDraftStore.getState(); |
| 1280 | const retryThreadId = ThreadId.make("thread-retry"); | 1131 | const retryThreadId = ThreadId.make("thread-retry"); |
| ... | @@ -1704,6 +1555,30 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -1704,6 +1555,30 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 1704 | }); | 1555 | }); |
| 1705 | }); | 1556 | }); |
| 1706 | 1557 | ||
| 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 | |||
| 1707 | it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { | 1582 | it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { |
| 1708 | const store = useComposerDraftStore.getState(); | 1583 | const store = useComposerDraftStore.getState(); |
| 1709 | store.setProjectDraftThreadId(projectRef, draftId, { | 1584 | store.setProjectDraftThreadId(projectRef, draftId, { |
apps/web/src/composerDraftStore.ts+69-126| ... | @@ -45,7 +45,6 @@ import { | ... | @@ -45,7 +45,6 @@ import { |
| 45 | type TerminalContextDraft, | 45 | type TerminalContextDraft, |
| 46 | ensureInlineTerminalContextPlaceholders, | 46 | ensureInlineTerminalContextPlaceholders, |
| 47 | normalizeTerminalContextText, | 47 | normalizeTerminalContextText, |
| 48 | stripInlineTerminalContextPlaceholders, | ||
| 49 | } from "./lib/terminalContext"; | 48 | } from "./lib/terminalContext"; |
| 50 | import { | 49 | import { |
| 51 | type ElementContextDraft, | 50 | type ElementContextDraft, |
| ... | @@ -116,6 +115,29 @@ export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean | ... | @@ -116,6 +115,29 @@ export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean |
| 116 | return file.file === null && file.uploadedAttachmentId === undefined; | 115 | return file.file === null && file.uploadedAttachmentId === undefined; |
| 117 | } | 116 | } |
| 118 | 117 | ||
| 118 | function 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 | |||
| 119 | export const PersistedComposerFileAttachment = Schema.Struct({ | 141 | export const PersistedComposerFileAttachment = Schema.Struct({ |
| 120 | id: Schema.String, | 142 | id: Schema.String, |
| 121 | name: Schema.String, | 143 | name: Schema.String, |
| ... | @@ -435,7 +457,11 @@ interface ComposerDraftStoreState { | ... | @@ -435,7 +457,11 @@ interface ComposerDraftStoreState { |
| 435 | getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; | 457 | getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; |
| 436 | listDraftThreadKeys: () => string[]; | 458 | listDraftThreadKeys: () => string[]; |
| 437 | hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; | 459 | 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 | */ | ||
| 439 | setLogicalProjectDraftThreadId: ( | 465 | setLogicalProjectDraftThreadId: ( |
| 440 | logicalProjectKey: string, | 466 | logicalProjectKey: string, |
| 441 | projectRef: ScopedProjectRef, | 467 | projectRef: ScopedProjectRef, |
| ... | @@ -606,13 +632,6 @@ interface ComposerDraftStoreState { | ... | @@ -606,13 +632,6 @@ interface ComposerDraftStoreState { |
| 606 | * prompt stash. Session-bound context stays in the source draft. | 632 | * prompt stash. Session-bound context stays in the source draft. |
| 607 | */ | 633 | */ |
| 608 | clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; | 634 | 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; | ||
| 616 | } | 635 | } |
| 617 | 636 | ||
| 618 | export interface EffectiveComposerModelState { | 637 | export interface EffectiveComposerModelState { |
| ... | @@ -2515,18 +2534,53 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2515,18 +2534,53 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2515 | options, | 2534 | options, |
| 2516 | ); | 2535 | ); |
| 2517 | const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId; | 2536 | 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 | ) { | ||
| 2519 | return state; | 2548 | return state; |
| 2520 | } | 2549 | } |
| 2521 | const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = { | 2550 | // A draft session belongs to one logical project at a time. When |
| 2522 | ...state.logicalProjectDraftThreadKeyByLogicalProjectKey, | 2551 | // an open draft is retargeted in place, remove any old mapping |
| 2523 | [normalizedLogicalProjectKey]: draftId, | 2552 | // for that same draft so the previous project cannot resolve it. |
| 2524 | }; | 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; | ||
| 2525 | const nextDraftThreadsByThreadKey: Record<string, DraftThreadState> = { | 2562 | const nextDraftThreadsByThreadKey: Record<string, DraftThreadState> = { |
| 2526 | ...state.draftThreadsByThreadKey, | 2563 | ...state.draftThreadsByThreadKey, |
| 2527 | [draftId]: nextDraftThread, | 2564 | [draftId]: nextDraftThread, |
| 2528 | }; | 2565 | }; |
| 2566 | const existingDraft = state.draftsByThreadKey[draftId]; | ||
| 2529 | let nextDraftsByThreadKey = state.draftsByThreadKey; | 2567 | 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 | } | ||
| 2530 | const previousDraftThread = | 2584 | const previousDraftThread = |
| 2531 | previousThreadKeyForLogicalProject === undefined | 2585 | previousThreadKeyForLogicalProject === undefined |
| 2532 | ? undefined | 2586 | ? undefined |
| ... | @@ -2550,7 +2604,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2550,7 +2604,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2550 | ) { | 2604 | ) { |
| 2551 | delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; | 2605 | delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; |
| 2552 | if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { | 2606 | if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { |
| 2553 | nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | 2607 | nextDraftsByThreadKey = { ...nextDraftsByThreadKey }; |
| 2554 | delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject]; | 2608 | delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject]; |
| 2555 | } | 2609 | } |
| 2556 | } | 2610 | } |
| ... | @@ -3805,117 +3859,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -3805,117 +3859,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3805 | return { draftsByThreadKey: nextDraftsByThreadKey }; | 3859 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 3806 | }); | 3860 | }); |
| 3807 | }, | 3861 | }, |
| 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 | }, | ||
| 3919 | }; | 3862 | }; |
| 3920 | }, | 3863 | }, |
| 3921 | { | 3864 | { |
apps/web/src/hooks/useHandleNewThread.ts+2-52| ... | @@ -25,6 +25,7 @@ import { | ... | @@ -25,6 +25,7 @@ import { |
| 25 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; | 25 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; |
| 26 | import { readThreadShell, useProjects, useThread } from "../state/entities"; | 26 | import { readThreadShell, useProjects, useThread } from "../state/entities"; |
| 27 | import { | 27 | import { |
| 28 | hasExplicitComposerModelSelection, | ||
| 28 | resolveNewDraftStartFromOrigin, | 29 | resolveNewDraftStartFromOrigin, |
| 29 | resolveNewThreadModelSelectionOverride, | 30 | resolveNewThreadModelSelectionOverride, |
| 30 | } from "../lib/chatThreadActions"; | 31 | } from "../lib/chatThreadActions"; |
| ... | @@ -33,7 +34,6 @@ import { primaryServerSettingsAtom } from "../state/server"; | ... | @@ -33,7 +34,6 @@ import { primaryServerSettingsAtom } from "../state/server"; |
| 33 | import { resolveThreadRouteTarget } from "../threadRoutes"; | 34 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 34 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | 35 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 35 | import { useClientSettings } from "./useSettings"; | 36 | import { useClientSettings } from "./useSettings"; |
| 36 | import { toastManager } from "../components/ui/toast"; | ||
| 37 | 37 | ||
| 38 | interface NewThreadWorkspaceOptions { | 38 | interface NewThreadWorkspaceOptions { |
| 39 | branch?: string | null; | 39 | branch?: string | null; |
| ... | @@ -78,14 +78,6 @@ export function useNewThreadHandler() { | ... | @@ -78,14 +78,6 @@ export function useNewThreadHandler() { |
| 78 | envMode?: DraftThreadEnvMode; | 78 | envMode?: DraftThreadEnvMode; |
| 79 | startFromOrigin?: boolean; | 79 | startFromOrigin?: boolean; |
| 80 | replace?: boolean; | 80 | 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; | ||
| 89 | }, | 81 | }, |
| 90 | // Which draft the thread ended up in, so a caller that has something to put in it — a | 82 | // Which draft the thread ended up in, so a caller that has something to put in it — a |
| 91 | // prepared checkout, a task to write — addresses that one rather than looking the project | 83 | // prepared checkout, a task to write — addresses that one rather than looking the project |
| ... | @@ -97,7 +89,6 @@ export function useNewThreadHandler() { | ... | @@ -97,7 +89,6 @@ export function useNewThreadHandler() { |
| 97 | getDraftSession, | 89 | getDraftSession, |
| 98 | getDraftThread, | 90 | getDraftThread, |
| 99 | applyStickyState, | 91 | applyStickyState, |
| 100 | moveComposerPromptAndImages, | ||
| 101 | setDraftThreadContext, | 92 | setDraftThreadContext, |
| 102 | setLogicalProjectDraftThreadId, | 93 | setLogicalProjectDraftThreadId, |
| 103 | setModelSelection, | 94 | setModelSelection, |
| ... | @@ -138,39 +129,6 @@ export function useNewThreadHandler() { | ... | @@ -138,39 +129,6 @@ export function useNewThreadHandler() { |
| 138 | carrySourceShell?.interactionMode ?? | 129 | carrySourceShell?.interactionMode ?? |
| 139 | carrySourceDraft?.interactionMode ?? | 130 | carrySourceDraft?.interactionMode ?? |
| 140 | null; | 131 | 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 | }; | ||
| 174 | const project = projects.find( | 132 | const project = projects.find( |
| 175 | (candidate) => | 133 | (candidate) => |
| 176 | candidate.id === projectRef.projectId && | 134 | candidate.id === projectRef.projectId && |
| ... | @@ -310,11 +268,7 @@ export function useNewThreadHandler() { | ... | @@ -310,11 +268,7 @@ export function useNewThreadHandler() { |
| 310 | // is looking at, because explicit picks are the only thing the | 268 | // is looking at, because explicit picks are the only thing the |
| 311 | // flag protects. | 269 | // flag protects. |
| 312 | const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); | 270 | const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); |
| 313 | const storedActiveSelection = storedDraft?.activeProvider | 271 | const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft); |
| 314 | ? storedDraft.modelSelectionByProvider[storedDraft.activeProvider] | ||
| 315 | : undefined; | ||
| 316 | const storedDraftHasExplicitModelPick = | ||
| 317 | Boolean(storedActiveSelection) && storedDraft?.modelSelectionExplicit === true; | ||
| 318 | if (!storedDraftHasExplicitModelPick) { | 272 | if (!storedDraftHasExplicitModelPick) { |
| 319 | applyStickyState(emptyStoredDraftThread.draftId); | 273 | applyStickyState(emptyStoredDraftThread.draftId); |
| 320 | const modelSelectionOverride = resolveModelSelectionOverride( | 274 | const modelSelectionOverride = resolveModelSelectionOverride( |
| ... | @@ -343,7 +297,6 @@ export function useNewThreadHandler() { | ... | @@ -343,7 +297,6 @@ export function useNewThreadHandler() { |
| 343 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | 297 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 344 | }, | 298 | }, |
| 345 | ); | 299 | ); |
| 346 | carryComposerContentTo(emptyStoredDraftThread.draftId); | ||
| 347 | const opened = { | 300 | const opened = { |
| 348 | draftId: emptyStoredDraftThread.draftId, | 301 | draftId: emptyStoredDraftThread.draftId, |
| 349 | threadId: emptyStoredDraftThread.threadId, | 302 | threadId: emptyStoredDraftThread.threadId, |
| ... | @@ -431,7 +384,6 @@ export function useNewThreadHandler() { | ... | @@ -431,7 +384,6 @@ export function useNewThreadHandler() { |
| 431 | interactionMode: racedDraft.interactionMode, | 384 | interactionMode: racedDraft.interactionMode, |
| 432 | ...pickExplicitWorkspaceOptions(options), | 385 | ...pickExplicitWorkspaceOptions(options), |
| 433 | }); | 386 | }); |
| 434 | carryComposerContentTo(racedDraft.draftId); | ||
| 435 | await router.navigate({ | 387 | await router.navigate({ |
| 436 | to: "/draft/$draftId", | 388 | to: "/draft/$draftId", |
| 437 | params: { draftId: racedDraft.draftId }, | 389 | params: { draftId: racedDraft.draftId }, |
| ... | @@ -461,8 +413,6 @@ export function useNewThreadHandler() { | ... | @@ -461,8 +413,6 @@ export function useNewThreadHandler() { |
| 461 | // state. The project default wins when both are present. | 413 | // state. The project default wins when both are present. |
| 462 | setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); | 414 | setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); |
| 463 | } | 415 | } |
| 464 | carryComposerContentTo(draftId); | ||
| 465 | |||
| 466 | await router.navigate({ | 416 | await router.navigate({ |
| 467 | to: "/draft/$draftId", | 417 | to: "/draft/$draftId", |
| 468 | params: { draftId }, | 418 | params: { draftId }, |
apps/web/src/lib/attachmentUploadQueue.test.ts+1-163| ... | @@ -1,5 +1,4 @@ | ... | @@ -1,5 +1,4 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 3 | ||
| 5 | import { | 4 | import { |
| ... | @@ -326,83 +325,6 @@ describe("attachmentUploadQueue", () => { | ... | @@ -326,83 +325,6 @@ describe("attachmentUploadQueue", () => { |
| 326 | } | 325 | } |
| 327 | }); | 326 | }); |
| 328 | 327 | ||
| 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 | |||
| 406 | it("verifies an uploaded file reference before restoring it", async () => { | 328 | it("verifies an uploaded file reference before restoring it", async () => { |
| 407 | const file: ComposerFileAttachment = { | 329 | const file: ComposerFileAttachment = { |
| 408 | ...makeFile("restored"), | 330 | ...makeFile("restored"), |
| ... | @@ -849,90 +771,6 @@ describe("attachmentUploadQueue", () => { | ... | @@ -849,90 +771,6 @@ describe("attachmentUploadQueue", () => { |
| 849 | }); | 771 | }); |
| 850 | }); | 772 | }); |
| 851 | 773 | ||
| 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 | |||
| 936 | it("does not let stalled uploads block another environment", async () => { | 774 | it("does not let stalled uploads block another environment", async () => { |
| 937 | const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage); | 775 | const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage); |
| 938 | for (const image of images) { | 776 | for (const image of images) { |
apps/web/src/lib/chatThreadActions.test.ts+17| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | import { describe, expect, it, vi } from "vite-plus/test"; | 8 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 9 | import { | 9 | import { |
| 10 | resolveThreadActionProjectRef, | 10 | resolveThreadActionProjectRef, |
| 11 | hasExplicitComposerModelSelection, | ||
| 11 | resolveNewDraftStartFromOrigin, | 12 | resolveNewDraftStartFromOrigin, |
| 12 | resolveNewThreadModelSelectionOverride, | 13 | resolveNewThreadModelSelectionOverride, |
| 13 | startNewThreadFromContext, | 14 | startNewThreadFromContext, |
| ... | @@ -37,6 +38,22 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh | ... | @@ -37,6 +38,22 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh |
| 37 | } | 38 | } |
| 38 | 39 | ||
| 39 | describe("chatThreadActions", () => { | 40 | describe("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 | |||
| 40 | it("does not carry a non-explicit model from the destination draft back into itself", () => { | 57 | it("does not carry a non-explicit model from the destination draft back into itself", () => { |
| 41 | expect( | 58 | expect( |
| 42 | resolveNewThreadModelSelectionOverride({ | 59 | resolveNewThreadModelSelectionOverride({ |
apps/web/src/lib/chatThreadActions.ts+18-1| ... | @@ -5,7 +5,12 @@ import type { | ... | @@ -5,7 +5,12 @@ import type { |
| 5 | ProjectId, | 5 | ProjectId, |
| 6 | ScopedProjectRef, | 6 | ScopedProjectRef, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import type { DraftThreadEnvMode } from "../composerDraftStore"; | 8 | import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore"; |
| 9 | |||
| 10 | type ComposerModelSelectionState = Pick< | ||
| 11 | ComposerThreadDraftState, | ||
| 12 | "activeProvider" | "modelSelectionByProvider" | "modelSelectionExplicit" | ||
| 13 | >; | ||
| 9 | 14 | ||
| 10 | interface ThreadContextLike { | 15 | interface ThreadContextLike { |
| 11 | environmentId: EnvironmentId; | 16 | environmentId: EnvironmentId; |
| ... | @@ -51,6 +56,18 @@ export function resolveNewThreadModelSelectionOverride(input: { | ... | @@ -51,6 +56,18 @@ export function resolveNewThreadModelSelectionOverride(input: { |
| 51 | ); | 56 | ); |
| 52 | } | 57 | } |
| 53 | 58 | ||
| 59 | export 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 | |||
| 54 | export function resolveThreadActionProjectRef( | 71 | export function resolveThreadActionProjectRef( |
| 55 | context: ChatThreadActionContext, | 72 | context: ChatThreadActionContext, |
| 56 | ): ScopedProjectRef | null { | 73 | ): ScopedProjectRef | null { |