11import { useCallback , useEffect , useRef , useState , ReactNode } from 'react' ;
22import { Send , Wifi , WifiOff , Loader2 , Trash2 } from 'lucide-react' ;
3+ import ThemeToggle from './ThemeToggle' ;
4+ import ModelSelector from './ModelSelector' ;
35import { useWebSocket , WSEvent } from '../hooks/useWebSocket' ;
46import MessageBubble , { ChatMessage , MediaItem } from './MessageBubble' ;
57import ApiKeysPanel from './ApiKeysPanel' ;
@@ -137,21 +139,28 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
137139 case 'complete' :
138140 setIsThinking ( false ) ;
139141 setStatusMsg ( '' ) ;
142+ // Only finalize the existing streaming message — never create a new one.
143+ // Snapshot refs into locals BEFORE the state setter runs.
140144 if ( streamId . current ) {
141- const finalContent = ev . content ?? streamBuf . current ;
142- setMessages ( prev => {
143- const exists = prev . find ( m => m . id === streamId . current ) ;
144- if ( exists ) {
145- return prev . map ( m =>
146- m . id === streamId . current
147- ? { ...m , content : finalContent , media : [ ...streamMedia . current ] , arraylakeSnippets : [ ...streamSnippets . current ] , isStreaming : false , toolLabel : undefined , statusText : undefined }
148- : m
149- ) ;
150- }
151- return [ ...prev , { id : streamId . current ! , role : 'assistant' , content : finalContent , media : [ ...streamMedia . current ] , arraylakeSnippets : [ ...streamSnippets . current ] } ] ;
152- } ) ;
153- } else {
154- setMessages ( prev => [ ...prev , { id : uid ( ) , role : 'assistant' , content : ev . content ?? '' } ] ) ;
145+ const capturedId = streamId . current ;
146+ const capturedContent = ev . content ?? streamBuf . current ;
147+ const capturedMedia = [ ...streamMedia . current ] ;
148+ const capturedSnippets = [ ...streamSnippets . current ] ;
149+ setMessages ( prev =>
150+ prev . map ( m => {
151+ if ( m . id !== capturedId ) return m ;
152+ return {
153+ ...m ,
154+ content : capturedContent || m . content ,
155+ // Preserve media/snippets already on the message if our refs are empty
156+ media : capturedMedia . length > 0 ? capturedMedia : ( m . media || [ ] ) ,
157+ arraylakeSnippets : capturedSnippets . length > 0 ? capturedSnippets : ( m . arraylakeSnippets || [ ] ) ,
158+ isStreaming : false ,
159+ toolLabel : undefined ,
160+ statusText : undefined ,
161+ } ;
162+ } )
163+ ) ;
155164 }
156165 streamBuf . current = '' ;
157166 streamMedia . current = [ ] ;
@@ -190,7 +199,7 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
190199 }
191200 } , [ ] ) ;
192201
193- const { status, sendMessage, configureKeys } = useWebSocket ( handleEvent ) ;
202+ const { status, send , sendMessage, configureKeys } = useWebSocket ( handleEvent ) ;
194203
195204 /* ── check if server has keys ── */
196205 useEffect ( ( ) => {
@@ -222,6 +231,7 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
222231
223232 /* ── clear conversation ── */
224233 const handleClear = async ( ) => {
234+ if ( ! confirm ( 'Clear conversation history?' ) ) return ;
225235 try {
226236 await fetch ( '/api/conversation' , { method : 'DELETE' } ) ;
227237 setMessages ( [ ] ) ;
@@ -256,14 +266,16 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
256266 < h1 > Eurus Climate Agent</ h1 >
257267 </ div >
258268 < div className = "chat-header-actions" >
259- { cacheToggle }
260- < button className = "icon-btn" onClick = { handleClear } title = "Clear conversation" >
261- < Trash2 size = { 16 } />
262- </ button >
263269 < div className = { statusClass } style = { { color : statusColor } } >
264270 < StatusIcon size = { 12 } />
265271 < span > { status } </ span >
266272 </ div >
273+ { cacheToggle }
274+ < ModelSelector send = { send } />
275+ < ThemeToggle />
276+ < button className = "icon-btn danger-btn" onClick = { handleClear } title = "Clear conversation" >
277+ < Trash2 size = { 16 } />
278+ </ button >
267279 </ div >
268280 </ header >
269281
@@ -281,11 +293,11 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
281293 ⚠️ < strong > Experimental</ strong > — research prototype. Avoid very large datasets. Use 📦 Arraylake Code for heavy workloads.
282294 </ p >
283295 < div className = "example-queries" >
284- < button onClick = { ( ) => { setInput ( 'Show SST for California coast , Jan 2024' ) ; } } >
285- 🌡 SST — California coast
296+ < button onClick = { ( ) => { setInput ( 'Show SST map for the North Atlantic , Jan 2024' ) ; } } >
297+ 🌡 SST — North Atlantic
286298 </ button >
287- < button onClick = { ( ) => { setInput ( 'Compare wind speed Berlin vs Tokyo, March 2023' ) ; } } >
288- 💨 Wind — Berlin vs Tokyo
299+ < button onClick = { ( ) => { setInput ( 'Compare 2m temperature Berlin vs Tokyo, March 2023' ) ; } } >
300+ 💨 Temperature — Berlin vs Tokyo
289301 </ button >
290302 < button onClick = { ( ) => { setInput ( 'Precipitation anomalies over Amazon, 2023' ) ; } } >
291303 🌧 Rain — Amazon basin
0 commit comments