From 4d6b8e2c665845474e173b828f4272405388588c Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 6 Sep 2026 15:32:23 +0800 Subject: [PATCH 01/12] =?UTF-8?q?fix(web):=20mobile=20layout=20=E2=80=94?= =?UTF-8?q?=20scope=20rail=20collapse=20to=20wide=20screens,=20let=20the?= =?UTF-8?q?=20chat=20column=20fit=20its=20pane=20(UX-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stacked failures made the web shell unusable on phones since the right panel started collapsed by default (#367): - body.rb-collapsed .frame (specificity 0,1,1) outranked the ≤720px single-column .frame rule (0,0,1), so a 375px viewport got the desktop "300px 1fr" grid: sidebar 300px, chat 75px. The collapse rule now lives inside @media (min-width: 721px); the ≤720px block owns the phone layout and the panel stays display:none at every width as before. - #viewChat's implicit auto grid column could never be narrower than the function bar's min-content (10 flex:none icon buttons + chip + find box ≈ 680px), so #log / #form / #fnbar grew to 681px inside any pane narrower than that and .main (overflow:hidden) clipped the send button away. This also hit tablets: at 768px the pane is 468px wide and #sendBtn sat at x=865–961. The column is now minmax(0, 1fr) and .fnbar is a min-width:0 overflow-x:auto strip, so it scrolls inside the pane instead of widening it. At ≤720px the bar additionally drops the five quick-panel buttons (all reachable from the Memory view; pairing is loopback-only anyway) and the right-panel toggle (nothing to toggle there), so chip · KB · mail · find · theme fit a 375px row without scrolling; log/composer padding tightens and .frame uses 100dvh where supported. Measured in headless Chromium 151 against a born-soul scratch instance (before → after): 375×812, rail collapsed (default): .frame columns 300px 75px → 375px; .main width 75 → 375; .main scrollWidth/clientWidth 681/75 → 375/375; document scrollWidth/innerWidth 375/375 → 375/375; #sendBtn x 800–884 (off-screen) → 277–361 visible; #log/#form 681 → 375. 375×812, rail open: .main scrollWidth 681/375 → 375/375; #sendBtn 583–667 (off-screen) → 277–361 visible. 768×900: #sendBtn 865–961 (off-screen) → 652–748 visible; .fnbar scrolls 528/468 instead of widening the column. 1024 and 1440 (both rail states): unchanged (.main 724 / 1140 / 820). lisa-html-snapshot.test.ts: byte pin recomputed (310580 bytes). Co-Authored-By: Claude Fable 5.1 (cherry picked from commit cad0f222af235c70fa4df8c467d00feb36c56ceb) --- src/web/lisa-css.ts | 56 ++++++++++++++++++++++++++---- src/web/lisa-html-snapshot.test.ts | 22 ++++++++---- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/web/lisa-css.ts b/src/web/lisa-css.ts index 96285d5..6a7ce39 100644 --- a/src/web/lisa-css.ts +++ b/src/web/lisa-css.ts @@ -910,7 +910,9 @@ export const MAIN_CSS = ` :root { display: flex; align-items: center; gap: 4px; - min-width: 0; + /* Shrinks first when the bar is tight, but keeps a sliver of the session + name rather than collapsing to nothing (the bar scrolls past that). */ + min-width: 90px; flex-shrink: 1; overflow: hidden; } @@ -1421,6 +1423,15 @@ export const MAIN_CSS = ` :root { #viewChat.view.active { display: grid; grid-template-rows: auto 1fr auto auto; + /* minmax(0, 1fr), not the implicit auto track: an auto track can never + be narrower than its items' min-content, and the function bar's row of + flex:none icon buttons has a ~680px min-content — so on any main pane + narrower than that (phones, and tablets up to ~1180px with the sidebar + open) the whole chat column silently grew to 680px and the composer's + send button landed off-screen (UX-2). Pinning the track to the pane + lets .fnbar scroll inside it instead. */ + grid-template-columns: minmax(0, 1fr); + min-width: 0; } /* ── Console views (dashboard / control / reve / sense / memory) ── */ @@ -2327,6 +2338,12 @@ export const MAIN_CSS = ` :root { padding: 7px 16px; border-bottom: 1px solid var(--border-new, rgba(255,255,255,.08)); background: rgba(255,255,255,.02); + /* The bar must fit the pane it sits in (see #viewChat above): it may scroll + sideways when the pane is narrower than its buttons, never widen it. */ + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; } .fbtn { width: 34px; height: 34px; flex: none; @@ -2418,12 +2435,19 @@ export const MAIN_CSS = ` :root { /* ── Manual right-panel collapse (F4, persisted client-side) ──── Collapsed is the DEFAULT state; the class is only absent once the user - has explicitly opened the panel (lisaRightbar === "open"). */ - body.rb-collapsed .frame { - grid-template-columns: 300px 1fr; - grid-template-areas: - "titlebar titlebar" - "sidebar main"; + has explicitly opened the panel (lisaRightbar === "open"). + Wide screens only: this selector (0,1,1) outranks the bare .frame + rules inside the breakpoints below (0,0,1), so unscoped it forced the + two-column desktop grid onto phones — a 375px viewport got + "300px 75px" and a 75px-wide chat (UX-2). The ≤720px block owns the + single-column layout; the panel itself stays hidden at every width. */ + @media (min-width: 721px) { + body.rb-collapsed .frame { + grid-template-columns: 300px 1fr; + grid-template-areas: + "titlebar titlebar" + "sidebar main"; + } } body.rb-collapsed .rightbar { display: none; } #fnPanel.active { background: var(--accent-soft); color: var(--accent); } @@ -2453,13 +2477,31 @@ export const MAIN_CSS = ` :root { "titlebar" "sidebar" "main"; + /* Mobile browsers shrink the viewport as their toolbars come and go; + dvh tracks that so the composer never hides behind the toolbar. + Older engines ignore the unknown unit and keep the 100vh above. */ + height: 100dvh; } + /* No traffic lights to clear on a phone. */ + .titlebar { padding: 0 14px; } .rightbar { display: none; } .sidebar { max-height: 38vh; padding: 14px 14px 12px; gap: 14px; } + /* Function bar: fewer buttons, tighter spacing. The five quick-panel + buttons (soul/skills/tools/plans/pair) are all reachable from the + Memory view, and pairing is a Mac-side (loopback-only) action anyway; + the right-panel toggle has nothing to toggle here (the rail is hidden + at this width). What remains — session chip · KB · mail · find · + theme — fits a 375px row without scrolling. */ + .fnbar { padding: 6px 10px; gap: 4px; } + .fnbar [data-panel], #fnPanel { display: none; } + .fn-find { width: 120px; } + #log { padding: 14px 12px 16px; } + .msg { max-width: 94%; } + #attachPreview { padding: 4px 12px 0; } #form { grid-template-columns: 36px 36px 1fr 84px; padding: 10px 14px 14px; diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index f31e72f..af12dfb 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -203,19 +203,27 @@ import { MAIN_HTML } from "./lisa-html.js"; * body.rb-collapsed before .frame is parsed so a fresh profile never flashes * the 3-column layout while the big inline bundle at the end of is * still loading. #fnPanel still toggles and persists it. + * Then: UX-2 mobile layout — the body.rb-collapsed .frame rule is scoped to + * ≥721px so it no longer beats the ≤720px single-column grid; #viewChat pins + * its column to minmax(0,1fr) and .fnbar becomes a min-width:0 horizontal + * scroller, so the chat column can no longer grow to the bar's ~680px + * min-content; the ≤720px block hides the five quick-panel buttons + #fnPanel + * and tightens fnbar/log/composer spacing. */ /* - * +56 bytes = 4 × len("archive/plans/"): PLAN_UI_SESSION_SHELL_v1.0.md and - * PLAN_KNOWLEDGE_BASE_v1.0.md moved to docs/archive/plans/, and four of the - * references to them live in CSS/JS *comments that ship inside MAIN_HTML*. - * Nothing rendered changed. Re-derive with: + * Two independent byte shifts since the last pin, both in comments that ship + * inside MAIN_HTML — nothing rendered changed: + * +56 = 4 × len("archive/plans/"), from moving PLAN_UI_SESSION_SHELL_v1.0.md + * and PLAN_KNOWLEDGE_BASE_v1.0.md into docs/archive/plans/; + * the UX-2 mobile-layout CSS and its comment block. + * Re-derive with: * node --import tsx --input-type=module -e 'import{MAIN_HTML}from"./src/web/lisa-html.ts"; * import{createHash}from"node:crypto";console.log(MAIN_HTML.length, * createHash("sha256").update(MAIN_HTML).digest("hex"))' */ -const EXPECTED_LENGTH = 308257; -const EXPECTED_SHA256 = - "30bda3fd688a6a1e99ea552227ab4851b1eefa07df159c81b4310e01fbb90030"; +const EXPECTED_LENGTH = 310636; +const EXPECTED_SHA256 = + "bfefe205a397efecdf5978dbdae7dd2f188cb9b1baab4e06ca4711e45b855e7a"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH); From 9f84b93c9a73f076f9ad611310eb31c3c20ce114 Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 6 Sep 2026 22:08:11 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(web):=20accessibility=20floor=20?= =?UTF-8?q?=E2=80=94=20focus=20ring,=20AA=20contrast,=2011.5px=20text,=20l?= =?UTF-8?q?ive=20regions=20(UX-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell had no visible keyboard focus at all, secondary text sat below the WCAG AA 4.5:1 floor in both themes, and a dozen labels were rendered at 8.5–11px where a 13" laptop at 100% zoom already loses them. Screen readers got nothing when Lisa started or finished a turn, and the "needs you" count changed silently. Focus - New --focus-ring / --focus-ring-offset tokens and one global :focus-visible rule (2px solid var(--accent), 2px offset). :focus-visible, not :focus, so a mouse click never paints a ring. Tree rows are real
@@ -221,7 +228,9 @@ catch (e) { document.body.classList.add('rb-collapsed'); }
needs you
-
+ +
all clear ✓
From 269083e5d0879fbc66bbcf75781bf172002538d7 Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 6 Sep 2026 22:17:21 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat(web):=20empty=20states=20=E2=80=94?= =?UTF-8?q?=20name=20new=20sessions,=20give=20the=20empty=20chat=20a=20fir?= =?UTF-8?q?st=20screen=20(UX-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes right after the birth ritual. (a) A session with no messages was labelled with its raw id — "20260905-220846-9f7d58" in the tree, the context chip, the title bar and the inspector, four places showing a string no human reads. (b) #log had zero children, so the first thing a new user saw was an empty pane whose only hint was the composer placeholder. Naming - sessionLabel() falls back to "New session · " when messageCount is 0 instead of the id; the first user message still wins the moment there is one. Measured on a scratch instance: tree leaf, context chip and title bar all read "New session · 2m". - The id is demoted, never dropped: tree leaf title (already), new context-chip title, new title-bar title, and the inspector's existing sub line (" · "). The title bar previously read "Lisa · 20260905-…"; it is "Lisa · New session · 2m" with the id one hover away, which is what anyone matching a session against ~/.lisa/sessions actually needs. - setActiveSessionUI runs before /api/sessions lands, so the title bar asks the sidebar closure through window.lisaSessionLabel and repaints from renderSessionUI on every list refresh; it falls back to the id meanwhile. Empty chat card (#chatEmpty, .chat-empty) - One identity line reusing what the sidebar already fetched — "Lisa · born 2026-05-01 · 128 days" from #identitySub, no extra request. - Three clickable starters. The first names her actual current pursuit when the soul has one ("How is \"understand the codebase I live in\" going?" on the scratch soul), so the card is about THIS Lisa, not a generic tour; without a desire it degrades to "What's on your mind right now?". Clicking FILLS the composer and focuses it — it never sends, so a mis-click cannot spend a model call. - Three ability lines (tools / knowledge / mail). - It is drawn only after the first /api/history answer (an empty #log before that just means the fetch is in flight) and is retired by the first real node in the log: removeChatEmpty() sits in el(), in prependHistoryMessages, and in the three idle SSE paths that append to #log directly. It repaints when /api/soul and /api/island/ping land so the identity line and the desire-based starter are not stale. Verified in headless chromium at 1440×900 against a scratch instance (seeded soul, placeholder key, --no-idle --no-reflect --no-mcp --no-plugins, port 5871, no model call): card 560px wide and centred, all three starters render, a click put the desire prompt in #input, zero console errors, and the first Tab stop paints the new 2px rgb(106,212,255) focus ring at 2px offset. New tests extract the served text of sessionLabel out of MAIN_CLIENT_JS and run it in a vm sandbox, so the assertions are on the exact bytes the browser gets. Co-Authored-By: Claude Opus 5 (cherry picked from commit bc0ac6ddeebc4ac1aad4a719511cdfa8051b77ae) --- src/web/lisa-client.test.ts | 48 ++++++++++ src/web/lisa-client.ts | 145 ++++++++++++++++++++++++++++- src/web/lisa-css.ts | 60 +++++++++++- src/web/lisa-html-snapshot.test.ts | 9 +- 4 files changed, 255 insertions(+), 7 deletions(-) diff --git a/src/web/lisa-client.test.ts b/src/web/lisa-client.test.ts index 3374812..a9244be 100644 --- a/src/web/lisa-client.test.ts +++ b/src/web/lisa-client.test.ts @@ -1,5 +1,6 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; +import { createContext, runInContext } from "node:vm"; import { MAIN_CLIENT_JS } from "./lisa-client.js"; // Regression guard for the idle "while you were away" sentinel regex. @@ -68,3 +69,50 @@ describe("idle-note sentinel regex survives template-literal cooking", () => { ); }); }); + +/** + * Behavioural tests for individual client functions. + * + * The client is one big template literal, so there is no module to import + * and no DOM in `npm test`. These pull a named function's exact served text + * out of MAIN_CLIENT_JS and run it in a `vm` sandbox with hand-made stubs — + * so what is tested is literally what the browser executes. + */ +function extractFunction(src: string, name: string): string { + const head = `function ${name}(`; + const start = src.indexOf(head); + assert.ok(start >= 0, `function ${name} not found in MAIN_CLIENT_JS`); + let depth = 0; + let i = src.indexOf("{", start); + assert.ok(i >= 0, `function ${name} has no body`); + for (let j = i; j < src.length; j++) { + if (src[j] === "{") depth++; + else if (src[j] === "}") { + depth--; + if (depth === 0) return src.slice(start, j + 1); + } + } + throw new Error(`unbalanced braces in ${name}`); +} + +describe("sessionLabel names an empty session instead of showing its raw id (UX-4)", () => { + const src = extractFunction(MAIN_CLIENT_JS, "sessionLabel"); + const ctx = createContext({ relativeTime: (iso: string) => (iso ? "2m" : "") }); + runInContext(`${src}; globalThis.__label = sessionLabel;`, ctx); + const label = (ctx as { __label: (s: unknown) => string }).__label; + const ID = "20260905-220846-9f7d58"; + + test("a session with no messages reads as a new session, not the id", () => { + assert.equal(label({ id: ID, messageCount: 0, startedAt: "2026-09-05T22:08:46Z" }), "New session · 2m"); + }); + test("the first user message still wins once there is one", () => { + assert.equal(label({ id: ID, messageCount: 1, firstUserMessage: "fix the mail sweep" }), "fix the mail sweep"); + }); + test("long names are ellipsised to 30 chars", () => { + const long = "a".repeat(80); + assert.equal(label({ id: ID, messageCount: 3, firstUserMessage: long }), "a".repeat(30) + "…"); + }); + test("a session with messages but no captured text falls back to the id", () => { + assert.equal(label({ id: ID, messageCount: 4 }), ID); + }); +}); diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index 3c8eaff..a0a5fef 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -427,9 +427,22 @@ function setActiveSessionUI(id) { activeSessionId = id; window.lisaActiveSessionId = id; sessionEl.textContent = id; - const titlebarSession = document.getElementById('titlebarSession'); - if (titlebarSession) titlebarSession.textContent = '· ' + id; + updateTitlebarSession(); } +// UX-4: the title bar used to read "Lisa · 20260905-220846-9f7d58". It shows +// the session's human label now ("New session · just now" until the first +// message names it) and keeps the id in the tooltip, so the id is still one +// hover away for anyone matching it against ~/.lisa/sessions. +function updateTitlebarSession() { + const tag = document.getElementById('titlebarSession'); + if (!tag) return; + const id = window.lisaActiveSessionId; + if (!id) { tag.textContent = ''; tag.title = ''; return; } + const label = (typeof window.lisaSessionLabel === 'function' && window.lisaSessionLabel(id)) || id; + tag.textContent = '· ' + label; + tag.title = id; +} +window.lisaUpdateTitlebar = updateTitlebarSession; window.lisaSetActiveSession = function (id) { if (!id || id === activeSessionId) return; setActiveSessionUI(id); @@ -488,11 +501,13 @@ function connectEvents() { idlePulseEl = document.createElement('div'); idlePulseEl.className = 'idle-pulse'; idlePulseEl.textContent = '⋯ Lisa is thinking on her own time ⋯'; + removeChatEmpty(); log.appendChild(idlePulseEl); log.scrollTop = log.scrollHeight; } } else if (ev.type === 'idle_message') { if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; } + removeChatEmpty(); log.appendChild(buildIdleBlock(ev.text, ev.at)); log.scrollTop = log.scrollHeight; // sidebar reflection card mirrors the latest while-you-were-away @@ -504,6 +519,7 @@ function connectEvents() { const e2 = document.createElement('div'); e2.className = 'err'; e2.textContent = '[idle error] ' + ev.message; + removeChatEmpty(); log.appendChild(e2); } else if (ev.type === 'agent_session_update') { // D4a — sidebar multi-agent monitor refresh (defined later in the @@ -724,6 +740,10 @@ startupGate(); let historyPage = 0; let historyLoading = false; let historyExhausted = false; +// The empty-state card may only be drawn once we KNOW the log is empty — i.e. +// after the first /api/history answer. Before that an empty #log just means +// the fetch is still in flight. +let historyFetched = false; function textOfMessage(msg) { if (typeof msg.content === 'string') return msg.content.trim(); @@ -759,6 +779,7 @@ function prependHistoryMessages(messages) { fragment.appendChild(roleDiv); fragment.appendChild(span); } + removeChatEmpty(); log.insertBefore(fragment, log.firstChild); } @@ -790,6 +811,8 @@ async function loadHistoryPage() { } } finally { historyLoading = false; + historyFetched = true; + renderChatEmpty(); } } @@ -811,9 +834,97 @@ window.lisaResetChatLog = function () { pendingTools.clear(); if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; } log.innerHTML = ''; + historyFetched = false; loadHistoryPage(); }; +// ── Empty chat: who she is · three ways to start · three things she does ── +// UX-4: straight out of the birth ritual #log had zero children and the only +// hint was the composer placeholder "Talk to Lisa…". This card is the first +// screen a new user actually sees. It reuses what the sidebar already +// fetched (identity line, current desire) rather than issuing its own calls, +// and it disappears the moment the log holds anything real. +function chatLogHasContent() { + for (let i = 0; i < log.children.length; i++) { + if (log.children[i].id !== 'chatEmpty') return true; + } + return false; +} +function removeChatEmpty() { + const card = document.getElementById('chatEmpty'); + if (card) card.remove(); +} +// Three openers. The first one names what she is actually pursuing when the +// soul has a desire, so the card is about THIS Lisa and not a generic tour. +function chatStarters() { + let desire = ''; + const d = document.getElementById('sbDesire'); + if (d && d.title) desire = d.title.trim(); + if (desire.length > 64) desire = desire.slice(0, 64).trim() + '…'; + return [ + desire ? 'How is "' + desire + '" going?' : "What's on your mind right now?", + 'What do you remember about me?', + 'What should we work on today?', + ]; +} +const CHAT_ABILITIES = [ + ['Tools', 'ask her to read a file, run a command, or look something up on the web.'], + ['Knowledge', "select any message and save it — she'll recall it in later sessions."], + ['Mail', 'connect a mailbox in the right rail and she triages it for you daily.'], +]; +function renderChatEmpty() { + if (!historyFetched || chatLogHasContent()) { removeChatEmpty(); return; } + let card = document.getElementById('chatEmpty'); + if (!card) { + card = document.createElement('div'); + card.id = 'chatEmpty'; + card.className = 'chat-empty'; + } + card.innerHTML = ''; + const who = document.createElement('div'); + who.className = 'ce-who'; + const sub = document.getElementById('identitySub'); + const subTxt = sub && sub.textContent && sub.textContent !== '—' ? sub.textContent : ''; + who.textContent = subTxt ? 'Lisa · ' + subTxt : 'Lisa'; + card.appendChild(who); + const lead = document.createElement('div'); + lead.className = 'ce-lead'; + lead.textContent = 'Say anything — or start here:'; + card.appendChild(lead); + const starters = document.createElement('div'); + starters.className = 'ce-starters'; + chatStarters().forEach(function (text) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'ce-starter'; + b.textContent = text; + // Fill, don't send: the user still edits and presses Enter, so a + // mis-click never spends a model call. + b.addEventListener('click', function () { + input.value = text; + input.focus(); + try { input.setSelectionRange(text.length, text.length); } catch (e) {} + // Let the composer's own 'input' listener re-measure its height. + input.dispatchEvent(new Event('input')); + }); + starters.appendChild(b); + }); + card.appendChild(starters); + const can = document.createElement('ul'); + can.className = 'ce-can'; + CHAT_ABILITIES.forEach(function (pair) { + const li = document.createElement('li'); + const b = document.createElement('b'); + b.textContent = pair[0]; + li.appendChild(b); + li.appendChild(document.createTextNode(' — ' + pair[1])); + can.appendChild(li); + }); + card.appendChild(can); + if (!card.parentNode) log.appendChild(card); +} +window.lisaRenderChatEmpty = renderChatEmpty; + // ── mascot crossfade on mood event ────────────────────────────────── const mascotEl = document.getElementById('mascot'); const mascotTagEl = document.getElementById('mascotTag'); @@ -1151,6 +1262,9 @@ function el(tag, cls, text) { const node = document.createElement(tag); if (cls) node.className = cls; if (text != null) node.textContent = text; + // The first thing appended to the log retires the empty-state card, so a + // reply never renders underneath "Say anything — or start here:". + removeChatEmpty(); log.appendChild(node); log.scrollTop = log.scrollHeight; return node; @@ -2134,6 +2248,8 @@ if ('serviceWorker' in navigator) { if (!r.ok) return; const data = await r.json(); setDesire(data.current_desire); + // Same for the desire-derived first starter prompt. + if (typeof window.lisaRenderChatEmpty === 'function') window.lisaRenderChatEmpty(); if (data.last_idle_message_text) { window.updateReflection(data.last_idle_message_text); } @@ -2285,6 +2401,9 @@ if ('serviceWorker' in navigator) { const days = Math.max(0, Math.floor((Date.now() - born.getTime()) / 86400000)); const ymd = born.toISOString().slice(0, 10); identitySub.textContent = 'born ' + ymd + ' · ' + days + ' day' + (days === 1 ? '' : 's'); + // The empty-chat card quotes this line; it renders before /api/soul + // lands, so repaint it once the identity is known. + if (typeof window.lisaRenderChatEmpty === 'function') window.lisaRenderChatEmpty(); } catch {} } @@ -2300,12 +2419,23 @@ if ('serviceWorker' in navigator) { // observed agent while the stream pane is open. function sessionLabel(s) { // F2 auto-naming: the FIRST user message is the session's name (the - // opening request describes the task); fall back to the latest one, - // then the raw id. + // opening request describes the task); fall back to the latest one. const t = (s && (s.firstUserMessage || s.lastUserMessage) ? (s.firstUserMessage || s.lastUserMessage) : '').trim(); if (t) return t.length > 30 ? t.slice(0, 30) + '…' : t; + // UX-4: a session with nothing in it yet used to be labelled with its raw + // id (20260905-220846-9f7d58) in the tree, the context chip, the title bar + // and the inspector — four places showing a string no human reads. It is + // "New session · 2m" now; the id survives as the tooltip / inspector sub. + if (s && !s.messageCount) return 'New session · ' + relativeTime(s.startedAt); return s ? s.id : ''; } + // The title bar is rendered outside this closure (setActiveSessionUI runs + // before /api/sessions lands), so it asks for the label through here and + // falls back to the raw id while the list is still in flight. + window.lisaSessionLabel = function (id) { + const s = sessionById(id); + return s ? sessionLabel(s) : ''; + }; function sessionById(id) { for (let i = 0; i < cachedSessions.length; i++) { if (cachedSessions[i].id === id) return cachedSessions[i]; @@ -2435,6 +2565,9 @@ if ('serviceWorker' in navigator) { const s = sessionById(window.lisaActiveSessionId); chip.querySelector('.ctx-name').textContent = s ? sessionLabel(s) : (window.lisaActiveSessionId || '—'); chip.querySelector('.ctx-meta').textContent = s ? String(s.messageCount || 0) + ' msgs' : ''; + // The raw id is demoted to the tooltip now that the name can be + // "New session · 2m" (UX-4). + chip.title = window.lisaActiveSessionId || ''; } strip.appendChild(chip); } @@ -2678,6 +2811,10 @@ if ('serviceWorker' in navigator) { function renderSessionUI() { renderSessionTree(); renderTabs(); + // The label depends on cachedSessions, which lands after the title bar's + // first paint — refresh it on every list update. + if (typeof window.lisaUpdateTitlebar === 'function') window.lisaUpdateTitlebar(); + if (typeof window.lisaRenderChatEmpty === 'function') window.lisaRenderChatEmpty(); } window.lisaRenderSessionTree = renderSessionUI; diff --git a/src/web/lisa-css.ts b/src/web/lisa-css.ts index f60d18b..e8e745c 100644 --- a/src/web/lisa-css.ts +++ b/src/web/lisa-css.ts @@ -1977,6 +1977,63 @@ export const MAIN_CSS = ` :root { scroll-behavior: smooth; } + /* ── Empty chat card (UX-4) ────────────────────────────────────── + The first screen after the birth ritual. Centred in the log, capped + so it doesn't read as a wall on a 1440px pane, and it never grows a + scrollbar of its own — if the log has content this card is gone. */ + .chat-empty { + margin: auto; + max-width: 560px; + width: 100%; + padding: 22px 20px; + border: 1px solid var(--border-new); + border-radius: 16px; + background: var(--bg-card); + display: flex; + flex-direction: column; + gap: 10px; + } + .chat-empty .ce-who { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + } + .chat-empty .ce-lead { font-size: 13px; color: var(--fg-2); } + .chat-empty .ce-starters { display: flex; flex-direction: column; gap: 6px; } + .chat-empty .ce-starter { + text-align: left; + font-family: inherit; + font-size: 12.5px; + line-height: 1.45; + color: var(--fg); + background: var(--bg-3); + border: 1px solid var(--border-new); + border-radius: 10px; + padding: 9px 12px; + min-height: 36px; + cursor: pointer; + transition: border-color 140ms ease, background 140ms ease; + } + .chat-empty .ce-starter:hover { + border-color: var(--accent-glow); + background: var(--accent-soft); + } + .chat-empty .ce-can { + margin: 2px 0 0; + padding: 12px 0 0; + border-top: 1px solid var(--border-new); + list-style: none; + display: flex; + flex-direction: column; + gap: 5px; + font-size: 11.5px; + line-height: 1.5; + color: var(--fg-3); + } + .chat-empty .ce-can b { color: var(--fg-2); font-weight: 700; } + /* Chat author label (.role .you/.lisa) */ .role { font-size: 11.5px; @@ -3006,6 +3063,7 @@ export const MAIN_CSS = ` :root { .session-row .pip, .ctrl-row .cr-pip, .tleaf .pip, .ctx-chip .pip, .needs-row .pip, #recordBtn.recording, .birth-stars, .cfg-stars, .birth-step .step-cursor { animation: none; } .birth-step, .birth-final, .birth-enter, .kb-toast, .identity .avatar-wrap img, - .ctrl-row, .nav-item, .fbtn, .badge, #input, #sendBtn, .cfg-save { transition: none; } + .ctrl-row, .nav-item, .fbtn, .badge, #input, #sendBtn, .cfg-save, + .chat-empty .ce-starter { transition: none; } #log { scroll-behavior: auto; } }`; diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index f0f1cb4..b072b69 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -216,10 +216,15 @@ import { MAIN_HTML } from "./lisa-html.js"; * 36px icon buttons with 44px hit areas at ≤720px, a reduced-motion block, * aria-live regions (#chatStatus, #sbNeedsCount) and tabindex=-1 on the * off-screened file input. + * Then: UX-4 empty states — sessionLabel names a message-less session + * "New session · " (tree / context chip / title bar / + * inspector), the raw id moves into tooltips, and an empty #log renders the + * .chat-empty card (identity line, three desire-aware starters, three + * ability lines) which is retired by the first real node in the log. */ -const EXPECTED_LENGTH = 315917; +const EXPECTED_LENGTH = 323837; const EXPECTED_SHA256 = - "0713117ae6e8db79c36f0f22ef9f7edd9dd2c99d26bb2df21cd345b5fbbcc9b5"; + "19499f946c1c83c3b1892f94c86e5a385aeab7da21678ed0bcf3d99dfc77e011"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH); From 3e44a41fe0832ef50b2d3ebefffe65a9b76f6465 Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 6 Sep 2026 22:20:09 +0800 Subject: [PATCH 04/12] feat(web): badge the collapsed right rail and open it once when an agent is blocked (UX-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the inspector, mail, reflection and tokens. The only way back in was a 34px icon whose tooltip said "Collapse / expand the right panel" — nothing anywhere told you that two agents were sitting on a permission prompt. - .fbtn-badge on #fnPanel carries the needs-you count while the rail is collapsed (>9 renders "9+"), and the button's title/aria-label become "2 agents need you — open the right panel". Both revert to the plain toggle text when the count drops to zero or the rail is open, so nothing lingers. Warm token, not accent: it means "something is waiting on you", not "this control is on". #1a1206 on --warm is 12.8:1 in Nebula and 5.8:1 in Calm. - window.lisaRightbarAttention(count, needsDecision) is fed from renderNeeds on every roster tick. A "waiting" or pendingPermission agent counts as a decision; an errored one is badged but does NOT open the rail — an error is news, not a question. - The rail opens itself ONCE per page load, and only for a profile that has never driven the toggle. The manual toggle now persists lisaRightbarTouched, which permanently disarms the nudge. The auto-expand deliberately does not write lisaRightbar, so a reload returns to the collapsed default and one blocked agent can never silently become the user's layout — there is a test asserting that function body contains no setItem. Measured in headless chromium at 1440×900 against the scratch instance: fresh profile → collapsed, no badge; attention(2,false) → badge "2", still collapsed, .rightbar display:none; attention(2,true) → collapsed cleared, .rightbar display:flex, badge gone; one manual click → touched=1 stored, and a subsequent attention(4,true) leaves it collapsed with badge "4". Badge box 15×15px, rgb(255,208,102) on rgb(26,18,6), 11.5px. Zero page errors. Co-Authored-By: Claude Opus 5 (cherry picked from commit 394926affb741811daea313bfa5217b33c108e2d) --- src/web/lisa-client.test.ts | 35 ++++++++++++++++++ src/web/lisa-client.ts | 59 +++++++++++++++++++++++++++++- src/web/lisa-css.ts | 18 +++++++++ src/web/lisa-html-snapshot.test.ts | 7 +++- 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/web/lisa-client.test.ts b/src/web/lisa-client.test.ts index a9244be..b925bfe 100644 --- a/src/web/lisa-client.test.ts +++ b/src/web/lisa-client.test.ts @@ -116,3 +116,38 @@ describe("sessionLabel names an empty session instead of showing its raw id (UX- assert.equal(label({ id: ID, messageCount: 4 }), ID); }); }); + +describe("collapsed right rail keeps a way in (UX-5)", () => { + test("the manual toggle records that the user has a preference", () => { + assert.match( + MAIN_CLIENT_JS, + /localStorage\.setItem\('lisaRightbarTouched', '1'\)/, + "the #fnPanel click handler must persist lisaRightbarTouched", + ); + assert.match( + MAIN_CLIENT_JS, + /localStorage\.getItem\('lisaRightbarTouched'\) === '1'/, + "the flag must be read back at boot", + ); + }); + + test("the auto-expand nudge never writes the layout preference", () => { + // It is a one-time nudge for someone who has never touched the toggle. + // If it ever persisted 'lisaRightbar', a single blocked agent would + // permanently change a layout the user did not choose. + const start = MAIN_CLIENT_JS.indexOf("window.lisaRightbarAttention = function"); + assert.ok(start >= 0, "lisaRightbarAttention not found"); + const end = MAIN_CLIENT_JS.indexOf("\n };", start); + const body = MAIN_CLIENT_JS.slice(start, end); + assert.ok(!body.includes("setItem"), `auto-expand must not persist: ${body}`); + assert.match(body, /!touched && !autoExpanded/, "must be gated on both flags"); + }); + + test("the needs-you renderer feeds it, and only a decision expands the rail", () => { + assert.match( + MAIN_CLIENT_JS, + /window\.lisaRightbarAttention\(needs\.length, needs\.some\(function \(s\) \{\s*return \(s\.activity && s\.activity\.pendingPermission\) \|\| s\.state === 'waiting';/, + "an errored agent must be counted but must not auto-expand the rail", + ); + }); +}); diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index a0a5fef..113f703 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -1237,18 +1237,66 @@ if (fnSearchBtn && fnFind) { { let collapsed = true; try { collapsed = localStorage.getItem('lisaRightbar') !== 'open'; } catch (e) {} + // UX-5: has the user ever driven this toggle themselves? Once they have, + // their choice is final and the auto-expand nudge below never fires again — + // including across reloads, which is why it is persisted. + let touched = false; + try { touched = localStorage.getItem('lisaRightbarTouched') === '1'; } catch (e) {} + let autoExpanded = false; + let attention = 0; + const PANEL_TITLE = 'Collapse / expand the right panel'; + // While the rail is collapsed the only sign that an agent is blocked on a + // decision was a 34px icon with a tooltip — the count now rides the icon. + const syncBadge = () => { + const btn = document.getElementById('fnPanel'); + if (!btn) return; + let dot = btn.querySelector('.fbtn-badge'); + if (!collapsed || attention <= 0) { + if (dot) dot.remove(); + btn.title = PANEL_TITLE; + btn.setAttribute('aria-label', 'Toggle right panel'); + return; + } + if (!dot) { + dot = document.createElement('span'); + dot.className = 'fbtn-badge'; + btn.appendChild(dot); + } + dot.textContent = attention > 9 ? '9+' : String(attention); + const noun = attention === 1 ? '1 agent needs you' : attention + ' agents need you'; + btn.title = noun + ' — open the right panel'; + btn.setAttribute('aria-label', noun + ', open the right panel'); + }; const applyRb = () => { document.body.classList.toggle('rb-collapsed', collapsed); const btn = document.getElementById('fnPanel'); if (btn) btn.classList.toggle('active', collapsed); + syncBadge(); }; applyRb(); const panelBtn = document.getElementById('fnPanel'); if (panelBtn) panelBtn.addEventListener('click', () => { collapsed = !collapsed; - try { localStorage.setItem('lisaRightbar', collapsed ? 'collapsed' : 'open'); } catch (e) {} + touched = true; + try { + localStorage.setItem('lisaRightbar', collapsed ? 'collapsed' : 'open'); + localStorage.setItem('lisaRightbarTouched', '1'); + } catch (e) {} applyRb(); }); + // Called from the "needs you" renderer on every roster tick. + window.lisaRightbarAttention = function (count, needsDecision) { + attention = count > 0 ? count : 0; + // One nudge per page load, and only for someone who has never expressed a + // preference: a blocked agent is worth stealing 320px for exactly once. + // Deliberately NOT persisted — the next reload returns to the collapsed + // default, so this can never become a sticky layout the user didn't pick. + if (needsDecision && collapsed && !touched && !autoExpanded) { + autoExpanded = true; + collapsed = false; + } + applyRb(); + }; } let currentLisaSpan = null; @@ -1853,6 +1901,15 @@ if ('serviceWorker' in navigator) { count.appendChild(sr); } } + // UX-5: mirror the count onto the collapsed rail's toggle, and let the + // rail open itself the first time something is actually blocked on the + // user. An "error" agent is reported but does NOT trigger the expand — + // only a decision waiting to be made does. + if (typeof window.lisaRightbarAttention === 'function') { + window.lisaRightbarAttention(needs.length, needs.some(function (s) { + return (s.activity && s.activity.pendingPermission) || s.state === 'waiting'; + })); + } if (!needs.length) { const ok = document.createElement('div'); ok.className = 'session-empty'; diff --git a/src/web/lisa-css.ts b/src/web/lisa-css.ts index e8e745c..6597a43 100644 --- a/src/web/lisa-css.ts +++ b/src/web/lisa-css.ts @@ -2459,6 +2459,24 @@ export const MAIN_CSS = ` :root { } .fbtn:hover { background: var(--bg-card, rgba(255,255,255,.06)); color: var(--fg); } .fbtn svg { width: 19px; height: 19px; display: block; } + /* Attention badge on the collapsed right-rail toggle (UX-5). Uses the + "needs you" warm tone, not the accent, so it reads as "something is + waiting on you" rather than "this control is active". */ + .fbtn-badge { + position: absolute; + top: -2px; right: -2px; + min-width: 15px; height: 15px; + padding: 0 3px; + border-radius: 999px; + background: var(--warm); + color: #1a1206; + font-size: 11.5px; + font-weight: 700; + line-height: 15px; + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; + } .fbar-spacer { flex: 1; } /* Theme toggle: the moon shows in Nebula (dark), the sun in Calm. */ body[data-theme="calm"] #fnThemeMoon { display: none; } diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index b072b69..5fb7bd1 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -221,10 +221,13 @@ import { MAIN_HTML } from "./lisa-html.js"; * inspector), the raw id moves into tooltips, and an empty #log renders the * .chat-empty card (identity line, three desire-aware starters, three * ability lines) which is retired by the first real node in the log. + * Then: UX-5 right-rail discoverability — a .fbtn-badge count on #fnPanel + * while the rail is collapsed, plus a one-shot auto-expand gated on the + * persisted lisaRightbarTouched flag. */ -const EXPECTED_LENGTH = 323837; +const EXPECTED_LENGTH = 326975; const EXPECTED_SHA256 = - "19499f946c1c83c3b1892f94c86e5a385aeab7da21678ed0bcf3d99dfc77e011"; + "ae4d7e189263a8c508c5b300740d193d338365bccb1add651876fd8a818d862e"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH); From 3067c6ebae9ab514f38415dbb5b4e9af60e9e65f Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 6 Sep 2026 22:27:33 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat(web):=20onboarding=20=E2=80=94=20hum?= =?UTF-8?q?an=20birth=20errors,=20a=20way=20back=20to=20the=20key=20gate,?= =?UTF-8?q?=20and=20Cancel=20(UX-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P0 dead end. A rejected key produced `401 {"type":"error","error": {"type":"authentication_error",...}}` printed verbatim under the ritual, and ENTER did location.reload() straight back into the same failing key. The gate never returned — /api/config/status reports "configured" the moment a key is on disk — so the only recovery was hand-editing ~/.lisa/config.env. The ritual had no cancel either: the POST ran to completion whatever the user did. Errors - birthErrorCode(ev) understands the new frame ({kind,code,message,retryable}) and classifies an old server's raw payload by substring — 401/403/ authentication_error/invalid_api_key/unauthorized → auth, 429/rate_limit → rate_limit, timed out/ETIMEDOUT → timeout, ECONNREFUSED/ENOTFOUND/fetch failed → network, else unknown. An unrecognised code falls through to classification rather than being trusted. Substring tests rather than regexes on purpose: this file is a template literal, and every backslash would need doubling. - BIRTH_ERROR_TEXT carries one sentence per class; a test asserts none of them contains "{". The provider payload lands on birthError.title — reachable on hover and in a bug report, never on screen. Recovery - The gate is reopenable: openKeyGate({reconfigure, reason}) retitles it "CHANGE · API · KEY", shows the reason banner (#cfgReason) explaining why the form is back, and always clears the key field — retyping is the point. - code "auth" (or retryable:false) offers "Change key"; everything else offers "Try again" + "Change key". - Saving from reconfigure mode restarts the ritual IN PLACE — no location.reload(), so the page keeps its SSE connection, its log and its scroll. resetBirthUI() clears steps/final/ENTER/error between runs. Cancel - The ritual fetch now carries an AbortController. A Cancel button sits in #birthActions for the whole run and is withdrawn when the stream ends; it aborts the request and returns to the gate with "Cancelled. Set a key and Lisa will start again." An AbortError is never rendered as a failure. Verified in headless chromium against a keyless scratch instance (empty LISA_HOME, no key, port 5872) with fetch stubbed for the ritual, so no outbound request and no model call: boot shows the gate focused on the key field; the raw 401 payload renders as the auth sentence with the JSON only in title and a single "Change key" that reopens the gate in CHANGE mode with the reason shown; timeout/network/rate_limit/derived-ECONNREFUSED each render their sentence with "Try again"+"Change key"; retryable:false collapses to "Change key"; during a stream the only action is "Cancel" and clicking it aborts the fetch (signal fired) and reopens the gate; a save from repair mode issued /api/config/save then /api/birth with no page reload, replayed the steps and revealed ENTER. Zero page errors throughout. Known gap for another stream: /api/birth still does not listen for the client disconnect, so an aborted ritual keeps inferring server-side until it finishes. Co-Authored-By: Claude Opus 5 (cherry picked from commit bd5ef57fdcd27c4f86be7f037981709f86972903) --- src/web/lisa-client.test.ts | 49 +++++++++ src/web/lisa-client.ts | 157 +++++++++++++++++++++++++++-- src/web/lisa-css.ts | 47 ++++++++- src/web/lisa-html-snapshot.test.ts | 8 +- src/web/lisa-html.ts | 13 ++- 5 files changed, 259 insertions(+), 15 deletions(-) diff --git a/src/web/lisa-client.test.ts b/src/web/lisa-client.test.ts index b925bfe..2131790 100644 --- a/src/web/lisa-client.test.ts +++ b/src/web/lisa-client.test.ts @@ -151,3 +151,52 @@ describe("collapsed right rail keeps a way in (UX-5)", () => { ); }); }); + +describe("birth errors are classified into human copy (UX-1)", () => { + const src = + MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("const BIRTH_ERROR_TEXT = {"), + MAIN_CLIENT_JS.indexOf("function showBirthError("), + ); + const ctx = createContext({}); + runInContext(`${src}; globalThis.__code = birthErrorCode; globalThis.__text = BIRTH_ERROR_TEXT;`, ctx); + const code = (ctx as { __code: (ev: unknown) => string }).__code; + const text = (ctx as { __text: Record }).__text; + + test("a new server's explicit code wins", () => { + assert.equal(code({ kind: "error", code: "rate_limit", message: "whatever" }), "rate_limit"); + }); + test("an unknown code falls back to classification rather than being trusted", () => { + assert.equal(code({ code: "teapot", message: "401 nope" }), "auth"); + }); + test("an old server's raw Anthropic 401 payload classifies as auth", () => { + const raw = + '401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"},"request_id":null}'; + assert.equal(code({ kind: "error", message: raw }), "auth"); + }); + test("network, timeout and rate-limit shapes are recognised without a code", () => { + assert.equal(code({ message: "ECONNREFUSED 127.0.0.1:443" }), "network"); + assert.equal(code({ message: "fetch failed" }), "network"); + assert.equal(code({ message: "request timed out after 600000ms" }), "timeout"); + assert.equal(code({ message: "429 rate_limit_error" }), "rate_limit"); + }); + test("anything else is unknown, never rendered raw", () => { + assert.equal(code({ message: "kaboom" }), "unknown"); + assert.equal(code({}), "unknown"); + assert.equal(code(null), "unknown"); + }); + test("every class has copy, and none of it is JSON", () => { + for (const k of ["auth", "timeout", "network", "rate_limit", "unknown"]) { + assert.ok(text[k] && text[k].length > 20, `missing copy for ${k}`); + assert.ok(!text[k]!.includes("{"), `copy for ${k} leaks a payload`); + } + }); + test("the raw payload goes to the title attribute, never to textContent", () => { + const show = MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("function showBirthError("), + MAIN_CLIENT_JS.indexOf("async function maybeBirth("), + ); + assert.match(show, /birthErrorEl\.textContent = BIRTH_ERROR_TEXT\[code\]/); + assert.match(show, /birthErrorEl\.title = String\(\(ev && ev\.message\)/); + }); +}); diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index 113f703..2d6678d 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -554,6 +554,31 @@ const cfgOpenai = document.getElementById('cfgOpenai'); const cfgSaveBtn = document.getElementById('cfgSave'); const cfgError = document.getElementById('cfgError'); +// UX-1: the gate used to be one-shot. Once a key was written to config.env +// /api/config/status reported "configured" forever, so a REJECTED key left no +// way back — the form never returned and the Settings view sat behind the +// birth overlay. It is reopenable now, and remembers whether this visit is the +// first run or a repair, so the copy and the post-save path can differ. +let cfgReconfigure = false; +function openKeyGate(opts) { + cfgReconfigure = !!(opts && opts.reconfigure); + const title = document.getElementById('cfgTitle'); + if (title) title.textContent = cfgReconfigure ? 'CHANGE · API · KEY' : 'SET · API · KEY'; + const reason = document.getElementById('cfgReason'); + if (reason) { + reason.textContent = (opts && opts.reason) ? opts.reason : ''; + reason.style.display = reason.textContent ? '' : 'none'; + } + // Never prefill the rejected key — retyping is the point. + cfgAnthropic.value = ''; + cfgOpenai.value = ''; + cfgError.textContent = ''; + cfgSaveBtn.disabled = false; + birthOverlay.classList.remove('open'); + cfgOverlay.classList.add('open'); + setTimeout(() => cfgAnthropic.focus(), 50); +} + cfgForm.addEventListener('submit', async (ev) => { ev.preventDefault(); cfgError.textContent = ''; @@ -579,7 +604,10 @@ cfgForm.addEventListener('submit', async (ev) => { cfgAnthropic.value = ''; cfgOpenai.value = ''; cfgOverlay.classList.remove('open'); - maybeBirth(); + // A repair restarts the ritual in place — no location.reload(), so the + // page keeps its SSE connection, its log and its scroll position. + if (cfgReconfigure) { cfgReconfigure = false; beginBirth(); } + else maybeBirth(); } catch (err) { cfgError.textContent = 'Save failed: ' + err.message; cfgSaveBtn.disabled = false; @@ -592,17 +620,108 @@ const birthStepsEl = document.getElementById('birthSteps'); const birthFinalEl = document.getElementById('birthFinal'); const birthEnterBtn = document.getElementById('birthEnter'); const birthErrorEl = document.getElementById('birthError'); +const birthActionsEl = document.getElementById('birthActions'); birthEnterBtn.addEventListener('click', () => { birthOverlay.classList.remove('open'); setTimeout(() => location.reload(), 300); }); +// The ritual can now run more than once per page load (a repaired key, a +// retry after a timeout), so its UI needs a clean slate between runs. +function resetBirthUI() { + birthStepsEl.innerHTML = ''; + birthFinalEl.textContent = ''; + birthFinalEl.classList.remove('shown'); + birthEnterBtn.classList.remove('shown'); + birthErrorEl.textContent = ''; + birthErrorEl.title = ''; + clearBirthActions(); +} +function clearBirthActions() { + birthActionsEl.innerHTML = ''; + birthActionsEl.style.display = 'none'; +} +function birthAction(label, onClick, primary) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'birth-action' + (primary ? ' primary' : ''); + b.textContent = label; + b.addEventListener('click', onClick); + birthActionsEl.appendChild(b); + birthActionsEl.style.display = ''; + return b; +} +function beginBirth() { + resetBirthUI(); + birthOverlay.classList.add('open'); + startBirthStream(); +} + +// Live stream control (UX-1): the ritual is a long POST that used to be +// un-cancellable — closing the tab was the only exit, and the server kept +// inferring. Cancel aborts the fetch and hands the user back to the gate. +let birthAbort = null; +function abortBirth() { + if (!birthAbort) return; + try { birthAbort.abort(); } catch (e) {} + birthAbort = null; +} + +// Human text for every failure class. The old client printed the provider's +// raw payload — users saw 401 {"type":"error","error":{"type": +// "authentication_error",...}} and had nothing to do about it. +const BIRTH_ERROR_TEXT = { + auth: 'That API key was rejected by the provider. Enter a different one and Lisa will try again.', + timeout: 'The provider took too long to answer. Nothing was lost — try again.', + network: 'Could not reach the provider. Check the network on this machine, then try again.', + rate_limit: 'The provider is rate-limiting this key right now. Wait a minute, then try again.', + unknown: 'Something went wrong while she was waking up.', +}; +// New servers send {kind:"error", code, message, retryable}. Older ones send +// {kind:"error", message} with the raw provider text — classify those by hand +// so the same human copy shows either way. Substring tests, not regexes: this +// file is a template literal and every backslash here would need doubling. +function birthErrorCode(ev) { + const code = ev && typeof ev.code === 'string' ? ev.code : ''; + if (code && BIRTH_ERROR_TEXT[code]) return code; + const raw = String((ev && ev.message) || ''); + const low = raw.toLowerCase(); + if (raw.indexOf('401') === 0 || raw.indexOf('403') === 0 || + low.indexOf('authentication_error') >= 0 || low.indexOf('invalid_api_key') >= 0 || + low.indexOf('invalid api key') >= 0 || low.indexOf('unauthorized') >= 0) return 'auth'; + if (raw.indexOf('429') === 0 || low.indexOf('rate_limit') >= 0 || low.indexOf('rate limit') >= 0) return 'rate_limit'; + if (low.indexOf('timeout') >= 0 || low.indexOf('timed out') >= 0 || low.indexOf('etimedout') >= 0) return 'timeout'; + if (low.indexOf('enotfound') >= 0 || low.indexOf('econnrefused') >= 0 || + low.indexOf('econnreset') >= 0 || low.indexOf('eai_again') >= 0 || + low.indexOf('fetch failed') >= 0 || low.indexOf('network') >= 0) return 'network'; + return 'unknown'; +} +function showBirthError(ev) { + abortBirth(); + const code = birthErrorCode(ev); + birthErrorEl.textContent = BIRTH_ERROR_TEXT[code] || BIRTH_ERROR_TEXT.unknown; + // The provider payload is diagnostics, not copy — reachable on hover and in + // the DOM for a bug report, never rendered as the message. + birthErrorEl.title = String((ev && ev.message) || ''); + clearBirthActions(); + const changeKey = function () { + openKeyGate({ reconfigure: true, reason: BIRTH_ERROR_TEXT[code] || BIRTH_ERROR_TEXT.unknown }); + }; + if (code === 'auth') { + birthAction('Change key', changeKey, true); + return; + } + // Anything the server marks non-retryable gets the key path instead. + if (ev && ev.retryable === false) { birthAction('Change key', changeKey, true); return; } + birthAction('Try again', beginBirth, true); + birthAction('Change key', changeKey); +} + async function maybeBirth() { const status = await fetch('/api/soul').then(r => r.json()); if (status.born) return; - birthOverlay.classList.add('open'); - startBirthStream(); + beginBirth(); } function appendBirthStep(step) { @@ -674,15 +793,31 @@ async function startBirthStream() { birthEnterBtn.classList.add('shown'); processing = false; } else if (ev.kind === 'error') { - birthErrorEl.textContent = ev.message; + showBirthError(ev); processing = false; } } + // Cancel is offered for the whole run and withdrawn the moment the stream + // ends — it aborts the fetch and returns to the gate rather than leaving the + // user staring at a ritual they cannot stop. + abortBirth(); + const ctrl = new AbortController(); + birthAbort = ctrl; + let cancelled = false; + birthAction('Cancel', function () { + cancelled = true; + abortBirth(); + clearBirthActions(); + openKeyGate({ reconfigure: true, reason: 'Cancelled. Set a key and Lisa will start again.' }); + }); + try { - const res = await fetch('/api/birth', { method: 'POST' }); + const res = await fetch('/api/birth', { method: 'POST', signal: ctrl.signal }); if (!res.ok) { - birthErrorEl.textContent = 'Birth failed: HTTP ' + res.status + '. Check ANTHROPIC_API_KEY.'; + // An HTTP-level refusal carries no SSE frame — classify it the same way. + showBirthError({ code: res.status === 401 || res.status === 403 ? 'auth' : 'unknown', + message: 'HTTP ' + res.status }); return; } const reader = res.body.getReader(); @@ -703,8 +838,13 @@ async function startBirthStream() { processQueue(); } } + // The stream ended without an error frame: the run is over, drop Cancel + // (processQueue has already revealed ENTER on a 'done' frame). + if (birthAbort === ctrl) { birthAbort = null; clearBirthActions(); } } catch (err) { - birthErrorEl.textContent = 'Birth failed: ' + err.message; + // An abort is the user's own Cancel — the gate is already up, say nothing. + if (cancelled || (err && err.name === 'AbortError')) return; + showBirthError({ message: (err && err.message) ? err.message : String(err) }); } } @@ -724,8 +864,7 @@ async function startupGate() { } lisaClearBanner(); if (!cfg.configured) { - cfgOverlay.classList.add('open'); - setTimeout(() => cfgAnthropic.focus(), 50); + openKeyGate(); return; } try { diff --git a/src/web/lisa-css.ts b/src/web/lisa-css.ts index 6597a43..e2d6232 100644 --- a/src/web/lisa-css.ts +++ b/src/web/lisa-css.ts @@ -2940,8 +2940,39 @@ export const MAIN_CSS = ` :root { color: var(--err-color); text-align: center; margin-top: 24px; + font-size: 12.5px; + line-height: 1.55; + max-width: 44ch; + margin-left: auto; + margin-right: auto; + } + /* Cancel during the ritual; Change key / Try again after it fails (UX-1). */ + .birth-actions { + display: flex; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 18px; + } + .birth-action { + font-family: inherit; font-size: 11.5px; - letter-spacing: 0.05em; + font-weight: 700; + letter-spacing: 0.10em; + text-transform: uppercase; + min-height: 36px; + padding: 0 18px; + border-radius: 999px; + cursor: pointer; + color: var(--fg-2); + background: transparent; + border: 1px solid var(--border-strong); + } + .birth-action:hover { color: var(--fg); border-color: var(--accent-glow); } + .birth-action.primary { + color: var(--accent); + border-color: var(--accent-glow); + background: var(--accent-soft); } /* =================================================================== @@ -2995,6 +3026,20 @@ export const MAIN_CSS = ` :root { line-height: 1.5; } .cfg-sub a { color: var(--accent); text-decoration: underline; } + /* Why you are looking at this form again (UX-1) — only set when the gate + is reopened after a failed or cancelled ritual. */ + .cfg-reason { + margin: -8px auto 18px; + max-width: 44ch; + padding: 9px 13px; + border-radius: 10px; + border: 1px solid rgba(255, 85, 119, 0.35); + background: rgba(255, 85, 119, 0.10); + color: var(--fg-2); + font-size: 12.5px; + line-height: 1.5; + text-align: center; + } .cfg-field { display: block; margin: 14px 0; } .cfg-label { display: block; diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index 5fb7bd1..cef44cf 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -224,10 +224,14 @@ import { MAIN_HTML } from "./lisa-html.js"; * Then: UX-5 right-rail discoverability — a .fbtn-badge count on #fnPanel * while the rail is collapsed, plus a one-shot auto-expand gated on the * persisted lisaRightbarTouched flag. + * Then: UX-1 onboarding — #cfgTitle / #cfgReason on the key gate (reopenable + * in reconfigure mode), #birthActions carrying Cancel during the stream and + * Change key / Try again after a failure, and human copy for every birth + * error class instead of the provider's raw JSON. */ -const EXPECTED_LENGTH = 326975; +const EXPECTED_LENGTH = 335123; const EXPECTED_SHA256 = - "ae4d7e189263a8c508c5b300740d193d338365bccb1add651876fd8a818d862e"; + "fab8ac0966e617fac2a26fac9624d7e2d10911399b52fd04f28a6ce362fcde4f"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH); diff --git a/src/web/lisa-html.ts b/src/web/lisa-html.ts index c5ab0a8..ae4b328 100644 --- a/src/web/lisa-html.ts +++ b/src/web/lisa-html.ts @@ -18,8 +18,9 @@ * unchanged: * log, input, form, sendBtn, sessionId, fileInput, attachPreview, * mascot, mascotTag, modalBg, modalTitle, modalBody, modalClose, - * cfgOverlay, cfgForm, cfgAnthropic, cfgOpenai, cfgSave, cfgError, - * birthOverlay, birthSteps, birthFinal, birthEnter, birthError, + * cfgOverlay, cfgForm, cfgTitle, cfgReason, cfgAnthropic, cfgOpenai, + * cfgSave, cfgError, + * birthOverlay, birthSteps, birthFinal, birthEnter, birthError, birthActions, * attachBtn * * New IDs for the sidebar live blocks (wired in the trailing @@ -306,7 +307,10 @@ catch (e) { document.body.classList.add('rb-collapsed'); }
✦ ✦ ✦ ✦ ✦
-
SET · API · KEY
+
SET · API · KEY
+ +
Lisa needs an Anthropic API key to wake up.
Get one at console.anthropic.com @@ -342,6 +346,9 @@ catch (e) { document.body.classList.add('rb-collapsed'); }
+ +
✦ ✦ ✦ ✦ ✦
From 964ab1d88ab30563abfc547dd08f456e7d82f31c Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 6 Sep 2026 22:34:05 +0800 Subject: [PATCH 06/12] feat(web): provider picker in the key gate and Settings (UX-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate asked for an ANTHROPIC_API_KEY (required) and an optional OPENAI_API_KEY. The CLI's `lisa doctor` lists 13 providers and the site advertises "10+ LLM providers", so a DeepSeek/GLM/Qwen user could not complete first run in the browser at all — the only route was editing config.env by hand. - One table drives both surfaces. It is built from /api/config/status.providers ({id, envKey, label, modelPrefixes, configured}) when the server reports one, and otherwise from a built-in list mirroring src/providers/registry.ts: Anthropic, OpenAI, DeepSeek, Zhipu GLM, Aliyun Qwen, Moonshot Kimi, Google Gemini and a custom OpenAI-compatible base URL. Matching between the two is by envKey first — ids belong to the server, the environment variable name is the stable identity — so a provider this client has never heard of still renders, with a generic placeholder. - The gate is now PROVIDER / KEY / MODEL, plus BASE URL for the custom entry only. The key label shows the actual env var, the console link retargets per provider, and a repair keeps the provider that just failed selected. - The model field is prefilled as a placeholder, not a value, and is only sent automatically for providers the server cannot auto-detect. Anthropic and OpenAI are resolved from the key alone by resolveDefaultModel(); every other provider would otherwise fall back to claude-sonnet-4-6 with a key that cannot pay for it, so those pin LISA_MODEL. - POST /api/config/save carries the new {keys:{ENV:value}, model, baseUrl} shape plus every legacy field name the current server reads (anthropicKey/openaiKey) and the shorter aliases (anthropic/openai). - After saving, the client re-reads the status and confirms the key survived. An older backend silently drops anything that is not Anthropic or OpenAI, so instead of walking into a ritual that cannot succeed the user gets: "This Lisa did not keep the DeepSeek key — it only accepts Anthropic and OpenAI keys. Update Lisa (npm i -g @oratis/lisa), or add DEEPSEEK_API_KEY=… to ~/.lisa/config.env and restart." Confirmation returns null (never blocks) when the answer is genuinely unknowable. - The Settings view lists every provider with a configured/not-set chip and uses the same picker + the same guard. Measured in headless chromium. Against the keyless instance (5872, old-shape status): 8 options, Anthropic preselected, switching to DeepSeek retitles the key label to DEEPSEEK_API_KEY and the model placeholder to deepseek-chat, custom reveals the base-URL field. A DeepSeek save posted {keys:{DEEPSEEK_API_KEY},model:"deepseek-chat"} and was refused with the note above (gate stayed open); an Anthropic save posted keys + anthropicKey + anthropic and closed the gate. Injecting a new-contract status with an unknown "Brand New Co" provider rebuilt the list to exactly those three and preselected the configured one. Against the born instance (5871) the Settings view listed seven providers with chips and a zhipu save posted {keys:{ZHIPU_API_KEY},model:"glm-4-plus"}. Zero page errors. Contract note for the server stream: today's handler reads `anthropicKey` / `openaiKey`, not `anthropic` / `openai` — this client sends all four spellings plus `keys`, so either naming works. Co-Authored-By: Claude Opus 5 (cherry picked from commit d0186bad5b571a211707b28f407b9805125e2600) --- src/web/lisa-client.test.ts | 84 +++++++++ src/web/lisa-client.ts | 294 ++++++++++++++++++++++++++--- src/web/lisa-css.ts | 4 + src/web/lisa-html-snapshot.test.ts | 8 +- src/web/lisa-html.ts | 31 ++- 5 files changed, 381 insertions(+), 40 deletions(-) diff --git a/src/web/lisa-client.test.ts b/src/web/lisa-client.test.ts index 2131790..2a75413 100644 --- a/src/web/lisa-client.test.ts +++ b/src/web/lisa-client.test.ts @@ -200,3 +200,87 @@ describe("birth errors are classified into human copy (UX-1)", () => { assert.match(show, /birthErrorEl\.title = String\(\(ev && ev\.message\)/); }); }); + +describe("provider picker works against both server generations (UX-1)", () => { + const src = MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("const LISA_PROVIDER_FALLBACK = ["), + MAIN_CLIENT_JS.indexOf("// ── API key config gate"), + ); + const ctx = createContext({ window: {} }); + runInContext(src, ctx); + const c = ctx as { + lisaProviderList: (s: unknown) => Array>; + lisaProviderSaveBody: (p: unknown, k: string, m: string, b: string) => Record; + lisaProviderConfirm: (p: unknown, s: unknown) => boolean | null; + }; + + test("an old status (no providers block) falls back to the built-in table", () => { + const list = c.lisaProviderList({ configured: true, anthropic: true, openai: false }); + assert.ok(list.length >= 8, `only ${list.length} providers`); + const ids = list.map((p) => p.id); + for (const want of ["anthropic", "openai", "deepseek", "zhipu", "dashscope", "moonshot", "gemini", "custom"]) { + assert.ok(ids.includes(want), `missing ${want}`); + } + assert.equal(list.find((p) => p.id === "anthropic")!.configured, true); + assert.equal(list.find((p) => p.id === "openai")!.configured, false); + }); + + test("a served providers block wins, including providers this client has never heard of", () => { + const list = c.lisaProviderList({ + providers: [ + { id: "zhipu", envKey: "ZHIPU_API_KEY", label: "Zhipu GLM", modelPrefixes: ["glm-"], configured: true }, + { id: "brandnew", envKey: "BRANDNEW_API_KEY", label: "Brand New Co", modelPrefixes: ["bn-"], configured: false }, + ], + }); + assert.equal(JSON.stringify(list.map((p) => p.id)), JSON.stringify(["zhipu", "brandnew"])); + assert.equal(list[0]!.configured, true); + // Local presentation hints still merge in for the ones we know. + assert.equal(list[0]!.model, "glm-4-plus"); + assert.equal(list[1]!.placeholder, "key..."); + }); + + test("the save body carries the new shape plus every legacy field name", () => { + const anthropic = c.lisaProviderList(null).find((p) => p.id === "anthropic")!; + const body = c.lisaProviderSaveBody(anthropic, "sk-ant-x", "", ""); + assert.equal(JSON.stringify(body.keys), JSON.stringify({ ANTHROPIC_API_KEY: "sk-ant-x" })); + assert.equal(body.anthropicKey, "sk-ant-x"); + assert.equal(body.anthropic, "sk-ant-x"); + const openai = c.lisaProviderList(null).find((p) => p.id === "openai")!; + const b2 = c.lisaProviderSaveBody(openai, "sk-o", "gpt-4o", ""); + assert.equal(b2.openaiKey, "sk-o"); + assert.equal(b2.openai, "sk-o"); + assert.equal(b2.model, "gpt-4o"); + // A third-party provider gets no legacy field — there is none to send. + const ds = c.lisaProviderList(null).find((p) => p.id === "deepseek")!; + const b3 = c.lisaProviderSaveBody(ds, "sk-d", "deepseek-chat", ""); + assert.equal(JSON.stringify(Object.keys(b3).sort()), JSON.stringify(["keys", "model"])); + const custom = c.lisaProviderList(null).find((p) => p.id === "custom")!; + assert.equal(c.lisaProviderSaveBody(custom, "k", "m", "https://h/v1").baseUrl, "https://h/v1"); + }); + + test("only providers the server cannot auto-detect pin a model", () => { + const byId = (id: string) => c.lisaProviderList(null).find((p) => p.id === id)!; + // Anthropic and OpenAI are resolved from the key alone by + // providers/registry.resolveDefaultModel; the rest would silently fall + // back to Claude if LISA_MODEL were left unset. + assert.equal(byId("anthropic").needsModel, false); + assert.equal(byId("openai").needsModel, false); + for (const id of ["deepseek", "zhipu", "dashscope", "moonshot", "gemini", "custom"]) { + assert.equal(byId(id).needsModel, true, `${id} must pin a model`); + } + }); + + test("confirm reports false only when we KNOW the server dropped the key", () => { + const ds = c.lisaProviderList(null).find((p) => p.id === "deepseek")!; + // Old server, third-party key: it cannot have kept it. + assert.equal(c.lisaProviderConfirm(ds, { configured: false, anthropic: false }), false); + // Old server, Anthropic key it did keep. + const an = c.lisaProviderList(null).find((p) => p.id === "anthropic")!; + assert.equal(c.lisaProviderConfirm(an, { anthropic: true }), true); + // New server that lists the provider. + const st = { providers: [{ id: "deepseek", envKey: "DEEPSEEK_API_KEY", configured: true }] }; + assert.equal(c.lisaProviderConfirm(ds, st), true); + // New server that does not list it at all — unknowable, never block. + assert.equal(c.lisaProviderConfirm({ envKey: "NOPE" }, st), null); + }); +}); diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index 2d6678d..9275b14 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -546,14 +546,180 @@ function connectEvents() { } connectEvents(); +// ── Providers ──────────────────────────────────────────────────── +// The gate and the Settings view both drive off one table. A new server +// reports it on /api/config/status as +// providers: [{id, envKey, label, modelPrefixes, configured}] +// plus a top-level "model"; this list is the fallback for servers that do not, +// and it also supplies the parts the server has no opinion on (placeholder, +// console link, a suggested model). Entries mirror src/providers/registry.ts. +const LISA_PROVIDER_FALLBACK = [ + { id: 'anthropic', envKey: 'ANTHROPIC_API_KEY', label: 'Anthropic (Claude)', + placeholder: 'sk-ant-...', model: 'claude-sonnet-4-6', + consoleUrl: 'https://console.anthropic.com/' }, + { id: 'openai', envKey: 'OPENAI_API_KEY', label: 'OpenAI (GPT)', + placeholder: 'sk-...', model: 'gpt-4o', + consoleUrl: 'https://platform.openai.com/api-keys' }, + { id: 'deepseek', envKey: 'DEEPSEEK_API_KEY', label: 'DeepSeek', + placeholder: 'sk-...', model: 'deepseek-chat', + consoleUrl: 'https://platform.deepseek.com/api_keys', needsModel: true }, + { id: 'zhipu', envKey: 'ZHIPU_API_KEY', label: 'Zhipu GLM', + placeholder: 'key...', model: 'glm-4-plus', + consoleUrl: 'https://open.bigmodel.cn/usercenter/apikeys', needsModel: true }, + { id: 'dashscope', envKey: 'DASHSCOPE_API_KEY', label: 'Aliyun Qwen', + placeholder: 'sk-...', model: 'qwen-plus', + consoleUrl: 'https://bailian.console.aliyun.com/', needsModel: true }, + { id: 'moonshot', envKey: 'MOONSHOT_API_KEY', label: 'Moonshot Kimi', + placeholder: 'sk-...', model: 'moonshot-v1-32k', + consoleUrl: 'https://platform.moonshot.cn/console/api-keys', needsModel: true }, + { id: 'gemini', envKey: 'GEMINI_API_KEY', label: 'Google Gemini', + placeholder: 'AIza...', model: 'gemini-2.5-pro', + consoleUrl: 'https://aistudio.google.com/apikey', needsModel: true }, + { id: 'custom', envKey: 'LISA_API_KEY', label: 'Custom (OpenAI-compatible)', + placeholder: 'key...', model: '', custom: true, consoleUrl: '', needsModel: true }, +]; +// Merge what the server knows (which keys exist, which are configured) with +// the local presentation hints. Matching on envKey first: ids are the server's +// to choose, the environment variable name is the stable identity. +function lisaProviderList(status) { + const hintOf = function (envKey, id) { + for (let i = 0; i < LISA_PROVIDER_FALLBACK.length; i++) { + const h = LISA_PROVIDER_FALLBACK[i]; + if (h.envKey === envKey || (id && h.id === id)) return h; + } + return null; + }; + const served = status && Array.isArray(status.providers) ? status.providers : null; + if (!served || !served.length) { + // No providers block: an older server. Mark configured from the two + // booleans it does report. + return LISA_PROVIDER_FALLBACK.map(function (h) { + const conf = h.envKey === 'ANTHROPIC_API_KEY' ? !!(status && status.anthropic) + : h.envKey === 'OPENAI_API_KEY' ? !!(status && status.openai) : false; + return { id: h.id, envKey: h.envKey, label: h.label, placeholder: h.placeholder, + model: h.model, consoleUrl: h.consoleUrl, custom: !!h.custom, + needsModel: !!h.needsModel, configured: conf, served: false }; + }); + } + return served.map(function (p) { + const h = hintOf(p.envKey, p.id) || {}; + return { + id: p.id || h.id || p.envKey, + envKey: p.envKey || h.envKey || '', + label: p.label || h.label || p.id || p.envKey, + placeholder: h.placeholder || 'key...', + model: h.model || '', + consoleUrl: h.consoleUrl || '', + custom: !!h.custom, + needsModel: !!h.needsModel, + configured: !!p.configured, + served: true, + }; + }); +} +// One body understood by both generations of the endpoint: the new +// {keys, model, baseUrl} shape plus every legacy field name. +function lisaProviderSaveBody(provider, key, model, baseUrl) { + const body = { keys: {} }; + if (key && provider.envKey) body.keys[provider.envKey] = key; + if (model) body.model = model; + if (baseUrl) body.baseUrl = baseUrl; + if (key && provider.envKey === 'ANTHROPIC_API_KEY') { body.anthropicKey = key; body.anthropic = key; } + if (key && provider.envKey === 'OPENAI_API_KEY') { body.openaiKey = key; body.openai = key; } + return body; +} +// After a save: did this server actually keep the key? true / false / null +// when there is no way to tell. An older server silently drops anything that +// is not an Anthropic or OpenAI key, which is exactly the case worth naming. +function lisaProviderConfirm(provider, status) { + const served = status && Array.isArray(status.providers) ? status.providers : null; + if (served && served.length) { + for (let i = 0; i < served.length; i++) { + if (served[i].envKey === provider.envKey) return !!served[i].configured; + } + return null; + } + if (provider.envKey === 'ANTHROPIC_API_KEY') return !!(status && status.anthropic); + if (provider.envKey === 'OPENAI_API_KEY') return !!(status && status.openai); + return false; +} +function lisaProviderUnsupportedNote(provider) { + return 'This Lisa did not keep the ' + provider.label + ' key — it only accepts Anthropic and ' + + 'OpenAI keys. Update Lisa (npm i -g @oratis/lisa), or add ' + provider.envKey + + '=... to ~/.lisa/config.env and restart.'; +} +// The Settings view lives in the console closure at the bottom of this file. +window.lisaProviderList = lisaProviderList; +window.lisaProviderSaveBody = lisaProviderSaveBody; +window.lisaProviderConfirm = lisaProviderConfirm; +window.lisaProviderUnsupportedNote = lisaProviderUnsupportedNote; + // ── API key config gate: show overlay if no key is configured ───── const cfgOverlay = document.getElementById('cfgOverlay'); const cfgForm = document.getElementById('cfgForm'); -const cfgAnthropic = document.getElementById('cfgAnthropic'); -const cfgOpenai = document.getElementById('cfgOpenai'); +const cfgProvider = document.getElementById('cfgProvider'); +const cfgKey = document.getElementById('cfgKey'); +const cfgKeyLabel = document.getElementById('cfgKeyLabel'); +const cfgModel = document.getElementById('cfgModel'); +const cfgBaseUrl = document.getElementById('cfgBaseUrl'); +const cfgBaseUrlField = document.getElementById('cfgBaseUrlField'); +const cfgConsole = document.getElementById('cfgConsole'); const cfgSaveBtn = document.getElementById('cfgSave'); const cfgError = document.getElementById('cfgError'); +// Populated by startupGate / openKeyGate from /api/config/status. +let cfgProviders = lisaProviderList(null); +let cfgStatus = null; +function cfgSelected() { + for (let i = 0; i < cfgProviders.length; i++) { + if (cfgProviders[i].id === cfgProvider.value) return cfgProviders[i]; + } + return cfgProviders[0]; +} +function cfgSyncProvider() { + const p = cfgSelected(); + if (!p) return; + cfgKeyLabel.textContent = p.envKey + (p.configured ? ' (configured — a new value replaces it)' : ''); + cfgKey.placeholder = p.placeholder; + cfgModel.placeholder = p.model || 'provider default'; + // Only the custom endpoint needs a base URL, and it needs a model too. + cfgBaseUrlField.style.display = p.custom ? '' : 'none'; + if (cfgConsole) { + if (p.consoleUrl) { + cfgConsole.href = p.consoleUrl; + cfgConsole.textContent = 'Get a key for ' + p.label + ' ↗'; + cfgConsole.style.display = ''; + } else { + cfgConsole.textContent = ''; + cfgConsole.style.display = 'none'; + } + } +} +function cfgRenderProviders(status) { + cfgStatus = status || cfgStatus; + cfgProviders = lisaProviderList(cfgStatus); + const keep = cfgProvider.value; + cfgProvider.innerHTML = ''; + cfgProviders.forEach(function (p) { + const o = document.createElement('option'); + o.value = p.id; + o.textContent = p.label + (p.configured ? ' · configured' : ''); + cfgProvider.appendChild(o); + }); + // Preselect: whatever is already picked (so a repair keeps the provider + // that just failed), else the one this Lisa is actually configured with, + // else Anthropic. + let firstConfigured = ''; + for (let i = 0; i < cfgProviders.length && !firstConfigured; i++) { + if (cfgProviders[i].configured) firstConfigured = cfgProviders[i].id; + } + const wanted = keep || firstConfigured || 'anthropic'; + cfgProvider.value = wanted; + if (!cfgProvider.value) cfgProvider.value = cfgProviders[0] ? cfgProviders[0].id : ''; + cfgSyncProvider(); +} +cfgProvider.addEventListener('change', cfgSyncProvider); + // UX-1: the gate used to be one-shot. Once a key was written to config.env // /api/config/status reported "configured" forever, so a REJECTED key left no // way back — the form never returned and the Settings view sat behind the @@ -569,31 +735,38 @@ function openKeyGate(opts) { reason.textContent = (opts && opts.reason) ? opts.reason : ''; reason.style.display = reason.textContent ? '' : 'none'; } - // Never prefill the rejected key — retyping is the point. - cfgAnthropic.value = ''; - cfgOpenai.value = ''; + // Never prefill the rejected key — retyping is the point. The PROVIDER is + // preselected (opts.provider, else whatever is already chosen), so a repair + // starts on the provider that just failed rather than back at Anthropic. + cfgKey.value = ''; cfgError.textContent = ''; cfgSaveBtn.disabled = false; + if (opts && opts.provider) cfgProvider.value = opts.provider; + cfgRenderProviders(cfgStatus); birthOverlay.classList.remove('open'); cfgOverlay.classList.add('open'); - setTimeout(() => cfgAnthropic.focus(), 50); + setTimeout(() => cfgKey.focus(), 50); } cfgForm.addEventListener('submit', async (ev) => { ev.preventDefault(); cfgError.textContent = ''; - const anthropic = cfgAnthropic.value.trim(); - const openai = cfgOpenai.value.trim(); - if (!anthropic) { - cfgError.textContent = 'ANTHROPIC_API_KEY is required.'; - return; - } + const provider = cfgSelected(); + const key = cfgKey.value.trim(); + // Anthropic and OpenAI are auto-detected server-side from the key alone; + // every other provider must pin LISA_MODEL or the run falls back to Claude. + const model = cfgModel.value.trim() || + (provider && provider.needsModel && provider.model ? provider.model : ''); + const baseUrl = provider && provider.custom ? cfgBaseUrl.value.trim() : ''; + if (!provider) { cfgError.textContent = 'Pick a provider.'; return; } + if (!key) { cfgError.textContent = provider.envKey + ' is required.'; return; } + if (provider.custom && !baseUrl) { cfgError.textContent = 'A custom endpoint needs its base URL.'; return; } cfgSaveBtn.disabled = true; try { const res = await fetch('/api/config/save', { method: 'POST', headers: {'content-type': 'application/json'}, - body: JSON.stringify({ anthropicKey: anthropic, openaiKey: openai || undefined }), + body: JSON.stringify(lisaProviderSaveBody(provider, key, model, baseUrl)), }); if (!res.ok) { const txt = await res.text().catch(() => ''); @@ -601,8 +774,17 @@ cfgForm.addEventListener('submit', async (ev) => { cfgSaveBtn.disabled = false; return; } - cfgAnthropic.value = ''; - cfgOpenai.value = ''; + // Confirm the server actually kept it. An older backend silently drops + // anything that is not an Anthropic/OpenAI key, which would otherwise send + // the user into a ritual that cannot possibly succeed. + const after = await fetch('/api/config/status').then(r => r.json()).catch(() => null); + if (after) { cfgStatus = after; cfgRenderProviders(after); } + if (after && lisaProviderConfirm(provider, after) === false) { + cfgError.textContent = lisaProviderUnsupportedNote(provider); + cfgSaveBtn.disabled = false; + return; + } + cfgKey.value = ''; cfgOverlay.classList.remove('open'); // A repair restarts the ritual in place — no location.reload(), so the // page keeps its SSE connection, its log and its scroll position. @@ -863,6 +1045,8 @@ async function startupGate() { return; } lisaClearBanner(); + cfgStatus = cfg; + cfgRenderProviders(cfg); if (!cfg.configured) { openKeyGate(); return; @@ -4154,13 +4338,23 @@ if ('serviceWorker' in navigator) { return '
'; }; var html = ''; - html += '
API Keys
'; - html += '
Anthropic
Required · powers Lisa
' + chip(!!status.anthropic) + '
'; - html += '
OpenAI
Optional · for gpt-* models
' + chip(!!status.openai) + '
'; + // Provider list + one picker, driven by the same table the key gate uses + // (UX-1 wave 2). Was an Anthropic-and-OpenAI-only pair of inputs, which + // made every other provider a config.env edit. + var provs = window.lisaProviderList(status); + html += '
Model provider
'; + for (var pi = 0; pi < provs.length; pi++) { + var pv = provs[pi]; + if (!pv.configured && pv.custom) continue; + html += '
' + esc(pv.label) + + '
' + esc(pv.envKey) + '
' + chip(pv.configured) + '
'; + } html += '
' + - '' + - '' + - '
' + + '' + + '' + + '' + + '' + + '
' + '
Saved to ~/.lisa/config.env (0600), on this machine. Accepted from localhost only.
'; html += '
'; html += '
Automation
'; @@ -4186,20 +4380,62 @@ if ('serviceWorker' in navigator) { ct.addEventListener('click', flip); ct.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flip(); } }); } + var sel = document.getElementById('setProvider'); + var keyEl = document.getElementById('setKey'); + var modelEl = document.getElementById('setModel'); + var baseEl = document.getElementById('setBaseUrl'); + var pick = function () { + for (var i = 0; i < provs.length; i++) if (provs[i].id === sel.value) return provs[i]; + return provs[0]; + }; + if (sel) { + var preselect = ''; + for (var q = 0; q < provs.length; q++) { + var o = document.createElement('option'); + o.value = provs[q].id; + o.textContent = provs[q].label + (provs[q].configured ? ' · configured' : ''); + sel.appendChild(o); + if (provs[q].configured && !preselect) preselect = provs[q].id; + } + sel.value = preselect || (provs[0] ? provs[0].id : ''); + var syncSel = function () { + var pv = pick(); + if (!pv) return; + keyEl.placeholder = pv.envKey + ' — ' + pv.placeholder + (pv.configured ? ' (leave blank to keep)' : ''); + modelEl.placeholder = pv.model ? ('model (optional · ' + pv.model + ')') : 'model (optional)'; + baseEl.style.display = pv.custom ? '' : 'none'; + }; + sel.addEventListener('change', syncSel); + syncSel(); + } var saveBtn = document.getElementById('setKeySave'); if (saveBtn) saveBtn.addEventListener('click', function () { - var aEl = document.getElementById('setAnthropicKey'); - var oEl = document.getElementById('setOpenaiKey'); var msg = document.getElementById('setKeyMsg'); - var body = {}; - if (aEl && aEl.value.trim()) body.anthropicKey = aEl.value.trim(); - if (oEl && oEl.value.trim()) body.openaiKey = oEl.value.trim(); - if (!body.anthropicKey && !body.openaiKey) { if (msg) { msg.style.color = ''; msg.textContent = 'Enter a key to update.'; } return; } + var pv = pick(); + var key = keyEl ? keyEl.value.trim() : ''; + var model = modelEl ? modelEl.value.trim() : ''; + // Same rule as the gate: pin a model only where the server cannot + // infer one from the key. + if (!model && key && pv.needsModel && pv.model) model = pv.model; + var base = (pv && pv.custom && baseEl) ? baseEl.value.trim() : ''; + if (!pv) return; + if (!key && !model && !base) { if (msg) { msg.style.color = ''; msg.textContent = 'Enter a key or a model to update.'; } return; } + if (pv.custom && key && !base) { if (msg) { msg.style.color = ''; msg.textContent = 'A custom endpoint needs its base URL.'; } return; } if (msg) { msg.style.color = ''; msg.textContent = 'Saving…'; } saveBtn.disabled = true; - fetch('/api/config/save', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + fetch('/api/config/save', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(window.lisaProviderSaveBody(pv, key, model, base)) }) .then(function (r) { if (!r.ok) return r.text().then(function (t) { throw new Error(t || ('failed (' + r.status + ')')); }); return r.json(); }) - .then(function () { if (aEl) aEl.value = ''; if (oEl) oEl.value = ''; if (msg) { msg.style.color = 'var(--proactive)'; msg.textContent = 'Saved.'; } loadSettings(); }) + .then(function () { return getJSON('/api/config/status').catch(function () { return null; }); }) + .then(function (after) { + // Same guard as the gate: say so when this backend dropped the key + // instead of reporting a save that did not happen. + if (key && after && window.lisaProviderConfirm(pv, after) === false) { + throw new Error(window.lisaProviderUnsupportedNote(pv)); + } + if (keyEl) keyEl.value = ''; + if (msg) { msg.style.color = 'var(--proactive)'; msg.textContent = 'Saved.'; } + loadSettings(); + }) .catch(function (err) { if (msg) { msg.style.color = ''; msg.textContent = (err && err.message) ? err.message : 'save failed'; } }) .then(function () { saveBtn.disabled = false; }); }); diff --git a/src/web/lisa-css.ts b/src/web/lisa-css.ts index e2d6232..95cc40f 100644 --- a/src/web/lisa-css.ts +++ b/src/web/lisa-css.ts @@ -1852,6 +1852,9 @@ export const MAIN_CSS = ` :root { padding: 8px 10px; } .set-input:focus { outline: none; border-color: var(--accent-glow); box-shadow: 0 0 0 3px var(--accent-soft); } + /* The provider picker is a + PROVIDER + + +
Saved to ~/.lisa/config.env with mode 0600. Stays on this machine. From 77e38da9836df97416ef37df74339c031651c80d Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 6 Sep 2026 22:41:00 +0800 Subject: [PATCH 07/12] feat(web): one string table (en / zh-CN) instead of mixed-language literals (UX-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell shipped as with ~30 Chinese literals baked into it — the knowledge-base button ("💾 存入知识库", "保存中…", "已存入知识库 ✓"), the chat error block ("⚠ 请求出错", "↻ 重试") — so an English user got Chinese buttons and a Chinese user got an otherwise English UI. The lang attribute never matched either. - LISA_STRINGS carries an `en` and a `zh-CN` table; tr(key, vars) looks up the locale, falls back to English for a missing key, returns the key itself rather than "undefined" if it is in neither, and interpolates {name} placeholders. A test asserts the two tables have identical key sets. - LISA_LOCALE comes from navigator.language (zh* → zh-CN, everything else → en) and is written to document.documentElement.lang, so screen readers and hyphenation follow what is actually rendered. - Routed through it: the former CJK strings, plus everything this stream added or touched — chat status announcements, empty-state card, session labels, right-rail badge, the whole key gate and every birth error. - Named tr(), not t(). "t" is already a local variable in twenty places in this 4000-line file, and sessionLabel's own `const t` silently shadowed the helper into a string on the first attempt. A test now scans the cooked bytes for any bare `t(` call so the trap cannot come back. - Two deliberate exceptions, both documented in the source: idleHeaderLabel keeps its ja/ko branches (they predate the table, and dropping them would be a regression), and the QQ / 163 mailbox setup help keeps 设置 / 服务 / 授权码 because it is quoting those providers' own Chinese UI labels — translating them would make the instructions wrong. The CJK-scan test allowlists exactly those two. - lisa-html.ts loses its two Chinese comments (九宫格 / 功能区). Measured in headless chromium against the scratch instances with the browser locale forced. en-US: lang="en", "Say anything — or start here:", tree "New session · 25m", rail "2 agents need you — open the right panel", gate "SET · API · KEY" / "ANTHROPIC_API_KEY is required." zh-CN: lang="zh-CN", "随便说点什么 —— 或者从这里开始:", tree "新会话 · 25m", rail "2 个 agent 在等你 —— 打开右侧面板", gate "设 · 置 · KEY" / "需要填写 ANTHROPIC_API_KEY。". Zero page errors in either locale. Co-Authored-By: Claude Opus 5 (cherry picked from commit f367daac66c91080a46bb308d053c092e2e20026) --- src/web/lisa-client.test.ts | 102 +++++++++++- src/web/lisa-client.ts | 250 ++++++++++++++++++++++------- src/web/lisa-html-snapshot.test.ts | 7 +- src/web/lisa-html.ts | 4 +- 4 files changed, 294 insertions(+), 69 deletions(-) diff --git a/src/web/lisa-client.test.ts b/src/web/lisa-client.test.ts index 2a75413..5218259 100644 --- a/src/web/lisa-client.test.ts +++ b/src/web/lisa-client.test.ts @@ -78,6 +78,19 @@ describe("idle-note sentinel regex survives template-literal cooking", () => { * out of MAIN_CLIENT_JS and run it in a `vm` sandbox with hand-made stubs — * so what is tested is literally what the browser executes. */ +/** The i18n block, so a sandbox renders the real strings rather than stubs. */ +const I18N_SRC = MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("const LISA_STRINGS = {"), + MAIN_CLIENT_JS.indexOf("const log = document.getElementById('log');"), +); +function i18nContext(extra: Record = {}) { + return createContext({ + navigator: { language: "en-US" }, + document: { documentElement: {} }, + ...extra, + }); +} + function extractFunction(src: string, name: string): string { const head = `function ${name}(`; const start = src.indexOf(head); @@ -97,8 +110,8 @@ function extractFunction(src: string, name: string): string { describe("sessionLabel names an empty session instead of showing its raw id (UX-4)", () => { const src = extractFunction(MAIN_CLIENT_JS, "sessionLabel"); - const ctx = createContext({ relativeTime: (iso: string) => (iso ? "2m" : "") }); - runInContext(`${src}; globalThis.__label = sessionLabel;`, ctx); + const ctx = i18nContext({ relativeTime: (iso: string) => (iso ? "2m" : "") }); + runInContext(`${I18N_SRC}\n${src}; globalThis.__label = sessionLabel;`, ctx); const label = (ctx as { __label: (s: unknown) => string }).__label; const ID = "20260905-220846-9f7d58"; @@ -153,13 +166,15 @@ describe("collapsed right rail keeps a way in (UX-5)", () => { }); describe("birth errors are classified into human copy (UX-1)", () => { + // The copy comes from the i18n table now, so the sandbox needs that block + // too — which also means these assertions run against the real strings. const src = MAIN_CLIENT_JS.slice( MAIN_CLIENT_JS.indexOf("const BIRTH_ERROR_TEXT = {"), MAIN_CLIENT_JS.indexOf("function showBirthError("), ); - const ctx = createContext({}); - runInContext(`${src}; globalThis.__code = birthErrorCode; globalThis.__text = BIRTH_ERROR_TEXT;`, ctx); + const ctx = i18nContext(); + runInContext(`${I18N_SRC}\n${src}; globalThis.__code = birthErrorCode; globalThis.__text = BIRTH_ERROR_TEXT;`, ctx); const code = (ctx as { __code: (ev: unknown) => string }).__code; const text = (ctx as { __text: Record }).__text; @@ -206,8 +221,8 @@ describe("provider picker works against both server generations (UX-1)", () => { MAIN_CLIENT_JS.indexOf("const LISA_PROVIDER_FALLBACK = ["), MAIN_CLIENT_JS.indexOf("// ── API key config gate"), ); - const ctx = createContext({ window: {} }); - runInContext(src, ctx); + const ctx = i18nContext({ window: {} }); + runInContext(`${I18N_SRC}\n${src}`, ctx); const c = ctx as { lisaProviderList: (s: unknown) => Array>; lisaProviderSaveBody: (p: unknown, k: string, m: string, b: string) => Record; @@ -284,3 +299,78 @@ describe("provider picker works against both server generations (UX-1)", () => { assert.equal(c.lisaProviderConfirm({ envKey: "NOPE" }, st), null); }); }); + +describe("interface language table (UX-8)", () => { + const ctxEn = i18nContext(); + runInContext(`${I18N_SRC}; globalThis.__tr = tr; globalThis.__loc = LISA_LOCALE;`, ctxEn); + const ctxZh = createContext({ navigator: { language: "zh-CN" }, document: { documentElement: {} } }); + runInContext(`${I18N_SRC}; globalThis.__tr = tr; globalThis.__loc = LISA_LOCALE;`, ctxZh); + const en = ctxEn as { __tr: (k: string, v?: Record) => string; __loc: string }; + const zh = ctxZh as { __tr: (k: string, v?: Record) => string; __loc: string }; + + test("navigator.language picks the locale, and document.lang follows", () => { + assert.equal(en.__loc, "en"); + assert.equal(zh.__loc, "zh-CN"); + assert.equal((ctxEn as { document: { documentElement: { lang?: string } } }).document.documentElement.lang, "en"); + assert.equal((ctxZh as { document: { documentElement: { lang?: string } } }).document.documentElement.lang, "zh-CN"); + }); + + test("both tables define exactly the same keys", () => { + const keys = (c: object) => { + const ctx = createContext({ navigator: { language: "en" }, document: { documentElement: {} } }); + runInContext(`${I18N_SRC}; globalThis.__k = Object.keys(LISA_STRINGS.en).sort().join(","); globalThis.__z = Object.keys(LISA_STRINGS['zh-CN']).sort().join(",");`, ctx); + return ctx as { __k: string; __z: string }; + }; + const k = keys({}); + assert.equal(k.__k, k.__z, "en and zh-CN tables have drifted apart"); + }); + + test("interpolation and fallback both work", () => { + assert.equal(en.__tr("rail.needs.many", { n: 3 }), "3 agents need you"); + assert.equal(zh.__tr("rail.needs.many", { n: 3 }), "3 个 agent 在等你"); + // An unknown key returns the key rather than "undefined" on screen. + assert.equal(en.__tr("nope.nope"), "nope.nope"); + }); + + test("the client carries no leftover CJK string literals", () => { + // Comments are fine; user-visible literals are not. The QQ/163 mailbox + // help quotes those providers' own Chinese UI labels ("设置", "授权码") + // and must stay as-is, so it is the one allowed island. + const cjk = /[一-鿿぀-ヿ가-힯]/; + const offenders: string[] = []; + for (const line of MAIN_CLIENT_JS.split("\n")) { + const code = line.replace(/\/\/.*$/, ""); + if (!cjk.test(code)) continue; + if (/授权码|设置|服务|账户/.test(code)) continue; // QQ / 163 provider labels + // idleHeaderLabel keeps ja/ko, which predate the table (en + zh-CN only). + if (/indexOf\('(ja|ko)'\)/.test(code)) continue; + if (code.includes("LISA_STRINGS") || code.includes("'zh-CN'")) continue; + offenders.push(line.trim()); + } + // Everything else must live in the zh-CN half of LISA_STRINGS, which sits + // between these two markers. + const zhStart = MAIN_CLIENT_JS.indexOf("'zh-CN': {"); + const zhEnd = MAIN_CLIENT_JS.indexOf("const LISA_LOCALE"); + const inTable = offenders.filter((l) => { + const at = MAIN_CLIENT_JS.indexOf(l); + return at > zhStart && at < zhEnd; + }); + assert.deepEqual( + offenders.filter((l) => !inTable.includes(l)), + [], + ); + }); +}); + +describe("no call site of the i18n helper is left un-renamed (UX-8)", () => { + test("the served source has no bare t(...) call", () => { + // "t" is a local in twenty places in this file, so a t(...) call reaching + // the i18n helper by accident — or a tr(...) call that was missed — is a + // silent TypeError only a browser would show. Scan the cooked bytes. + const offenders = MAIN_CLIENT_JS.split("\n").filter((line) => { + const code = line.replace(/\/\/.*$/, ""); + return /[^A-Za-z0-9_$.]t\(/.test(code); + }); + assert.deepEqual(offenders, []); + }); +}); diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index 9275b14..ff03413 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -40,6 +40,141 @@ window.addEventListener('unhandledrejection', function (e) { lisaBanner('Lisa task failed: ' + ((r && r.message) ? r.message : String(r))); }); +// ── Interface language (UX-8) ──────────────────────────────────── +// The shell was English with ~30 Chinese literals mixed into it (the KB +// button, the error block, the chat status). One table, two locales, picked +// from navigator.language; anything missing falls back to English rather than +// rendering a key. Only the strings this pass touches are in here — it is a +// seam for the rest, not a claim that the whole UI is translated. +const LISA_STRINGS = { + en: { + 'session.new': 'New session', + 'chat.thinking': 'Lisa is thinking', + 'chat.replying': 'Lisa is replying', + 'chat.done': 'Lisa finished replying', + 'chat.failed': 'The request failed', + 'idle.header': 'WHILE YOU WERE AWAY', + 'empty.lead': 'Say anything — or start here:', + 'empty.starter.desire': 'How is "{desire}" going?', + 'empty.starter.open': 'What is on your mind right now?', + 'empty.starter.memory': 'What do you remember about me?', + 'empty.starter.today': 'What should we work on today?', + 'empty.can.tools.k': 'Tools', + 'empty.can.tools.v': 'ask her to read a file, run a command, or look something up on the web.', + 'empty.can.kb.k': 'Knowledge', + 'empty.can.kb.v': 'select any message and save it — she will recall it in later sessions.', + 'empty.can.mail.k': 'Mail', + 'empty.can.mail.v': 'connect a mailbox in the right rail and she triages it for you daily.', + 'rail.toggle': 'Collapse / expand the right panel', + 'rail.toggleAria': 'Toggle right panel', + 'rail.needs.one': '1 agent needs you', + 'rail.needs.many': '{n} agents need you', + 'rail.needs.unit.one': 'agent needs you', + 'rail.needs.unit.many': 'agents need you', + 'rail.needs.open': '{noun} — open the right panel', + 'rail.needs.openAria': '{noun}, open the right panel', + 'birth.err.auth': 'That API key was rejected by the provider. Enter a different one and Lisa will try again.', + 'birth.err.timeout': 'The provider took too long to answer. Nothing was lost — try again.', + 'birth.err.network': 'Could not reach the provider. Check the network on this machine, then try again.', + 'birth.err.rate_limit': 'The provider is rate-limiting this key right now. Wait a minute, then try again.', + 'birth.err.unknown': 'Something went wrong while she was waking up.', + 'birth.cancel': 'Cancel', + 'birth.cancelled': 'Cancelled. Set a key and Lisa will start again.', + 'birth.changeKey': 'Change key', + 'birth.retry': 'Try again', + 'gate.title.set': 'SET · API · KEY', + 'gate.title.change': 'CHANGE · API · KEY', + 'gate.pickProvider': 'Pick a provider.', + 'gate.keyRequired': '{env} is required.', + 'gate.baseUrlRequired': 'A custom endpoint needs its base URL.', + 'gate.saveFailed': 'Save failed: ', + 'gate.alreadySet': ' (configured — a new value replaces it)', + 'gate.getKey': 'Get a key for {label} ↗', + 'gate.providerDefault': 'provider default', + 'gate.unsupported': 'This Lisa did not keep the {label} key — it only accepts Anthropic and OpenAI keys. Update Lisa (npm i -g @oratis/lisa), or add {env}=... to ~/.lisa/config.env and restart.', + 'kb.save': '💾 Save to knowledge base', + 'kb.saving': 'Saving…', + 'kb.saved': 'Saved to knowledge base ✓', + 'kb.exists': 'Already in the knowledge base ✓', + 'kb.failed': 'Save failed', + 'err.request': '⚠ Request failed', + 'err.retry': '↻ Retry', + }, + 'zh-CN': { + 'session.new': '新会话', + 'chat.thinking': 'Lisa 正在思考', + 'chat.replying': 'Lisa 正在回复', + 'chat.done': 'Lisa 回复完成', + 'chat.failed': '请求失败', + 'idle.header': '你不在的时候', + 'empty.lead': '随便说点什么 —— 或者从这里开始:', + 'empty.starter.desire': '“{desire}”进展如何?', + 'empty.starter.open': '你现在在想什么?', + 'empty.starter.memory': '你还记得我的什么?', + 'empty.starter.today': '我们今天做点什么?', + 'empty.can.tools.k': '工具', + 'empty.can.tools.v': '可以让她读文件、执行命令,或者上网查东西。', + 'empty.can.kb.k': '知识库', + 'empty.can.kb.v': '选中任意消息存进去,之后的会话她还会记得。', + 'empty.can.mail.k': '邮件', + 'empty.can.mail.v': '在右栏连接一个邮箱,她每天帮你分拣。', + 'rail.toggle': '收起 / 展开右侧面板', + 'rail.toggleAria': '切换右侧面板', + 'rail.needs.one': '1 个 agent 在等你', + 'rail.needs.many': '{n} 个 agent 在等你', + 'rail.needs.unit.one': '个 agent 在等你', + 'rail.needs.unit.many': '个 agent 在等你', + 'rail.needs.open': '{noun} —— 打开右侧面板', + 'rail.needs.openAria': '{noun},打开右侧面板', + 'birth.err.auth': '这个 API key 被服务商拒绝了。换一个再试一次。', + 'birth.err.timeout': '服务商响应太慢。什么都没丢,再试一次。', + 'birth.err.network': '连不上服务商。检查这台机器的网络后再试。', + 'birth.err.rate_limit': '这个 key 正在被限流。等一分钟再试。', + 'birth.err.unknown': '她醒来的过程中出了点问题。', + 'birth.cancel': '取消', + 'birth.cancelled': '已取消。填一个 key,Lisa 会重新开始。', + 'birth.changeKey': '更换 key', + 'birth.retry': '重试', + 'gate.title.set': '设 · 置 · KEY', + 'gate.title.change': '更 · 换 · KEY', + 'gate.pickProvider': '先选一个服务商。', + 'gate.keyRequired': '需要填写 {env}。', + 'gate.baseUrlRequired': '自定义端点需要填 base URL。', + 'gate.saveFailed': '保存失败:', + 'gate.alreadySet': '(已配置 —— 填新值会覆盖)', + 'gate.getKey': '去申请 {label} 的 key ↗', + 'gate.providerDefault': '服务商默认', + 'gate.unsupported': '这个 Lisa 没有保存 {label} 的 key —— 它只接受 Anthropic 和 OpenAI 的 key。请升级 Lisa(npm i -g @oratis/lisa),或者把 {env}=... 写进 ~/.lisa/config.env 后重启。', + 'kb.save': '💾 存入知识库', + 'kb.saving': '保存中…', + 'kb.saved': '已存入知识库 ✓', + 'kb.exists': '已在知识库 ✓', + 'kb.failed': '保存失败', + 'err.request': '⚠ 请求出错', + 'err.retry': '↻ 重试', + }, +}; +const LISA_LOCALE = (function () { + var l = String((navigator && navigator.language) || 'en').toLowerCase(); + return l.indexOf('zh') === 0 ? 'zh-CN' : 'en'; +})(); +// The document ships as lang="en"; correct it so screen readers and hyphenation +// follow the language actually rendered. +try { document.documentElement.lang = LISA_LOCALE; } catch (e) {} +// Named tr(), not t(): "t" is already a local variable in twenty places in +// this file (const t = e.target, const t = await res.text(), …) and one of +// them — sessionLabel — shadowed the helper into a string. +function tr(key, vars) { + var table = LISA_STRINGS[LISA_LOCALE] || LISA_STRINGS.en; + var s = table[key]; + if (s == null) s = LISA_STRINGS.en[key]; + if (s == null) return key; + if (vars) { + for (var k in vars) s = s.split('{' + k + '}').join(String(vars[k])); + } + return s; +} + const log = document.getElementById('log'); const input = document.getElementById('input'); const form = document.getElementById('form'); @@ -461,11 +596,12 @@ fetch('/session').then(r => r.json()).then(s => { // reload. The label follows the UI language; the note body is already written // in the user's language by the idle runner. function idleHeaderLabel() { - var l = (navigator.language || 'en').toLowerCase(); - if (l.indexOf('zh') === 0) return '你不在的时候'; + // ja/ko are not in the string table (this pass only added en + zh-CN) but + // the idle card already had them — keep them rather than regress. + var l = String((navigator && navigator.language) || 'en').toLowerCase(); if (l.indexOf('ja') === 0) return '不在のあいだに'; if (l.indexOf('ko') === 0) return '자리를 비운 사이'; - return 'WHILE YOU WERE AWAY'; + return tr('idle.header'); } function buildIdleBlock(text, at) { const block = document.createElement('div'); @@ -644,9 +780,7 @@ function lisaProviderConfirm(provider, status) { return false; } function lisaProviderUnsupportedNote(provider) { - return 'This Lisa did not keep the ' + provider.label + ' key — it only accepts Anthropic and ' + - 'OpenAI keys. Update Lisa (npm i -g @oratis/lisa), or add ' + provider.envKey + - '=... to ~/.lisa/config.env and restart.'; + return tr('gate.unsupported', { label: provider.label, env: provider.envKey }); } // The Settings view lives in the console closure at the bottom of this file. window.lisaProviderList = lisaProviderList; @@ -679,15 +813,15 @@ function cfgSelected() { function cfgSyncProvider() { const p = cfgSelected(); if (!p) return; - cfgKeyLabel.textContent = p.envKey + (p.configured ? ' (configured — a new value replaces it)' : ''); + cfgKeyLabel.textContent = p.envKey + (p.configured ? tr('gate.alreadySet') : ''); cfgKey.placeholder = p.placeholder; - cfgModel.placeholder = p.model || 'provider default'; + cfgModel.placeholder = p.model || tr('gate.providerDefault'); // Only the custom endpoint needs a base URL, and it needs a model too. cfgBaseUrlField.style.display = p.custom ? '' : 'none'; if (cfgConsole) { if (p.consoleUrl) { cfgConsole.href = p.consoleUrl; - cfgConsole.textContent = 'Get a key for ' + p.label + ' ↗'; + cfgConsole.textContent = tr('gate.getKey', { label: p.label }); cfgConsole.style.display = ''; } else { cfgConsole.textContent = ''; @@ -729,7 +863,7 @@ let cfgReconfigure = false; function openKeyGate(opts) { cfgReconfigure = !!(opts && opts.reconfigure); const title = document.getElementById('cfgTitle'); - if (title) title.textContent = cfgReconfigure ? 'CHANGE · API · KEY' : 'SET · API · KEY'; + if (title) title.textContent = tr(cfgReconfigure ? 'gate.title.change' : 'gate.title.set'); const reason = document.getElementById('cfgReason'); if (reason) { reason.textContent = (opts && opts.reason) ? opts.reason : ''; @@ -758,9 +892,9 @@ cfgForm.addEventListener('submit', async (ev) => { const model = cfgModel.value.trim() || (provider && provider.needsModel && provider.model ? provider.model : ''); const baseUrl = provider && provider.custom ? cfgBaseUrl.value.trim() : ''; - if (!provider) { cfgError.textContent = 'Pick a provider.'; return; } - if (!key) { cfgError.textContent = provider.envKey + ' is required.'; return; } - if (provider.custom && !baseUrl) { cfgError.textContent = 'A custom endpoint needs its base URL.'; return; } + if (!provider) { cfgError.textContent = tr('gate.pickProvider'); return; } + if (!key) { cfgError.textContent = tr('gate.keyRequired', { env: provider.envKey }); return; } + if (provider.custom && !baseUrl) { cfgError.textContent = tr('gate.baseUrlRequired'); return; } cfgSaveBtn.disabled = true; try { const res = await fetch('/api/config/save', { @@ -770,7 +904,7 @@ cfgForm.addEventListener('submit', async (ev) => { }); if (!res.ok) { const txt = await res.text().catch(() => ''); - cfgError.textContent = 'Save failed: HTTP ' + res.status + (txt ? ' — ' + txt.slice(0, 120) : ''); + cfgError.textContent = tr('gate.saveFailed') + 'HTTP ' + res.status + (txt ? ' — ' + txt.slice(0, 120) : ''); cfgSaveBtn.disabled = false; return; } @@ -791,7 +925,7 @@ cfgForm.addEventListener('submit', async (ev) => { if (cfgReconfigure) { cfgReconfigure = false; beginBirth(); } else maybeBirth(); } catch (err) { - cfgError.textContent = 'Save failed: ' + err.message; + cfgError.textContent = tr('gate.saveFailed') + err.message; cfgSaveBtn.disabled = false; } }); @@ -854,11 +988,11 @@ function abortBirth() { // raw payload — users saw 401 {"type":"error","error":{"type": // "authentication_error",...}} and had nothing to do about it. const BIRTH_ERROR_TEXT = { - auth: 'That API key was rejected by the provider. Enter a different one and Lisa will try again.', - timeout: 'The provider took too long to answer. Nothing was lost — try again.', - network: 'Could not reach the provider. Check the network on this machine, then try again.', - rate_limit: 'The provider is rate-limiting this key right now. Wait a minute, then try again.', - unknown: 'Something went wrong while she was waking up.', + auth: tr('birth.err.auth'), + timeout: tr('birth.err.timeout'), + network: tr('birth.err.network'), + rate_limit: tr('birth.err.rate_limit'), + unknown: tr('birth.err.unknown'), }; // New servers send {kind:"error", code, message, retryable}. Older ones send // {kind:"error", message} with the raw provider text — classify those by hand @@ -891,13 +1025,13 @@ function showBirthError(ev) { openKeyGate({ reconfigure: true, reason: BIRTH_ERROR_TEXT[code] || BIRTH_ERROR_TEXT.unknown }); }; if (code === 'auth') { - birthAction('Change key', changeKey, true); + birthAction(tr('birth.changeKey'), changeKey, true); return; } // Anything the server marks non-retryable gets the key path instead. - if (ev && ev.retryable === false) { birthAction('Change key', changeKey, true); return; } - birthAction('Try again', beginBirth, true); - birthAction('Change key', changeKey); + if (ev && ev.retryable === false) { birthAction(tr('birth.changeKey'), changeKey, true); return; } + birthAction(tr('birth.retry'), beginBirth, true); + birthAction(tr('birth.changeKey'), changeKey); } async function maybeBirth() { @@ -987,11 +1121,11 @@ async function startBirthStream() { const ctrl = new AbortController(); birthAbort = ctrl; let cancelled = false; - birthAction('Cancel', function () { + birthAction(tr('birth.cancel'), function () { cancelled = true; abortBirth(); clearBirthActions(); - openKeyGate({ reconfigure: true, reason: 'Cancelled. Set a key and Lisa will start again.' }); + openKeyGate({ reconfigure: true, reason: tr('birth.cancelled') }); }); try { @@ -1185,16 +1319,12 @@ function chatStarters() { if (d && d.title) desire = d.title.trim(); if (desire.length > 64) desire = desire.slice(0, 64).trim() + '…'; return [ - desire ? 'How is "' + desire + '" going?' : "What's on your mind right now?", - 'What do you remember about me?', - 'What should we work on today?', + desire ? tr('empty.starter.desire', { desire: desire }) : tr('empty.starter.open'), + tr('empty.starter.memory'), + tr('empty.starter.today'), ]; } -const CHAT_ABILITIES = [ - ['Tools', 'ask her to read a file, run a command, or look something up on the web.'], - ['Knowledge', "select any message and save it — she'll recall it in later sessions."], - ['Mail', 'connect a mailbox in the right rail and she triages it for you daily.'], -]; +const CHAT_ABILITY_KEYS = ['tools', 'kb', 'mail']; function renderChatEmpty() { if (!historyFetched || chatLogHasContent()) { removeChatEmpty(); return; } let card = document.getElementById('chatEmpty'); @@ -1212,7 +1342,7 @@ function renderChatEmpty() { card.appendChild(who); const lead = document.createElement('div'); lead.className = 'ce-lead'; - lead.textContent = 'Say anything — or start here:'; + lead.textContent = tr('empty.lead'); card.appendChild(lead); const starters = document.createElement('div'); starters.className = 'ce-starters'; @@ -1235,12 +1365,12 @@ function renderChatEmpty() { card.appendChild(starters); const can = document.createElement('ul'); can.className = 'ce-can'; - CHAT_ABILITIES.forEach(function (pair) { + CHAT_ABILITY_KEYS.forEach(function (k) { const li = document.createElement('li'); const b = document.createElement('b'); - b.textContent = pair[0]; + b.textContent = tr('empty.can.' + k + '.k'); li.appendChild(b); - li.appendChild(document.createTextNode(' — ' + pair[1])); + li.appendChild(document.createTextNode(' — ' + tr('empty.can.' + k + '.v'))); can.appendChild(li); }); card.appendChild(can); @@ -1567,7 +1697,7 @@ if (fnSearchBtn && fnFind) { try { touched = localStorage.getItem('lisaRightbarTouched') === '1'; } catch (e) {} let autoExpanded = false; let attention = 0; - const PANEL_TITLE = 'Collapse / expand the right panel'; + const PANEL_TITLE = tr('rail.toggle'); // While the rail is collapsed the only sign that an agent is blocked on a // decision was a 34px icon with a tooltip — the count now rides the icon. const syncBadge = () => { @@ -1577,7 +1707,7 @@ if (fnSearchBtn && fnFind) { if (!collapsed || attention <= 0) { if (dot) dot.remove(); btn.title = PANEL_TITLE; - btn.setAttribute('aria-label', 'Toggle right panel'); + btn.setAttribute('aria-label', tr('rail.toggleAria')); return; } if (!dot) { @@ -1586,9 +1716,9 @@ if (fnSearchBtn && fnFind) { btn.appendChild(dot); } dot.textContent = attention > 9 ? '9+' : String(attention); - const noun = attention === 1 ? '1 agent needs you' : attention + ' agents need you'; - btn.title = noun + ' — open the right panel'; - btn.setAttribute('aria-label', noun + ', open the right panel'); + const noun = attention === 1 ? tr('rail.needs.one') : tr('rail.needs.many', { n: attention }); + btn.title = tr('rail.needs.open', { noun: noun }); + btn.setAttribute('aria-label', tr('rail.needs.openAria', { noun: noun })); }; const applyRb = () => { document.body.classList.toggle('rb-collapsed', collapsed); @@ -1654,7 +1784,7 @@ function ensureLisaSpan() { if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; } el('div', 'role lisa', 'LISA'); currentLisaSpan = el('span', 'msg', ''); - setChatStatus('Lisa is replying'); + setChatStatus(tr('chat.replying')); return currentLisaSpan; } @@ -1728,7 +1858,7 @@ async function send(message) { await runChat(message, filesToSend); } -// ── chat → KB: a bare URL in the user's message gets a one-tap 存入知识库 +// ── chat → KB: a bare URL in the user's message gets a one-tap save-to-KB // chip under the bubble; it calls the same /api/kb/ingest the KB view uses. function maybeOfferKbIngest(message) { if (!message) return; @@ -1739,27 +1869,27 @@ function maybeOfferKbIngest(message) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'kb-ingest-btn'; - btn.textContent = '💾 存入知识库'; + btn.textContent = tr('kb.save'); chip.appendChild(btn); btn.addEventListener('click', function () { btn.disabled = true; - btn.textContent = '保存中…'; + btn.textContent = tr('kb.saving'); fetch('/api/kb/ingest', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: url }) }) .then(function (r) { return r.json(); }) .then(function (d) { if (d && d.ok) { - btn.textContent = d.deduped ? '已在知识库 ✓' : '已存入知识库 ✓'; + btn.textContent = d.deduped ? tr('kb.exists') : tr('kb.saved'); if (typeof window.lisaReloadKb === 'function') window.lisaReloadKb(); } else { btn.disabled = false; - btn.textContent = '💾 存入知识库'; - if (typeof window.lisaKbToast === 'function') window.lisaKbToast((d && d.error) ? d.error : '保存失败'); + btn.textContent = tr('kb.save'); + if (typeof window.lisaKbToast === 'function') window.lisaKbToast((d && d.error) ? d.error : tr('kb.failed')); } }) .catch(function () { btn.disabled = false; - btn.textContent = '💾 存入知识库'; - if (typeof window.lisaKbToast === 'function') window.lisaKbToast('保存失败'); + btn.textContent = tr('kb.save'); + if (typeof window.lisaKbToast === 'function') window.lisaKbToast(tr('kb.failed')); }); }); } @@ -1772,7 +1902,7 @@ function showError(detail, message, filesToSend) { const block = el('div', 'err-block', null); const head = document.createElement('div'); head.className = 'err-head'; - head.textContent = '⚠ 请求出错'; + head.textContent = tr('err.request'); block.appendChild(head); const body = document.createElement('div'); body.className = 'err-detail'; @@ -1781,7 +1911,7 @@ function showError(detail, message, filesToSend) { const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'err-retry'; - retry.textContent = '↻ 重试'; + retry.textContent = tr('err.retry'); retry.addEventListener('click', () => { block.remove(); runChat(message, filesToSend); @@ -1800,14 +1930,14 @@ async function runChat(message, filesToSend) { currentLisaSpan = null; pendingTools.clear(); thinkingEl = el('div', 'thinking', '⋯ thinking'); - setChatStatus('Lisa is thinking'); + setChatStatus(tr('chat.thinking')); // The agent emits an error event AND the server re-sends it from its turn // catch — dedupe so one failure renders exactly one error block. let errored = false; const fail = (detail) => { if (errored || gen !== chatGeneration) return; errored = true; - setChatStatus('The request failed'); + setChatStatus(tr('chat.failed')); showError(detail, message, filesToSend); }; try { @@ -1876,7 +2006,7 @@ async function runChat(message, filesToSend) { } else if (ev.type === 'done') { if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; } flushLisaRender(); - setChatStatus('Lisa finished replying'); + setChatStatus(tr('chat.done')); } } } @@ -2220,7 +2350,9 @@ if ('serviceWorker' in navigator) { count.appendChild(document.createTextNode(String(needs.length))); const sr = document.createElement('span'); sr.className = 'sr-only'; - sr.textContent = needs.length === 1 ? ' agent needs you' : ' agents need you'; + // The visible node already carries the number — the hidden part is + // just the unit, so a reader hears "2 agents need you", not "2 2 …". + sr.textContent = ' ' + tr(needs.length === 1 ? 'rail.needs.unit.one' : 'rail.needs.unit.many'); count.appendChild(sr); } } @@ -2806,7 +2938,7 @@ if ('serviceWorker' in navigator) { // id (20260905-220846-9f7d58) in the tree, the context chip, the title bar // and the inspector — four places showing a string no human reads. It is // "New session · 2m" now; the id survives as the tooltip / inspector sub. - if (s && !s.messageCount) return 'New session · ' + relativeTime(s.startedAt); + if (s && !s.messageCount) return tr('session.new') + ' · ' + relativeTime(s.startedAt); return s ? s.id : ''; } // The title bar is rendered outside this closure (setActiveSessionUI runs diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index e5b4941..1e710c8 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -232,10 +232,13 @@ import { MAIN_HTML } from "./lisa-html.js"; * #cfgProvider / #cfgKey / #cfgModel / #cfgBaseUrl driven by * /api/config/status.providers with a built-in fallback table, and the * Settings view uses the same list. + * Then: UX-8 language — a LISA_STRINGS (en / zh-CN) table plus tr(key, vars), + * locale picked from navigator.language, document.documentElement.lang set to + * match, and every CJK literal the shell used to render moved into the table. */ -const EXPECTED_LENGTH = 347685; +const EXPECTED_LENGTH = 353747; const EXPECTED_SHA256 = - "261db264196f4210cf0ecdedf8c4b128525bdf0fa5bd3e7c92fb04596f8841b6"; + "c8c822f9dc52aa0572b83dd19dd3ba04a4a2b62059e33430e684fea241384210"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH); diff --git a/src/web/lisa-html.ts b/src/web/lisa-html.ts index 5198899..f50b345 100644 --- a/src/web/lisa-html.ts +++ b/src/web/lisa-html.ts @@ -100,7 +100,7 @@ catch (e) { document.body.classList.add('rb-collapsed'); }
- +