@@ -190,9 +190,12 @@ function RetrievalPanel({ turn, highlight, settings, paperTitle, routingAvailabl
190190 ) ;
191191 }
192192 const cands = turn . candidates ;
193- // Rerank scores are logits (often > 1); scale bars to the set's best score
194- // so they stay comparative instead of all clamping to 100%.
195- const maxScore = cands . reduce ( ( m , c ) => Math . max ( m , c . score || 0 ) , 0 ) ;
193+ // Rerank scores are logits (any sign, any magnitude) — min-max scale the
194+ // bars within the set so they stay comparative; all-negative sets would
195+ // otherwise render every bar empty.
196+ const scores = cands . map ( ( c ) => c . score || 0 ) ;
197+ const sMax = Math . max ( ...scores ) , sMin = Math . min ( ...scores ) ;
198+ const relScore = ( s ) => ( sMax === sMin ? 1 : ( s - sMin ) / ( sMax - sMin ) ) ;
196199 const vis = cands . filter ( ( c ) => c . kind === "visual" ) ;
197200 const total = cands . length ;
198201 const visShare = total ? Math . round ( ( vis . length / total ) * 100 ) : 0 ;
@@ -217,7 +220,7 @@ function RetrievalPanel({ turn, highlight, settings, paperTitle, routingAvailabl
217220 </ div >
218221 ) : (
219222 < div className = "route-card" >
220- < div className = "gate" > < RoutePill route = { turn . route || "text" } /> < span className = "cand-src" style = { { marginLeft : "auto" } } > mode: { settings . route } </ span > </ div >
223+ < div className = "gate" > < RoutePill route = { turn . route || "text" } /> < span className = "cand-src" style = { { marginLeft : "auto" } } > mode: { turn . mode || settings . route } </ span > </ div >
221224 < div className = "route-bars" >
222225 < div className = "rb" > < span className = "lbl" > text</ span > < div className = "scorebar" > < i style = { { width : txtShare + "%" } } > </ i > </ div > < span className = "pct" > { txtShare } %</ span > </ div >
223226 < div className = "rb" > < span className = "lbl" > visual</ span > < div className = "scorebar visual" > < i style = { { width : visShare + "%" } } > </ i > </ div > < span className = "pct" > { visShare } %</ span > </ div >
@@ -235,11 +238,11 @@ function RetrievalPanel({ turn, highlight, settings, paperTitle, routingAvailabl
235238 < div key = { c . chunk_id || i } className = { "cand row-clickable" + ( hl ? " hl" : "" ) }
236239 onClick = { ( ) => setPageItem ( c ) } title = "View source region on page" >
237240 < div className = "cand-top" >
238- < span className = { "cand-num" + ( c . kind === "visual" ? " visual" : "" ) } > { c . kind === "visual" ? "IMG" : ( num || "·" ) } </ span >
241+ < span className = { "cand-num" + ( c . kind === "visual" ? " visual" : "" ) } > { num || ( c . kind === "visual" ? "IMG" : "·" ) } </ span >
239242 < span className = "cand-src" > { c . paper } · p.{ c . page } </ span >
240243 < span className = "cand-score" > { c . score . toFixed ( 3 ) } </ span >
241244 </ div >
242- < ScoreBar score = { maxScore > 0 ? Math . min ( c . score / maxScore , 1 ) : 0 } kind = { c . kind } />
245+ < ScoreBar score = { relScore ( c . score || 0 ) } kind = { c . kind } />
243246 { c . text && < div className = "cand-quote" > { previewQuote ( c . text ) } </ div > }
244247 < div className = "cand-meta" >
245248 < span className = "tag" > { previewQuote ( paperTitle ( c . paper ) , 30 ) } </ span >
@@ -311,28 +314,37 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
311314 return next ;
312315 } ) ;
313316
317+ // Incremented by New chat: an in-flight ask compares its captured value and
318+ // stops writing, so a zombie stream can't leak into the next conversation.
319+ const runSeq = useRef ( 0 ) ;
320+
314321 const ask = useCallback ( async ( q ) => {
315322 if ( busy ) return ;
323+ const myRun = ++ runSeq . current ;
324+ const live = ( fn ) => { if ( runSeq . current === myRun ) fn ( ) ; } ;
325+ const upd = ( patch ) => live ( ( ) => updateLast ( patch ) ) ;
316326 setHighlight ( null ) ;
317327 setStatus ( "" ) ;
318328 setBusy ( true ) ;
319329
320330 const priorTurns = turnsRef . current
321- . filter ( ( t ) => t . role === "user" || ( t . role === "assistant" && t . answer ) )
331+ // Error and notice turns are UI copy ("add your key…"), not assistant
332+ // answers — feeding them to condense/generation pollutes the history.
333+ . filter ( ( t ) => t . role === "user" || ( t . role === "assistant" && t . answer && ! t . error && ! t . notice ) )
322334 . map ( ( t ) => ( { role : t . role , text : t . role === "user" ? t . text : t . answer } ) ) ;
323335
324336 setTurns ( ( ts ) => [
325337 ...ts ,
326338 { role : "user" , text : q } ,
327- { role : "assistant" , q, answer : "" , streaming : true , candidates : null , citations : [ ] , route : null } ,
339+ { role : "assistant" , q, answer : "" , streaming : true , candidates : null , citations : [ ] , route : null , mode : settings . route } ,
328340 ] ) ;
329341
330342 try {
331343 // Agentic search can't run keyless: the server-side agent spends the
332344 // caller's OpenRouter key. Stop before retrieval with a notice instead
333345 // of letting the thrown error render as a generic failure.
334346 if ( settings . route === "agentic" && ! ( apiKey && apiKey . trim ( ) ) ) {
335- updateLast ( {
347+ upd ( {
336348 answer : "Agentic search runs a search agent server-side on your OpenRouter key, so it needs one — add yours (top-right) to try it. The standard retrieval modes work without a key." ,
337349 streaming : false ,
338350 notice : true ,
@@ -359,10 +371,10 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
359371 } else if ( demoAvailable ) {
360372 setStatus ( "Condensing the follow-up into a search query…" ) ;
361373 searchQuery = await window . RAG . condenseDemo ( priorTurns , q ) ;
362- setStatus ( "" ) ;
374+ live ( ( ) => setStatus ( "" ) ) ;
363375 }
364376 }
365- updateLast ( { searchedFor : searchQuery } ) ;
377+ upd ( { searchedFor : searchQuery } ) ;
366378
367379 // Retrieve.
368380 const t0 = performance . now ( ) ;
@@ -373,17 +385,23 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
373385 paperId : settings . paper || "" ,
374386 dci : settings . route === "agentic" ,
375387 apiKey,
376- onStatus : setStatus ,
388+ onStatus : ( s ) => live ( ( ) => setStatus ( s ) ) ,
377389 } ) ;
378- setStatus ( "" ) ;
390+ live ( ( ) => setStatus ( "" ) ) ;
379391 const tRetrieve = performance . now ( ) - t0 ;
380392 const candidates = results . map ( toCand ) ;
381393 // With no router on the deployment the route label is always "text" —
382394 // noise, not information. Suppress the per-message pill there.
383- updateLast ( { candidates, route : routingAvailable === false ? null : window . RAG . routeLabel ( routing ) , routing, trace } ) ;
395+ // Agentic turns are their own path (DCI), not a router decision; the
396+ // pill should say so rather than defaulting to "text route".
397+ upd ( {
398+ candidates,
399+ route : settings . route === "agentic" ? "agentic" : routingAvailable === false ? null : window . RAG . routeLabel ( routing ) ,
400+ routing, trace,
401+ } ) ;
384402
385403 if ( results . length === 0 ) {
386- updateLast ( { answer : "No chunks retrieved. The corpus may not cover this query." , streaming : false , latencyMs : Math . round ( tRetrieve ) } ) ;
404+ upd ( { answer : "No chunks retrieved. The corpus may not cover this query." , streaming : false , latencyMs : Math . round ( tRetrieve ) } ) ;
387405 return ;
388406 }
389407
@@ -393,8 +411,8 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
393411 // retrieval with the bring-a-key notice.
394412 const hasKey = ! ! ( apiKey && apiKey . trim ( ) ) ;
395413 if ( ! hasKey && ! demoAvailable ) {
396- setStatus ( "Add your OpenRouter key (top-right) to generate a cited answer." ) ;
397- updateLast ( {
414+ live ( ( ) => setStatus ( "Add your OpenRouter key (top-right) to generate a cited answer." ) ) ;
415+ upd ( {
398416 answer : "Retrieved the chunks shown on the right. Add your OpenRouter key (top-right) to generate a cited answer from them." ,
399417 streaming : false ,
400418 notice : true ,
@@ -406,10 +424,10 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
406424 // The demo chain is all vision-capable models, so keyless turns always
407425 // attach page images when pages are served.
408426 const useImages = pagesAvailable && ( hasKey ? window . RAG . supportsVision ( model ) : true ) ;
409- if ( useImages ) setStatus ( hasKey ? "Reading the retrieved page images…" : "Free demo model is reading the retrieved pages…" ) ;
427+ if ( useImages ) live ( ( ) => setStatus ( hasKey ? "Reading the retrieved page images…" : "Free demo model is reading the retrieved pages…" ) ) ;
410428 const messages = await window . RAG . buildMessages ( priorTurns , q , results , useImages , figures ) ;
411429 const tGen = performance . now ( ) ;
412- const onDelta = ( delta ) => updateLast ( ( prev ) => ( { answer : prev . answer + delta } ) ) ;
430+ const onDelta = ( delta ) => upd ( ( prev ) => ( { answer : prev . answer + delta } ) ) ;
413431 const { text, usage } = hasKey
414432 ? await window . RAG . streamChat ( apiKey , model , messages , onDelta )
415433 : await window . RAG . streamDemoChat ( messages , onDelta ) ;
@@ -426,7 +444,7 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
426444 const pages = c ? c . page_numbers || [ ] : [ ] ;
427445 return { n : i + 1 , id, paper : c ? c . paper_id : id , page : pages [ 0 ] , quote : c ? previewQuote ( c . text ) : null , kind : c && c . source === "visual" ? "visual" : "text" } ;
428446 } ) ;
429- updateLast ( {
447+ upd ( {
430448 answer : newText ,
431449 streaming : false ,
432450 citations,
@@ -439,33 +457,41 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
439457 // The shared free quota ran out for today: hand off to the key modal
440458 // instead of rendering it as a failure — retrieval still worked.
441459 onNeedKey && onNeedKey ( ) ;
442- updateLast ( {
460+ upd ( {
443461 answer : "Today's free demo answers are used up, but retrieval still works — the chunks are on the right. Add your own OpenRouter key (top-right) to keep generating answers." ,
444462 streaming : false ,
445463 notice : true ,
446464 } ) ;
447465 } else if ( err && ( err . code === "demo_down" || err . code === "stream_error" ) ) {
448466 // Upstream model failure, not the visitor's fault — say so without
449- // blaming a key they may not even have.
450- updateLast ( {
451- answer : `${ ( err && err . message ) || "The model provider failed." } Retrieval still worked — the chunks are on the right.` ,
467+ // blaming a key they may not even have, and keep any partial answer.
468+ upd ( ( prev ) => ( {
469+ answer : prev . answer
470+ ? `${ prev . answer } \n\nGeneration stopped early: ${ ( err && err . message ) || "the model provider failed." } `
471+ : `${ ( err && err . message ) || "The model provider failed." } Retrieval still worked — the chunks are on the right.` ,
452472 streaming : false ,
453473 notice : true ,
454- } ) ;
474+ } ) ) ;
455475 } else {
456- updateLast ( {
457- answer : `Request failed: ${ ( err && err . message ) || err } . Either the server isn't reachable, or your OpenRouter key is invalid.` ,
476+ // Keep whatever streamed before the failure — wiping a half-answer is
477+ // worse than showing it with an honest interruption note.
478+ upd ( ( prev ) => ( {
479+ answer : prev . answer
480+ ? `${ prev . answer } \n\nGeneration interrupted: ${ ( err && err . message ) || err } `
481+ : `Request failed: ${ ( err && err . message ) || err } . Either the server isn't reachable, or your OpenRouter key is invalid.` ,
458482 streaming : false ,
459- error : true ,
460- } ) ;
483+ error : ! prev . answer ,
484+ notice : ! ! prev . answer ,
485+ } ) ) ;
461486 }
462- setStatus ( "" ) ;
487+ live ( ( ) => setStatus ( "" ) ) ;
463488 } finally {
464- setBusy ( false ) ;
489+ live ( ( ) => setBusy ( false ) ) ;
465490 }
466491 } , [ busy , apiKey , model , settings , figures , pagesAvailable , demoAvailable , routingAvailable , onNeedKey ] ) ;
467492
468- const newChat = ( ) => { setTurns ( [ ] ) ; setHighlight ( null ) ; setBusy ( false ) ; setStatus ( "" ) ; } ;
493+ // Bumping runSeq orphans any in-flight ask: its guarded writes become no-ops.
494+ const newChat = ( ) => { runSeq . current += 1 ; setTurns ( [ ] ) ; setHighlight ( null ) ; setBusy ( false ) ; setStatus ( "" ) ; } ;
469495 // The retrieval panel is hidden in focus layout and at phone width, so a
470496 // highlight there would be invisible — open the source-page modal instead.
471497 // Page-image citations always open the modal: they have no panel row.
@@ -476,11 +502,15 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
476502 setPageItem ( { chunk_id : cit . id , paper : cit . paper , page : cit . page , pages : [ cit . page ] , kind : "visual" , bbox : null , text : "" , page_cite : true } ) ;
477503 return ;
478504 }
505+ // The panel only ever shows the LAST turn's evidence, so a highlight is
506+ // wrong for citations in older messages — open the modal for those too.
479507 const panelHidden = layout === "single" || ( window . matchMedia && window . matchMedia ( "(max-width: 760px)" ) . matches ) ;
480- if ( panelHidden && cit && msg . candidates ) {
508+ const isLastTurn = msg === lastAssistant ;
509+ if ( ( panelHidden || ! isLastTurn ) && cit && msg . candidates ) {
481510 const cand = msg . candidates . find ( ( cd ) => cd . chunk_id === cit . id ) ;
482511 if ( cand ) { setPageItem ( cand ) ; return ; }
483512 }
513+ if ( ! isLastTurn ) return ;
484514 setHighlight ( tag ) ;
485515 } ;
486516 const openFig = ( cand ) => setPageItem ( cand ) ;
@@ -515,7 +545,7 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
515545 < div className = "msg msg-user rise" key = { i } > < div className = "bubble" > { t . text } </ div > </ div >
516546 ) : (
517547 < AiMessage key = { i } msg = { t } onCite = { ( tag ) => onCite ( tag , t ) } onFig = { openFig } paperTitle = { paperTitle }
518- pendingLabel = { status || undefined } />
548+ pendingLabel = { status || ( routingAvailable === false ? "retrieving…" : undefined ) } />
519549 )
520550 ) }
521551 </ div >
0 commit comments