diff --git a/src/llm/web/chat.html b/src/llm/web/chat.html index c08ba53..e75a49a 100644 --- a/src/llm/web/chat.html +++ b/src/llm/web/chat.html @@ -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; } + }
@@ -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); }