fix(llm): chat panes never push the page sideways; markdown tables, fenced code, quotes and rules render; sticky autoscroll only when already at the bottom
Some checks failed
CI / lint (push) Successful in 32s
CI / notebooks-smoke (push) Successful in 1m36s
Deploy / notebooks (push) Has been skipped
CI / test (push) Successful in 2m13s
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / zotero (push) Successful in 14s
Infra CI / docs (push) Successful in 1m32s
Infra CI / api (push) Successful in 1m13s
Infra CI / llm (push) Successful in 47s
Infra CI / mc (push) Failing after 33s
Deploy / report (push) Successful in 18s
Infra CI / notebooks (push) Successful in 5m32s
Some checks failed
CI / lint (push) Successful in 32s
CI / notebooks-smoke (push) Successful in 1m36s
Deploy / notebooks (push) Has been skipped
CI / test (push) Successful in 2m13s
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / zotero (push) Successful in 14s
Infra CI / docs (push) Successful in 1m32s
Infra CI / api (push) Successful in 1m13s
Infra CI / llm (push) Successful in 47s
Infra CI / mc (push) Failing after 33s
Deploy / report (push) Successful in 18s
Infra CI / notebooks (push) Successful in 5m32s
- .msg gets min-width: 0 and the lineage/valuation wrappers max-width: 100%, so a wide table scrolls inside its message instead of stretching the column; #log hides horizontal overflow. - long cited labels and the valuation descriptor / lineage event cells may wrap (they were nowrap); bubbles use overflow-wrap: anywhere. - paint() handles | pipe | tables, ``` fences (also unterminated while streaming), > quotes and --- rules instead of showing them raw. - streaming tokens and arriving panes only scroll the log when the reader was within 120px of the bottom; a new question still jumps. - narrow viewports: header and input row wrap, user bubbles full width. Verified with a Node DOM-stub harness over the page script and tests/llm/test_api.py.
This commit is contained in:
@@ -44,18 +44,21 @@
|
||||
text-transform: uppercase; letter-spacing: .08em; opacity: .8;
|
||||
}
|
||||
#log {
|
||||
flex: 1 1 auto; overflow-y: auto; padding: 26px 22px;
|
||||
flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 26px 22px;
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
max-width: 860px; width: 100%; margin: 0 auto;
|
||||
}
|
||||
.msg { display: flex; flex-direction: column; gap: 7px; }
|
||||
/* min-width: 0 lets a wide table pane scroll inside its message instead
|
||||
of stretching the column (a flex child's default min-width is its
|
||||
content width, which is what pushed the page sideways). */
|
||||
.msg { display: flex; flex-direction: column; gap: 7px; min-width: 0; max-width: 100%; }
|
||||
.msg .role {
|
||||
font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--muted-fg);
|
||||
}
|
||||
.bubble {
|
||||
padding: 14px 16px; white-space: pre-wrap; word-wrap: break-word;
|
||||
border: 1px solid var(--border); border-radius: 3px;
|
||||
padding: 14px 16px; white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
border: 1px solid var(--border); border-radius: 3px; max-width: 100%;
|
||||
}
|
||||
.user { align-items: flex-end; }
|
||||
.user .role { text-align: right; }
|
||||
@@ -65,10 +68,11 @@
|
||||
}
|
||||
.bot .bubble { background: var(--card); }
|
||||
/* Model-cited [ID] tokens rendered as editorial small-caps mono. */
|
||||
.bot .bubble .cite {
|
||||
.bot .bubble .cite, .lineage-wrap .cite, .provenance .cite {
|
||||
font-family: var(--font-mono); font-size: .82em; color: var(--accent);
|
||||
background: var(--secondary); padding: 1px 5px; border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
/* a 90-char label must be allowed to wrap rather than overflow */
|
||||
white-space: normal; overflow-wrap: anywhere; box-decoration-break: clone;
|
||||
}
|
||||
/* A cite with a known paragraph URL is a jump link (FR ¶, eCFR §). */
|
||||
.bot .bubble a.cite { text-decoration: underline dotted; cursor: pointer; }
|
||||
@@ -84,6 +88,30 @@
|
||||
.bot .bubble ul, .bot .bubble ol { margin: 0 0 .6em 1.2em; padding: 0; }
|
||||
.bot .bubble li { margin: .15em 0; }
|
||||
.bot .bubble code { font-family: var(--font-mono); font-size: .88em; }
|
||||
.bot .bubble pre {
|
||||
font-family: var(--font-mono); font-size: .85em; line-height: 1.4;
|
||||
background: var(--secondary); border: 1px solid var(--border); border-radius: 3px;
|
||||
padding: 10px 12px; margin: 0 0 .6em; overflow-x: auto; white-space: pre; max-width: 100%;
|
||||
}
|
||||
.bot .bubble pre code { font-size: inherit; }
|
||||
.bot .bubble blockquote {
|
||||
margin: 0 0 .6em; padding: 2px 12px; border-left: 3px solid var(--border);
|
||||
color: var(--muted-fg);
|
||||
}
|
||||
.bot .bubble hr { border: 0; border-top: 1px solid var(--border); margin: .8em 0; }
|
||||
/* Markdown tables the model writes (| a | b |) render as real tables in
|
||||
a horizontally scrolling wrapper, never as raw pipe text. */
|
||||
.bot .bubble .md-table { overflow-x: auto; max-width: 100%; margin: 0 0 .6em; }
|
||||
.bot .bubble table.md {
|
||||
border-collapse: collapse; font-size: 14px; white-space: normal;
|
||||
}
|
||||
.bot .bubble table.md th, .bot .bubble table.md td {
|
||||
border: 1px solid var(--border); padding: 4px 8px; text-align: left; vertical-align: top;
|
||||
}
|
||||
.bot .bubble table.md th {
|
||||
font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--muted-fg);
|
||||
}
|
||||
table.valuation {
|
||||
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -94,22 +122,28 @@
|
||||
table.valuation th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--muted-fg); }
|
||||
table.valuation td:nth-child(-n+3), table.valuation th:nth-child(-n+3) { text-align: left; }
|
||||
/* the descriptor is the one cell that may wrap; numbers stay on one line */
|
||||
table.valuation td:nth-child(2) { white-space: normal; min-width: 160px; max-width: 320px; }
|
||||
table.valuation tr.proposed td { font-style: italic; opacity: .85; }
|
||||
table.valuation caption {
|
||||
caption-side: bottom; text-align: left; font-size: 11px;
|
||||
color: var(--muted-fg); padding: 4px 2px 0;
|
||||
}
|
||||
.valuation-wrap { overflow-x: auto; }
|
||||
.valuation-wrap { overflow-x: auto; max-width: 100%; }
|
||||
table.lineage {
|
||||
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
|
||||
}
|
||||
table.lineage th, table.lineage td {
|
||||
border: 1px solid var(--border); padding: 4px 6px; text-align: left; white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
/* event text can carry long from → to code lists; let it wrap */
|
||||
table.lineage td:nth-child(2), table.lineage td:nth-child(4) { white-space: normal; }
|
||||
table.lineage td:nth-child(2) { max-width: 360px; }
|
||||
table.lineage th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--muted-fg); }
|
||||
tr.unanchored td { color: var(--muted, #888); font-style: italic; }
|
||||
.lineage-wrap { overflow-x: auto; margin-bottom: 6px; }
|
||||
.lineage-wrap { overflow-x: auto; max-width: 100%; margin-bottom: 6px; }
|
||||
.lineage-wrap .caption { font-size: 12px; color: var(--muted-fg); margin-bottom: 2px; }
|
||||
ul.elements, ul.guidance {
|
||||
font-size: 12px; color: var(--muted-fg); margin: 6px 0 0; padding-left: 18px;
|
||||
@@ -129,7 +163,7 @@
|
||||
details.sources[open] > summary::before { content: "▾ "; }
|
||||
.src {
|
||||
border-left: 3px solid var(--border); padding: 6px 12px; margin: 10px 0;
|
||||
color: var(--muted-fg);
|
||||
color: var(--muted-fg); overflow-wrap: anywhere;
|
||||
}
|
||||
.src b { color: var(--accent); font-family: var(--font-mono); font-size: .8em; font-weight: 500; }
|
||||
.err { color: var(--destructive); border-color: var(--destructive); }
|
||||
@@ -176,6 +210,19 @@
|
||||
margin: auto; max-width: 500px; text-align: center; color: var(--muted-fg);
|
||||
font-style: italic;
|
||||
}
|
||||
/* Narrow viewports: the header and the input row wrap instead of
|
||||
squeezing; a user bubble may use the full width. */
|
||||
@media (max-width: 640px) {
|
||||
header { flex-wrap: wrap; gap: 6px 14px; padding: 12px 14px; }
|
||||
header .who { margin-left: 0; flex-basis: 100%; }
|
||||
#log { padding: 16px 12px; gap: 16px; }
|
||||
form { flex-wrap: wrap; padding: 12px; gap: 8px; }
|
||||
textarea { flex: 1 1 100%; }
|
||||
form label.recent, form label.mode { flex: 1 1 auto; }
|
||||
button { flex: 0 0 auto; margin-left: auto; padding: 8px 18px; }
|
||||
.user .bubble { max-width: 100%; }
|
||||
table.valuation td:nth-child(2) { min-width: 120px; max-width: 200px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -209,6 +256,16 @@
|
||||
const box = document.getElementById('q');
|
||||
const send = document.getElementById('send');
|
||||
|
||||
// Only follow the stream when the reader is already at the bottom —
|
||||
// scrolling up to re-read an earlier answer must not be yanked back by
|
||||
// every token or pane that arrives.
|
||||
function nearBottom() {
|
||||
return log.scrollHeight - log.scrollTop - log.clientHeight < 120;
|
||||
}
|
||||
function stick(wasNear) {
|
||||
if (wasNear) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
fetch('whoami').then(r => r.json()).then(d => {
|
||||
if (d.user) document.getElementById('who').textContent = d.user;
|
||||
}).catch(() => {});
|
||||
@@ -263,10 +320,35 @@
|
||||
// Block markdown: headings (# … ######), bullet and numbered lists,
|
||||
// paragraphs. The model writes markdown; showing raw "## History" was
|
||||
// the bug this replaces.
|
||||
function isTableRow(line) {
|
||||
return /^\s*\|.*\|\s*$/.test(line);
|
||||
}
|
||||
function isTableRule(line) {
|
||||
return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
|
||||
}
|
||||
function splitRow(line) {
|
||||
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
|
||||
}
|
||||
function paintTable(el, rows, links) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'md-table';
|
||||
const t = document.createElement('table');
|
||||
t.className = 'md';
|
||||
const [head, ...body] = rows;
|
||||
const tr = t.createTHead().insertRow();
|
||||
for (const cell of head) { const th = document.createElement('th'); paintInline(th, cell, links); tr.appendChild(th); }
|
||||
const tb = t.createTBody();
|
||||
for (const r of body) {
|
||||
const row = tb.insertRow();
|
||||
for (let i = 0; i < head.length; i++) { paintInline(row.insertCell(), r[i] || '', links); }
|
||||
}
|
||||
wrap.appendChild(t); el.appendChild(wrap);
|
||||
}
|
||||
|
||||
function paint(el, text, links) {
|
||||
el.innerHTML = '';
|
||||
const lines = text.split('\n');
|
||||
let para = [], list = null;
|
||||
let para = [], list = null, table = null, fence = null, quote = null;
|
||||
const flushPara = () => {
|
||||
if (!para.length) return;
|
||||
const p = document.createElement('p');
|
||||
@@ -274,8 +356,47 @@
|
||||
el.appendChild(p); para = [];
|
||||
};
|
||||
const flushList = () => { list = null; };
|
||||
const flushTable = () => {
|
||||
if (table && table.length) paintTable(el, table, links);
|
||||
table = null;
|
||||
};
|
||||
const flushQuote = () => { quote = null; };
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/\s+$/, '');
|
||||
// fenced code: everything until the closing fence is verbatim
|
||||
if (fence) {
|
||||
if (/^\s*```/.test(line)) { fence = null; continue; }
|
||||
fence.textContent += (fence.textContent ? '\n' : '') + raw;
|
||||
continue;
|
||||
}
|
||||
if (/^\s*```/.test(line)) {
|
||||
flushPara(); flushList(); flushTable(); flushQuote();
|
||||
const pre = document.createElement('pre');
|
||||
fence = document.createElement('code');
|
||||
pre.appendChild(fence); el.appendChild(pre);
|
||||
continue;
|
||||
}
|
||||
if (isTableRow(line)) {
|
||||
flushPara(); flushList(); flushQuote();
|
||||
if (isTableRule(line)) continue; // the |---|---| separator
|
||||
if (!table) table = [];
|
||||
table.push(splitRow(line));
|
||||
continue;
|
||||
}
|
||||
flushTable();
|
||||
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
||||
flushPara(); flushList(); flushQuote();
|
||||
el.appendChild(document.createElement('hr'));
|
||||
continue;
|
||||
}
|
||||
const q = /^\s*>\s?(.*)$/.exec(line);
|
||||
if (q) {
|
||||
flushPara(); flushList();
|
||||
if (!quote) { quote = document.createElement('blockquote'); el.appendChild(quote); }
|
||||
const p = document.createElement('p'); paintInline(p, q[1], links); quote.appendChild(p);
|
||||
continue;
|
||||
}
|
||||
flushQuote();
|
||||
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
const li = /^\s*(?:[-*•]|\d+[.)])\s+(.*)$/.exec(line);
|
||||
if (h) {
|
||||
@@ -297,7 +418,7 @@
|
||||
flushList(); para.push(line);
|
||||
}
|
||||
}
|
||||
flushPara();
|
||||
flushPara(); flushTable();
|
||||
}
|
||||
|
||||
// label → url for this turn, filled by the lineage/valuation/sources
|
||||
@@ -321,6 +442,7 @@
|
||||
}
|
||||
|
||||
function renderLineage(wrap, ev) {
|
||||
const near = nearBottom();
|
||||
const events = ev.events || [];
|
||||
const box = document.createElement('div');
|
||||
box.className = 'lineage-wrap';
|
||||
@@ -413,7 +535,7 @@
|
||||
box.appendChild(note);
|
||||
}
|
||||
wrap.appendChild(box);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
stick(near);
|
||||
}
|
||||
|
||||
const VAL_COLS = [
|
||||
@@ -430,6 +552,7 @@
|
||||
return String(v);
|
||||
}
|
||||
function renderValuation(wrap, ev) {
|
||||
const near = nearBottom();
|
||||
const rows = ev.rows || [];
|
||||
const box = document.createElement('div');
|
||||
box.className = 'valuation-wrap';
|
||||
@@ -486,10 +609,11 @@
|
||||
}
|
||||
box.appendChild(prov);
|
||||
wrap.appendChild(box);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
stick(near);
|
||||
}
|
||||
|
||||
function renderSources(wrap, ev) {
|
||||
const near = nearBottom();
|
||||
const sources = ev.sources || [];
|
||||
if (ev.model) {
|
||||
const meta = document.createElement('div');
|
||||
@@ -532,7 +656,7 @@
|
||||
d.appendChild(el);
|
||||
}
|
||||
wrap.appendChild(d);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
stick(near);
|
||||
}
|
||||
|
||||
async function ask(question) {
|
||||
@@ -566,7 +690,7 @@
|
||||
const line = part.replace(/^data: /, '').trim();
|
||||
if (!line) continue;
|
||||
const ev = JSON.parse(line);
|
||||
if (ev.type === 'token') { answer += ev.text; paint(b, answer, links); log.scrollTop = log.scrollHeight; }
|
||||
if (ev.type === 'token') { const near = nearBottom(); answer += ev.text; paint(b, answer, links); stick(near); }
|
||||
else if (ev.type === 'lineage') { collectLinks(links, ev); renderLineage(wrap, ev); }
|
||||
else if (ev.type === 'valuation') { collectLinks(links, ev); renderValuation(wrap, ev); }
|
||||
else if (ev.type === 'sources') { collectLinks(links, ev); renderSources(wrap, ev); paint(b, answer, links); }
|
||||
|
||||
Reference in New Issue
Block a user