| 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 | 7367 | } |
| 7368 | 7368 | > |
| 7369 | 7369 | <DraftHeroHeadline |
| 7370 | draftId={draftId} | |
| 7370 | 7371 | activeProjectRef={activeProjectRef} |
| 7371 | 7372 | activeProjectTitle={activeProject?.title ?? null} |
| 7372 | 7373 | /> |
apps/web/src/components/chat/DraftHeroHeadline.tsx+31-8| ... | ... | @@ -1,11 +1,13 @@ |
| 1 | import type { DraftId } from "~/composerDraftStore"; | |
| 2 | import { useComposerDraftStore } from "~/composerDraftStore"; | |
| 1 | 3 | import type { ScopedProjectRef } from "@t3tools/contracts"; |
| 2 | 4 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 3 | 5 | import { FolderPlusIcon } from "lucide-react"; |
| 4 | 6 | import { useCallback, useMemo } from "react"; |
| 5 | 7 | |
| 6 | 8 | import { openCommandPalette } from "~/commandPaletteBus"; |
| 7 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; | |
| 8 | 9 | import { useClientSettings } from "~/hooks/useSettings"; |
| 10 | import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; | |
| 9 | 11 | import { selectProjectGroupingSettings } from "~/logicalProject"; |
| 10 | 12 | import { |
| 11 | 13 | buildSidebarProjectPickerEntries, |
| ... | ... | @@ -26,11 +28,13 @@ import { |
| 26 | 28 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 27 | 29 | |
| 28 | 30 | interface DraftHeroHeadlineProps { |
| 31 | readonly draftId: DraftId | null; | |
| 29 | 32 | readonly activeProjectRef: ScopedProjectRef | null; |
| 30 | 33 | readonly activeProjectTitle: string | null; |
| 31 | 34 | } |
| 32 | 35 | |
| 33 | 36 | export function DraftHeroHeadline({ |
| 37 | draftId, | |
| 34 | 38 | activeProjectRef, |
| 35 | 39 | activeProjectTitle, |
| 36 | 40 | }: DraftHeroHeadlineProps) { |
| ... | ... | @@ -40,7 +44,12 @@ export function DraftHeroHeadline({ |
| 40 | 44 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 41 | 45 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 42 | 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 | 53 | const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); |
| 45 | 54 | |
| 46 | 55 | const environmentLabelById = useMemo( |
| ... | ... | @@ -126,12 +135,26 @@ export function DraftHeroHeadline({ |
| 126 | 135 | return; |
| 127 | 136 | } |
| 128 | 137 | 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 | } | |
| 135 | 158 | }} |
| 136 | 159 | > |
| 137 | 160 | {projectPickerEntries.map(({ group }) => { |
apps/web/src/composerDraftStore.test.ts+36-161| ... | ... | @@ -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 | 639 | describe("composerDraftStore syncPersistedAttachments", () => { |
| 801 | 640 | const threadId = ThreadId.make("thread-sync-persisted"); |
| 802 | 641 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); |
| ... | ... | @@ -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 | 1129 | it("rotates a failed bootstrap thread id without losing its draft", () => { |
| 1279 | 1130 | const store = useComposerDraftStore.getState(); |
| 1280 | 1131 | const retryThreadId = ThreadId.make("thread-retry"); |
| ... | ... | @@ -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 | 1582 | it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { |
| 1708 | 1583 | const store = useComposerDraftStore.getState(); |
| 1709 | 1584 | store.setProjectDraftThreadId(projectRef, draftId, { |
apps/web/src/composerDraftStore.ts+69-126| ... | ... | @@ -45,7 +45,6 @@ import { |
| 45 | 45 | type TerminalContextDraft, |
| 46 | 46 | ensureInlineTerminalContextPlaceholders, |
| 47 | 47 | normalizeTerminalContextText, |
| 48 | stripInlineTerminalContextPlaceholders, | |
| 49 | 48 | } from "./lib/terminalContext"; |
| 50 | 49 | import { |
| 51 | 50 | type ElementContextDraft, |
| ... | ... | @@ -116,6 +115,29 @@ export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean |
| 116 | 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 | 141 | export const PersistedComposerFileAttachment = Schema.Struct({ |
| 120 | 142 | id: Schema.String, |
| 121 | 143 | name: Schema.String, |
| ... | ... | @@ -435,7 +457,11 @@ interface ComposerDraftStoreState { |
| 435 | 457 | getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; |
| 436 | 458 | listDraftThreadKeys: () => string[]; |
| 437 | 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 | 465 | setLogicalProjectDraftThreadId: ( |
| 440 | 466 | logicalProjectKey: string, |
| 441 | 467 | projectRef: ScopedProjectRef, |
| ... | ... | @@ -606,13 +632,6 @@ interface ComposerDraftStoreState { |
| 606 | 632 | * prompt stash. Session-bound context stays in the source draft. |
| 607 | 633 | */ |
| 608 | 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 | 637 | export interface EffectiveComposerModelState { |
| ... | ... | @@ -2515,18 +2534,53 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2515 | 2534 | options, |
| 2516 | 2535 | ); |
| 2517 | 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 | 2548 | return state; |
| 2520 | 2549 | } |
| 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; | |
| 2525 | 2562 | const nextDraftThreadsByThreadKey: Record<string, DraftThreadState> = { |
| 2526 | 2563 | ...state.draftThreadsByThreadKey, |
| 2527 | 2564 | [draftId]: nextDraftThread, |
| 2528 | 2565 | }; |
| 2566 | const existingDraft = state.draftsByThreadKey[draftId]; | |
| 2529 | 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 | 2584 | const previousDraftThread = |
| 2531 | 2585 | previousThreadKeyForLogicalProject === undefined |
| 2532 | 2586 | ? undefined |
| ... | ... | @@ -2550,7 +2604,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2550 | 2604 | ) { |
| 2551 | 2605 | delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; |
| 2552 | 2606 | if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { |
| 2553 | nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2607 | nextDraftsByThreadKey = { ...nextDraftsByThreadKey }; | |
| 2554 | 2608 | delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject]; |
| 2555 | 2609 | } |
| 2556 | 2610 | } |
| ... | ... | @@ -3805,117 +3859,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3805 | 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 | 25 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; |
| 26 | 26 | import { readThreadShell, useProjects, useThread } from "../state/entities"; |
| 27 | 27 | import { |
| 28 | hasExplicitComposerModelSelection, | |
| 28 | 29 | resolveNewDraftStartFromOrigin, |
| 29 | 30 | resolveNewThreadModelSelectionOverride, |
| 30 | 31 | } from "../lib/chatThreadActions"; |
| ... | ... | @@ -33,7 +34,6 @@ import { primaryServerSettingsAtom } from "../state/server"; |
| 33 | 34 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 34 | 35 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 35 | 36 | import { useClientSettings } from "./useSettings"; |
| 36 | import { toastManager } from "../components/ui/toast"; | |
| 37 | 37 | |
| 38 | 38 | interface NewThreadWorkspaceOptions { |
| 39 | 39 | branch?: string | null; |
| ... | ... | @@ -78,14 +78,6 @@ export function useNewThreadHandler() { |
| 78 | 78 | envMode?: DraftThreadEnvMode; |
| 79 | 79 | startFromOrigin?: boolean; |
| 80 | 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 | 82 | // Which draft the thread ended up in, so a caller that has something to put in it — a |
| 91 | 83 | // prepared checkout, a task to write — addresses that one rather than looking the project |
| ... | ... | @@ -97,7 +89,6 @@ export function useNewThreadHandler() { |
| 97 | 89 | getDraftSession, |
| 98 | 90 | getDraftThread, |
| 99 | 91 | applyStickyState, |
| 100 | moveComposerPromptAndImages, | |
| 101 | 92 | setDraftThreadContext, |
| 102 | 93 | setLogicalProjectDraftThreadId, |
| 103 | 94 | setModelSelection, |
| ... | ... | @@ -138,39 +129,6 @@ export function useNewThreadHandler() { |
| 138 | 129 | carrySourceShell?.interactionMode ?? |
| 139 | 130 | carrySourceDraft?.interactionMode ?? |
| 140 | 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 | 132 | const project = projects.find( |
| 175 | 133 | (candidate) => |
| 176 | 134 | candidate.id === projectRef.projectId && |
| ... | ... | @@ -310,11 +268,7 @@ export function useNewThreadHandler() { |
| 310 | 268 | // is looking at, because explicit picks are the only thing the |
| 311 | 269 | // flag protects. |
| 312 | 270 | 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); | |
| 318 | 272 | if (!storedDraftHasExplicitModelPick) { |
| 319 | 273 | applyStickyState(emptyStoredDraftThread.draftId); |
| 320 | 274 | const modelSelectionOverride = resolveModelSelectionOverride( |
| ... | ... | @@ -343,7 +297,6 @@ export function useNewThreadHandler() { |
| 343 | 297 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 344 | 298 | }, |
| 345 | 299 | ); |
| 346 | carryComposerContentTo(emptyStoredDraftThread.draftId); | |
| 347 | 300 | const opened = { |
| 348 | 301 | draftId: emptyStoredDraftThread.draftId, |
| 349 | 302 | threadId: emptyStoredDraftThread.threadId, |
| ... | ... | @@ -431,7 +384,6 @@ export function useNewThreadHandler() { |
| 431 | 384 | interactionMode: racedDraft.interactionMode, |
| 432 | 385 | ...pickExplicitWorkspaceOptions(options), |
| 433 | 386 | }); |
| 434 | carryComposerContentTo(racedDraft.draftId); | |
| 435 | 387 | await router.navigate({ |
| 436 | 388 | to: "/draft/$draftId", |
| 437 | 389 | params: { draftId: racedDraft.draftId }, |
| ... | ... | @@ -461,8 +413,6 @@ export function useNewThreadHandler() { |
| 461 | 413 | // state. The project default wins when both are present. |
| 462 | 414 | setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); |
| 463 | 415 | } |
| 464 | carryComposerContentTo(draftId); | |
| 465 | ||
| 466 | 416 | await router.navigate({ |
| 467 | 417 | to: "/draft/$draftId", |
| 468 | 418 | params: { draftId }, |
apps/web/src/lib/attachmentUploadQueue.test.ts+1-163| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 3 | |
| 5 | 4 | import { |
| ... | ... | @@ -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 | 328 | it("verifies an uploaded file reference before restoring it", async () => { |
| 407 | 329 | const file: ComposerFileAttachment = { |
| 408 | 330 | ...makeFile("restored"), |
| ... | ... | @@ -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 | 774 | it("does not let stalled uploads block another environment", async () => { |
| 937 | 775 | const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage); |
| 938 | 776 | for (const image of images) { |
apps/web/src/lib/chatThreadActions.test.ts+17| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 9 | 9 | import { |
| 10 | 10 | resolveThreadActionProjectRef, |
| 11 | hasExplicitComposerModelSelection, | |
| 11 | 12 | resolveNewDraftStartFromOrigin, |
| 12 | 13 | resolveNewThreadModelSelectionOverride, |
| 13 | 14 | startNewThreadFromContext, |
| ... | ... | @@ -37,6 +38,22 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh |
| 37 | 38 | } |
| 38 | 39 | |
| 39 | 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 | 57 | it("does not carry a non-explicit model from the destination draft back into itself", () => { |
| 41 | 58 | expect( |
| 42 | 59 | resolveNewThreadModelSelectionOverride({ |
apps/web/src/lib/chatThreadActions.ts+18-1| ... | ... | @@ -5,7 +5,12 @@ import type { |
| 5 | 5 | ProjectId, |
| 6 | 6 | ScopedProjectRef, |
| 7 | 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 | 15 | interface ThreadContextLike { |
| 11 | 16 | environmentId: EnvironmentId; |
| ... | ... | @@ -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 | 71 | export function resolveThreadActionProjectRef( |
| 55 | 72 | context: ChatThreadActionContext, |
| 56 | 73 | ): ScopedProjectRef | null { |