From d2eedd85f823f2db8ca5d88d03edea84b3c95d59 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Fri, 26 Jun 2026 17:22:09 +0800 Subject: [PATCH 1/5] Show extension sidecar runtime status --- docs/EXTENSIONS.md | 7 +- static/panels.js | 95 ++++++++++++++++++++++++- static/style.css | 5 ++ tests/test_extensions_settings_panel.py | 22 +++++- 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 985b5368..24b69c46 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -414,4 +414,9 @@ directly with `fetch(..., { credentials: 'omit', cache: 'no-store' })` and a short timeout. WebUI does **not** proxy sidecar requests and does not send WebUI cookies to sidecars. A successful HTTP response is shown as healthy, a non-OK HTTP response as unhealthy, and CORS/network/timeouts as unreachable or blocked; -health response bodies are never rendered. +raw health response bodies are never rendered. If a healthy response includes an +optional top-level `runtime` object, the panel may parse it and render only +allowlisted scalar fields such as `sidecar`, `native_host`, `bridge`, +`last_seen_at`, and `webui_origin`. This keeps sidecar-specific diagnostics +machine-readable without making WebUI depend on any one extension's private +payload shape. diff --git a/static/panels.js b/static/panels.js index ddc565d6..d488b465 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8101,6 +8101,70 @@ function _extensionSidecarHealthBadge(status,label){ return `${esc(label||safeStatus)}`; } +function _extensionRuntimeStatusValue(value){ + const normalized=String(value||'').trim().toLowerCase(); + return ['running','connected','waiting','stale','unloaded','stopped','not_registered','unknown'].includes(normalized) + ? normalized + : 'unknown'; +} + +function _extensionRuntimeStatusLabel(value){ + const normalized=_extensionRuntimeStatusValue(value); + if(normalized==='not_registered') return 'not registered'; + return normalized.replace(/_/g,' '); +} + +function _extensionRuntimeLastSeen(value){ + const raw=Number(value); + if(!Number.isFinite(raw)||raw<=0) return ''; + const seconds=raw>1000000000000?raw/1000:raw; + const age=Math.max(0,Math.floor(Date.now()/1000-seconds)); + if(age<5) return 'just now'; + if(age<60) return `${age}s ago`; + const minutes=Math.floor(age/60); + if(minutes<60) return `${minutes}m ago`; + const hours=Math.floor(minutes/60); + if(hours<24) return `${hours}h ago`; + return `${Math.floor(hours/24)}d ago`; +} + +function _extensionRuntimeOrigin(value){ + const text=String(value||'').trim(); + if(!text) return ''; + try{ + const parsed=new URL(text); + if(parsed.protocol==='http:'&&(parsed.hostname==='127.0.0.1'||parsed.hostname==='localhost')){ + return parsed.origin; + } + }catch(_e){} + return ''; +} + +function _extensionRuntimeRows(runtime){ + if(!runtime||typeof runtime!=='object') return []; + const rows=[]; + if(Object.prototype.hasOwnProperty.call(runtime,'sidecar')){ + rows.push(['Sidecar',_extensionRuntimeStatusLabel(runtime.sidecar)]); + } + if(Object.prototype.hasOwnProperty.call(runtime,'native_host')){ + rows.push(['Native host',_extensionRuntimeStatusLabel(runtime.native_host)]); + } + if(Object.prototype.hasOwnProperty.call(runtime,'bridge')){ + rows.push(['Bridge',_extensionRuntimeStatusLabel(runtime.bridge)]); + } + const lastSeen=_extensionRuntimeLastSeen(runtime.last_seen_at); + if(lastSeen) rows.push(['Last update',lastSeen]); + const origin=_extensionRuntimeOrigin(runtime.webui_origin); + if(origin) rows.push(['WebUI origin',origin]); + return rows; +} + +function _extensionRuntimeDetails(runtime){ + const rows=_extensionRuntimeRows(runtime); + if(!rows.length) return ''; + return rows.map(([label,value])=>`
${esc(label)}${esc(value)}
`).join(''); +} + function _extensionSidecarCard(sidecars){ const list=Array.isArray(sidecars)?sidecars:[]; const body=list.length?`
${list.map((sidecar,index)=>{ @@ -8122,6 +8186,7 @@ function _extensionSidecarCard(sidecars){
Health path${esc(healthPath)}
Health URL${esc(healthUrl)}
+ `; }).join('')}`:'
No loopback sidecars declared.
'; return ` @@ -8143,10 +8208,24 @@ function _setExtensionSidecarHealth(index,status,label){ if(el) el.innerHTML=_extensionSidecarHealthBadge(status,label); } +function _setExtensionSidecarRuntime(index,runtime){ + const el=document.querySelector(`[data-sidecar-runtime-index="${index}"]`); + if(!el) return; + const details=_extensionRuntimeDetails(runtime); + if(!details){ + el.hidden=true; + el.innerHTML=''; + return; + } + el.hidden=false; + el.innerHTML=details; +} + async function _checkExtensionSidecarHealth(sidecar,index,seq){ const healthUrl=sidecar&&sidecar.health_url; if(!healthUrl){ _setExtensionSidecarHealth(index,'blocked','unreachable / blocked'); + _setExtensionSidecarRuntime(index,null); return; } let controller=null; @@ -8158,11 +8237,22 @@ async function _checkExtensionSidecarHealth(sidecar,index,seq){ } const res=await fetch(healthUrl,{credentials:'omit',cache:'no-store',signal:controller?controller.signal:undefined}); if(seq!==_extensionsSidecarMonitorSeq) return; - if(res.ok) _setExtensionSidecarHealth(index,'healthy','healthy'); - else _setExtensionSidecarHealth(index,'unhealthy','unhealthy'); + if(res.ok){ + _setExtensionSidecarHealth(index,'healthy','healthy'); + let body=null; + try{ + body=await res.json(); + }catch(_e){} + if(seq!==_extensionsSidecarMonitorSeq) return; + _setExtensionSidecarRuntime(index,body&&typeof body==='object'?body.runtime:null); + }else{ + _setExtensionSidecarHealth(index,'unhealthy','unhealthy'); + _setExtensionSidecarRuntime(index,null); + } }catch(_e){ if(seq!==_extensionsSidecarMonitorSeq) return; _setExtensionSidecarHealth(index,'blocked','unreachable / blocked'); + _setExtensionSidecarRuntime(index,null); }finally{ if(timeoutId) clearTimeout(timeoutId); } @@ -8314,6 +8404,7 @@ function switchExtensionsTab(tab){ document.querySelectorAll('[data-extensions-pane]').forEach(pane=>{ pane.hidden=pane.dataset.extensionsPane!==tab; }); + if(tab==='diagnostics') loadExtensionsPanel(); if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery(); } diff --git a/static/style.css b/static/style.css index 8d238c6d..8e0b13d1 100644 --- a/static/style.css +++ b/static/style.css @@ -4736,6 +4736,11 @@ main.main > #mainPlugin{display:none;} .extension-sidecar-fields>div{display:flex;align-items:flex-start;gap:8px;min-width:0;font-size:12px;color:var(--muted);} .extension-sidecar-fields span{flex:0 0 82px;} .extension-sidecar-fields code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;} +.extension-sidecar-runtime{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:6px;margin-top:9px;padding-top:9px;border-top:1px solid var(--border);} +.extension-sidecar-runtime[hidden]{display:none!important;} +.extension-sidecar-runtime>div{display:flex;flex-direction:column;gap:2px;min-width:0;border:1px solid var(--border);border-radius:7px;background:var(--card-bg,var(--sidebar-bg));padding:6px 8px;} +.extension-sidecar-runtime span{font-size:10.5px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:0;} +.extension-sidecar-runtime code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;} .extension-sidecar-status-badge{display:inline-flex;align-items:center;white-space:nowrap;border-radius:999px;padding:2px 7px;font-size:10.5px;font-weight:700;font-family:var(--font-mono);line-height:1.45;background:var(--code-bg);color:var(--muted);} .extension-sidecar-status-healthy{background:rgba(34,197,94,.14);color:#22c55e;} .extension-sidecar-status-unhealthy{background:rgba(245,158,11,.14);color:#f59e0b;} diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index 0890ef2b..ab4ac6c0 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -96,6 +96,13 @@ def test_extensions_panel_fetches_status_endpoint_without_mutating_settings(): assert "extensions-error" in load_block +def test_extensions_diagnostics_tab_refreshes_runtime_status(): + tab_block = _function_block("switchExtensionsTab", extra=900) + + assert "if(tab==='diagnostics') loadExtensionsPanel();" in tab_block + assert "if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();" in tab_block + + def test_extensions_panel_renders_sanitized_status_payload(): render_block = _between("function _renderExtensionsPanel", "async function loadExtensionsPanel") warning_block = _function_block("_extensionWarningList", extra=900) @@ -128,7 +135,9 @@ def test_extensions_panel_renders_sanitized_status_payload(): def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): + runtime_block = _between("function _extensionRuntimeStatusValue", "function _extensionSidecarCard") sidecar_block = _between("function _extensionSidecarCard", "function _setExtensionSidecarHealth") + runtime_setter_block = _between("function _setExtensionSidecarRuntime", "async function _checkExtensionSidecarHealth") monitor_block = _between("async function _checkExtensionSidecarHealth", "function _renderExtensionsPanel") render_block = _between("function _renderExtensionsPanel", "async function loadExtensionsPanel") load_block = _between("async function loadExtensionsPanel", "async function copyExtensionsDiagnostics") @@ -140,6 +149,7 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): assert "esc(origin)" in sidecar_block assert "esc(healthPath)" in sidecar_block assert "esc(healthUrl)" in sidecar_block + assert 'data-sidecar-runtime-index="${index}"' in sidecar_block assert "fetch(healthUrl,{credentials:'omit',cache:'no-store'" in monitor_block assert "function _monitorExtensionSidecars(sidecars,seq)" in monitor_block assert "const seq=_extensionsSidecarMonitorSeq" not in monitor_block @@ -150,7 +160,14 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): assert "_renderExtensionsPanel(data,seq)" in load_block assert "res.ok" in monitor_block assert "res.text" not in monitor_block - assert "res.json" not in monitor_block + assert "body=await res.json()" in monitor_block + assert "_setExtensionSidecarRuntime(index,body&&typeof body==='object'?body.runtime:null)" in monitor_block + assert "runtime.sidecar" in runtime_block + assert "runtime.native_host" in runtime_block + assert "runtime.bridge" in runtime_block + assert "runtime.last_seen_at" in runtime_block + assert "runtime.webui_origin" in runtime_block + assert "el.innerHTML=details" in runtime_setter_block assert "api('/api/settings'" not in monitor_block assert "api('/extensions/" not in monitor_block assert "sidecar/*" not in monitor_block @@ -196,6 +213,7 @@ def test_extensions_styles_are_scoped_to_extensions_panel(): assert ".extension-installed-list" in STYLE_CSS assert ".extension-toggle-btn" in STYLE_CSS assert ".extension-sidecar-list" in STYLE_CSS + assert ".extension-sidecar-runtime" in STYLE_CSS assert ".extension-sidecar-status-badge" in STYLE_CSS @@ -222,6 +240,8 @@ def test_extensions_docs_mentions_settings_panel_without_install_or_proxy_claims assert "sanitized loopback sidecars" in diagnostics_section assert "credentials: 'omit'" in diagnostics_section assert "does **not** proxy sidecar requests" in diagnostics_section + assert "optional top-level `runtime` object" in diagnostics_section + assert "allowlisted scalar fields" in diagnostics_section assert "do **not**" in diagnostics_section assert "return `HERMES_WEBUI_EXTENSION_DIR`" in diagnostics_section assert "override state-file path" in diagnostics_section From 71dd4a6ab86f6389e34330ab4b1ac0bc7feff8f0 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Fri, 26 Jun 2026 17:55:08 +0800 Subject: [PATCH 2/5] Polish extension sidecar diagnostics refresh Keep the Diagnostics tab content visible while status refreshes on repeat visits, and reject malformed or far-future sidecar runtime timestamps before rendering relative age text. Co-Authored-By: Paperclip --- static/panels.js | 17 +++++++++++------ tests/test_extensions_settings_panel.py | 8 +++++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/static/panels.js b/static/panels.js index d488b465..ea479574 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8115,10 +8115,14 @@ function _extensionRuntimeStatusLabel(value){ } function _extensionRuntimeLastSeen(value){ - const raw=Number(value); + const text=String(value??'').trim(); + if(!/^\d+(?:\.\d+)?$/.test(text)) return ''; + const raw=Number(text); if(!Number.isFinite(raw)||raw<=0) return ''; const seconds=raw>1000000000000?raw/1000:raw; - const age=Math.max(0,Math.floor(Date.now()/1000-seconds)); + const now=Math.floor(Date.now()/1000); + if(seconds>now+300) return ''; + const age=Math.max(0,Math.floor(now-seconds)); if(age<5) return 'just now'; if(age<60) return `${age}s ago`; const minutes=Math.floor(age/60); @@ -8377,13 +8381,14 @@ async function handleExtensionToggle(btn){ } } -async function loadExtensionsPanel(){ +async function loadExtensionsPanel(opts){ const target=$('extensionsDiagnostics'); const copyBtn=$('extensionsCopyDiagnosticsBtn'); if(!target) return; - if(copyBtn) copyBtn.disabled=true; + const preserveExisting=!!(opts&&opts.preserveExisting&&target.innerHTML.trim()); + if(copyBtn&&!preserveExisting) copyBtn.disabled=true; const seq=++_extensionsSidecarMonitorSeq; - target.innerHTML='
Loading extension diagnostics…
'; + if(!preserveExisting) target.innerHTML='
Loading extension diagnostics…
'; try{ const data=await api('/api/extensions/status'); if(seq!==_extensionsSidecarMonitorSeq) return; @@ -8404,7 +8409,7 @@ function switchExtensionsTab(tab){ document.querySelectorAll('[data-extensions-pane]').forEach(pane=>{ pane.hidden=pane.dataset.extensionsPane!==tab; }); - if(tab==='diagnostics') loadExtensionsPanel(); + if(tab==='diagnostics') loadExtensionsPanel({preserveExisting:true}); if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery(); } diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index ab4ac6c0..e33379db 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -99,7 +99,7 @@ def test_extensions_panel_fetches_status_endpoint_without_mutating_settings(): def test_extensions_diagnostics_tab_refreshes_runtime_status(): tab_block = _function_block("switchExtensionsTab", extra=900) - assert "if(tab==='diagnostics') loadExtensionsPanel();" in tab_block + assert "if(tab==='diagnostics') loadExtensionsPanel({preserveExisting:true});" in tab_block assert "if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();" in tab_block @@ -156,12 +156,18 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): assert "_monitorExtensionSidecars(sidecars,seq)" in render_block assert "function _renderExtensionsPanel(data,seq)" in render_block assert "const seq=++_extensionsSidecarMonitorSeq" in load_block + assert "preserveExisting=!!(opts&&opts.preserveExisting&&target.innerHTML.trim())" in load_block + assert "if(!preserveExisting) target.innerHTML" in load_block + assert "loadExtensionsPanel({preserveExisting:true})" in load_block assert "if(seq!==_extensionsSidecarMonitorSeq) return;" in load_block assert "_renderExtensionsPanel(data,seq)" in load_block assert "res.ok" in monitor_block assert "res.text" not in monitor_block assert "body=await res.json()" in monitor_block assert "_setExtensionSidecarRuntime(index,body&&typeof body==='object'?body.runtime:null)" in monitor_block + assert "String(value??'').trim()" in runtime_block + assert r"/^\d+(?:\.\d+)?$/.test(text)" in runtime_block + assert "seconds>now+300" in runtime_block assert "runtime.sidecar" in runtime_block assert "runtime.native_host" in runtime_block assert "runtime.bridge" in runtime_block From 33e95277ca8434e9d2835aa2dd87060b6a6d88ac Mon Sep 17 00:00:00 2001 From: Frank Song Date: Fri, 26 Jun 2026 22:26:00 +0800 Subject: [PATCH 3/5] Honor preserved extension diagnostics refresh failures Co-Authored-By: Paperclip --- static/panels.js | 2 ++ tests/test_extensions_settings_panel.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/static/panels.js b/static/panels.js index ea479574..3199b003 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8394,6 +8394,8 @@ async function loadExtensionsPanel(opts){ if(seq!==_extensionsSidecarMonitorSeq) return; _renderExtensionsPanel(data,seq); }catch(e){ + if(seq!==_extensionsSidecarMonitorSeq) return; + if(preserveExisting&&target.innerHTML.trim()) return; _extensionsStatusData=null; if(copyBtn) copyBtn.disabled=true; target.innerHTML='
Failed to load extension diagnostics: '+esc(e.message||String(e))+'
'; diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index e33379db..2567d796 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -141,6 +141,7 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): monitor_block = _between("async function _checkExtensionSidecarHealth", "function _renderExtensionsPanel") render_block = _between("function _renderExtensionsPanel", "async function loadExtensionsPanel") load_block = _between("async function loadExtensionsPanel", "async function copyExtensionsDiagnostics") + load_catch_block = load_block[load_block.index("}catch(e){"):] assert "Loopback sidecars" in sidecar_block assert "No loopback sidecars declared." in sidecar_block @@ -160,6 +161,8 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): assert "if(!preserveExisting) target.innerHTML" in load_block assert "loadExtensionsPanel({preserveExisting:true})" in load_block assert "if(seq!==_extensionsSidecarMonitorSeq) return;" in load_block + assert "if(seq!==_extensionsSidecarMonitorSeq) return;" in load_catch_block + assert "if(preserveExisting&&target.innerHTML.trim()) return;" in load_catch_block assert "_renderExtensionsPanel(data,seq)" in load_block assert "res.ok" in monitor_block assert "res.text" not in monitor_block From 64e877b4327c01d1f0b862d0c39ab5558cf55972 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 27 Jun 2026 05:21:45 +0000 Subject: [PATCH 4/5] fix(extensions): don't preserve loading/error placeholder as existing diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex gate finding on #4979: loadExtensionsPanel({preserveExisting:true}) on the diagnostics tab treated the 'Loading extension diagnostics…' placeholder as real content, so a failed refresh returned early (preserveExisting && innerHTML.trim()) and left the panel stuck on 'Loading…' instead of rendering the error. Exclude the .extensions-loading/.extensions-error placeholders from the preserve check + add a regression assertion. Co-authored-by: franksong2702 --- static/panels.js | 8 +++++++- tests/test_extensions_settings_panel.py | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/static/panels.js b/static/panels.js index 3199b003..ab9d50ff 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8385,7 +8385,13 @@ async function loadExtensionsPanel(opts){ const target=$('extensionsDiagnostics'); const copyBtn=$('extensionsCopyDiagnosticsBtn'); if(!target) return; - const preserveExisting=!!(opts&&opts.preserveExisting&&target.innerHTML.trim()); + // Only preserve REAL rendered diagnostics across a refresh — never the + // "Loading…" / error placeholders, or a failed refresh would leave the panel + // stuck on "Loading extension diagnostics…" instead of rendering the error. + const preserveExisting=!!( + opts&&opts.preserveExisting&&target.innerHTML.trim() + &&!target.querySelector('.extensions-loading,.extensions-error') + ); if(copyBtn&&!preserveExisting) copyBtn.disabled=true; const seq=++_extensionsSidecarMonitorSeq; if(!preserveExisting) target.innerHTML='
Loading extension diagnostics…
'; diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index 2567d796..ca7853ea 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -157,7 +157,11 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): assert "_monitorExtensionSidecars(sidecars,seq)" in render_block assert "function _renderExtensionsPanel(data,seq)" in render_block assert "const seq=++_extensionsSidecarMonitorSeq" in load_block - assert "preserveExisting=!!(opts&&opts.preserveExisting&&target.innerHTML.trim())" in load_block + assert "opts&&opts.preserveExisting&&target.innerHTML.trim()" in load_block + # A failed refresh must NOT be preserved as "existing content": the Loading/ + # error placeholders are excluded so a fetch error always renders the error + # instead of leaving the panel stuck on "Loading extension diagnostics…". + assert "!target.querySelector('.extensions-loading,.extensions-error')" in load_block assert "if(!preserveExisting) target.innerHTML" in load_block assert "loadExtensionsPanel({preserveExisting:true})" in load_block assert "if(seq!==_extensionsSidecarMonitorSeq) return;" in load_block From 0825612a598b8758703ed1319841c490ba8cf072 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 27 Jun 2026 05:33:40 +0000 Subject: [PATCH 5/5] Release: extension sidecar runtime status in diagnostics panel (#4979) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3c6e5a..1f488f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **The Settings → Extensions diagnostics panel now shows per-extension sidecar runtime status.** When an extension's loopback sidecar health response includes an optional top-level `runtime` object, the diagnostics panel parses it and renders only allowlisted scalar fields (sidecar/native-host/bridge status, last-seen time, and the loopback origin) so you can see at a glance whether an extension's helper process is running, waiting, or stale — without WebUI depending on any one extension's private payload shape. All values are status-enum-allowlisted, HTML-escaped, timestamp-validated, and origin-limited to `http://127.0.0.1`/`localhost`; a missing or malformed `runtime` object degrades cleanly, and a failed diagnostics refresh now renders the error instead of staying stuck on "Loading…". Thanks @franksong2702. (#4979) + - **The "Response complete" notification now previews the final answer, not the start of the stream.** The browser/PWA completion notification body was built from the live streaming accumulator sliced to 100 chars, so it showed the *beginning* of the response (often a preamble or empty) rather than what the user actually got. It now derives the preview from the settled final answer (anchor projection → message content → raw text), strips inline thinking, normalizes whitespace, and truncates at 100 chars — and works for completions that finish while the session isn't the active pane. Thanks @claw-io. (#5036, fixes #5035) - **Onboarding now has the same searchable model picker as Settings.** A fresh install (e.g. an OpenRouter-only first run) previously showed only a small default model list in the onboarding step — no search box and no way to enter a custom model ID — so picking a model not in the short list meant finishing setup and fixing it later. Onboarding now reuses the same searchable picker (with custom-model-ID entry) that Settings uses, instead of a separate onboarding-only dropdown that could drift. The custom-provider free-text branch is unchanged, and the plain-`