Skip to content

Commit cb11f1f

Browse files
fix(web): chat lifecycle, layout, and tab bugs
Chat: - New chat orphans the in-flight request via a run counter; its stream could write the old answer, citations, and busy state into the next conversation - citation chips in older messages open the source-page modal for their own evidence; the panel only shows the last turn - stream failures keep the partial answer with an interruption note instead of replacing it with key-blaming boilerplate - error and notice turns stay out of the model-facing history, and error turns get visible styling - figure thumbnails get sizing CSS (raw ~1240px page PNGs); the routing card shows the mode the turn ran under; agentic turns get their own pill; cited visual rows show their number Shell: - layout preference survives reload (the tweaks effect clobbered it on mount, and the "focus" tweak wrote a value the layout state machine spells "single") - ChatView stays mounted across tab switches, so visiting Papers no longer destroys the conversation; the URL hash follows the tab instead of overriding it on reload - KeyModal clears stale input per open; routing_available needs positive confirmation from /health Display: - relevance bars min-max scale within the result set in chat and Inspection (all-negative logit sets drew empty bars) - docling table chunks surface as "table" in the gallery with their own filter chip; drawer captions hide [chunk_id] placeholders; Papers explains empty and failed loads; the Why tab no longer renders 0/0 stats while data loads and stops promising live routing where the router is off - orphaned CSS fixed or removed; SSE streams flush a final unterminated frame
1 parent e073caa commit cb11f1f

10 files changed

Lines changed: 192 additions & 73 deletions

File tree

web/app/api.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,12 @@
242242
: new RegExp(`^fig(?:ure)?\\.?\\s*${num}\\b`, "i");
243243
for (const paperId of papers) {
244244
const f = figureIndex.find((g) => g.paper_id === paperId && re.test(String(g.caption || "").trim()));
245-
if (f && typeof f.page_number === "number") out.push({ paperId, page: f.page_number });
245+
if (f && typeof f.page_number === "number") {
246+
out.push({ paperId, page: f.page_number });
247+
// One page per reference — matching the same "Figure 2" in a second
248+
// paper would crowd out the question's other references.
249+
break;
250+
}
246251
}
247252
}
248253
return out.slice(0, 2);
@@ -359,6 +364,23 @@
359364
if (obj.usage) usage = obj.usage;
360365
}
361366
}
367+
// Flush a final line that arrived without a trailing newline — the
368+
// usage-bearing frame is often the last thing in the stream.
369+
const tail = buf.trim();
370+
if (tail.startsWith("data:")) {
371+
const payload = tail.slice(5).trim();
372+
if (payload && payload !== "[DONE]") {
373+
try {
374+
const obj = JSON.parse(payload);
375+
if (obj.usage) usage = obj.usage;
376+
const delta = obj.choices?.[0]?.delta?.content || "";
377+
if (delta) {
378+
acc += delta;
379+
onDelta(delta);
380+
}
381+
} catch { /* partial frame — drop */ }
382+
}
383+
}
362384
return { text: acc, usage };
363385
}
364386

web/app/app.jsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ const KEY_MODAL_COPY = {
201201
};
202202
function KeyModal({ open, onSave, onClose }) {
203203
const [val, setVal] = useState("");
204+
// Reset on every open: a stale half-typed key from a previous open must not
205+
// be one Enter press away from becoming the active key.
206+
useEffect(() => { if (open) setVal(""); }, [open]);
204207
useEffect(() => {
205208
if (!open) return;
206209
const onEsc = (e) => { if (e.key === "Escape") onClose(); };
@@ -254,7 +257,12 @@ function App() {
254257

255258
const setTheme = (th) => {setThemeRaw(th);localStorage.setItem("sr-theme", th);};
256259
useEffect(() => {document.documentElement.setAttribute("data-theme", theme);}, [theme]);
257-
useEffect(() => {localStorage.setItem("sr-tab", tab);}, [tab]);
260+
useEffect(() => {
261+
localStorage.setItem("sr-tab", tab);
262+
// Keep the hash in sync — a stale deep-link hash would otherwise override
263+
// the saved tab on every reload.
264+
if ((location.hash || "").replace(/^#/, "") !== tab) history.replaceState(null, "", "#" + tab);
265+
}, [tab]);
258266
useEffect(() => {localStorage.setItem("sr-layout", layout);}, [layout]);
259267

260268
// Real corpus data: the paper list (feeds the paper filter) and whether page
@@ -263,7 +271,10 @@ function App() {
263271
useEffect(() => {
264272
window.RAG.loadPapers().then(setPapers);
265273
window.RAG.loadFigures().then(setFigures);
266-
window.RAG.loadHealth().then((h) => { setPagesAvailable(!!h.pages_available); setDemoAvailable(!!h.demo_available); setRoutingAvailable(h.routing_available !== false); });
274+
// routing_available must be POSITIVELY confirmed — a failed /health (or
275+
// an older server without the field) should not leave routing controls
276+
// offered on a deployment that can't honor them.
277+
window.RAG.loadHealth().then((h) => { setPagesAvailable(!!h.pages_available); setDemoAvailable(!!h.demo_available); setRoutingAvailable(h.routing_available === true); });
267278
}, []);
268279

269280
// apply tweaks → CSS
@@ -277,7 +288,15 @@ function App() {
277288
}, [t.accent, theme]);
278289
useEffect(() => {document.documentElement.setAttribute("data-density", t.density);}, [t.density]);
279290
useEffect(() => {document.documentElement.setAttribute("data-answerfont", t.answerFont);}, [t.answerFont]);
280-
useEffect(() => {setLayout(t.defaultLayout); /* eslint-disable-next-line */}, [t.defaultLayout]);
291+
// Apply the layout tweak only when it actually changes: running on mount
292+
// would clobber the persisted sr-layout choice with the tweak default. The
293+
// tweak speaks "focus"; the layout state machine speaks "single".
294+
const layoutTweakFirst = useRef(true);
295+
useEffect(() => {
296+
if (layoutTweakFirst.current) { layoutTweakFirst.current = false; return; }
297+
setLayout(t.defaultLayout === "focus" ? "single" : t.defaultLayout);
298+
/* eslint-disable-next-line */
299+
}, [t.defaultLayout]);
281300

282301
const crumb = CRUMB[tab];
283302
const stats = { papers: papers.length, figures: figures ? figures.length : 0 };
@@ -308,11 +327,16 @@ function App() {
308327
</div>
309328

310329
<div className="view">
311-
{tab === "chat" && <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")} />}
330+
{/* ChatView stays mounted across tab switches — unmounting would
331+
destroy the conversation while the chat itself points users at
332+
the Papers and Figures tabs. */}
333+
<div style={{ display: tab === "chat" ? "contents" : "none" }}>
334+
<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")} />
335+
</div>
312336
{tab === "inspection" && <InspectionView settings={settings} apiKey={apiKey} model={model} papers={papers} pagesAvailable={pagesAvailable} routingAvailable={routingAvailable} />}
313337
{tab === "papers" && <PapersView setTab={setTab} papers={papers} figures={figures} />}
314338
{tab === "figures" && <FiguresView figures={figures} />}
315-
{tab === "why" && <WhyView setTab={setTab} />}
339+
{tab === "why" && <WhyView setTab={setTab} routingAvailable={routingAvailable} />}
316340
</div>
317341
</main>
318342

web/app/chat.jsx

Lines changed: 64 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)