* fix(dictation): preserve composer text when server STT commits
Dictation via the server STT path (MediaRecorder → POST /api/transcribe)
replaced any text already in the composer with the recognized transcript.
Browser SpeechRecognition worked correctly; only the async path was broken.
Root cause: the MediaRecorder.onstop handler calls _setRecording(false)
before awaiting _transcribeBlob's fetch. _setRecording(false) clears the
module-level _prefix. By the time the fetch resolves and _commitTranscript
runs, _prefix is '' — so the existing append logic collapses to
'just the transcript' and overwrites the textarea.
Browser SR dodged this because sr.onend builds the merged string into a
local variable before invoking _setRecording(false). Server STT is async
and could not use that pattern without help.
Fix:
- _commitTranscript accepts an optional prefixOverride parameter.
- _transcribeBlob snapshots _prefix into a local before the await.
- Pass the snapshot through to _commitTranscript.
Add a Settings → Voice → 'Append dictation to composer' toggle (default
ON) so users can opt into replace mode if they prefer the old buggy
behavior. The bug fix changes the default to append, matching what the
browser SR path always did.
i18n: new keys dictation_append_label / dictation_append_desc added to
all 14 locale blocks in static/i18n.js (English fallback where no native
translation exists).
Files: static/boot.js (+43/-7), static/index.html (+7), static/i18n.js (+30).
* fix(dictation): snapshot _prefix in onstop, not inside _transcribeBlob
First attempt at the append-mode fix took the snapshot inside
_transcribeBlob via 'const prefixSnapshot = _prefix'. By the time
_transcribeBlob runs (awaited from recorder.onstop), the sync block
above has already called _setRecording(false), which clears _prefix
inside _setRecording's !on branch. So the snapshot was always '' and
the append path fell through to the replace branch.
Move the snapshot into recorder.onstop, before _setRecording(false)
runs. Pass it as a parameter into _transcribeBlob so the call site
doesn't need to re-read the (now-cleared) module-level _prefix.
No behavior change for the browser-SR path — sr.onend still builds
the merged string into a local variable before invoking
_setRecording(false), and was already correct.
* fix(dictation): trim comments to fit test_590_recording_stops_before_transcribe
test_bugbatch_apr2026.py::test_590_recording_stops_before_transcribe slices
700 chars from 'recorder.onstop' and asserts that _setRecording(false)
comes before _sendRawAudio(/_transcribeBlob( inside that window. With
the verbose 6-line comment block from the previous fix, the actual
call sites landed at offsets 834/890 — past the 700-char window — so
the test's .find() returned -1 and the assertion failed mechanically.
Also: the verbose comment in _transcribeBlob contained the substring
'recorder.onstop', which made the test's first .find() land in the
comment instead of the actual handler, returning a wrong onstop_start.
Trim both comment blocks to single lines that fit within the test's
window and don't contain the literal 'recorder.onstop' substring.
Functional behavior unchanged from the previous fix; this is purely
a comment-shape adjustment to satisfy the brittle test.
* fix(tests): anchor test_590 on handler definition + widen window per review
The reviewer's fix-spec for test_590_recording_stops_before_transcribe
was more thorough than my single-shot comment trim:
(1) Reword the _transcribeBlob comment so it doesn't contain the
literal 'recorder.onstop' substring.
(2) Make the test robust to the handler growing: anchor on
'recorder.onstop=async' instead of 'recorder.onstop', and
widen the fixed 700-char window to 1200.
Applied both. With these changes the test passes regardless of how
much comment text gets added in or around the handler in the future.
Also restored the verbose explanatory comments in both locations
(the previous a7f84027 had trimmed them to fit the brittle 700-char
window — that workaround is no longer needed).
Backward-compat check: the new test logic also passes against
origin/master (verified locally), so this change doesn't regress
the existing green-master invariant.
* fix(dictation): preserve user's mid-transcription edits in async server STT
Greptile flagged this as a 4/5 concern on the append-mode branch:
the async server-STT path appended the dictated text to the stale
snapshot (`_prefix` captured at recording start), which clobbered
any keystrokes the user made during the ~1s transcription round-trip.
Before this fix, with append mode ON:
User typed before mic: 'Hello, '
User typed during STT: 'Hello, from K' (typed while waiting)
Dictated text: 'world'
Result: 'Hello, world' (lost 'from K')
After this fix:
User typed before mic: 'Hello, '
User typed during STT: 'Hello, from K'
Dictated text: 'world'
Result: 'Hello, from K world' (live state preserved)
The fix prefers live `ta.value` as the append base when the textarea
is non-empty, falling back to the `prefixOverride` snapshot only
when the textarea is genuinely empty (the original empty-composer path
where nothing to race with).
Adds a regression test in test_raw_audio_upload.py that asserts the
new `ta.value ||` fallback structure on `_commitTranscript`.
* fix(dictation): don't resurrect cleared text on async transcription resolve
Greptile P1 (re-reviewed at 20:07 UTC): the previous race fix
(`ta.value || (prefixOverride ?? _prefix)`) treats an empty
`ta.value` as falsy and falls back to the recording-start snapshot.
If the user explicitly clears the textarea while server transcription
is in flight, the cleared text reappears with the dictated text
appended — the user's intent is violated.
Fix: when prefixOverride IS passed (server-STT path), trust live
ta.value UNCONDITIONALLY — even when empty. The fallback to _prefix
only fires when no prefixOverride is given AND ta.value is empty,
which is purely defensive (no current caller hits this branch — the
browser-SR path commits inline in sr.onend without calling this
function).
Concrete repro:
User typed before mic: 'Hello, '
User clears textarea during STT
Dictated text: 'world'
OLD (0239bb4e) result: 'Hello, world' (cleared text resurrected)
NEW result: 'world' (user intent respected)
Greptile's suggested one-liner:
const base = prefixOverride !== undefined ? ta.value : (ta.value || _prefix);
Applied with expanded inline comment documenting the three concerns
this function balances (race, clear-during-STT, defensive fallback).
* Release exp-v0.52.49: preserve composer text on server STT commit (#5895)
---------
Co-authored-by: Konstantin M <kopamedve@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
On a hard refresh, boot-restore auto-opens the last session from localStorage,
but that load can finish after the user has started typing into the fresh
composer — and the saved-session draft restore clobbered the typed input.
_restoreComposerDraft already had a preserveActiveInput guard, but the boot
path never set it.
boot.js now calls loadSession(saved, {preserveActiveInput:true}); loadSession
threads it into the draft restore as
preserveActiveInput:!!opts.preserveActiveInput || (currentSid===sid&&forceReload),
preserving the prior same-session-force-reload behavior. A blank composer still
restores the saved draft (the guard only skips when active input is non-empty
and differs). opts is read via arguments[1] so the call is signature-safe.
The 4 one-line test changes refresh exact-string boot snapshots to the new call
signature; test_issue5060_boot_composer_preserve.py adds behavioral coverage
(2 assertions proven RED on master).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* stage bg_task trio combined (#2979 superset) on master for deep review
* fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test
* test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix
* CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review)
* bg_task trio: apply 3 independent-review (greptile) fixes
1. start_session_turn now threads the session PROFILE model defaults
(_read_profile_model_config) into the wakeup model-resolve, so a brand-new
session with an empty model falls back to the profile default not global
DEFAULT_MODEL. Updated the white-box spy test signature accordingly.
2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive
default) to match the #3103 long-lived-SSE pattern.
3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce
timestamp map can't grow one permanent entry per session forever.
nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions.
* test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
* feat: add manual 'Check for Updates' button in System settings (#785)
Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.
Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
settings_up_to_date, settings_updates_available, settings_updates_disabled)
in all 6 locales (en, ru, es, de, zh, zh-Hant)
* fix: sanitize error message in checkUpdatesNow to avoid exposing paths
Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.
* fix: fully sanitize error in update check — never expose raw e.message in UI
Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales
* fix(#785): align HTML selectors with CSS and add regression tests
- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales
* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)
* feat: categorize providers in setup wizard (#603)
- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant
* ci: re-run tests
* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644
The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.
Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.
* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests
- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
gemini, deepseek, mistral, and x-ai through apply_onboarding_setup
* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai
* fix(onboarding): correct stale model defaults for specialized providers
Three issues in the new specialized provider catalog (#1027 hold reason):
1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
which returns []. The catalog in api/config.py is keyed under "google"
(even though the agent's alias map normalizes google -> gemini).
Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
the actual 5-model list. Also forward-compatible lookup for x-ai
(xai or x-ai key).
Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.
deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.
Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
prefixes (3.x for gemini, 4.x for grok)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: inline HTML preview in workspace panel (#779)
Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.
Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales
The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.
* docs: document sandbox security tradeoff for HTML preview
Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.
* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading
routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.
* fix(security): CSP sandbox header for inline HTML preview
The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.
Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.
Added test_inline_html_response_sets_csp_sandbox to pin the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)
* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209
The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.
Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User bubble selection contrast fixed via scoped ::selection CSS (closes#877). Also adds missing provider i18n keys to es/de/zh/ru/zh-Hant locales, fixing 3 CI failures that crept in from PR #867.
## Summary
Rebased on behalf of @aronprins from fork branch `codex/dark-user-bubbles`. Two asset-only commits (PR screenshot add/remove) were dropped; the two code commits are applied cleanly on top of current master (v0.50.110).
### What changed
**Dark-mode user bubbles** (`static/style.css`):
- `:root.dark` now overrides `--user-bubble-bg`/`--user-bubble-border` to `var(--accent-bg-strong)` (a 15% opacity tint) — keeps the bubble visually subdued in dark skins instead of a glaring bright accent fill
- Removes 6 per-skin `--user-bubble-text` hacks (ares, mono, slate, poseidon, sisyphus, charizard); text falls back to `var(--text)` which is already correct in dark mode
- Adds `--user-bubble-placeholder` token; edit-area box-shadow now uses `--focus-ring` instead of hardcoded `rgba(255,255,255,.15)`
**Thinking card collapsibility** (`static/ui.js` + `static/style.css`):
- `_thinkingMarkup()` now includes `onclick` toggle and chevron affordance, matching the compression reference card pattern
- `.thinking-card-header` gets `display:flex; gap:8px` for proper icon/label/chevron alignment
**Tests**: 2 new in `test_bugbatch_apr2026.py` (dark bubble token contract + no-per-skin-hack assertion), 2 updated in `test_ui_card_animation.py` (flex header layout + onclick pattern).
1520 passed. QA 20/20. Browser verified: dark mode bubble uses subtle tint, thinking card toggles correctly.
(credit: @aronprins)
Independent review by @nesquena confirmed all blockers resolved. Theme×skin two-axis system replaces old monolithic color schemes. Closes#627. Co-Authored-By: aronprins <aronprins@users.noreply.github.com>
Fixes four bugs + locks in one existing fix with regression tests.
Closes#594 (light theme dialogs), #576 (workspace panel snap), #585 (stale model list after CLI change), #567 (docker-compose macOS UID docs). Confirms and tests #590 (transcribing spinner already present).
Reviewed and approved by @nesquena. 1340 tests passing.