@@ -262,6 +262,140 @@ describe("ChatPage composer integration", () => {
262262 expect ( screen . getByRole ( "button" , { name : "Stop generating" } ) ) . toBeInTheDocument ( ) ;
263263 } ) ;
264264
265+ it ( "moves drafts created during new-chat acceptance into the accepted thread" , async ( ) => {
266+ const user = userEvent . setup ( ) ;
267+ const lateAttachment : ChatAttachment = {
268+ attachment_id : "late-doc" ,
269+ filename : "next-turn.md" ,
270+ mime_type : "text/markdown" ,
271+ size : 9 ,
272+ sha256 : "1" . repeat ( 64 ) ,
273+ kind : "document" ,
274+ expires_at : "2099-01-01T00:00:00Z" ,
275+ } ;
276+ let accept ! : ( value : { job_id : string ; kind : "generation" ; status : "queued" ; thread_id : string ; user_message_id : string } ) => void ;
277+ const accepted = new Promise < { job_id : string ; kind : "generation" ; status : "queued" ; thread_id : string ; user_message_id : string } > ( ( resolve ) => { accept = resolve ; } ) ;
278+ const api = chatApi ( {
279+ chat : vi . fn ( async ( id : string ) => emptyChat ( id ) ) ,
280+ generate : vi . fn ( ( ) => accepted ) ,
281+ stageChatAttachment : vi . fn ( ) . mockResolvedValue ( lateAttachment ) ,
282+ } ) ;
283+ function RoutedChat ( ) {
284+ const [ threadId , setThreadId ] = useState < string | null > ( null ) ;
285+ return (
286+ < ChatPage
287+ api = { api }
288+ threadId = { threadId }
289+ runtimeReady
290+ runtimeMessage = { null }
291+ localModels = { [ "local-chat:7b" ] }
292+ selectedModel = "local-chat:7b"
293+ modelBusy = { false }
294+ onSelectModel = { async ( ) => true }
295+ onRescanModels = { async ( ) => undefined }
296+ onThreadCreated = { setThreadId }
297+ onChatChanged = { vi . fn ( ) }
298+ onForked = { vi . fn ( ) }
299+ />
300+ ) ;
301+ }
302+ render ( < RoutedChat /> ) ;
303+
304+ const composer = await screen . findByLabelText ( "Message Cortex" ) ;
305+ const attachmentInput = screen . getByLabelText ( "Attach images or documents" ) ;
306+ await user . type ( composer , "First turn" ) ;
307+ await user . click ( screen . getByRole ( "button" , { name : "Send message" } ) ) ;
308+ await waitFor ( ( ) => expect ( api . generate ) . toHaveBeenCalledTimes ( 1 ) ) ;
309+
310+ await user . clear ( composer ) ;
311+ await user . type ( composer , "Draft for the next turn" ) ;
312+ await user . upload ( attachmentInput , new File ( [ "next turn" ] , "next-turn.md" , { type : "text/markdown" } ) ) ;
313+ expect ( await screen . findByRole ( "button" , { name : "Remove next-turn.md" } ) ) . toBeInTheDocument ( ) ;
314+
315+ accept ( {
316+ job_id : "job-new" ,
317+ kind : "generation" ,
318+ status : "queued" ,
319+ thread_id : "thread-new" ,
320+ user_message_id : "message-new" ,
321+ } ) ;
322+
323+ await waitFor ( ( ) => expect ( screen . getByLabelText ( "Message Cortex" ) ) . toHaveValue ( "Draft for the next turn" ) ) ;
324+ expect ( screen . getByRole ( "button" , { name : "Remove next-turn.md" } ) ) . toBeInTheDocument ( ) ;
325+ expect ( window . sessionStorage . getItem ( "cortex.composer.draft.new" ) ) . toBeNull ( ) ;
326+ expect ( window . sessionStorage . getItem ( "cortex.composer.draft.thread-new" ) ) . toBe ( "Draft for the next turn" ) ;
327+ expect ( window . sessionStorage . getItem ( "cortex.composer.attachments.new" ) ) . toBeNull ( ) ;
328+ expect ( JSON . parse ( window . sessionStorage . getItem ( "cortex.composer.attachments.thread-new" ) ?? "[]" ) ) . toEqual ( [ lateAttachment ] ) ;
329+ } ) ;
330+
331+ it ( "retargets an in-flight new-chat attachment when acceptance wins the race" , async ( ) => {
332+ const user = userEvent . setup ( ) ;
333+ const stagedAttachment : ChatAttachment = {
334+ attachment_id : "inverse-order-doc" ,
335+ filename : "after-acceptance.md" ,
336+ mime_type : "text/markdown" ,
337+ size : 16 ,
338+ sha256 : "2" . repeat ( 64 ) ,
339+ kind : "document" ,
340+ expires_at : "2099-01-01T00:00:00Z" ,
341+ } ;
342+ let accept ! : ( value : { job_id : string ; kind : "generation" ; status : "queued" ; thread_id : string ; user_message_id : string } ) => void ;
343+ let finishStaging ! : ( value : ChatAttachment ) => void ;
344+ const accepted = new Promise < { job_id : string ; kind : "generation" ; status : "queued" ; thread_id : string ; user_message_id : string } > ( ( resolve ) => { accept = resolve ; } ) ;
345+ const staging = new Promise < ChatAttachment > ( ( resolve ) => { finishStaging = resolve ; } ) ;
346+ const api = chatApi ( {
347+ chat : vi . fn ( async ( id : string ) => emptyChat ( id ) ) ,
348+ generate : vi . fn ( ( ) => accepted ) ,
349+ stageChatAttachment : vi . fn ( ( ) => staging ) ,
350+ } ) ;
351+ function RoutedChat ( ) {
352+ const [ threadId , setThreadId ] = useState < string | null > ( null ) ;
353+ return (
354+ < ChatPage
355+ api = { api }
356+ threadId = { threadId }
357+ runtimeReady
358+ runtimeMessage = { null }
359+ localModels = { [ "local-chat:7b" ] }
360+ selectedModel = "local-chat:7b"
361+ modelBusy = { false }
362+ onSelectModel = { async ( ) => true }
363+ onRescanModels = { async ( ) => undefined }
364+ onThreadCreated = { setThreadId }
365+ onChatChanged = { vi . fn ( ) }
366+ onForked = { vi . fn ( ) }
367+ />
368+ ) ;
369+ }
370+ render ( < RoutedChat /> ) ;
371+
372+ const composer = await screen . findByLabelText ( "Message Cortex" ) ;
373+ await user . type ( composer , "First turn" ) ;
374+ await user . click ( screen . getByRole ( "button" , { name : "Send message" } ) ) ;
375+ await waitFor ( ( ) => expect ( api . generate ) . toHaveBeenCalledTimes ( 1 ) ) ;
376+ await user . upload (
377+ screen . getByLabelText ( "Attach images or documents" ) ,
378+ new File ( [ "after acceptance" ] , "after-acceptance.md" , { type : "text/markdown" } ) ,
379+ ) ;
380+ await waitFor ( ( ) => expect ( api . stageChatAttachment ) . toHaveBeenCalledTimes ( 1 ) ) ;
381+
382+ accept ( {
383+ job_id : "job-inverse" ,
384+ kind : "generation" ,
385+ status : "queued" ,
386+ thread_id : "thread-inverse" ,
387+ user_message_id : "message-inverse" ,
388+ } ) ;
389+ await waitFor ( ( ) => expect ( api . chat ) . toHaveBeenCalledWith ( "thread-inverse" ) ) ;
390+ expect ( window . sessionStorage . getItem ( "cortex.composer.attachments.new" ) ) . toBeNull ( ) ;
391+
392+ act ( ( ) => finishStaging ( stagedAttachment ) ) ;
393+
394+ expect ( await screen . findByRole ( "button" , { name : "Remove after-acceptance.md" } ) ) . toBeInTheDocument ( ) ;
395+ expect ( window . sessionStorage . getItem ( "cortex.composer.attachments.new" ) ) . toBeNull ( ) ;
396+ expect ( JSON . parse ( window . sessionStorage . getItem ( "cortex.composer.attachments.thread-inverse" ) ?? "[]" ) ) . toEqual ( [ stagedAttachment ] ) ;
397+ } ) ;
398+
265399 it ( "replays an active generation from the beginning after a remount" , async ( ) => {
266400 window . sessionStorage . setItem ( "cortex.active.generation" , JSON . stringify ( { jobId : "job-replay" , threadId : "thread-a" , lastEventId : 7 } ) ) ;
267401 const streamCalls : Array < { afterEventId ?: number } > = [ ] ;
@@ -429,6 +563,94 @@ describe("ChatPage composer integration", () => {
429563 expect ( screen . getByLabelText ( "Message Cortex" ) ) . toBeEnabled ( ) ;
430564 } ) ;
431565
566+ it ( "regenerates from the selected user turn instead of stale cross-chat or composer state" , async ( ) => {
567+ const user = userEvent . setup ( ) ;
568+ const originalAttachment : ChatAttachment = {
569+ attachment_id : "original-doc" ,
570+ filename : "original.md" ,
571+ mime_type : "text/markdown" ,
572+ size : 12 ,
573+ sha256 : "d" . repeat ( 64 ) ,
574+ kind : "document" ,
575+ expires_at : "2099-01-01T00:00:00Z" ,
576+ } ;
577+ const draftAttachment : ChatAttachment = {
578+ attachment_id : "next-draft-doc" ,
579+ filename : "next-draft.md" ,
580+ mime_type : "text/markdown" ,
581+ size : 14 ,
582+ sha256 : "e" . repeat ( 64 ) ,
583+ kind : "document" ,
584+ expires_at : "2099-01-01T00:00:00Z" ,
585+ } ;
586+ const threadA = emptyChat ( "thread-a" ) ;
587+ const threadB : ChatResponse = {
588+ ...emptyChat ( "thread-b" ) ,
589+ revision : 2 ,
590+ messages : [
591+ { id : "user-b" , role : "user" , content : "Prompt from B" , attachments : [ originalAttachment ] } ,
592+ { id : "assistant-b" , role : "assistant" , content : "Answer from B" } ,
593+ ] ,
594+ } ;
595+ const api = chatApi ( {
596+ chat : vi . fn ( async ( id : string ) => id === "thread-b" ? threadB : threadA ) ,
597+ generate : vi . fn ( ) . mockResolvedValue ( {
598+ job_id : "job-a" , kind : "generation" , status : "queued" , thread_id : "thread-a" , user_message_id : "user-a" ,
599+ } ) ,
600+ regenerate : vi . fn ( ) . mockResolvedValue ( {
601+ job_id : "job-b" , kind : "generation" , status : "queued" , thread_id : "thread-b" ,
602+ } ) ,
603+ stageChatAttachment : vi . fn ( ) . mockResolvedValue ( draftAttachment ) ,
604+ streamGeneration : vi . fn ( async ( jobId , onEvent ) => {
605+ const completedThreadId = jobId === "job-a" ? "thread-a" : "thread-b" ;
606+ onEvent ( {
607+ event_id : 1 ,
608+ event : "generation.completed" ,
609+ job_id : jobId ,
610+ thread_id : completedThreadId ,
611+ data : { } ,
612+ } ) ;
613+ } ) ,
614+ } ) ;
615+ const view = renderChat ( api , "thread-a" ) ;
616+ const composer = await screen . findByLabelText ( "Message Cortex" ) ;
617+ await user . type ( composer , "Prompt from A" ) ;
618+ await user . click ( screen . getByRole ( "button" , { name : "Send message" } ) ) ;
619+ await waitFor ( ( ) => expect ( window . sessionStorage . getItem ( "cortex.active.generation" ) ) . toBeNull ( ) ) ;
620+
621+ view . rerender (
622+ < ChatPage
623+ api = { api }
624+ threadId = "thread-b"
625+ runtimeReady
626+ runtimeMessage = { null }
627+ localModels = { [ "local-chat:7b" ] }
628+ selectedModel = "local-chat:7b"
629+ modelBusy = { false }
630+ onSelectModel = { async ( ) => true }
631+ onRescanModels = { async ( ) => undefined }
632+ onThreadCreated = { vi . fn ( ) }
633+ onChatChanged = { vi . fn ( ) }
634+ onForked = { vi . fn ( ) }
635+ /> ,
636+ ) ;
637+ expect ( await screen . findByText ( "Answer from B" ) ) . toBeInTheDocument ( ) ;
638+ await user . upload (
639+ screen . getByLabelText ( "Attach images or documents" ) ,
640+ new File ( [ "next" ] , "next-draft.md" , { type : "text/markdown" } ) ,
641+ ) ;
642+ expect ( await screen . findByRole ( "button" , { name : "Remove next-draft.md" } ) ) . toBeInTheDocument ( ) ;
643+
644+ await user . click ( screen . getByRole ( "button" , { name : "Regenerate response" } ) ) ;
645+
646+ await waitFor ( ( ) => expect ( api . regenerate ) . toHaveBeenCalledWith ( "thread-b" , expect . objectContaining ( {
647+ message_id : "assistant-b" ,
648+ user_input : "Prompt from B" ,
649+ attachments : [ originalAttachment ] ,
650+ } ) ) ) ;
651+ expect ( screen . getByRole ( "button" , { name : "Remove next-draft.md" } ) ) . toBeInTheDocument ( ) ;
652+ } ) ;
653+
432654 it ( "stages a document without putting its contents into the composer and sends its opaque metadata" , async ( ) => {
433655 const user = userEvent . setup ( ) ;
434656 const attachment : ChatAttachment = {
@@ -458,6 +680,51 @@ describe("ChatPage composer integration", () => {
458680 } ) ) ) ;
459681 } ) ;
460682
683+ it ( "keeps attachments staged while generation acceptance is pending" , async ( ) => {
684+ const user = userEvent . setup ( ) ;
685+ const firstAttachment : ChatAttachment = {
686+ attachment_id : "doc-first" ,
687+ filename : "first.md" ,
688+ mime_type : "text/markdown" ,
689+ size : 5 ,
690+ sha256 : "f" . repeat ( 64 ) ,
691+ kind : "document" ,
692+ expires_at : "2099-01-01T00:00:00Z" ,
693+ } ;
694+ const nextAttachment : ChatAttachment = {
695+ attachment_id : "doc-next" ,
696+ filename : "next.md" ,
697+ mime_type : "text/markdown" ,
698+ size : 4 ,
699+ sha256 : "0" . repeat ( 64 ) ,
700+ kind : "document" ,
701+ expires_at : "2099-01-01T00:00:00Z" ,
702+ } ;
703+ let accept ! : ( value : { job_id : string ; kind : "generation" ; status : "queued" ; thread_id : string ; user_message_id : string } ) => void ;
704+ const accepted = new Promise < { job_id : string ; kind : "generation" ; status : "queued" ; thread_id : string ; user_message_id : string } > ( ( resolve ) => { accept = resolve ; } ) ;
705+ const api = chatApi ( {
706+ stageChatAttachment : vi . fn ( )
707+ . mockResolvedValueOnce ( firstAttachment )
708+ . mockResolvedValueOnce ( nextAttachment ) ,
709+ generate : vi . fn ( ( ) => accepted ) ,
710+ } ) ;
711+ renderChat ( api ) ;
712+
713+ const attachmentInput = await screen . findByLabelText ( "Attach images or documents" ) ;
714+ await user . upload ( attachmentInput , new File ( [ "first" ] , "first.md" , { type : "text/markdown" } ) ) ;
715+ await screen . findByRole ( "button" , { name : "Remove first.md" } ) ;
716+ await user . click ( screen . getByRole ( "button" , { name : "Send message" } ) ) ;
717+ await waitFor ( ( ) => expect ( api . generate ) . toHaveBeenCalledTimes ( 1 ) ) ;
718+
719+ await user . upload ( attachmentInput , new File ( [ "next" ] , "next.md" , { type : "text/markdown" } ) ) ;
720+ expect ( await screen . findByRole ( "button" , { name : "Remove next.md" } ) ) . toBeInTheDocument ( ) ;
721+
722+ accept ( { job_id : "job-1" , kind : "generation" , status : "queued" , thread_id : "thread-a" , user_message_id : "message-1" } ) ;
723+
724+ await waitFor ( ( ) => expect ( screen . queryByRole ( "button" , { name : "Remove first.md" } ) ) . not . toBeInTheDocument ( ) ) ;
725+ expect ( screen . getByRole ( "button" , { name : "Remove next.md" } ) ) . toBeInTheDocument ( ) ;
726+ } ) ;
727+
461728 it ( "explains the image capability mismatch before a generation request is made" , async ( ) => {
462729 const user = userEvent . setup ( ) ;
463730 const attachment : ChatAttachment = {
0 commit comments