@@ -297,6 +297,12 @@ function NewChatPageInner() {
297297 } , [ ] ) ;
298298 const [ createdSessionId , setCreatedSessionId ] = useState < string | undefined > ( ) ;
299299 const abortControllerRef = useRef < AbortController | null > ( null ) ;
300+ // #615: guards the first-message send while it's mid-flight. We defer the
301+ // isStreaming / optimistic-bubble flips until the backend ACCEPTS the message
302+ // (otherwise flipping `isNewChat` remounts the composer and eats the
303+ // screenshot), which means the usual `if (isStreaming) return` re-entry guard
304+ // isn't armed during that window — this ref blocks a double-submit instead.
305+ const firstSendInFlightRef = useRef ( false ) ;
300306 // Effort level — lifted here so the first message includes it
301307 const [ selectedEffort , setSelectedEffort ] = useState < string | undefined > ( undefined ) ;
302308 // Provider options (thinking mode + 1M context)
@@ -721,10 +727,13 @@ function NewChatPageInner() {
721727
722728 const sendFirstMessage = useCallback (
723729 async ( content : string , files ?: FileAttachment [ ] , systemPromptAppend ?: string , displayOverride ?: string , mentions ?: MentionRef [ ] , selectedSkills ?: readonly string [ ] ) => {
724- if ( isStreaming ) return ;
730+ // Each early-out below is a NOT-delivered case: return false so the
731+ // composer preserves the user's text + attachments instead of letting
732+ // PromptInput clear a first-message screenshot that never got sent (#615).
733+ if ( isStreaming ) return false ;
725734
726735 // Wait for model/provider to be resolved from the global default before allowing send
727- if ( ! modelReady ) return ;
736+ if ( ! modelReady ) return false ;
728737
729738 // Block send when the runtime-filtered API returned an empty group
730739 // list — user has providers but none are compatible with the
@@ -737,7 +746,7 @@ function NewChatPageInner() {
737746 message : t ( 'error.providerUnavailable' ) ,
738747 description : t ( 'chat.empty.noProvider' ) ,
739748 } ) ;
740- return ;
749+ return false ; // not delivered → preserve composer (#615)
741750 }
742751
743752 // Phase 6 UI收口 P0 (2026-05-14): pinned-invalid is a GLOBAL
@@ -760,7 +769,7 @@ function NewChatPageInner() {
760769 // Require a project directory before sending
761770 if ( ! workingDir . trim ( ) ) {
762771 setErrorBanner ( { message : t ( 'chat.empty.noDirectory' ) } ) ;
763- return ;
772+ return false ; // not delivered → preserve composer (#615)
764773 }
765774
766775 // Phase 6 P0 follow-up (2026-05-15) — Codex Account is a virtual
@@ -783,19 +792,28 @@ function NewChatPageInner() {
783792 message : t ( 'error.providerUnavailable' ) ,
784793 description : t ( 'chat.empty.noProvider' ) ,
785794 } ) ;
786- return ;
795+ return false ; // not delivered → preserve composer (#615)
787796 }
788797
789- setIsStreaming ( true ) ;
790- setStreamingContent ( '' ) ;
791- setToolUses ( [ ] ) ;
792- setToolResults ( [ ] ) ;
793- setStatusText ( undefined ) ;
798+ // #615 remount fix: do NOT flip isStreaming / push the optimistic bubble
799+ // yet. Either flips `isNewChat` (messages.length === 0 && !isStreaming),
800+ // which swaps the whole layout ternary — the composer moves from the
801+ // centered hero branch to the active-layout branch (a DIFFERENT parent), so
802+ // MessageInput remounts and PromptInput loses the attachment, BEFORE we even
803+ // learn the send failed. Defer those flips to the post-accept point so a
804+ // pre-acceptance failure leaves the hero (and the screenshot) untouched.
805+ if ( firstSendInFlightRef . current ) return false ; // double-submit guard while mid-flight
806+ firstSendInFlightRef . current = true ;
794807
795808 const controller = new AbortController ( ) ;
796809 abortControllerRef . current = controller ;
797810
798811 let sessionId = '' ;
812+ // #615: tracks whether the message reached a delivered / recoverable state
813+ // (session created + POST /api/chat accepted). A failure BEFORE this must
814+ // return false so the composer preserves the user's text + attachments —
815+ // otherwise a session-create 500 silently eats the screenshot.
816+ let accepted = false ;
799817
800818 try {
801819 // Create a new session with working directory + model/provider
@@ -844,23 +862,10 @@ function NewChatPageInner() {
844862 // Notify ChatListPanel to refresh immediately
845863 window . dispatchEvent ( new CustomEvent ( 'session-created' ) ) ;
846864
847- // Add user message to UI — use displayOverride for chat bubble if provided
848- const displayUserContent = displayOverride || content ;
849- // Optimistic save preserves base64 `data` so images can render
850- // their thumbnail immediately (see ChatView for the full
851- // explanation; backend still strips `data` before persistence).
852- const contentWithFileMeta = files && files . length > 0
853- ? `<!--files:${ JSON . stringify ( files . map ( f => ( { id : f . id , name : f . name , type : f . type , size : f . size , data : f . data } ) ) ) } -->${ displayUserContent } `
854- : displayUserContent ;
855- const userMessage : Message = {
856- id : 'temp-' + Date . now ( ) ,
857- session_id : session . id ,
858- role : 'user' ,
859- content : contentWithFileMeta ,
860- created_at : new Date ( ) . toISOString ( ) ,
861- token_usage : null ,
862- } ;
863- setMessages ( [ userMessage ] ) ;
865+ // NOTE: the optimistic user bubble is pushed AFTER the message is
866+ // accepted (post-accept block below), not here — pushing it now would
867+ // make messages non-empty → flip isNewChat → remount the composer and
868+ // eat the screenshot on a /api/chat rejection. (#615)
864869
865870 // Build thinking config from settings
866871 const thinkingConfig = thinkingMode && thinkingMode !== 'adaptive'
@@ -902,6 +907,37 @@ function NewChatPageInner() {
902907 }
903908 throw new Error ( err ?. error || 'Failed to send message' ) ;
904909 }
910+ // Backend accepted the message + files (POST /api/chat is 2xx and the
911+ // stream is opening) — from here the screenshot is committed
912+ // server-side, so a later error must NOT preserve the composer (#615).
913+ accepted = true ;
914+
915+ // Flip the layout-driving state ONLY now: show streaming + push the
916+ // optimistic user bubble. Deferring to here keeps `isNewChat` true
917+ // through any pre-acceptance failure, so the composer never remounts and
918+ // the screenshot survives (#615).
919+ setIsStreaming ( true ) ;
920+ setStreamingContent ( '' ) ;
921+ setToolUses ( [ ] ) ;
922+ setToolResults ( [ ] ) ;
923+ setStatusText ( undefined ) ;
924+ {
925+ // Optimistic user bubble — preserves base64 `data` so images render
926+ // their thumbnail immediately (backend strips `data` before persisting).
927+ const displayUserContent = displayOverride || content ;
928+ const contentWithFileMeta = files && files . length > 0
929+ ? `<!--files:${ JSON . stringify ( files . map ( f => ( { id : f . id , name : f . name , type : f . type , size : f . size , data : f . data } ) ) ) } -->${ displayUserContent } `
930+ : displayUserContent ;
931+ const userMessage : Message = {
932+ id : 'temp-' + Date . now ( ) ,
933+ session_id : session . id ,
934+ role : 'user' ,
935+ content : contentWithFileMeta ,
936+ created_at : new Date ( ) . toISOString ( ) ,
937+ token_usage : null ,
938+ } ;
939+ setMessages ( [ userMessage ] ) ;
940+ }
905941
906942 const reader = response . body ?. getReader ( ) ;
907943 if ( ! reader ) throw new Error ( 'No response stream' ) ;
@@ -1115,6 +1151,11 @@ function NewChatPageInner() {
11151151 const errMsg = error instanceof Error ? error . message : 'Unknown error' ;
11161152 setErrorBanner ( { message : t ( 'error.sessionCreateFailed' ) , description : errMsg } ) ;
11171153 }
1154+ // #615: a failure BEFORE the message was accepted for delivery (session
1155+ // creation or POST /api/chat rejected) must preserve the composer so the
1156+ // user's screenshot isn't cleared. Post-acceptance errors (mid-stream)
1157+ // keep today's behavior — the message already went, so the composer clears.
1158+ if ( ! accepted ) return false ;
11181159 } finally {
11191160 setIsStreaming ( false ) ;
11201161 setStreamingContent ( '' ) ;
@@ -1127,6 +1168,7 @@ function NewChatPageInner() {
11271168 setPermissionResolved ( null ) ;
11281169 setPendingApprovalSessionId ( '' ) ;
11291170 abortControllerRef . current = null ;
1171+ firstSendInFlightRef . current = false ;
11301172 }
11311173 } ,
11321174 [ isStreaming , router , workingDir , mode , currentModel , currentProviderId , permissionProfile , selectedEffort , thinkingMode , context1m , setPendingApprovalSessionId , t , canSendWithCurrentProvider , modelReady , noCompatibleProvider , invalidDefault ]
@@ -1210,8 +1252,14 @@ function NewChatPageInner() {
12101252 // ChatComposerActionBar across two branches.
12111253 const composerStack = (
12121254 < >
1255+ { /* #615: stable keys so MessageInput keeps its identity (and PromptInput
1256+ keeps its attachment state) when ErrorBanner appears/disappears as a
1257+ sibling. The dominant remount cause — the isNewChat layout swap — is
1258+ fixed by deferring the layout-flip until accept (see sendFirstMessage);
1259+ these keys cover the within-parent ErrorBanner toggle. */ }
12131260 { errorBanner && (
12141261 < ErrorBanner
1262+ key = "composer-error-banner"
12151263 message = { errorBanner . message }
12161264 description = { errorBanner . description }
12171265 className = "mx-4 mb-2"
@@ -1221,14 +1269,16 @@ function NewChatPageInner() {
12211269 ] }
12221270 />
12231271 ) }
1224- < RunCheckpoint reasons = { checkpointReasons } className = "mb-2" onAction = { handleCheckpointAction } />
1272+ < RunCheckpoint key = "composer-run-checkpoint" reasons = { checkpointReasons } className = "mb-2" onAction = { handleCheckpointAction } />
12251273 < PermissionPrompt
1274+ key = "composer-permission-prompt"
12261275 pendingPermission = { pendingPermission }
12271276 permissionResolved = { permissionResolved }
12281277 onPermissionResponse = { handlePermissionResponse }
12291278 toolUses = { toolUses }
12301279 />
12311280 < MessageInput
1281+ key = "composer-message-input"
12321282 onSend = { sendFirstMessage }
12331283 onCommand = { handleCommand }
12341284 onStop = { stopStreaming }
0 commit comments