@@ -23,6 +23,23 @@ function previewQuote(raw, max = 180) {
2323 return t . length > max ? t . slice ( 0 , max ) . trim ( ) + "…" : t ;
2424}
2525
26+ // Page images shown under an answer must be the ones the answer CITED, not
27+ // every visual candidate retrieved: a retrieved-but-uncited page would read as
28+ // "the figure this answer describes" when the text never used it. Dedupe by
29+ // page so a figure cited in several sentences yields one thumbnail.
30+ function citedVisualPages ( citations ) {
31+ const seen = new Set ( ) ;
32+ const out = [ ] ;
33+ for ( const c of citations || [ ] ) {
34+ if ( c . kind !== "visual" || c . page == null ) continue ;
35+ const key = `${ c . paper } :${ c . page } ` ;
36+ if ( seen . has ( key ) ) continue ;
37+ seen . add ( key ) ;
38+ out . push ( c ) ;
39+ }
40+ return out ;
41+ }
42+
2643function AdvancedPanel ( { settings, set, papers, routingAvailable } ) {
2744 // When the server runs without the multimodal router (no GPU visual leg),
2845 // force_route/routing_mode are no-ops — grey them out rather than offering
@@ -91,7 +108,7 @@ function EmptyState({ onAsk, routingAvailable }) {
91108
92109function AiMessage ( { msg, onCite, onFig, paperTitle, pendingLabel } ) {
93110 const done = ! msg . streaming ;
94- const figs = ( msg . candidates || [ ] ) . filter ( ( c ) => c . kind === "visual" ) ;
111+ const citedFigs = citedVisualPages ( msg . citations ) ;
95112 const tokens = msg . usage ? ( msg . usage . prompt_tokens || 0 ) + ( msg . usage . completion_tokens || 0 ) : 0 ;
96113 // KaTeX over the finished answer only: a done message's props never change,
97114 // so React won't fight the DOM mutation (same pattern as the figure
@@ -131,14 +148,18 @@ function AiMessage({ msg, onCite, onFig, paperTitle, pendingLabel }) {
131148 </ React . Fragment >
132149 ) }
133150 </ div >
134- { done && ! msg . error && figs . length > 0 && (
151+ { done && ! msg . error && citedFigs . length > 0 && (
135152 < div className = "answer-figs rise" >
136- { figs . slice ( 0 , 4 ) . map ( ( f , i ) => (
137- < button key = { f . chunk_id || i } className = "answer-fig" onClick = { ( ) => onFig ( f ) } >
153+ { citedFigs . slice ( 0 , 4 ) . map ( ( f ) => (
154+ < button key = { f . id } className = "answer-fig" title = "View cited page"
155+ onClick = { ( ) => onFig ( {
156+ chunk_id : f . id , paper : f . paper , page : f . page , pages : [ f . page ] ,
157+ kind : "visual" , bbox : f . bbox || null , text : f . quote || "" , page_cite : ! ! f . page_cite ,
158+ } ) } >
138159 < div className = "answer-fig-img" style = { { height : 92 } } >
139160 < img src = { window . RAG . pageImageUrl ( f . paper , f . page ) } alt = { `page ${ f . page } ` } loading = "lazy" />
140161 </ div >
141- < div className = "cap" > < b > p.{ f . page } </ b > { previewQuote ( f . text , 64 ) } </ div >
162+ < div className = "cap" > < b > [ { f . n } ] p.{ f . page } </ b > { previewQuote ( f . quote || "" , 56 ) } </ div >
142163 </ button >
143164 ) ) }
144165 </ div >
@@ -181,10 +202,26 @@ function Composer({ onAsk, busy }) {
181202/* ---- retrieval panel ---- */
182203function RetrievalPanel ( { turn, highlight, settings, paperTitle, routingAvailable } ) {
183204 const [ pageItem , setPageItem ] = useState ( null ) ;
205+ const [ collapsed , setCollapsed ] = useState ( ( ) => localStorage . getItem ( "sr-retr-collapsed" ) === "1" ) ;
206+ const toggleCollapsed = ( ) =>
207+ setCollapsed ( ( v ) => { localStorage . setItem ( "sr-retr-collapsed" , v ? "0" : "1" ) ; return ! v ; } ) ;
208+ if ( collapsed ) {
209+ return (
210+ < aside className = "retr-panel collapsed" >
211+ < button className = "retr-rail" onClick = { toggleCollapsed } title = "Show retrieval panel" aria-label = "Show retrieval panel" >
212+ < Icon name = "chevron" size = { 16 } style = { { transform : "rotate(180deg)" } } />
213+ < span className = "rail-label" > Retrieval</ span >
214+ </ button >
215+ </ aside >
216+ ) ;
217+ }
184218 if ( ! turn || ! turn . candidates ) {
185219 return (
186220 < aside className = "retr-panel" >
187- < div className = "retr-head" > < Icon name = "route" size = { 15 } /> < h3 > Retrieval</ h3 > </ div >
221+ < div className = "retr-head" >
222+ < Icon name = "route" size = { 15 } /> < h3 > Retrieval</ h3 >
223+ < button className = "retr-collapse" style = { { marginLeft : "auto" } } onClick = { toggleCollapsed } title = "Hide retrieval panel" aria-label = "Hide retrieval panel" > < Icon name = "chevron" size = { 15 } /> </ button >
224+ </ div >
188225 < div className = "retr-empty" > No turn yet.< br /> Ask a question and the live routing decision, ranked candidates, and retrieved figures appear here.</ div >
189226 </ aside >
190227 ) ;
@@ -238,6 +275,7 @@ function RetrievalPanel({ turn, highlight, settings, paperTitle, routingAvailabl
238275 < Icon name = "route" size = { 15 } />
239276 < h3 > Retrieval</ h3 >
240277 < span className = "live" > < span className = "dot" > </ span > live</ span >
278+ < button className = "retr-collapse" onClick = { toggleCollapsed } title = "Hide retrieval panel" aria-label = "Hide retrieval panel" > < Icon name = "chevron" size = { 15 } /> </ button >
241279 </ div >
242280 < div className = "retr-body" >
243281 < div className = "retr-section" >
@@ -351,7 +389,7 @@ function RetrievalPanel({ turn, highlight, settings, paperTitle, routingAvailabl
351389 ) ;
352390}
353391
354- function ChatView ( { settings, set, layout , resetSignal, apiKey, model, papers, figures, pagesAvailable, demoAvailable, routingAvailable, onNeedKey } ) {
392+ function ChatView ( { settings, set, resetSignal, apiKey, model, papers, figures, pagesAvailable, demoAvailable, routingAvailable, onNeedKey } ) {
355393 const [ turns , setTurns ] = useState ( [ ] ) ;
356394 const [ busy , setBusy ] = useState ( false ) ;
357395 const [ advOpen , setAdvOpen ] = useState ( false ) ;
@@ -574,7 +612,7 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
574612
575613 // Bumping runSeq orphans any in-flight ask: its guarded writes become no-ops.
576614 const newChat = ( ) => { runSeq . current += 1 ; setTurns ( [ ] ) ; setHighlight ( null ) ; setBusy ( false ) ; setStatus ( "" ) ; } ;
577- // The retrieval panel is hidden in focus layout and at phone width, so a
615+ // The retrieval panel is hidden at phone width, so a
578616 // highlight there would be invisible — open the source-page modal instead.
579617 // Page-image citations always open the modal: they have no panel row.
580618 const onCite = ( tag , msg ) => {
@@ -591,7 +629,7 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
591629 }
592630 // The panel only ever shows the LAST turn's evidence, so a highlight is
593631 // wrong for citations in older messages — open the modal for those too.
594- const panelHidden = layout === "single" || ( window . matchMedia && window . matchMedia ( "(max-width: 760px)" ) . matches ) ;
632+ const panelHidden = window . matchMedia && window . matchMedia ( "(max-width: 760px)" ) . matches ;
595633 const isLastTurn = msg === lastAssistant ;
596634 if ( ( panelHidden || ! isLastTurn ) && cit && msg . candidates ) {
597635 const cand = msg . candidates . find ( ( cd ) => cd . chunk_id === cit . id ) ;
@@ -609,7 +647,7 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
609647 } , [ resetSignal ] ) ;
610648
611649 return (
612- < div className = { "chat-wrap" + ( layout === "single" ? " single" : "" ) } >
650+ < div className = "chat-wrap" >
613651 < div className = "chat-col" >
614652 < div className = "adv-toggle-row" >
615653 < div className = { "adv-toggle" + ( advOpen ? " open" : "" ) } onClick = { ( ) => setAdvOpen ( ( o ) => ! o ) } >
@@ -643,7 +681,7 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
643681 < Composer onAsk = { ask } busy = { busy } />
644682 </ div >
645683
646- { layout !== "single" && < RetrievalPanel turn = { lastAssistant } highlight = { highlight } settings = { settings } paperTitle = { paperTitle } routingAvailable = { routingAvailable } /> }
684+ < RetrievalPanel turn = { lastAssistant } highlight = { highlight } settings = { settings } paperTitle = { paperTitle } routingAvailable = { routingAvailable } />
647685 < PageRegionModal item = { pageItem } onClose = { ( ) => setPageItem ( null ) } paperTitle = { paperTitle } />
648686 </ div >
649687 ) ;
0 commit comments