@@ -91,7 +91,9 @@ import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
9191import { MOTHERSHIP_STREAM_REPLAY_HEADER } from '@/lib/mothership/constants'
9292import type { MothershipStreamV1EventEnvelope } from '@/lib/mothership/generated/mothership-stream-v1'
9393import { getChatResourceSelectionId } from '@/lib/mothership/resources/types'
94+ import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
9495import { collectCitedMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources'
96+ import { ModelSelector } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
9597import {
9698 readQueuedSendHandoffState ,
9799 writeQueuedSendHandoffState ,
@@ -472,6 +474,77 @@ function renderHomeLikeSurface(): {
472474 }
473475}
474476
477+ /** Holds the chat POST of the first send until the test settles it. */
478+ function holdFirstSend ( ) : PromiseWithResolvers < Response > {
479+ const post = Promise . withResolvers < Response > ( )
480+ vi . stubGlobal ( 'fetch' , ( input : RequestInfo | URL , init ?: RequestInit ) => {
481+ if ( String ( input ) !== '/api/mothership/chat' || init ?. method !== 'POST' ) {
482+ return fetchStub ( input , init )
483+ }
484+ state . postBodies . push ( JSON . parse ( String ( init . body ) ) )
485+ return post . promise
486+ } )
487+ return post
488+ }
489+
490+ /**
491+ * Mounts a chatless surface shaped like `home.tsx`, with real composers: the empty-state one
492+ * swaps for the chat view's once messages show, and the chat view's names the resolved chat.
493+ */
494+ function renderComposerSwap ( ) : {
495+ container : HTMLElement
496+ getResult : ( ) => ReturnType < typeof useChat >
497+ shownEffort : ( ) => string | null | undefined
498+ visit : ( pathname : string ) => void
499+ } {
500+ ; ( globalThis as { IS_REACT_ACT_ENVIRONMENT ?: boolean } ) . IS_REACT_ACT_ENVIRONMENT = true
501+ useMothershipEffortStore . getState ( ) . reset ( )
502+ queryClient = new QueryClient ( { defaultOptions : { queries : { retry : false } } } )
503+ const container = document . createElement ( 'div' )
504+ const root = createRoot ( container )
505+ mountedRoots . push ( root )
506+ let result : ReturnType < typeof useChat > | undefined
507+
508+ function HomeLike ( ) {
509+ result = useChat ( 'ws-1' , undefined )
510+ return result . messages . length > 0 ? (
511+ < section key = 'chat' >
512+ < ChatSurfaceProvider chatId = { result . resolvedChatId } >
513+ < ModelSelector />
514+ </ ChatSurfaceProvider >
515+ </ section >
516+ ) : (
517+ < main key = 'empty' >
518+ < ModelSelector />
519+ </ main >
520+ )
521+ }
522+
523+ const render = ( ) =>
524+ act ( ( ) => {
525+ root . render (
526+ < QueryClientProvider client = { queryClient } >
527+ < HomeLike />
528+ </ QueryClientProvider >
529+ )
530+ } )
531+ render ( )
532+
533+ return {
534+ container,
535+ getResult : ( ) => {
536+ if ( result === undefined ) throw new Error ( 'Hook result is not ready' )
537+ return result
538+ } ,
539+ shownEffort : ( ) =>
540+ container . querySelector ( '[aria-label="Reasoning effort"]' ) ?. getAttribute ( 'aria-description' ) ,
541+ visit : ( pathname ) => {
542+ mockUsePathname . mockReturnValue ( pathname )
543+ render ( )
544+ } ,
545+ }
546+ }
547+
475548/**
476549 * Mounts the hook under StrictMode with a handoff already in storage, mirroring
477550 * `home.tsx`'s consume-and-auto-send effect. This is the production-shaped
@@ -4701,6 +4774,96 @@ describe('useChat remount send recovery', () => {
47014774 ] )
47024775 } )
47034776
4777+ it ( 'keeps the new-chat effort across the composer swap of a first send that fails' , async ( ) => {
4778+ const post = holdFirstSend ( )
4779+ const surface = renderComposerSwap ( )
4780+ act ( ( ) => useMothershipEffortStore . getState ( ) . setNewChatEffort ( 'low' ) )
4781+ expect ( surface . shownEffort ( ) ) . toBe ( 'Low' )
4782+
4783+ await act ( async ( ) => {
4784+ void surface . getResult ( ) . sendMessage ( 'Plan the launch' )
4785+ } )
4786+ await waitFor ( ( ) => state . postBodies . length === 1 )
4787+ expect ( state . postBodies [ 0 ] . effort ) . toBe ( 'low' )
4788+ expect ( surface . container . querySelector ( 'section' ) ) . not . toBeNull ( )
4789+ expect ( surface . shownEffort ( ) ) . toBe ( 'Low' )
4790+
4791+ await act ( async ( ) => {
4792+ post . reject ( new TypeError ( 'Failed to fetch' ) )
4793+ } )
4794+ await waitFor ( ( ) => surface . container . querySelector ( 'main' ) !== null )
4795+
4796+ expect ( useMothershipEffortStore . getState ( ) . newChatEffort ) . toBe ( 'low' )
4797+ expect ( surface . shownEffort ( ) ) . toBe ( 'Low' )
4798+ } )
4799+
4800+ it ( 'keeps a new-chat effort picked while the first send is pending when that send fails' , async ( ) => {
4801+ const post = holdFirstSend ( )
4802+ const surface = renderComposerSwap ( )
4803+ act ( ( ) => useMothershipEffortStore . getState ( ) . setNewChatEffort ( 'low' ) )
4804+ await act ( async ( ) => {
4805+ void surface . getResult ( ) . sendMessage ( 'Plan the launch' )
4806+ } )
4807+ await waitFor ( ( ) => state . postBodies . length === 1 )
4808+ act ( ( ) => useMothershipEffortStore . getState ( ) . setNewChatEffort ( 'medium' ) )
4809+
4810+ await act ( async ( ) => {
4811+ post . reject ( new TypeError ( 'Failed to fetch' ) )
4812+ } )
4813+ await waitFor ( ( ) => surface . container . querySelector ( 'main' ) !== null )
4814+
4815+ expect ( surface . shownEffort ( ) ) . toBe ( 'Medium' )
4816+ } )
4817+
4818+ it ( 'starts the next new chat at the default after a first send stopped before admission' , async ( ) => {
4819+ const surface = renderComposerSwap ( )
4820+ act ( ( ) => useMothershipEffortStore . getState ( ) . setNewChatEffort ( 'low' ) )
4821+ await act ( async ( ) => {
4822+ void surface . getResult ( ) . sendMessage ( 'Plan the launch' )
4823+ } )
4824+ await waitFor ( ( ) => state . postBodies . length === 1 )
4825+ await act ( async ( ) => {
4826+ await surface . getResult ( ) . stopGeneration ( )
4827+ } )
4828+ await waitFor ( ( ) => surface . getResult ( ) . resolvedChatId === DEDUPED_CHAT_ID )
4829+
4830+ surface . visit ( `/workspace/ws-1/chat/${ DEDUPED_CHAT_ID } ` )
4831+ surface . visit ( '/workspace/ws-1/home' )
4832+ await waitFor ( ( ) => surface . container . querySelector ( 'main' ) !== null )
4833+
4834+ expect ( surface . shownEffort ( ) ) . toBe ( 'High' )
4835+ } )
4836+
4837+ it . each ( [ 'leaves the page' , 'opens another chat' ] as const ) (
4838+ 'drops an unsent new-chat effort when the surface %s' ,
4839+ ( leave ) => {
4840+ useMothershipEffortStore . getState ( ) . reset ( )
4841+ ; ( globalThis as { IS_REACT_ACT_ENVIRONMENT ?: boolean } ) . IS_REACT_ACT_ENVIRONMENT = true
4842+ queryClient = new QueryClient ( { defaultOptions : { queries : { retry : false } } } )
4843+ const root = createRoot ( document . createElement ( 'div' ) )
4844+ mountedRoots . push ( root )
4845+ function Surface ( { chatId } : { chatId ?: string } ) {
4846+ useChat ( 'ws-1' , chatId )
4847+ return null
4848+ }
4849+ const render = ( chatId ?: string ) =>
4850+ act ( ( ) =>
4851+ root . render (
4852+ < QueryClientProvider client = { queryClient } >
4853+ < Surface chatId = { chatId } />
4854+ </ QueryClientProvider >
4855+ )
4856+ )
4857+ render ( )
4858+ useMothershipEffortStore . getState ( ) . setNewChatEffort ( 'low' )
4859+
4860+ if ( leave === 'leaves the page' ) act ( ( ) => root . unmount ( ) )
4861+ else render ( 'chat-other' )
4862+
4863+ expect ( useMothershipEffortStore . getState ( ) . newChatEffort ) . toBeNull ( )
4864+ }
4865+ )
4866+
47044867 it ( 'loads the saved transcript once when its own stream completes' , async ( ) => {
47054868 const chatId = 'chat-own-completion'
47064869 const history : MothershipChatHistory = {
0 commit comments