55 type DesktopSnapShotEvent ,
66 DEFAULT_CLIENT_SETTINGS ,
77 EnvironmentId ,
8+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS ,
89 ProjectId ,
910 ThreadId ,
1011} from "@t3tools/contracts" ;
@@ -18,11 +19,17 @@ import * as settings from "../../hooks/useSettings";
1819import * as newThread from "../../hooks/useHandleNewThread" ;
1920import * as snapShotSound from "../../lib/snapShotSound" ;
2021import { toastManager } from "../ui/toast" ;
22+ import * as imageCompression from "../../lib/imageCompression" ;
23+ import {
24+ questionAttachmentDraftId ,
25+ trackOpenQuestionAttachmentDraft ,
26+ } from "../../questionAttachments" ;
2127import {
2228 beginSnapShotAnimationWhenReady ,
2329 deliverSnapShot ,
2430 dismissFailedSnapShot ,
2531 resolveExistingSnapShotTarget ,
32+ resolveSnapShotAttachmentTarget ,
2633 resolveSnapShotTargetOnce ,
2734 resolveSnapShotDeliveryTarget ,
2835 SnapShotCoordinator ,
@@ -38,6 +45,7 @@ import {
3845vi . mock ( import ( "../../hooks/useSettings" ) , { spy : true } ) ;
3946vi . mock ( import ( "../../hooks/useHandleNewThread" ) , { spy : true } ) ;
4047vi . mock ( import ( "../../lib/snapShotSound" ) , { spy : true } ) ;
48+ vi . mock ( import ( "../../lib/imageCompression" ) , { spy : true } ) ;
4149
4250const storage = vi . hoisted ( ( ) => {
4351 const values = new Map < string , string > ( ) ;
@@ -537,7 +545,7 @@ describe("window capture delivery", () => {
537545 }
538546 } ) ;
539547 } ) ;
540- const delivery = deliverSnapShot ( bridge , item , target ) ;
548+ const delivery = deliverSnapShot ( bridge , item , ( ) => target ) ;
541549 await persisted ;
542550 await vi . advanceTimersByTimeAsync ( 0 ) ;
543551
@@ -605,6 +613,128 @@ describe("window capture target resolution", () => {
605613 } ) ;
606614} ) ;
607615
616+ describe ( "open question delivery" , ( ) => {
617+ const threadRef = scopeThreadRef ( environmentId , ThreadId . make ( "question-thread" ) ) ;
618+ const first = questionAttachmentDraftId ( environmentId , threadRef . threadId , "request-1" , "q1" ) ;
619+ const second = questionAttachmentDraftId ( environmentId , threadRef . threadId , "request-1" , "q2" ) ;
620+ const request = { draftId : first , requestKeys : [ first , second ] } ;
621+ const capture = {
622+ id : "12345678-1234-1234-1234-123456789abc" ,
623+ name : "window.png" ,
624+ mimeType : "image/png" as const ,
625+ sizeBytes : 3 ,
626+ dataUrl : "data:image/png;base64,AQID" ,
627+ source : {
628+ kind : "snap-shot" as const ,
629+ capturedAt : "2026-09-01T00:00:00.000Z" ,
630+ appName : "Editor" ,
631+ windowTitle : "main.ts" ,
632+ } ,
633+ } ;
634+ const makeBridge = ( readSnapShot : ( ) => Promise < typeof capture > = async ( ) => capture ) => {
635+ const acknowledgeSnapShot = vi . fn ( async ( ) => undefined ) ;
636+ const bridge = { readSnapShot, acknowledgeSnapShot } as unknown as DesktopSnapShotBridge ;
637+ return { bridge, acknowledgeSnapShot } ;
638+ } ;
639+
640+ it ( "attaches a capture to the open question draft and leaves the thread draft alone" , async ( ) => {
641+ const pins = new Map ( ) ;
642+ const untrack = trackOpenQuestionAttachmentDraft ( threadRef , request ) ;
643+ try {
644+ const { bridge, acknowledgeSnapShot } = makeBridge ( ) ;
645+ await deliverSnapShot ( bridge , capture , ( ) =>
646+ resolveSnapShotAttachmentTarget ( pins , capture . id , threadRef ) ,
647+ ) ;
648+ const store = useComposerDraftStore . getState ( ) ;
649+ expect ( store . getComposerDraft ( first ) ?. images . map ( ( { id } ) => id ) ) . toEqual ( [ capture . id ] ) ;
650+ expect ( store . getComposerDraft ( threadRef ) ?. images ?? [ ] ) . toHaveLength ( 0 ) ;
651+ expect ( acknowledgeSnapShot ) . toHaveBeenCalledExactlyOnceWith ( capture . id ) ;
652+ } finally {
653+ untrack ( ) ;
654+ }
655+ } ) ;
656+
657+ it ( "falls back to the thread when a newer question replaces the pinned one during compression" , async ( ) => {
658+ const pins = new Map ( ) ;
659+ const untrackFirst = trackOpenQuestionAttachmentDraft ( threadRef , request ) ;
660+ let untrackSecond = ( ) => { } ;
661+ const compression = vi
662+ . spyOn ( imageCompression , "compressImageToByteLimit" )
663+ . mockImplementationOnce ( async ( file ) => {
664+ untrackSecond = trackOpenQuestionAttachmentDraft ( threadRef , {
665+ ...request ,
666+ draftId : second ,
667+ } ) ;
668+ untrackFirst ( ) ;
669+ return { ok : true , file, recompressed : false } ;
670+ } ) ;
671+ try {
672+ const { bridge, acknowledgeSnapShot } = makeBridge ( ) ;
673+ const resolveTarget = ( ) => resolveSnapShotAttachmentTarget ( pins , capture . id , threadRef ) ;
674+ expect ( resolveTarget ( ) ) . toBe ( first ) ;
675+ await deliverSnapShot ( bridge , capture , resolveTarget ) ;
676+ const store = useComposerDraftStore . getState ( ) ;
677+ expect ( store . getComposerDraft ( first ) ?. images ?? [ ] ) . toHaveLength ( 0 ) ;
678+ expect ( store . getComposerDraft ( second ) ?. images ?? [ ] ) . toHaveLength ( 0 ) ;
679+ expect ( store . getComposerDraft ( threadRef ) ?. images . map ( ( { id } ) => id ) ) . toEqual ( [ capture . id ] ) ;
680+ expect ( acknowledgeSnapShot ) . toHaveBeenCalledExactlyOnceWith ( capture . id ) ;
681+ } finally {
682+ compression . mockRestore ( ) ;
683+ untrackFirst ( ) ;
684+ untrackSecond ( ) ;
685+ }
686+ } ) ;
687+
688+ it ( "keeps a capture pinned before any question off a question that opens during the read" , async ( ) => {
689+ const pins = new Map ( ) ;
690+ const resolveTarget = ( ) => resolveSnapShotAttachmentTarget ( pins , capture . id , threadRef ) ;
691+ // The drain pins a capture it never saw requested before reading it.
692+ expect ( resolveTarget ( ) ) . toEqual ( threadRef ) ;
693+ let untrack = ( ) => { } ;
694+ const { bridge, acknowledgeSnapShot } = makeBridge ( async ( ) => {
695+ untrack = trackOpenQuestionAttachmentDraft ( threadRef , request ) ;
696+ return capture ;
697+ } ) ;
698+ try {
699+ await deliverSnapShot ( bridge , capture , resolveTarget ) ;
700+ const store = useComposerDraftStore . getState ( ) ;
701+ expect ( store . getComposerDraft ( first ) ?. images ?? [ ] ) . toHaveLength ( 0 ) ;
702+ expect ( store . getComposerDraft ( threadRef ) ?. images . map ( ( { id } ) => id ) ) . toEqual ( [ capture . id ] ) ;
703+ expect ( acknowledgeSnapShot ) . toHaveBeenCalledExactlyOnceWith ( capture . id ) ;
704+ } finally {
705+ untrack ( ) ;
706+ }
707+ } ) ;
708+
709+ it ( "refuses a capture once the request's other questions hold the shared limit" , async ( ) => {
710+ useComposerDraftStore . getState ( ) . addImages (
711+ first ,
712+ Array . from ( { length : PROVIDER_SEND_TURN_MAX_ATTACHMENTS } , ( _ , index ) => ( {
713+ type : "image" as const ,
714+ id : `staged-${ index } ` ,
715+ name : `staged-${ index } .png` ,
716+ mimeType : "image/png" ,
717+ sizeBytes : 3 ,
718+ previewUrl : `data:image/png;base64,AQID${ index } ` ,
719+ file : new File ( [ new Uint8Array ( [ 1 , 2 , 3 ] ) ] , `staged-${ index } .png` , { type : "image/png" } ) ,
720+ } ) ) ,
721+ ) ;
722+ const untrack = trackOpenQuestionAttachmentDraft ( threadRef , { ...request , draftId : second } ) ;
723+ try {
724+ const { bridge, acknowledgeSnapShot } = makeBridge ( ) ;
725+ await expect ( deliverSnapShot ( bridge , capture , ( ) => second ) ) . rejects . toThrow (
726+ "Remove an attachment" ,
727+ ) ;
728+ expect ( useComposerDraftStore . getState ( ) . getComposerDraft ( second ) ?. images ?? [ ] ) . toHaveLength (
729+ 0 ,
730+ ) ;
731+ expect ( acknowledgeSnapShot ) . not . toHaveBeenCalled ( ) ;
732+ } finally {
733+ untrack ( ) ;
734+ }
735+ } ) ;
736+ } ) ;
737+
608738describe ( "durable snapshot delivery" , ( ) => {
609739 it . each ( [ false , true ] ) (
610740 "retains a capture on quota failure and retries without duplicates (staged: %s)" ,
@@ -643,15 +773,15 @@ describe("durable snapshot delivery", () => {
643773 } ) ;
644774 void store . syncPersistedAttachments ( target , [ capture ] ) ;
645775 }
646- await expect ( deliverSnapShot ( bridge , capture , target ) ) . rejects . toThrow (
776+ await expect ( deliverSnapShot ( bridge , capture , ( ) => target ) ) . rejects . toThrow (
647777 "could not be saved" ,
648778 ) ;
649779 expect ( acknowledgeSnapShot ) . not . toHaveBeenCalled ( ) ;
650780 expect (
651781 useComposerDraftStore . getState ( ) . getComposerDraft ( target ) ?. nonPersistedImageIds ,
652782 ) . toContain ( capture . id ) ;
653783 storage . setItem . mockImplementation ( write ) ;
654- await deliverSnapShot ( bridge , capture , target ) ;
784+ await deliverSnapShot ( bridge , capture , ( ) => target ) ;
655785 expect ( acknowledgeSnapShot ) . toHaveBeenCalledExactlyOnceWith ( capture . id ) ;
656786 expect ( useComposerDraftStore . getState ( ) . getComposerDraft ( target ) ?. images ) . toHaveLength ( 1 ) ;
657787 expect (
0 commit comments