Skip to content

Commit 4c9c1d1

Browse files
feat(web): collapsible retrieval panel; figure under answer matches citation
- Add a collapse toggle to the retrieval panel: shrinks to a labeled rail, state persists in localStorage. - Under an answer, show only the cited page's figure (deduped by page) so the thumbnail matches the [n] citation rather than a stray retrieved candidate. - Remove the redundant split/focus layout toggle and its dead `single` state; the collapse control supersedes it.
1 parent e2dd8ad commit 4c9c1d1

3 files changed

Lines changed: 72 additions & 36 deletions

File tree

web/app/app.jsx

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const NAV = [
88
{ id: "why", label: "Why multimodal?", icon: "why" }];
99

1010

11-
function Sidebar({ tab, setTab, theme, setTheme, layout, setLayout, stats, open }) {
11+
function Sidebar({ tab, setTab, theme, setTheme, stats, open }) {
1212
return (
1313
<aside className={"sidebar" + (open ? " open" : "")}>
1414
<div className="brand">
@@ -68,8 +68,7 @@ function hexToRgba(hex, a) {
6868
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
6969
"accent": "#3b82f6",
7070
"density": "regular",
71-
"answerFont": "sans",
72-
"defaultLayout": "split"
71+
"answerFont": "sans"
7372
} /*EDITMODE-END*/;
7473

7574
/* Two top-bar pills: model picker and key entry. Separate menus, because
@@ -231,7 +230,6 @@ function App() {
231230
const valid = ["chat", "inspection", "papers", "figures", "why"];
232231
return (valid.includes(h) && h) || localStorage.getItem("sr-tab") || "chat";
233232
});
234-
const [layout, setLayout] = useState(() => localStorage.getItem("sr-layout") || "split");
235233
const [navOpen, setNavOpen] = useState(false);
236234
const [model, setModel] = useState("openai/gpt-4o-mini");
237235
const [apiKey, setApiKeyRaw] = useState(() => localStorage.getItem("sr-key") || "");
@@ -255,7 +253,6 @@ function App() {
255253
// the saved tab on every reload.
256254
if ((location.hash || "").replace(/^#/, "") !== tab) history.replaceState(null, "", "#" + tab);
257255
}, [tab]);
258-
useEffect(() => {localStorage.setItem("sr-layout", layout);}, [layout]);
259256

260257
// Real corpus data: the paper list (feeds the paper filter) and whether page
261258
// PNGs are mounted (gates vision generation). Best-effort; on failure the
@@ -280,16 +277,6 @@ function App() {
280277
}, [t.accent, theme]);
281278
useEffect(() => {document.documentElement.setAttribute("data-density", t.density);}, [t.density]);
282279
useEffect(() => {document.documentElement.setAttribute("data-answerfont", t.answerFont);}, [t.answerFont]);
283-
// Apply the layout tweak only when it actually changes: running on mount
284-
// would clobber the persisted sr-layout choice with the tweak default. The
285-
// tweak speaks "focus"; the layout state machine speaks "single".
286-
const layoutTweakFirst = useRef(true);
287-
useEffect(() => {
288-
if (layoutTweakFirst.current) { layoutTweakFirst.current = false; return; }
289-
setLayout(t.defaultLayout === "focus" ? "single" : t.defaultLayout);
290-
/* eslint-disable-next-line */
291-
}, [t.defaultLayout]);
292-
293280
const crumb = CRUMB[tab];
294281
const stats = { papers: papers.length, figures: figures ? figures.length : 0 };
295282
// Tapping a nav item also dismisses the mobile drawer.
@@ -299,7 +286,7 @@ function App() {
299286

300287
return (
301288
<div className="app">
302-
<Sidebar tab={tab} setTab={selectTab} theme={theme} setTheme={setTheme} layout={layout} setLayout={setLayout} stats={stats} open={navOpen} />
289+
<Sidebar tab={tab} setTab={selectTab} theme={theme} setTheme={setTheme} stats={stats} open={navOpen} />
303290
{navOpen && <div className="nav-scrim" onClick={() => setNavOpen(false)}></div>}
304291
<main className="main">
305292
<div className="topbar">
@@ -312,10 +299,6 @@ function App() {
312299
{(tab === "chat" || tab === "inspection") &&
313300
<ConnectionControl apiKey={apiKey} setApiKey={setApiKey} model={model} setModel={setModel} demoAvailable={demoAvailable} />
314301
}
315-
{tab === "chat" &&
316-
<Segmented value={layout} onChange={setLayout}
317-
options={[{ value: "split", label: "split" }, { value: "single", label: "focus" }]} />
318-
}
319302
</div>
320303
</div>
321304

@@ -324,7 +307,7 @@ function App() {
324307
destroy the conversation while the chat itself points users at
325308
the Papers and Figures tabs. */}
326309
<div style={{ display: tab === "chat" ? "contents" : "none" }}>
327-
<ChatView settings={settings} set={set} layout={layout} apiKey={apiKey} model={model} papers={papers} figures={figures} pagesAvailable={pagesAvailable} demoAvailable={demoAvailable} routingAvailable={routingAvailable} onNeedKey={(reason) => setKeyModalOpen(reason || "quota")} />
310+
<ChatView settings={settings} set={set} apiKey={apiKey} model={model} papers={papers} figures={figures} pagesAvailable={pagesAvailable} demoAvailable={demoAvailable} routingAvailable={routingAvailable} onNeedKey={(reason) => setKeyModalOpen(reason || "quota")} />
328311
</div>
329312
{tab === "inspection" && <InspectionView settings={settings} apiKey={apiKey} model={model} papers={papers} pagesAvailable={pagesAvailable} routingAvailable={routingAvailable} />}
330313
{tab === "papers" && <PapersView setTab={setTab} papers={papers} figures={figures} uploadAvailable={uploadAvailable} onUploaded={reloadCorpus} />}
@@ -341,8 +324,6 @@ function App() {
341324
options={["#3b82f6", "#8b5cf6", "#14b8a6", "#e0993a"]}
342325
onChange={(v) => setTweak("accent", v)} />
343326
<TweakSection label="Layout" />
344-
<TweakRadio label="Chat default" value={t.defaultLayout}
345-
options={["split", "focus"]} onChange={(v) => setTweak("defaultLayout", v)} />
346327
<TweakRadio label="Density" value={t.density}
347328
options={["compact", "regular", "comfy"]} onChange={(v) => setTweak("density", v)} />
348329
<TweakSection label="Reading" />

web/app/chat.jsx

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2643
function 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

92109
function 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 ---- */
182203
function 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
);

web/app/styles.css

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,7 @@ body {
247247
.topbar-sub { display: none; }
248248
.topbar .tag { display: none; }
249249
/* Single-column chat: a two-pane split is unusable at phone width, so hide
250-
the retrieval side-panel and its toggle and let the conversation fill. */
251-
.topbar .segmented { display: none; }
250+
the retrieval side-panel and let the conversation fill. */
252251
.endpoint-model { white-space: nowrap; }
253252
.endpoint-keylabel { display: none; }
254253
.chat-wrap .retr-panel { display: none; }
@@ -581,7 +580,25 @@ body {
581580
.retr-panel {
582581
width: 384px; flex: none; border-left: 1px solid var(--border);
583582
background: var(--panel); display: flex; flex-direction: column; min-height: 0;
583+
transition: width .16s ease;
584584
}
585+
.retr-panel.collapsed { width: 40px; }
586+
.retr-rail {
587+
flex: 1; width: 100%; display: flex; flex-direction: column; align-items: center; gap: 14px;
588+
padding: 16px 0; background: none; border: none; cursor: pointer; color: var(--text-dim);
589+
transition: color .12s, background .12s;
590+
}
591+
.retr-rail:hover { color: var(--text); background: var(--border-soft); }
592+
.retr-rail .rail-label {
593+
writing-mode: vertical-rl; text-orientation: mixed; font-size: 11px; font-weight: 600;
594+
letter-spacing: .12em; text-transform: uppercase;
595+
}
596+
.retr-collapse {
597+
display: inline-flex; align-items: center; justify-content: center; flex: none;
598+
width: 24px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 6px;
599+
background: none; color: var(--text-dim); cursor: pointer; transition: color .12s, border-color .12s;
600+
}
601+
.retr-collapse:hover { color: var(--text); border-color: var(--accent-line); }
585602
.retr-head { padding: 14px 16px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 8px; flex: none; }
586603
.retr-head h3 { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-dim); }
587604
.retr-head .live { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-family: "IBM Plex Mono", monospace; font-size: 10.5px; color: var(--good); }

0 commit comments

Comments
 (0)