diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 475cd15..39f3fea 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -127,7 +127,7 @@ jobs:
run: |
cd webui && npm ci && npm run build
cd ..
- npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts
+ npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts tests/e2e/import-session.spec.ts tests/e2e/shortcuts-sheet.spec.ts tests/e2e/streaming-polish.spec.ts
- name: Run live Pages chat e2e (real proxy)
env:
diff --git a/CONCEPTS.md b/CONCEPTS.md
index 3ed65ad..1ec6a22 100644
--- a/CONCEPTS.md
+++ b/CONCEPTS.md
@@ -40,6 +40,7 @@ Shared vocabulary for the static chat gateway and Python library.
| **Turnstile session** | Optional bot check at Worker; 1h KV pass per IP after successful siteverify |
| **Session export** | Sidebar menu download of current chat as Markdown or JSON (client-only; no server copy) |
| **Hash session link** | URL `#/chat/{sessionId}` restores a session from local IndexedDB on the same browser/profile |
+| **Conversation import** | File-picker restore of exported Markdown/JSON into a new local session (symmetry with session export) |
## Learnings index
diff --git a/docs/assets/chat.js b/docs/assets/chat.js
index 711545d..dbc11c3 100644
--- a/docs/assets/chat.js
+++ b/docs/assets/chat.js
@@ -3098,7 +3098,6 @@ function isSafeUrl(url, allowedPrefixes) {
}
// node_modules/murm-ui/dist/components/message-node.js
-var MARKDOWN_THROTTLE_MS = 70;
var MessageNode = class {
constructor(msg, config) {
this.config = config;
@@ -3229,17 +3228,22 @@ var MessageNode = class {
clearTimeout(state.timer);
state.timer = void 0;
}
+ state.plainEl = void 0;
state.renderSeq++;
void this.applyMarkdown(block.id, block.text, state.renderSeq);
return;
}
- if (state.timer)
- return;
- state.timer = window.setTimeout(() => {
+ if (state.timer) {
+ clearTimeout(state.timer);
state.timer = void 0;
- state.renderSeq++;
- void this.applyMarkdown(block.id, block.text, state.renderSeq);
- }, MARKDOWN_THROTTLE_MS);
+ }
+ if (!state.plainEl) {
+ state.container.replaceChildren();
+ state.plainEl = document.createElement("div");
+ state.plainEl.className = "mur-streaming-text";
+ state.container.appendChild(state.plainEl);
+ }
+ state.plainEl.textContent = block.text;
}
renderFileBlock(block, container) {
if (container.hasChildNodes())
@@ -5500,6 +5504,16 @@ function showRateLimitBanner(seconds) {
const suffix = seconds !== void 0 && seconds > 0 ? ` Try again in ${seconds} second${seconds === 1 ? "" : "s"}.` : " Wait and try again.";
mount.innerHTML = `Rate limited.${suffix}`;
}
+function showStatusMessage(text, durationMs = 3500) {
+ const mount = document.getElementById("lfStatusStrip");
+ if (!mount) return;
+ mount.innerHTML = `${text}`;
+ window.setTimeout(() => {
+ if (mount.querySelector(".lf-status-text")?.textContent === text) {
+ void refreshStatusStrip(mount);
+ }
+ }, durationMs);
+}
// src/providers/errors.ts
var ChatRouteError = class extends Error {
@@ -6813,6 +6827,210 @@ function downloadBlob(filename, content, mime) {
URL.revokeObjectURL(url);
}
+// src/import-session.ts
+var ImportError = class extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "ImportError";
+ }
+};
+function newMessage(role, text) {
+ const id = crypto.randomUUID();
+ const now = Date.now();
+ return {
+ id,
+ role,
+ blocks: [{ id: crypto.randomUUID(), type: "text", text }],
+ runId: id,
+ createdAt: now,
+ updatedAt: now
+ };
+}
+function parseJsonExport(text) {
+ let parsed;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ throw new ImportError("Invalid JSON \u2014 could not parse file.");
+ }
+ if (!parsed || typeof parsed !== "object") {
+ throw new ImportError("Invalid JSON \u2014 expected an object with a messages array.");
+ }
+ const payload = parsed;
+ if (!Array.isArray(payload.messages) || payload.messages.length === 0) {
+ throw new ImportError("Invalid JSON \u2014 messages array is missing or empty.");
+ }
+ const messages = [];
+ for (const item of payload.messages) {
+ if (!item || typeof item !== "object") continue;
+ const role = item.role;
+ const msgText = typeof item.text === "string" ? item.text.trim() : "";
+ if (role !== "user" && role !== "assistant") {
+ throw new ImportError(`Invalid JSON \u2014 unknown role "${String(role)}".`);
+ }
+ if (!msgText) continue;
+ messages.push(newMessage(role, msgText));
+ }
+ if (messages.length === 0) {
+ throw new ImportError("No messages with text found in JSON export.");
+ }
+ return messages;
+}
+function parseMarkdownExport(text) {
+ const trimmed = text.trim();
+ if (!trimmed) {
+ throw new ImportError("Markdown file is empty.");
+ }
+ const sections = trimmed.split(/^##\s+(User|Assistant)\s*$/im);
+ if (sections.length < 3) {
+ throw new ImportError("Could not find ## User / ## Assistant headings in Markdown.");
+ }
+ const messages = [];
+ for (let i = 1; i < sections.length; i += 2) {
+ const roleLabel = sections[i]?.toLowerCase();
+ const content = (sections[i + 1] ?? "").trim();
+ if (!content) continue;
+ const role = roleLabel === "user" ? "user" : "assistant";
+ messages.push(newMessage(role, content));
+ }
+ if (messages.length === 0) {
+ throw new ImportError("No message content found under headings.");
+ }
+ return messages;
+}
+function parseExportText(text, filename) {
+ if (filename.toLowerCase().endsWith(".json")) {
+ return parseJsonExport(text);
+ }
+ return parseMarkdownExport(text);
+}
+async function importMessagesFromFile(file) {
+ const text = await file.text();
+ return parseExportText(text, file.name);
+}
+function importTitleFromFile(text, filename) {
+ if (filename.toLowerCase().endsWith(".json")) {
+ try {
+ const parsed = JSON.parse(text);
+ const title = parsed.title?.trim();
+ if (title) return title;
+ } catch {
+ }
+ }
+ const base = filename.replace(/\.(md|json)$/i, "");
+ const slug = base.replace(/^llm-fallbacks-/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
+ return slug.replace(/-/g, " ").trim() || void 0;
+}
+
+// src/plugins/shortcuts-sheet/index.ts
+var HINT_KEY = "llm_fallbacks_shortcuts_hint_dismissed";
+var SHORTCUTS = [
+ { keys: "Enter", action: "Send message" },
+ { keys: "Shift + Enter", action: "New line in composer" },
+ { keys: "Esc", action: "Stop generation (when streaming)" },
+ { keys: "/", action: "Focus composer" },
+ { keys: "?", action: "Show this shortcuts sheet" }
+];
+function isTypingTarget(target) {
+ if (!(target instanceof HTMLElement)) return false;
+ const tag = target.tagName;
+ return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
+}
+function ensureModal() {
+ let modal = document.getElementById("lfShortcutsModal");
+ if (modal) return modal;
+ modal = document.createElement("div");
+ modal.id = "lfShortcutsModal";
+ modal.className = "lf-shortcuts-modal";
+ modal.hidden = true;
+ modal.innerHTML = `
+
+
+ `;
+ const list = modal.querySelector(".lf-shortcuts-list");
+ for (const { keys, action } of SHORTCUTS) {
+ const li = document.createElement("li");
+ li.innerHTML = `${keys}${action}`;
+ list.appendChild(li);
+ }
+ document.body.appendChild(modal);
+ return modal;
+}
+function openModal() {
+ const modal = ensureModal();
+ modal.hidden = false;
+ modal.querySelector(".lf-shortcuts-close")?.focus();
+}
+function closeModal() {
+ const modal = document.getElementById("lfShortcutsModal");
+ if (modal) modal.hidden = true;
+}
+function ensureHint() {
+ let hint = document.getElementById("lfShortcutsHint");
+ if (hint) return hint;
+ hint = document.createElement("div");
+ hint.id = "lfShortcutsHint";
+ hint.className = "lf-shortcuts-hint";
+ hint.innerHTML = `
+ Tip: press ? for keyboard shortcuts
+
+ `;
+ const mount = document.getElementById("chatMount");
+ if (mount) {
+ mount.appendChild(hint);
+ } else {
+ document.body.appendChild(hint);
+ }
+ return hint;
+}
+function ShortcutsSheetPlugin() {
+ return {
+ name: "shortcuts-sheet",
+ onMount() {
+ ensureModal();
+ const modal = document.getElementById("lfShortcutsModal");
+ modal.addEventListener("click", (e) => {
+ const target = e.target;
+ if (target.dataset.close || target.classList.contains("lf-shortcuts-close")) {
+ closeModal();
+ }
+ });
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ closeModal();
+ return;
+ }
+ if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey && !isTypingTarget(e.target)) {
+ e.preventDefault();
+ openModal();
+ }
+ });
+ if (localStorage.getItem(HINT_KEY) !== "1") {
+ const hint = ensureHint();
+ hint.hidden = false;
+ hint.querySelector(".lf-shortcuts-hint-dismiss")?.addEventListener("click", () => {
+ hint.hidden = true;
+ localStorage.setItem(HINT_KEY, "1");
+ });
+ }
+ const footerLink = document.createElement("button");
+ footerLink.type = "button";
+ footerLink.className = "lf-shortcuts-footer-link";
+ footerLink.textContent = "Shortcuts";
+ footerLink.title = "Keyboard shortcuts (?)";
+ footerLink.addEventListener("click", () => openModal());
+ const credits = document.querySelector(".credits-actions");
+ credits?.appendChild(footerLink);
+ }
+ };
+}
+
// src/main.ts
async function loadCatalog(config) {
let catalog = [];
@@ -6860,6 +7078,38 @@ async function bootstrap() {
provider.setCatalog(catalog, providerUrls);
let catalogRef2 = catalog;
let providerUrlsRef = providerUrls;
+ const importInput = document.createElement("input");
+ importInput.type = "file";
+ importInput.accept = ".md,.json,text/markdown,application/json";
+ importInput.hidden = true;
+ document.body.appendChild(importInput);
+ let importEngine = null;
+ importInput.addEventListener("change", async () => {
+ const file = importInput.files?.[0];
+ importInput.value = "";
+ if (!file || !importEngine) return;
+ if (importEngine.state.generatingMessageId) {
+ showStatusMessage("Wait for the current reply to finish before importing.");
+ return;
+ }
+ try {
+ const text = await file.text();
+ const messages = await importMessagesFromFile(file);
+ await importEngine.sessions.create();
+ const ok = await importEngine.setMessages(messages);
+ if (!ok) {
+ throw new ImportError("Could not import while a reply is generating.");
+ }
+ const title = importTitleFromFile(text, file.name);
+ if (title) {
+ await importEngine.sessions.updateTitle(importEngine.state.currentSessionId, title);
+ }
+ showStatusMessage(`Imported ${messages.length} message${messages.length === 1 ? "" : "s"}.`);
+ } catch (err) {
+ const msg = err instanceof ImportError ? err.message : "Import failed.";
+ showStatusMessage(msg);
+ }
+ });
const ui = new ChatUI({
container: "#chatMount",
provider,
@@ -6905,6 +7155,28 @@ async function bootstrap() {
"application/json;charset=utf-8"
);
}
+ },
+ {
+ id: "import-conversation",
+ label: "Import conversation",
+ disabled: ctx.engine.state.generatingMessageId !== null,
+ onClick: () => {
+ importInput.click();
+ }
+ },
+ {
+ id: "copy-session-link",
+ label: "Copy session link",
+ onClick: async () => {
+ const hash = `#/chat/${encodeURIComponent(ctx.session.id)}`;
+ const url = `${window.location.origin}${window.location.pathname}${window.location.search}${hash}`;
+ try {
+ await navigator.clipboard.writeText(url);
+ showStatusMessage("Session link copied (local browser only).");
+ } catch {
+ window.prompt("Copy session link:", url);
+ }
+ }
}
];
},
@@ -6935,9 +7207,11 @@ async function bootstrap() {
ModelExplorerPlugin({
getCatalog: () => catalogRef2,
getCatalogUrl: () => readRuntimeConfig().catalogUrl
- })
+ }),
+ ShortcutsSheetPlugin()
]
});
+ importEngine = ui.engine;
wireChatInputIds(document.querySelector("#chatMount"));
const observer = new MutationObserver(() => wireChatInputIds(document.querySelector("#chatMount")));
observer.observe(document.querySelector("#chatMount"), { childList: true, subtree: true });
diff --git a/docs/assets/shell/chat-overrides.css b/docs/assets/shell/chat-overrides.css
index 2802d5e..ad20d36 100644
--- a/docs/assets/shell/chat-overrides.css
+++ b/docs/assets/shell/chat-overrides.css
@@ -151,7 +151,7 @@ body.lf-chat-page::before {
}
#chatMount.mur-app-embedded.mur-chat-empty .mur-chat-layout-wrapper::before {
- content: "Free model chat";
+ content: "Ranked free models, zero config";
display: block;
position: absolute;
top: 36%;
@@ -167,7 +167,7 @@ body.lf-chat-page::before {
}
#chatMount.mur-app-embedded.mur-chat-empty .mur-chat-layout-wrapper::after {
- content: "Zero-config demo — ranked free alias via edge proxy. Optional BYOK for browser-direct routes.";
+ content: "Daily-updated catalog via edge proxy. Pick a model above or send a message. Optional BYOK for direct routes.";
display: block;
position: absolute;
top: 44%;
@@ -807,3 +807,156 @@ body.lf-chat-page::before {
.explorer-table-wrap td {
vertical-align: middle;
}
+
+/* ── Wave 4: streaming polish + shortcuts ───────────────────── */
+#chatMount .mur-streaming-text {
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: inherit;
+ line-height: 1.55;
+ contain: content;
+}
+
+#chatMount .mur-message-assistant.mur-generating .mur-content-block {
+ min-height: 1.5em;
+}
+
+.lf-shortcuts-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 10050;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.lf-shortcuts-modal[hidden] {
+ display: none !important;
+}
+
+.lf-shortcuts-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.62);
+}
+
+.lf-shortcuts-panel {
+ position: relative;
+ z-index: 1;
+ width: min(22rem, 92vw);
+ padding: 1.25rem 1.35rem;
+ border-radius: 12px;
+ background: #1a1a2e;
+ border: 1px solid rgba(157, 78, 221, 0.28);
+ box-shadow: var(--mur-shadow-modal, 0 10px 28px rgba(0, 0, 0, 0.45));
+}
+
+.lf-shortcuts-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.85rem;
+}
+
+.lf-shortcuts-header h2 {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: #e8e8ef;
+}
+
+.lf-shortcuts-close {
+ border: none;
+ background: transparent;
+ color: #8b8ba3;
+ font-size: 1.35rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.lf-shortcuts-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.lf-shortcuts-list li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.45rem 0;
+ border-bottom: 1px solid rgba(157, 78, 221, 0.12);
+ font-size: 0.88rem;
+ color: #c4c4d4;
+}
+
+.lf-shortcuts-list li:last-child {
+ border-bottom: none;
+}
+
+.lf-shortcuts-list kbd {
+ display: inline-block;
+ padding: 0.15rem 0.45rem;
+ border-radius: 5px;
+ background: rgba(157, 78, 221, 0.16);
+ border: 1px solid rgba(157, 78, 221, 0.28);
+ color: #e4c7ff;
+ font-family: inherit;
+ font-size: 0.78rem;
+}
+
+.lf-shortcuts-hint {
+ position: absolute;
+ bottom: 5.5rem;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.45rem 0.75rem;
+ border-radius: 8px;
+ background: rgba(26, 26, 46, 0.92);
+ border: 1px solid rgba(157, 78, 221, 0.25);
+ color: #c4c4d4;
+ font-size: 0.82rem;
+ pointer-events: auto;
+}
+
+.lf-shortcuts-hint[hidden] {
+ display: none !important;
+}
+
+.lf-shortcuts-hint kbd {
+ padding: 0.1rem 0.35rem;
+ border-radius: 4px;
+ background: rgba(157, 78, 221, 0.2);
+ border: 1px solid rgba(157, 78, 221, 0.3);
+ font-size: 0.75rem;
+}
+
+.lf-shortcuts-hint-dismiss {
+ border: none;
+ background: transparent;
+ color: #8b8ba3;
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+}
+
+.lf-shortcuts-footer-link {
+ border: none;
+ background: transparent;
+ color: #8b8ba3;
+ font-size: 0.78rem;
+ cursor: pointer;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ padding: 0 0.35rem;
+}
+
+.lf-shortcuts-footer-link:hover {
+ color: #c77dff;
+}
+
diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md
new file mode 100644
index 0000000..4ac6136
--- /dev/null
+++ b/docs/brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md
@@ -0,0 +1,113 @@
+---
+title: Chat UI Wave 4 — streaming polish & UX micro
+date: 2026-07-25
+status: confirmed
+priority_wave: wave4-polish
+origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md
+prior_waves: [wave1, wave2, wave3]
+strategy: STRATEGY.md
+---
+
+# Chat UI Wave 4 — streaming polish & UX micro
+
+## Summary
+
+Close the remaining “feels basic” gaps **without** multimodal upload or model compare. Prioritize the original **R5** streaming-markdown polish, then low-cost UX micro: **import conversation**, clearer **empty state**, and a **keyboard-shortcuts** affordance. Stays static-first and murm-ui–compatible.
+
+## Problem
+
+Waves 1–3 (R1–R4, R6–R18, R10–R14) shipped table-stakes chat UX. Visitors still perceive jank during long SSE replies because assistant markdown re-renders the full block on each throttle tick. Export exists but import does not — sessions feel trapped. First-run empty state and shortcuts are easy to miss on the embedded shell.
+
+## Requirements
+
+### Streaming & rendering (R5 carryover)
+
+| ID | Requirement |
+|----|-------------|
+| R19 | Assistant **streaming text** renders without visible full-message flicker or layout jump during active SSE — completed blocks stay stable while only the tail updates. |
+| R20 | Code blocks and tables **do not re-highlight or re-layout** on every token once their fence/table row is closed. |
+| R21 | Streaming remains **accessible**: `aria-live="polite"` on the feed; completion announced without re-reading the entire message. |
+
+### Session portability (symmetry with R13)
+
+| ID | Requirement |
+|----|-------------|
+| R22 | Users **import** a previously exported Markdown or JSON transcript into a **new** IndexedDB session (file picker; no server upload). |
+| R23 | Import preserves **user/assistant turn order** and plain text; malformed files show a clear error without corrupting existing sessions. |
+| R24 | Sidebar offers **Copy session link** when hash routing is enabled (`#/chat/{id}`), with tooltip that the link is same-browser only (see `docs/CAVEATS.md`). |
+
+### UX micro
+
+| ID | Requirement |
+|----|-------------|
+| R25 | **Empty state** names the ranked-`free` value prop and one primary action (“Send a message” / “Pick a model”) without ResearchWizard dead chrome. |
+| R26 | **Keyboard shortcuts** sheet (e.g. `?` or footer link): Enter send, Shift+Enter newline, Escape stop generation, `/` focus composer — matches murm-ui behavior where applicable. |
+| R27 | Optional **compact shortcut hints** on first visit (dismissible, `localStorage` flag); no modal blocking send. |
+
+## Approaches considered
+
+### A. Throttle + tail-only DOM (recommended)
+
+Reduce perceived flicker by ensuring only the **active streaming block** mutates DOM; rely on murm-ui throttle where present and add a first-party plugin only if tail isolation is insufficient. **Pros:** Smallest diff; no markdown library swap. **Cons:** May require murm-ui patch or version bump if root cause is full `syncDOMChildren` replace.
+
+### B. Incremental markdown renderer swap
+
+Adopt an incremental block parser (industry pattern: parse deltas, memoize closed blocks) via murm-ui extension or fork. **Pros:** Best long-term perf. **Cons:** Higher carrying cost; couples us to renderer maintenance.
+
+### C. UX-only (skip R5)
+
+Ship R22–R27 only. **Pros:** Fast. **Cons:** Leaves the most visible “cheap chat UI” signal unfixed.
+
+**Recommendation:** **A first**, spike B only if A cannot meet R19–R20 in one wave. Ship R22–R27 in the same PR for cohesive “polish” release.
+
+## Scope boundaries
+
+**In scope:** `webui/`, Playwright e2e, `docs/CAVEATS.md`, `docs/chat-ui-plugins.md`.
+
+**Out of scope (unchanged from July 24 brainstorm):**
+
+- Vision/file upload, side-by-side model compare, tool-call/reasoning UI, PWA offline shell
+- Cloud session sync, user accounts, Open WebUI embedding
+- Full TypeScript port of Python discovery
+
+**Explicitly deferred:**
+
+- Conversation **merge** (import into existing session with dedupe)
+- Custom themes beyond existing dark shell
+- Voice input, MCP panels (remain hidden)
+
+## Success criteria
+
+- Side-by-side manual test: 500+ token streamed reply shows no full-body flash; code fence stable after closing backticks.
+- Import round-trip: export MD → import → messages visible and sendable in new session.
+- Playwright: import happy path + hash link copy smoke (where testable).
+- First-time visitor reads empty-state copy and finds `?` shortcuts without opening Failover settings.
+
+## Key decisions
+
+| ID | Decision | Rationale |
+|----|----------|-----------|
+| K1 | Wave 4A before multimodal/compare | User priority; lowest risk vs STRATEGY static budget |
+| K2 | Import creates **new** session | Avoids IndexedDB merge bugs; matches export as portable artifact |
+| K3 | R5 fix prefers murm-ui-compatible path | Forking murm-ui is last resort |
+| K4 | Copy link complements R14 | Makes hash routing discoverable without implying cloud share |
+
+## Dependencies & assumptions
+
+- Waves 1–3 merged and deployed (PRs #13, #14) before Wave 4 ships to `main`.
+- murm-ui `MessageNode` uses throttled full-block markdown today; R19–R20 may need upstream coordination or pinned version bump.
+- Export JSON schema from Wave 3 is the import contract.
+
+## Outstanding questions
+
+| ID | Question | Default |
+|----|----------|---------|
+| Q1 | Patch murm-ui in-repo vs npm bump? | Try npm bump / plugin first; patch only if blocked |
+| Q2 | Import MD: strict format only vs best-effort `## User`/`## Assistant`? | Best-effort parser with strict JSON path |
+| Q3 | Shortcuts sheet modal vs slide panel? | Small modal overlay (shell pattern) |
+
+## Research references
+
+- [Jason Laster — Chat UI best practices (2026)](https://www.jasonlaster.com/posts/2026-04-25-chat-ui)
+- [Performant AI markdown renderer](https://tigerabrodi.blog/how-to-build-a-performant-ai-markdown-renderer)
+- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (R5, deferred list)
diff --git a/docs/chat-ui-plugins.md b/docs/chat-ui-plugins.md
index e2d12cb..283e9ca 100644
--- a/docs/chat-ui-plugins.md
+++ b/docs/chat-ui-plugins.md
@@ -34,12 +34,17 @@ Set `APP_VERSION` when building for cache busting (CI sets this from `github.sha
| `message-actions` | Messages | Regenerate, edit user message; stop preserves partial output when present |
| `status-strip` | Top bar | Proxy liveness dot + optional daily chat count from `/v1/metrics` |
| `turnstile-gate` | Body (optional) | Cloudflare Turnstile widget when `turnstileSiteKey` is in config |
+| `shortcuts-sheet` | Footer / `?` key | Keyboard shortcuts modal + dismissible first-visit hint |
Wave 3 adds **catalog enrichment** (context + capability badges in Models panel and composer subtitle), **session export** (sidebar menu → Markdown/JSON), and **hash routing** (`#/chat/{sessionId}` via murm-ui `AppRouter`).
-## Session export and hash links
+Wave 4 adds **streaming polish** (plain-text tail during SSE, full markdown on completion — `patch-package` on murm-ui), **conversation import** (symmetry with export), **copy session link**, empty-state copy, and the shortcuts sheet.
+
+## Session export, import, and hash links
- **Export** — Session ⋮ menu → “Export as Markdown” or “Export as JSON”. Serializes text blocks only; empty chats disable the items.
+- **Import** — Session ⋮ menu → “Import conversation”. Creates a **new** session from exported `.md` or `.json`; does not merge into the active session.
+- **Copy session link** — Session ⋮ menu → copies `#/chat/{id}` URL (local browser only).
- **Hash routing** — Enabled in `webui/src/main.ts` with `routing: { type: "hash", pathPrefix: "#/chat/" }`. Links are local-only; see [CAVEATS.md](CAVEATS.md).
- **Why this rank?** — Composer link to [README quality scoring](https://github.com/bodecloud/llm_fallbacks#quality-scoring).
@@ -64,6 +69,7 @@ ResearchWizard includes MCP config UI backed by a server. Static Pages cannot ho
| `llm_fallbacks_guest_token` | Bearer token for proxy auth |
| `llm_fallbacks_default_model` | Default chat model (usually `free`) |
| `llm_fallbacks_api_keys` | Optional BYOK map |
+| `llm_fallbacks_shortcuts_hint_dismissed` | `1` after user dismisses first-visit shortcuts hint |
Zero-config values seed from `docs/config.js` on first visit (`seedZeroConfigFromPageConfig` in `webui/src/config.ts`).
diff --git a/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md b/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md
new file mode 100644
index 0000000..7f98aa0
--- /dev/null
+++ b/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md
@@ -0,0 +1,307 @@
+---
+title: "feat: Chat UI Wave 4 — streaming polish & UX micro"
+status: completed
+date: 2026-07-25
+type: feat
+origin: docs/brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md
+strategy: STRATEGY.md
+wave: 4
+requirements: R19-R27
+prior_plan: docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md
+---
+
+# feat: Chat UI Wave 4 — streaming polish & UX micro
+
+> **Origin:** [Wave 4 polish brainstorm](../brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md) — closes original **R5** carryover plus session import and UX micro. Waves 1–3 in PRs [#13](https://github.com/bodecloud/llm_fallbacks/pull/13) / [#14](https://github.com/bodecloud/llm_fallbacks/pull/14).
+
+## Summary
+
+Fix streaming markdown jank, add **import conversation** (symmetry with Wave 3 export), **copy session link**, clearer **empty state**, and a **keyboard shortcuts** sheet — all client-only, static-first.
+
+## Problem Frame
+
+After Waves 1–3 the demo has model picker, routing chip, catalog badges, export, and hash links. It still reads as “basic” because long SSE replies flash when murm-ui re-parses the full markdown block every ~70ms, import is missing, and first-run affordances are buried in the embedded shell.
+
+## Requirements (Wave 4 traceability)
+
+| ID | Source | Wave 4 requirement |
+|----|--------|-------------------|
+| R19 | Wave 4 | Streaming assistant text without full-message flicker |
+| R20 | Wave 4 | Closed code blocks/tables stable during stream |
+| R21 | Wave 4 | Accessible streaming (`aria-live` unchanged or improved) |
+| R22 | Wave 4 | Import exported MD/JSON into **new** session |
+| R23 | Wave 4 | Import errors are clear; no corruption of existing sessions |
+| R24 | Wave 4 | Sidebar **Copy session link** with local-only tooltip |
+| R25 | Wave 4 | Empty state names ranked-`free` value + primary CTA |
+| R26 | Wave 4 | `?` shortcuts sheet (Enter, Shift+Enter, Esc, `/`) |
+| R27 | Wave 4 | Dismissible first-visit shortcut hint |
+
+**Out of scope:** vision upload, model compare, tool/reasoning UI, PWA, cloud sync (see origin doc).
+
+## Key Technical Decisions
+
+| ID | Decision | Rationale |
+|----|----------|-----------|
+| KTD1 | **Spike murm-ui upgrade first** for R19–R20 | `murm-ui@^0.2.0` `MessageNode` throttles then `marked.parse` + `syncDOMChildren` on full content; upstream fix preferred over fork |
+| KTD2 | **If upgrade insufficient: `patch-package` on murm-ui** | Incremental tail render (block memoization or higher throttle + skip re-highlight) without forking repo; document patch in `webui/README` or `docs/chat-ui-plugins.md` |
+| KTD3 | **`import-session.ts` mirrors `export-session.ts`** | Shared `messageText` shape; JSON is strict schema; MD is best-effort `## User` / `## Assistant` (origin Q2 default) |
+| KTD4 | **Import flow: `engine.sessions.create()` → `engine.setMessages()`** | murm-ui exposes both; avoids merge into active session (origin K2) |
+| KTD5 | **Sidebar menu for import + copy link** | Reuse Wave 3 `sidebarMenu` pattern in `webui/src/main.ts` |
+| KTD6 | **Copy link via `navigator.clipboard` + toast/status** | Hash URL from `AppRouter.hrefFor(id)` or `location.hash`; tooltip cites CAVEATS |
+| KTD7 | **Shortcuts: small modal overlay** | Origin Q3 default; shell already uses modals (`#sysMask`) |
+| KTD8 | **Branch from `main` after Wave 3 merge** | Avoid stacking four waves; rebase if #14 lands first |
+
+## High-Level Technical Design
+
+```mermaid
+flowchart TB
+ subgraph stream [Streaming R19-R21]
+ SSE[SSE tokens]
+ MN[murm-ui MessageNode]
+ Fix[Upgrade or patch]
+ SSE --> MN
+ Fix --> MN
+ end
+
+ subgraph portable [Sessions R22-R24]
+ Export[export-session.ts]
+ Import[import-session.ts]
+ Menu[sidebarMenu]
+ Export -.symmetry.-> Import
+ Import --> Engine[ChatEngine.setMessages]
+ Menu --> Import
+ Menu --> CopyLink[clipboard hash URL]
+ end
+
+ subgraph micro [UX R25-R27]
+ Empty[empty-state copy CSS]
+ Shortcuts[shortcuts plugin modal]
+ end
+```
+
+## Implementation Units
+
+### U1. Streaming flicker spike + fix (R19–R21)
+
+**Goal:** Long streamed replies feel stable; closed markdown structures do not re-layout every token.
+
+**Requirements:** R19, R20, R21
+
+**Dependencies:** None (blocks visual acceptance of polish release)
+
+**Files:**
+- `webui/package.json` — murm-ui version bump if upstream fix exists
+- `webui/patches/` (new, if `patch-package` needed) — murm-ui message-node patch
+- `webui/shell/chat-overrides.css` — optional `contain` / min-height on streaming assistant blocks
+- `webui/src/plugins/streaming-polish/index.ts` (new, only if DOM-level mitigation needed without patch)
+
+**Approach:**
+1. Reproduce flicker with mocked long SSE (code fence + list) in manual test.
+2. Check latest `murm-ui` release notes / diff for `message-node.js` streaming changes.
+3. **Path A (preferred):** bump dependency; verify R19–R20 manually.
+4. **Path B:** `patch-package` to (a) increase throttle modestly, (b) skip `applyMarkdown` when plain-text tail unchanged, or (c) append text node for in-progress paragraph before full markdown pass — pick smallest change that passes acceptance.
+5. Verify `aria-live` on feed unchanged (`webui/src/main.ts` `wireChatInputIds`).
+
+**Patterns to follow:** Industry guidance — throttle DOM not stream; memoize closed blocks (see origin research links).
+
+**Test scenarios:**
+- Mock 500+ token stream with fenced code → no full-body white flash mid-stream.
+- After closing ` ``` `, code block does not re-highlight on subsequent tokens.
+- Stream completes → final markdown matches non-stream render.
+
+**Verification:** Manual + optional Playwright in U6 (`streaming-polish.spec.ts` if stable selectors exist).
+
+---
+
+### U2. Import session serializers (R22–R23)
+
+**Goal:** Parse Wave 3 export formats into murm-ui `Message[]`.
+
+**Requirements:** R22, R23
+
+**Dependencies:** None
+
+**Files:**
+- `webui/src/import-session.ts` (new)
+- `webui/src/import-session.test.ts` (new)
+- `webui/src/export-session.ts` — extract shared `messageText` / types if duplication grows
+
+**Approach:**
+- `parseJsonExport(text)` → validate `{ messages: [{ role, text }] }`; map to `Message` with new `uuid` ids via murm-ui pattern or `crypto.randomUUID`.
+- `parseMarkdownExport(text)` → split on `## User` / `## Assistant` headings (case-insensitive); best-effort; reject empty.
+- `importMessagesFromFile(file)` → detect `.json` vs `.md` by extension/MIME; throw `ImportError` with user-facing message.
+
+**Test scenarios:**
+- Round-trip: `toJson` output → `parseJsonExport` → same roles/order.
+- Wave 3 MD sample with two turns → two messages.
+- Invalid JSON → throws with clear message.
+- Empty MD → throws; no partial state.
+
+**Verification:** `cd webui && npm test`
+
+---
+
+### U3. Sidebar import + copy session link (R22–R24)
+
+**Goal:** Session menu gains Import and Copy link alongside Export.
+
+**Requirements:** R22, R23, R24
+
+**Dependencies:** U2
+
+**Files:**
+- `webui/src/main.ts` — extend `sidebarMenu`
+- `webui/src/import-session.ts`
+- `docs/CAVEATS.md` — copy-link tooltip alignment (if not already sufficient)
+
+**Approach:**
+- Add hidden `` triggered from menu item **Import conversation**.
+- On file read: `engine.sessions.create()` then `engine.setMessages(parsed)`; set title from export meta or filename slug.
+- On failure: `engine.clearError()`-safe banner or `alert`/`status` strip message — do not touch other sessions.
+- **Copy session link:** build URL from `window.location.pathname + '#/chat/' + encodeURIComponent(session.id)`; `navigator.clipboard.writeText`; brief status in `status-strip` or inline toast class.
+- Disable import while generation active (`engine.state.isGenerating` if exposed, or guard via plugin).
+
+**Patterns to follow:** Wave 3 export items in `webui/src/main.ts` `sidebarMenu`.
+
+**Test scenarios:**
+- Covers AE: export MD → import → user message visible in new session (Playwright U6).
+- Copy link → clipboard contains `#/chat/`.
+- Malformed file → error shown; prior session messages unchanged.
+
+**Verification:** Playwright `tests/e2e/import-session.spec.ts`
+
+---
+
+### U4. Empty state polish (R25)
+
+**Goal:** First visit communicates ranked-`free` demo value and next action.
+
+**Requirements:** R25
+
+**Dependencies:** None
+
+**Files:**
+- `webui/shell/chat-overrides.css` — refine `::before` / `::after` on empty layout
+- `webui/src/plugins/empty-state-hint/index.ts` (new, optional) — only if CSS pseudo-elements insufficient
+
+**Approach:**
+- Update empty-state hero copy: mention **daily ranked catalog**, **zero-config proxy**, **optional BYOK**.
+- Add visible hint: “Pick a model above” or link to `#explorerSetting` if shell button exists.
+- Keep ResearchWizard dead chrome hidden (R18 carryover).
+
+**Test scenarios:**
+- Fresh localStorage → empty chat shows updated copy (Playwright smoke).
+
+**Verification:** Visual + `tests/e2e/empty-state.spec.ts` (optional lightweight)
+
+---
+
+### U5. Keyboard shortcuts sheet (R26–R27)
+
+**Goal:** Discoverable shortcuts without blocking send.
+
+**Requirements:** R26, R27
+
+**Dependencies:** None
+
+**Files:**
+- `webui/src/plugins/shortcuts-sheet/index.ts` (new)
+- `webui/shell/chat-overrides.css`
+- `webui/src/main.ts` — register plugin
+
+**Approach:**
+- Global `?` key (when not typing in input) opens modal listing: Enter send, Shift+Enter newline, Esc stop (if murm-ui supports), `/` focus composer.
+- Footer link “Shortcuts” as secondary affordance.
+- First visit: compact dismissible banner (`localStorage` key `llm_fallbacks_shortcuts_hint_dismissed`); `localStorage` only, matches project pattern.
+
+**Patterns to follow:** Shell modal pattern (`#sysMask`, panel-header classes).
+
+**Test scenarios:**
+- Press `?` → modal visible with Enter/Shift+Enter text.
+- Dismiss hint → flag set; reload → hint hidden.
+
+**Verification:** Playwright `tests/e2e/shortcuts-sheet.spec.ts`
+
+---
+
+### U6. E2E, docs, build (R19–R27)
+
+**Goal:** CI coverage and operator docs.
+
+**Requirements:** All
+
+**Dependencies:** U1–U5
+
+**Files:**
+- `tests/e2e/import-session.spec.ts` (new)
+- `tests/e2e/shortcuts-sheet.spec.ts` (new)
+- `tests/e2e/streaming-polish.spec.ts` (new, if U1 yields testable signal)
+- `.github/workflows/deploy-pages.yml` — add specs to mocked e2e job
+- `docs/chat-ui-plugins.md`
+- `CONCEPTS.md` — **Conversation import** (already drafted; commit with wave)
+
+**Test scenarios:**
+- Import round-trip after export (mocked provider).
+- Shortcuts modal opens.
+- (Optional) Stream long mocked reply; assert no regression in assistant text presence.
+
+**Verification:** `cd webui && npm test && npm run build`; Playwright wave 4 specs green.
+
+## Sequencing
+
+```mermaid
+flowchart LR
+ U1[U1 streaming] --> U6[U6 e2e docs]
+ U2[U2 import parse] --> U3[U3 sidebar]
+ U3 --> U6
+ U4[U4 empty state] --> U6
+ U5[U5 shortcuts] --> U6
+```
+
+**Recommended order:** U1 (spike early) → U2 → U3 → U4 → U5 → U6
+
+U4/U5 parallel with U2/U3 after U1 spike confirms patch path.
+
+## Scope Boundaries
+
+**In scope:** `webui/`, Playwright, docs updates.
+
+**Out of scope:** multimodal, compare, tool UI, murm-ui fork as standalone project.
+
+### Deferred to Follow-Up Work
+
+- Wave 4B vision upload / Wave 4C model compare (separate brainstorm if pursued)
+- Import merge into existing session
+- Upstream murm-ui PR for incremental markdown (if we ship patch-package locally)
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| murm-ui has no hook for streaming render | patch-package or CSS mitigation; document upgrade path |
+| `setMessages` after `create()` race | await `sessions.create()`; verify in Playwright |
+| Wave 3 not merged | branch from `feat/chat-ui-wave3` or rebase on `main` post-merge |
+| Clipboard API denied | fallback `prompt` with pre-selected URL for copy link |
+
+## Acceptance Examples
+
+- **AE1.** 500+ token stream with code block: no full-message flash; fence stable after close.
+- **AE2.** Export JSON → Import → new session shows same turns; user can send follow-up.
+- **AE3.** Copy session link puts `#/chat/{id}` on clipboard; CAVEATS explains local-only.
+- **AE4.** Empty state mentions ranked catalog; `?` opens shortcuts without opening Server settings.
+
+## Open Questions
+
+| ID | Question | Default for planning |
+|----|----------|---------------------|
+| Q1 | murm-ui bump vs patch-package | Try bump first (U1 spike) |
+| Q2 | MD import strictness | Best-effort headings (origin default) |
+| Q3 | Modal vs slide panel for shortcuts | Modal (origin default) |
+
+## Sources / Research
+
+- [Wave 4 requirements](../brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md)
+- [Jason Laster — Chat UI best practices (2026)](https://www.jasonlaster.com/posts/2026-04-25-chat-ui)
+- `webui/node_modules/murm-ui/dist/components/message-node.js` — `MARKDOWN_THROTTLE_MS = 70`, full `marked.parse` path
+- `webui/node_modules/murm-ui/dist/core/chat-engine.d.ts` — `setMessages()`
+- Wave 3: `docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md`
diff --git a/tests/e2e/import-session.spec.ts b/tests/e2e/import-session.spec.ts
new file mode 100644
index 0000000..8817015
--- /dev/null
+++ b/tests/e2e/import-session.spec.ts
@@ -0,0 +1,67 @@
+import { test, expect } from "@playwright/test";
+import {
+ DEMO_PROXY,
+ installLocalChatBundle,
+ installTestConfigMock,
+ waitForAssistantText,
+} from "./helpers";
+
+async function openSessionMenu(page: import("@playwright/test").Page) {
+ await page.evaluate(() => {
+ document.querySelector("#chatMount")?.classList.remove("mur-sidebar-closed");
+ localStorage.setItem("mur_sidebar_closed", "false");
+ });
+ await page.locator(".mur-sidebar-item-link").first().focus();
+ await page.locator(".mur-sidebar-options-btn").first().dispatchEvent("click");
+}
+
+test.describe("Wave 4 — import session", () => {
+ test.beforeEach(async ({ page }) => {
+ await installTestConfigMock(page);
+ await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "text/event-stream; charset=utf-8",
+ body:
+ 'data: {"choices":[{"index":0,"delta":{"content":"imported ok"},"finish_reason":null}]}\n\n' +
+ 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' +
+ "data: [DONE]\n\n",
+ });
+ });
+ await installLocalChatBundle(page);
+ await page.goto("./", { waitUntil: "domcontentloaded" });
+ await page.evaluate(() => localStorage.clear());
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.locator("#chatinput")).toBeVisible({ timeout: 45_000 });
+ });
+
+ test("imports exported markdown into a new session", async ({ page }) => {
+ await page.locator("#chatinput").fill("seed message");
+ await page.locator("#sendbutton").click();
+ await waitForAssistantText(page, 30_000);
+
+ const downloadPromise = page.waitForEvent("download", { timeout: 15_000 });
+ await openSessionMenu(page);
+ await page.getByRole("menuitem", { name: "Export as Markdown" }).click();
+ const download = await downloadPromise;
+ const exportPath = await download.path();
+ expect(exportPath).toBeTruthy();
+
+ await page.locator(".mur-new-chat-btn").click();
+ await expect(page.locator(".mur-message")).toHaveCount(0, { timeout: 10_000 });
+
+ await openSessionMenu(page);
+ const importItem = page.getByRole("menuitem", { name: "Import conversation" });
+ await expect(importItem).toBeVisible({ timeout: 5_000 });
+
+ const fileInput = page.locator('input[type="file"][accept*=".md"]');
+ await fileInput.setInputFiles(exportPath!);
+
+ await expect(page.locator(".mur-message-user").getByText("seed message")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator(".mur-message-assistant").getByText("imported ok")).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+});
diff --git a/tests/e2e/shortcuts-sheet.spec.ts b/tests/e2e/shortcuts-sheet.spec.ts
new file mode 100644
index 0000000..c1060fa
--- /dev/null
+++ b/tests/e2e/shortcuts-sheet.spec.ts
@@ -0,0 +1,33 @@
+import { test, expect } from "@playwright/test";
+import {
+ installLocalChatBundle,
+ installTestConfigMock,
+} from "./helpers";
+
+test.describe("Wave 4 — shortcuts sheet", () => {
+ test.beforeEach(async ({ page }) => {
+ await installTestConfigMock(page);
+ await installLocalChatBundle(page);
+ await page.goto("./", { waitUntil: "domcontentloaded" });
+ await page.evaluate(() => {
+ localStorage.clear();
+ localStorage.setItem("llm_fallbacks_shortcuts_hint_dismissed", "1");
+ });
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.locator("#chatinput")).toBeVisible({ timeout: 45_000 });
+ });
+
+ test("opens shortcuts modal on ?", async ({ page }) => {
+ await page.keyboard.press("?");
+ const modal = page.locator("#lfShortcutsModal");
+ await expect(modal).toBeVisible();
+ await expect(modal.getByText("Enter", { exact: true })).toBeVisible();
+ await expect(modal.getByText("Send message")).toBeVisible();
+ await expect(modal.getByText("Shift + Enter")).toBeVisible();
+ });
+
+ test("footer link opens shortcuts", async ({ page }) => {
+ await page.getByRole("button", { name: "Shortcuts" }).click();
+ await expect(page.locator("#lfShortcutsModal")).toBeVisible();
+ });
+});
diff --git a/tests/e2e/streaming-polish.spec.ts b/tests/e2e/streaming-polish.spec.ts
new file mode 100644
index 0000000..a6c1ab4
--- /dev/null
+++ b/tests/e2e/streaming-polish.spec.ts
@@ -0,0 +1,42 @@
+import { test, expect } from "@playwright/test";
+import {
+ DEMO_PROXY,
+ installLocalChatBundle,
+ installTestConfigMock,
+ mockProxySse,
+ waitForAssistantText,
+} from "./helpers";
+
+test.describe("Wave 4 — streaming polish", () => {
+ test.beforeEach(async ({ page }) => {
+ await installTestConfigMock(page);
+ await installLocalChatBundle(page);
+ await page.goto("./", { waitUntil: "domcontentloaded" });
+ await page.evaluate(() => localStorage.clear());
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.locator("#chatinput")).toBeVisible({ timeout: 45_000 });
+ });
+
+ test("streams long reply with plain-text tail then final markdown", async ({ page }) => {
+ const longReply =
+ "Here is code:\n\n```js\nconst x = 1;\n```\n\nDone streaming.";
+ await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "text/event-stream; charset=utf-8",
+ body: mockProxySse(longReply),
+ });
+ });
+
+ await page.locator("#chatinput").fill("stream test");
+ await page.locator("#sendbutton").click();
+
+ const assistant = page.locator(".mur-message-assistant").last();
+ await waitForAssistantText(page, 30_000);
+ await expect(assistant).not.toHaveClass(/mur-generating/);
+ await expect(assistant.getByText("Done streaming.")).toBeVisible();
+ await expect(assistant.locator("pre code, .mur-content-block code")).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+});
diff --git a/webui/package-lock.json b/webui/package-lock.json
index 1544ef9..ea7974d 100644
--- a/webui/package-lock.json
+++ b/webui/package-lock.json
@@ -7,11 +7,13 @@
"": {
"name": "llm-fallbacks-webui",
"version": "0.1.0",
+ "hasInstallScript": true,
"dependencies": {
"murm-ui": "^0.2.0"
},
"devDependencies": {
"esbuild": "^0.25.0",
+ "patch-package": "^8.0.1",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
@@ -994,6 +996,29 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@yarnpkg/lockfile": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1004,6 +1029,19 @@
"node": ">=12"
}
},
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -1014,6 +1052,56 @@
"node": ">=8"
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -1031,6 +1119,23 @@
"node": ">=18"
}
},
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
@@ -1041,6 +1146,57 @@
"node": ">= 16"
}
},
+ "node_modules/ci-info": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+ "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1069,6 +1225,59 @@
"node": ">=6"
}
},
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1076,6 +1285,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1156,6 +1378,44 @@
}
}
},
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-yarn-workspace-root": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
+ "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "micromatch": "^4.0.2"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1171,6 +1431,177 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -1178,6 +1609,59 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-stable-stringify": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+ "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "isarray": "^2.0.5",
+ "jsonify": "^0.0.1",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/jsonify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+ "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+ "dev": true,
+ "license": "Public Domain",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/klaw-sync": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
+ "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.1.11"
+ }
+ },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -1207,6 +1691,53 @@
"node": ">= 20"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1245,6 +1776,73 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/open": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0",
+ "is-wsl": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/patch-package": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
+ "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@yarnpkg/lockfile": "^1.1.0",
+ "chalk": "^4.1.2",
+ "ci-info": "^3.7.0",
+ "cross-spawn": "^7.0.3",
+ "find-yarn-workspace-root": "^2.0.0",
+ "fs-extra": "^10.0.0",
+ "json-stable-stringify": "^1.0.2",
+ "klaw-sync": "^6.0.0",
+ "minimist": "^1.2.6",
+ "open": "^7.4.2",
+ "semver": "^7.5.3",
+ "slash": "^2.0.0",
+ "tmp": "^0.2.4",
+ "yaml": "^2.2.2"
+ },
+ "bin": {
+ "patch-package": "index.js"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">5"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1356,6 +1954,60 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1363,6 +2015,16 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1400,6 +2062,19 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -1461,6 +2136,29 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tmp": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+ "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1475,6 +2173,16 @@
"node": ">=14.17"
}
},
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -2130,6 +2838,22 @@
}
}
},
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -2146,6 +2870,22 @@
"engines": {
"node": ">=8"
}
+ },
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
}
}
}
diff --git a/webui/package.json b/webui/package.json
index f194f7a..9c03692 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -7,13 +7,15 @@
"sync:shell": "bash scripts/sync-researchwizard-shell.sh",
"build": "node esbuild.config.mjs",
"dev": "node esbuild.config.mjs --watch",
- "test": "node --experimental-vm-modules node_modules/vitest/vitest.mjs run"
+ "test": "node --experimental-vm-modules node_modules/vitest/vitest.mjs run",
+ "postinstall": "patch-package"
},
"dependencies": {
"murm-ui": "^0.2.0"
},
"devDependencies": {
"esbuild": "^0.25.0",
+ "patch-package": "^8.0.1",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
diff --git a/webui/patches/murm-ui+0.2.0.patch b/webui/patches/murm-ui+0.2.0.patch
new file mode 100644
index 0000000..a2aef1a
--- /dev/null
+++ b/webui/patches/murm-ui+0.2.0.patch
@@ -0,0 +1,34 @@
+diff --git a/node_modules/murm-ui/dist/components/message-node.js b/node_modules/murm-ui/dist/components/message-node.js
+index db876cc..aab7639 100644
+--- a/node_modules/murm-ui/dist/components/message-node.js
++++ b/node_modules/murm-ui/dist/components/message-node.js
+@@ -140,17 +140,23 @@ export class MessageNode {
+ clearTimeout(state.timer);
+ state.timer = undefined;
+ }
++ state.plainEl = undefined;
+ state.renderSeq++;
+ void this.applyMarkdown(block.id, block.text, state.renderSeq);
+ return;
+ }
+- if (state.timer)
+- return;
+- state.timer = window.setTimeout(() => {
++ // Streaming: plain-text tail — defer markdown until generation completes (llm-fallbacks patch)
++ if (state.timer) {
++ clearTimeout(state.timer);
+ state.timer = undefined;
+- state.renderSeq++;
+- void this.applyMarkdown(block.id, block.text, state.renderSeq);
+- }, MARKDOWN_THROTTLE_MS);
++ }
++ if (!state.plainEl) {
++ state.container.replaceChildren();
++ state.plainEl = document.createElement("div");
++ state.plainEl.className = "mur-streaming-text";
++ state.container.appendChild(state.plainEl);
++ }
++ state.plainEl.textContent = block.text;
+ }
+ renderFileBlock(block, container) {
+ if (container.hasChildNodes())
diff --git a/webui/shell/chat-overrides.css b/webui/shell/chat-overrides.css
index 2802d5e..ad20d36 100644
--- a/webui/shell/chat-overrides.css
+++ b/webui/shell/chat-overrides.css
@@ -151,7 +151,7 @@ body.lf-chat-page::before {
}
#chatMount.mur-app-embedded.mur-chat-empty .mur-chat-layout-wrapper::before {
- content: "Free model chat";
+ content: "Ranked free models, zero config";
display: block;
position: absolute;
top: 36%;
@@ -167,7 +167,7 @@ body.lf-chat-page::before {
}
#chatMount.mur-app-embedded.mur-chat-empty .mur-chat-layout-wrapper::after {
- content: "Zero-config demo — ranked free alias via edge proxy. Optional BYOK for browser-direct routes.";
+ content: "Daily-updated catalog via edge proxy. Pick a model above or send a message. Optional BYOK for direct routes.";
display: block;
position: absolute;
top: 44%;
@@ -807,3 +807,156 @@ body.lf-chat-page::before {
.explorer-table-wrap td {
vertical-align: middle;
}
+
+/* ── Wave 4: streaming polish + shortcuts ───────────────────── */
+#chatMount .mur-streaming-text {
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: inherit;
+ line-height: 1.55;
+ contain: content;
+}
+
+#chatMount .mur-message-assistant.mur-generating .mur-content-block {
+ min-height: 1.5em;
+}
+
+.lf-shortcuts-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 10050;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.lf-shortcuts-modal[hidden] {
+ display: none !important;
+}
+
+.lf-shortcuts-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.62);
+}
+
+.lf-shortcuts-panel {
+ position: relative;
+ z-index: 1;
+ width: min(22rem, 92vw);
+ padding: 1.25rem 1.35rem;
+ border-radius: 12px;
+ background: #1a1a2e;
+ border: 1px solid rgba(157, 78, 221, 0.28);
+ box-shadow: var(--mur-shadow-modal, 0 10px 28px rgba(0, 0, 0, 0.45));
+}
+
+.lf-shortcuts-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.85rem;
+}
+
+.lf-shortcuts-header h2 {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: #e8e8ef;
+}
+
+.lf-shortcuts-close {
+ border: none;
+ background: transparent;
+ color: #8b8ba3;
+ font-size: 1.35rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.lf-shortcuts-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.lf-shortcuts-list li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.45rem 0;
+ border-bottom: 1px solid rgba(157, 78, 221, 0.12);
+ font-size: 0.88rem;
+ color: #c4c4d4;
+}
+
+.lf-shortcuts-list li:last-child {
+ border-bottom: none;
+}
+
+.lf-shortcuts-list kbd {
+ display: inline-block;
+ padding: 0.15rem 0.45rem;
+ border-radius: 5px;
+ background: rgba(157, 78, 221, 0.16);
+ border: 1px solid rgba(157, 78, 221, 0.28);
+ color: #e4c7ff;
+ font-family: inherit;
+ font-size: 0.78rem;
+}
+
+.lf-shortcuts-hint {
+ position: absolute;
+ bottom: 5.5rem;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.45rem 0.75rem;
+ border-radius: 8px;
+ background: rgba(26, 26, 46, 0.92);
+ border: 1px solid rgba(157, 78, 221, 0.25);
+ color: #c4c4d4;
+ font-size: 0.82rem;
+ pointer-events: auto;
+}
+
+.lf-shortcuts-hint[hidden] {
+ display: none !important;
+}
+
+.lf-shortcuts-hint kbd {
+ padding: 0.1rem 0.35rem;
+ border-radius: 4px;
+ background: rgba(157, 78, 221, 0.2);
+ border: 1px solid rgba(157, 78, 221, 0.3);
+ font-size: 0.75rem;
+}
+
+.lf-shortcuts-hint-dismiss {
+ border: none;
+ background: transparent;
+ color: #8b8ba3;
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+}
+
+.lf-shortcuts-footer-link {
+ border: none;
+ background: transparent;
+ color: #8b8ba3;
+ font-size: 0.78rem;
+ cursor: pointer;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ padding: 0 0.35rem;
+}
+
+.lf-shortcuts-footer-link:hover {
+ color: #c77dff;
+}
+
diff --git a/webui/src/import-session.test.ts b/webui/src/import-session.test.ts
new file mode 100644
index 0000000..48b8216
--- /dev/null
+++ b/webui/src/import-session.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import { toJson, toMarkdown } from "./export-session";
+import {
+ ImportError,
+ parseJsonExport,
+ parseMarkdownExport,
+ parseExportText,
+} from "./import-session";
+
+describe("import-session", () => {
+ it("round-trips JSON export", () => {
+ const messages = [
+ { id: "1", role: "user" as const, blocks: [{ type: "text" as const, text: "Hello" }] },
+ {
+ id: "2",
+ role: "assistant" as const,
+ blocks: [{ type: "text" as const, text: "Hi there" }],
+ },
+ ];
+ const json = toJson(messages, { id: "sess-1", title: "Test" });
+ const parsed = parseJsonExport(json);
+ expect(parsed).toHaveLength(2);
+ expect(parsed[0].role).toBe("user");
+ expect(parsed[1].role).toBe("assistant");
+ expect(parsed[0].blocks[0]).toMatchObject({ type: "text", text: "Hello" });
+ expect(parsed[1].blocks[0]).toMatchObject({ type: "text", text: "Hi there" });
+ });
+
+ it("parses markdown with two turns", () => {
+ const md = toMarkdown(
+ [
+ { id: "1", role: "user", blocks: [{ type: "text", text: "Question?" }] },
+ { id: "2", role: "assistant", blocks: [{ type: "text", text: "Answer." }] },
+ ],
+ { id: "x", title: "Chat" }
+ );
+ const parsed = parseMarkdownExport(md);
+ expect(parsed).toHaveLength(2);
+ expect(parsed[0].blocks[0]).toMatchObject({ text: "Question?" });
+ expect(parsed[1].blocks[0]).toMatchObject({ text: "Answer." });
+ });
+
+ it("rejects invalid JSON", () => {
+ expect(() => parseJsonExport("{")).toThrow(ImportError);
+ expect(() => parseJsonExport('{"messages":[]}')).toThrow(/empty/i);
+ });
+
+ it("rejects empty markdown", () => {
+ expect(() => parseMarkdownExport(" ")).toThrow(/empty/i);
+ expect(() => parseMarkdownExport("# Title only")).toThrow(/headings/i);
+ });
+
+ it("detects format by extension", () => {
+ const json = parseExportText('{"messages":[{"role":"user","text":"Hi"}]}', "x.json");
+ expect(json).toHaveLength(1);
+ });
+});
diff --git a/webui/src/import-session.ts b/webui/src/import-session.ts
new file mode 100644
index 0000000..d611806
--- /dev/null
+++ b/webui/src/import-session.ts
@@ -0,0 +1,107 @@
+import type { Message } from "murm-ui";
+
+export class ImportError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ImportError";
+ }
+}
+
+interface JsonExportPayload {
+ title?: string | null;
+ messages?: Array<{ role?: string; text?: string }>;
+}
+
+function newMessage(role: "user" | "assistant", text: string): Message {
+ const id = crypto.randomUUID();
+ const now = Date.now();
+ return {
+ id,
+ role,
+ blocks: [{ id: crypto.randomUUID(), type: "text", text }],
+ runId: id,
+ createdAt: now,
+ updatedAt: now,
+ };
+}
+
+export function parseJsonExport(text: string): Message[] {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ throw new ImportError("Invalid JSON — could not parse file.");
+ }
+ if (!parsed || typeof parsed !== "object") {
+ throw new ImportError("Invalid JSON — expected an object with a messages array.");
+ }
+ const payload = parsed as JsonExportPayload;
+ if (!Array.isArray(payload.messages) || payload.messages.length === 0) {
+ throw new ImportError("Invalid JSON — messages array is missing or empty.");
+ }
+ const messages: Message[] = [];
+ for (const item of payload.messages) {
+ if (!item || typeof item !== "object") continue;
+ const role = item.role;
+ const msgText = typeof item.text === "string" ? item.text.trim() : "";
+ if (role !== "user" && role !== "assistant") {
+ throw new ImportError(`Invalid JSON — unknown role "${String(role)}".`);
+ }
+ if (!msgText) continue;
+ messages.push(newMessage(role, msgText));
+ }
+ if (messages.length === 0) {
+ throw new ImportError("No messages with text found in JSON export.");
+ }
+ return messages;
+}
+
+export function parseMarkdownExport(text: string): Message[] {
+ const trimmed = text.trim();
+ if (!trimmed) {
+ throw new ImportError("Markdown file is empty.");
+ }
+ const sections = trimmed.split(/^##\s+(User|Assistant)\s*$/im);
+ if (sections.length < 3) {
+ throw new ImportError("Could not find ## User / ## Assistant headings in Markdown.");
+ }
+ const messages: Message[] = [];
+ for (let i = 1; i < sections.length; i += 2) {
+ const roleLabel = sections[i]?.toLowerCase();
+ const content = (sections[i + 1] ?? "").trim();
+ if (!content) continue;
+ const role = roleLabel === "user" ? "user" : "assistant";
+ messages.push(newMessage(role, content));
+ }
+ if (messages.length === 0) {
+ throw new ImportError("No message content found under headings.");
+ }
+ return messages;
+}
+
+export function parseExportText(text: string, filename: string): Message[] {
+ if (filename.toLowerCase().endsWith(".json")) {
+ return parseJsonExport(text);
+ }
+ return parseMarkdownExport(text);
+}
+
+export async function importMessagesFromFile(file: File): Promise {
+ const text = await file.text();
+ return parseExportText(text, file.name);
+}
+
+export function importTitleFromFile(text: string, filename: string): string | undefined {
+ if (filename.toLowerCase().endsWith(".json")) {
+ try {
+ const parsed = JSON.parse(text) as { title?: string | null };
+ const title = parsed.title?.trim();
+ if (title) return title;
+ } catch {
+ /* fall through */
+ }
+ }
+ const base = filename.replace(/\.(md|json)$/i, "");
+ const slug = base.replace(/^llm-fallbacks-/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
+ return slug.replace(/-/g, " ").trim() || undefined;
+}
diff --git a/webui/src/main.ts b/webui/src/main.ts
index 8fc3142..31db069 100644
--- a/webui/src/main.ts
+++ b/webui/src/main.ts
@@ -1,4 +1,4 @@
-import { ChatUI, IndexedDBStorage } from "murm-ui/with-css";
+import { ChatUI, IndexedDBStorage, type ChatEngine } from "murm-ui/with-css";
import { CopyPlugin } from "murm-ui/plugins/copy";
import {
loadRuntimeConfig,
@@ -28,6 +28,13 @@ import {
toJson,
toMarkdown,
} from "./export-session";
+import {
+ ImportError,
+ importMessagesFromFile,
+ importTitleFromFile,
+} from "./import-session";
+import { ShortcutsSheetPlugin } from "./plugins/shortcuts-sheet";
+import { showStatusMessage } from "./plugins/status-strip";
async function loadCatalog(config: AppConfig): Promise<{
catalog: CatalogEntry[];
@@ -88,6 +95,41 @@ async function bootstrap(): Promise {
let catalogRef = catalog;
let providerUrlsRef = providerUrls;
+ const importInput = document.createElement("input");
+ importInput.type = "file";
+ importInput.accept = ".md,.json,text/markdown,application/json";
+ importInput.hidden = true;
+ document.body.appendChild(importInput);
+
+ let importEngine: ChatEngine | null = null;
+
+ importInput.addEventListener("change", async () => {
+ const file = importInput.files?.[0];
+ importInput.value = "";
+ if (!file || !importEngine) return;
+ if (importEngine.state.generatingMessageId) {
+ showStatusMessage("Wait for the current reply to finish before importing.");
+ return;
+ }
+ try {
+ const text = await file.text();
+ const messages = await importMessagesFromFile(file);
+ await importEngine.sessions.create();
+ const ok = await importEngine.setMessages(messages);
+ if (!ok) {
+ throw new ImportError("Could not import while a reply is generating.");
+ }
+ const title = importTitleFromFile(text, file.name);
+ if (title) {
+ await importEngine.sessions.updateTitle(importEngine.state.currentSessionId, title);
+ }
+ showStatusMessage(`Imported ${messages.length} message${messages.length === 1 ? "" : "s"}.`);
+ } catch (err) {
+ const msg = err instanceof ImportError ? err.message : "Import failed.";
+ showStatusMessage(msg);
+ }
+ });
+
const ui = new ChatUI({
container: "#chatMount",
provider,
@@ -134,6 +176,28 @@ async function bootstrap(): Promise {
);
},
},
+ {
+ id: "import-conversation",
+ label: "Import conversation",
+ disabled: ctx.engine.state.generatingMessageId !== null,
+ onClick: () => {
+ importInput.click();
+ },
+ },
+ {
+ id: "copy-session-link",
+ label: "Copy session link",
+ onClick: async () => {
+ const hash = `#/chat/${encodeURIComponent(ctx.session.id)}`;
+ const url = `${window.location.origin}${window.location.pathname}${window.location.search}${hash}`;
+ try {
+ await navigator.clipboard.writeText(url);
+ showStatusMessage("Session link copied (local browser only).");
+ } catch {
+ window.prompt("Copy session link:", url);
+ }
+ },
+ },
];
},
plugins: (engine) => [
@@ -164,9 +228,12 @@ async function bootstrap(): Promise {
getCatalog: () => catalogRef,
getCatalogUrl: () => readRuntimeConfig().catalogUrl,
}),
+ ShortcutsSheetPlugin(),
],
});
+ importEngine = ui.engine;
+
wireChatInputIds(document.querySelector("#chatMount")!);
const observer = new MutationObserver(() => wireChatInputIds(document.querySelector("#chatMount")!));
diff --git a/webui/src/plugins/shortcuts-sheet/index.ts b/webui/src/plugins/shortcuts-sheet/index.ts
new file mode 100644
index 0000000..7aaea0a
--- /dev/null
+++ b/webui/src/plugins/shortcuts-sheet/index.ts
@@ -0,0 +1,125 @@
+import type { ChatPlugin } from "murm-ui";
+
+const HINT_KEY = "llm_fallbacks_shortcuts_hint_dismissed";
+
+const SHORTCUTS = [
+ { keys: "Enter", action: "Send message" },
+ { keys: "Shift + Enter", action: "New line in composer" },
+ { keys: "Esc", action: "Stop generation (when streaming)" },
+ { keys: "/", action: "Focus composer" },
+ { keys: "?", action: "Show this shortcuts sheet" },
+];
+
+function isTypingTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false;
+ const tag = target.tagName;
+ return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
+}
+
+function ensureModal(): HTMLElement {
+ let modal = document.getElementById("lfShortcutsModal");
+ if (modal) return modal;
+
+ modal = document.createElement("div");
+ modal.id = "lfShortcutsModal";
+ modal.className = "lf-shortcuts-modal";
+ modal.hidden = true;
+ modal.innerHTML = `
+
+
+ `;
+ const list = modal.querySelector(".lf-shortcuts-list")!;
+ for (const { keys, action } of SHORTCUTS) {
+ const li = document.createElement("li");
+ li.innerHTML = `${keys}${action}`;
+ list.appendChild(li);
+ }
+ document.body.appendChild(modal);
+ return modal;
+}
+
+function openModal(): void {
+ const modal = ensureModal();
+ modal.hidden = false;
+ modal.querySelector(".lf-shortcuts-close")?.focus();
+}
+
+function closeModal(): void {
+ const modal = document.getElementById("lfShortcutsModal");
+ if (modal) modal.hidden = true;
+}
+
+function ensureHint(): HTMLElement {
+ let hint = document.getElementById("lfShortcutsHint");
+ if (hint) return hint;
+
+ hint = document.createElement("div");
+ hint.id = "lfShortcutsHint";
+ hint.className = "lf-shortcuts-hint";
+ hint.innerHTML = `
+ Tip: press ? for keyboard shortcuts
+
+ `;
+ const mount = document.getElementById("chatMount");
+ if (mount) {
+ mount.appendChild(hint);
+ } else {
+ document.body.appendChild(hint);
+ }
+ return hint;
+}
+
+export function ShortcutsSheetPlugin(): ChatPlugin {
+ return {
+ name: "shortcuts-sheet",
+ onMount() {
+ ensureModal();
+
+ const modal = document.getElementById("lfShortcutsModal")!;
+ modal.addEventListener("click", (e) => {
+ const target = e.target as HTMLElement;
+ if (target.dataset.close || target.classList.contains("lf-shortcuts-close")) {
+ closeModal();
+ }
+ });
+
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ closeModal();
+ return;
+ }
+ if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey && !isTypingTarget(e.target)) {
+ e.preventDefault();
+ openModal();
+ }
+ });
+
+ if (localStorage.getItem(HINT_KEY) !== "1") {
+ const hint = ensureHint();
+ hint.hidden = false;
+ hint.querySelector(".lf-shortcuts-hint-dismiss")?.addEventListener("click", () => {
+ hint.hidden = true;
+ localStorage.setItem(HINT_KEY, "1");
+ });
+ }
+
+ const footerLink = document.createElement("button");
+ footerLink.type = "button";
+ footerLink.className = "lf-shortcuts-footer-link";
+ footerLink.textContent = "Shortcuts";
+ footerLink.title = "Keyboard shortcuts (?)";
+ footerLink.addEventListener("click", () => openModal());
+
+ const credits = document.querySelector(".credits-actions");
+ credits?.appendChild(footerLink);
+ },
+ };
+}
+
+export { openModal as openShortcutsModal, closeModal as closeShortcutsModal };
diff --git a/webui/src/plugins/status-strip/index.ts b/webui/src/plugins/status-strip/index.ts
index 05e823c..fa14440 100644
--- a/webui/src/plugins/status-strip/index.ts
+++ b/webui/src/plugins/status-strip/index.ts
@@ -79,3 +79,14 @@ export function showRateLimitBanner(seconds?: number): void {
: " Wait and try again.";
mount.innerHTML = `Rate limited.${suffix}`;
}
+
+export function showStatusMessage(text: string, durationMs = 3500): void {
+ const mount = document.getElementById("lfStatusStrip");
+ if (!mount) return;
+ mount.innerHTML = `${text}`;
+ window.setTimeout(() => {
+ if (mount.querySelector(".lf-status-text")?.textContent === text) {
+ void refreshStatusStrip(mount);
+ }
+ }, durationMs);
+}