Skip to content

feat: preserve manual rename + complete full-width chat layout - #2

Merged
zhonghuaY merged 2 commits into
fix/sidebar-rename-models-fullwidthfrom
feat/title-manual-override-and-fullwidth
Apr 29, 2026
Merged

feat: preserve manual rename + complete full-width chat layout#2
zhonghuaY merged 2 commits into
fix/sidebar-rename-models-fullwidthfrom
feat/title-manual-override-and-fullwidth

Conversation

@zhonghuaY

Copy link
Copy Markdown
Owner

Summary

Two unrelated but ready-to-ship fixes that were sitting in a WIP stash. Split into 2 commits for clean review.

Commit 1 — feat(title): preserve manual rename against LLM auto-title overwrite

Problem

When a user manually renames a session (via /title slash command, sidebar context menu, titlebar dialog, or duplicate-with-rename), the background LLM title generator/refresher would silently overwrite their title on the next model response.

Fix

  • /api/session/rename now accepts a manual: bool parameter to distinguish explicit user renames from LLM-generated provisional/refined titles.
  • When manual=True, the server sets s.user_renamed_title = True.
  • The background title generator (_run_background_title_update) and refresher (_run_background_title_refresh) now check this flag at every commit point and short-circuit with 'skipped: manual_title'.
  • New helper _has_manual_title_override(session) uses strict is True comparison to avoid being fooled by MagicMock attribute access in tests.
  • Session.__init__ now accepts arbitrary **kwargs so the new user_renamed_title field round-trips through deserialization without requiring schema migration.

Call site semantics

Caller manual? Why
/title <name> slash command true explicit user intent
Sidebar right-click → Rename true explicit user intent
Titlebar inline edit dialog true explicit user intent
Sidebar Duplicate (writes "(copy)" suffix) true user-initiated, name should stick
First-message provisional title false placeholder, LLM may refine
Background LLM refine (writes new title) false (resets flag) LLM-owned

Files

  • api/models.py, api/routes.py, api/streaming.py (server-side)
  • static/{commands,messages,panels,sessions}.js (4 call sites)
  • tests/test_session_extra_fields.py (new), plus updates to test_rename_session.py, test_title_aux_routing.py, test_1058_adaptive_title_refresh.py

Commit 2 — fix(layout): remove leftover msg max-width caps for full-width chat

Problem

Follow-up to 6eba0c15 feat(layout): full-width chat output container. That commit removed the outer cap but several inner elements still had max-width: var(--msg-max), so message bodies, tool cards, thinking cards, error rows, and the message footer remained narrow on wide screens.

Fix

  • Drop max-width from .msg-body, .tool-card, .thinking-card, .msg-foot, .msg-row[data-error], .messages-inner (and its @media 1400/1800px overrides).
  • Update tests/test_full_width_chat.py assertions to enforce the absence of these caps so future regressions are caught.

Files

  • static/style.css, tests/test_full_width_chat.py

Testing

Both commits sanity-tested. Pre-existing 5 failures in test_rename_session.py are env-related (auth/connection refused on the in-process test server) and identical on baseline before this PR — not caused by these changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI added 2 commits April 29, 2026 10:04
Adds a 'manual' bool to /api/session/rename so the server can distinguish
explicit user renames from LLM-generated provisional/refined titles.
When manual=True, sets s.user_renamed_title=True; the background title
generator and refresher then short-circuit ('skipped: manual_title') at
every check point so user intent is never silently overwritten.

- /title slash command, sidebar context menu, titlebar dialog, duplicate
  -> manual:true
- first-message provisional title (auto), background LLM refine (auto)
  -> manual:false
- new helper _has_manual_title_override() uses 'is True' to avoid being
  fooled by MagicMock attribute access in tests
- Session.__init__ now accepts arbitrary kwargs so the new field
  round-trips through deserialization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-up to 6eba0c1 (feat(layout): full-width chat output container).
That commit removed the outer cap but several inner elements still
had 'max-width: var(--msg-max)', so message bodies, tool cards,
thinking cards, error rows and the message footer remained narrow.

- drop max-width from .msg-body, .tool-card, .thinking-card, .msg-foot,
  .msg-row[data-error], .messages-inner (and its 1400/1800px overrides)
- update test_full_width_chat assertions to enforce the absence of
  these caps so future regressions are caught

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@zhonghuaY
zhonghuaY merged commit a58e1b5 into fix/sidebar-rename-models-fullwidth Apr 29, 2026
@zhonghuaY
zhonghuaY deleted the feat/title-manual-override-and-fullwidth branch April 29, 2026 02:07
zhonghuaY added a commit that referenced this pull request Jun 2, 2026
* feat: integrate agent-api-gateway with lowest coupling

Add gateway_provider.py module for dynamic model discovery from
agent-api-gateway instances. Gateway models appear in the UI dropdown
and route through the gateway's OpenAI-compatible API with
x-instance-keyword header injection via request_overrides.

- New: api/gateway_provider.py (self-contained gateway integration)
- Modified: api/config.py (5-line delegation to gateway_provider)
- Modified: api/streaming.py (header injection via request_overrides)
- New: tests/test_gateway_provider.py (38 unit tests)
- New: tests/test_streaming_gateway.py (14 integration tests)

Made-with: Cursor

* feat: path-based routing and /model copilot shorthand (#1)

- gateway_provider: embed keyword in URL path (/v1/k/{keyword}) so no
  extra HTTP headers are needed; prefix model with 'gw:' to prevent
  Responses API auto-detection for GPT-5+ models
- commands.js: support copilot-{label}/{model}/{keyword} shorthand in
  /model command (e.g. '/model copilot-local/gpt-4.1/ins-1')

Co-authored-by: Albert Yang <yangzh0906@thundersoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: use x-instance-keyword header instead of path-based routing (#2)

Revert to standard /v1 base URL and pass keyword via x-instance-keyword
HTTP header in extra_headers. The gw: model prefix prevents Responses
API auto-detection; the gateway strips it for strict matching.

Co-authored-by: Albert Yang <yangzh0906@thundersoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: full-width chat layout + fix gateway provider test (#3)

- Remove max-width constraints from .messages-inner, .msg-body,
  .composer-box, .reconnect-banner, .update-banner, .approval-card
  so the chat area fills the available panel width
- Add 3 regression tests verifying no max-width on chat elements
- Fix test_resolve_valid_model to expect gw: model prefix

Co-authored-by: Albert Yang <yangzh0906@thundersoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: status indicators — gateway dot, status-dot CSS, sidebar accent, CLI busy

Re-applied #4 + nesquena#6 against upstream v0.50.217:

- Add /api/gateway-status endpoint returning instance statuses
- Add status dot CSS (.status-dot variants + .stream-cursor + accent rule)
- Add gatewayDot inside composer-model-chip (replaces removed topbar modelChip)
- Append per-session .status-dot in renderSessionListFromCache with
  data-session-id, data-cli-session, data-updated-at metadata
- Per-conversation accent via _hashHue() → --conv-accent CSS var
- Append pollGatewayStatus() (10s) + updateSessionDots() (3s) + setBusy hook
  to static/ui.js — recognizes both composerModelChip and modelChip ids
- get_gateway_instance_statuses() helper in api/gateway_provider.py

Streaming-cursor injection in messages.js was skipped because upstream
replaced the simple Thinking placeholder with smd-based incremental
rendering; upstream's session-state-indicator already covers that role.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: support renaming CLI / agent sessions in /api/session/rename

The double-click rename action in the sidebar appeared to revert after a
page refresh for any session whose data lives in ~/.hermes/state.db
(CLI sessions, telegram/discord/gateway-imported sessions, etc.).

Root cause: the rename handler called get_session(sid) which raised
KeyError for non-WebUI-owned sessions and returned 404, while the
frontend optimistically updated the UI without awaiting the API and had
no rollback.

Fixes:

* api/state_sync.py — new rename_cli_session() that goes through
  SessionDB.set_session_title() (with sanitization + uniqueness check)
  and falls back to a defensive raw-SQL UPDATE if the on-disk schema
  version doesn't match the installed hermes_state package.
* api/routes.py — rename handler now falls through to that helper on
  KeyError, surfaces title-uniqueness conflicts as 409, and best-effort
  mirrors WebUI renames to state.db so /insights and CLI listings stay
  in sync.
* static/sessions.js — finish() now snapshots the old title and rolls
  back the optimistic update on API failure so the UI no longer lies
  about persistence.

Tests:

* tests/test_rename_session.py — 5 new tests covering WebUI rename
  persistence, length cap, blank fallback, CLI rename writing to
  state.db, and clean 404 on unknown id.
* tests/test_gateway_provider.py — 3 new tests guarding the gw: model
  prefix invariant (commit e8d89a7c), x-instance-keyword header
  presence with no path-routing leak, and cli_route 'cursor' fallback
  for unknown (model, keyword) pairs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(sidebar): add JS-harness tests for CLI busy + per-conversation accent

Re-applies the test file from PR nesquena#6.  The production code (status-dot
metadata, _hashHue, --conv-accent rule, updateSessionDots auto-refresh)
was folded into the earlier 'feat: status indicators' commit when the
status-dot system was re-applied against upstream v0.50.217.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: restore gateway delegation + adapt nesquena#6 tests for upstream layout

* api/config.py: restore the 5-line gateway delegation in
  resolve_model_provider() that was dropped during the cherry-pick of
  39706bb against the heavily refactored upstream config (the cold-path
  caching layer).  Without it, test_resolve_model_provider_delegates_gateway
  fails because gateway model IDs leak through unresolved.
* tests/test_streaming_gateway.py: bump two assertions to expect the
  'gw:' model prefix introduced by 49a2be4f / e8d89a7c (already done in
  88ad007b; re-applied here since the original cherry-pick was abandoned).
* static/style.css: restate per-conversation accent rule using
  border-left-color so the regex in test_session_item_has_conv_accent_rule
  matches.
* static/sessions.js: coerce _hashHue input to string so null/undefined
  inputs don't crash (test_hash_hue_returns_int_in_range).
* static/ui.js: rewrite updateSessionDots() with a top-level
  _CLI_BUSY_WINDOW_S constant + window.HERMES_CLI_BUSY_WINDOW_S override
  so it works as a self-contained snippet under the node JS-harness;
  set dot.title='CLI active (...)' so the harness can detect promotion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(sidebar): persist CLI rename across compression-chain refresh

When the sidebar renders a compression-chain conversation it shows the
chain *head*'s title but routes navigation (and rename writes) to the
*tip*'s session_id. Before this change /api/session/rename updated the
tip's row, so the next sidebar refresh re-projected the unchanged head
title and the rename appeared to revert.

Fix: `rename_cli_session` now walks parent_session_id upward across
compression boundaries to find the lineage root and updates that row's
title. The existing head-prefer projection then surfaces the new name
on the next refresh.

Touching exactly one row keeps us compatible with hermes_state's
UNIQUE INDEX on sessions.title (a chain-wide propagation strategy
would collide with the auto-generated #N intermediate titles).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(models): refresh gateway model picker on dropdown open + gateway SSE

The model picker's gateway slice was caught by two layers of staleness:

1. Backend: `api.config._available_models_cache` had a 24h TTL and
   wrapped the gateway-group assembly, so console-side model changes
   only surfaced once a day regardless of the gateway's own ~30s cache.
2. Frontend: `_liveModelCache` in static/ui.js only ADDED options to
   the <select>, never removed them — models pulled from a gateway
   never disappeared without a hard reload.

Backend: split assembly so provider/credential discovery stays cached
but gateway groups are re-attached fresh on every /api/models call via
`_attach_fresh_gateway_groups` (still gated by gateway_provider's
~30s TTL, so cost is negligible).

Frontend: new `refreshGatewayModelOptions()` does a surgical rewrite —
strips only optgroups whose provider matches `_isGatewayProvider`
(`gateway*`, `gw-*`, or `gw`), re-fetches /api/models with
cache:'no-store', re-appends fresh gateway groups, preserves the user's
current selection. Exposed on `window` so it can be called from
sessions.js without imports. Hooked into `toggleModelDropdown`
(refresh on open) and the `sessions_changed` gateway-SSE handler
(refresh while dropdown is closed).

Tests: 4 backend unit tests cover the cache split + helper predicate.
4 frontend tests use the existing node-driven JS harness pattern to
verify export-on-window, dropdown hook, SSE hook, and the surgical
strip-only-gateway-groups behaviour.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(layout): full-width chat output container

Re-applies the CSS half of the original full-width-chat work (PR #3),
which got upstreamed with only the regression test — the actual style
changes were dropped. This caused the message column to keep its
1100/1200px caps at the 1400/1800px breakpoints and the message body
to keep its 680px cap.

Removed:
- `.messages-inner` max-width:1100px @1400px and 1200px @1800px
- `.msg-body` max-width:680px
- `.composer-box`, `.reconnect-banner`, `.update-banner` max-width:780px

`.approval-card`'s `var(--msg-max)` cap is left alone (different
design spec — modal-style cards intentionally narrower).

Tests: 4 new tests pin the absence of the caps via a media-block
stripper helper. test_sprint35.py's old assertion (which expected the
caps to be present) is updated to assert the inverse, with a comment
explaining the historical regression.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(mermaid): suppress giant 'Syntax error in text' overlay

Mermaid 10.x's render() injects a global error SVG into <body> on parse
failures, in addition to throwing. The previous catch only repaired the
inline block; the orphan SVG floated in the DOM and consumed massive
vertical space, ruining the chat layout.

Three-layer defense:
- mermaid.initialize({suppressErrorRendering: true})  — official 10.6+ opt-out
- mermaid.parse(code, {suppressErrors: true}) before render()  — short-circuit invalid input
- cleanupOrphanError() in catch path  — defensive removal of any leftover element

Adds tests/test_mermaid_syntax_error_overlay.py (4 assertions).
Existing tests/test_1044_mermaid_csp_font.py still passes (3/3).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(perf): add session-switch performance optimization plan

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(test): add session-switch perf baseline harness (Task 0)

200-msg synthetic session, mixed markdown/code/mermaid/katex.
Baseline numbers in docs/plans/PERF_BASELINE.md.

Server is fast (11ms full, 0.7ms metadata, gzip on).
Client-side is the real bottleneck — Tasks 2/3/4/5/7 prioritized.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(api): paginate /api/session via tail/since_idx/limit

Adds optional pagination params to /api/session, fully backward
compatible (no params = full payload as before):

- tail=N        -> last N messages
- since_idx=K   -> messages with index >= K
- limit=N       -> cap window size (combines with since_idx)
- messages=0    -> metadata only (precedence preserved)

Each returned message gets _idx (its position in full history) and
the response includes pagination={start_idx,end_idx,total} so clients
can detect 'no more above' by comparing end_idx to total.

Out-of-range since_idx clamps to total (returns empty window) so
clients have a stable signal.

Foundation for client-side windowed render (Task 3).

Refs: docs/plans/2026-04-28-session-switch-perf.md (Task 1)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(ui): cache rendered session as cloned DOM, fingerprint last msg

The session-render cache used to store innerHTML strings keyed by
(session_id, msgCount).  Two limitations in that design:

1. Re-parsing a 200-message HTML blob on every back-switch is the
   single biggest cost in cross-session navigation — comparable to a
   full markdown rebuild for code-heavy chats.
2. The (sid, count) key serves stale HTML after edits/retries that
   mutate content without changing the count — documented as
   'acceptable' but actually surprising in practice.

This commit:
* Stores inner.cloneNode(true) (a real Element) instead of HTML string.
  Restoration is replaceChildren(...clone.childNodes) — no parse.
  Cache copy is re-cloned on each restore so the stored node never
  drifts with the live DOM.
* Adds _sessionCacheKey(messages) -> {count, lastKey}.  lastKey is a
  cheap fingerprint of the *last* message (role|len|head|tail).  O(1)
  per switch, catches the dominant edit-without-count-change case.
* Bumps LRU 8 -> 16 sessions; adds touch-on-hit so hot sessions stay.
* Adds childElementCount<=2000 guard alongside the existing 300KB
  HTML-length guard so cloning huge sessions can't stall a switch.

8 new node-driven tests pin _sessionCacheKey behaviour (empty session,
identical-msgs determinism, content-change invalidation, role-change
invalidation, array content blocks, missing-content tool-call message,
intentional-trade-off: only-tail fingerprint).  All 51 perf-related
tests pass.

Refs: docs/plans/2026-04-28-session-switch-perf.md (Task 2)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(ui): lazy-render mermaid/katex when scrolled into view

Long sessions can have 50+ mermaid diagrams.  The previous code
rendered every mermaid + katex block in a single forEach as soon as
the lib was ready — blocking the main thread for hundreds of ms on
session-switch / first paint, and doing wasted work for blocks the
user never scrolls to.

This commit gates mermaid.render() and katex.render() behind a shared
IntersectionObserver per type:

* rootMargin '300px 0px' — diagrams begin rendering ~300px before
  they enter the viewport so the user doesn't see the
  <pre><code> placeholder flash to SVG mid-scroll.
* unobserve on first intersect — guarantees a block is never rendered
  twice (cheap correctness defence + saves observer bookkeeping).
* Feature-detect IntersectionObserver and fall back to eager render
  for browsers (and jsdom) that lack it.
* Render path extracted to renderOne(block) so observer + fallback
  share identical syntax-error / orphan-cleanup handling — the giant
  'Syntax error in text' overlay fix from f872496 is preserved
  on both paths.

8 new grep-level tests pin the IntersectionObserver mechanism so a
future refactor can't silently regress to the eager loop.  The 7
existing mermaid syntax-error tests still pass.

Refs: docs/plans/2026-04-28-session-switch-perf.md (Task 5)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(api): ETag + 304 fast-path for /api/session

Adds conditional GET to the heaviest endpoint.  When the client's
If-None-Match matches the server's current fingerprint, the server
returns 304 Not Modified with no body — skipping JSON serialize,
redact, and gzip on the server, and skipping JSON parse + DOM
re-render on the client (the api() helper resurrects the cached body).

Server (api/routes.py):
  * Cheap weak ETag: SHA1 of (sid, updated_at, msg_count, query
    params).  All inputs cheap to read after the metadata-only
    Session load — no extra disk IO.
  * If-None-Match check happens BEFORE serialization / redact / gzip
    so a 304 is genuinely cheap (sub-ms).
  * ETag varies by query params (messages, resolve_model, tail,
    since_idx, limit) so windowed reads can't accidentally hit a
    cached full-message ETag.

Server (api/helpers.py):
  * j() now respects a Cache-Control override in extra_headers — the
    default 'no-store' would have prevented browsers from sending
    If-None-Match at all (defeating the entire fast path).  /api/session
    sends 'Cache-Control: no-cache' (revalidate every time, but the
    304 keeps it cheap).
  * New send_304(handler, etag) helper for the empty-body 304 path.

Client (static/workspace.js):
  * api() now maintains a 32-entry LRU keyed by full URL → {etag, body}
    for /api/session* GETs.  When request matches a cached URL it sends
    If-None-Match; on 304 it returns the stored body verbatim.
  * Scoped narrowly to /api/session — won't accidentally serve stale
    data from endpoints whose ETag semantics weren't designed for it.

8 new tests (test_session_etag.py) cover: ETag emission, weak format,
304 on match, 200 on mismatch, varies-by-query, Cache-Control allows
revalidation.  Existing baseline 'no ETag' test flipped to 'has ETag'.

Refs: docs/plans/2026-04-28-session-switch-perf.md (Task 6)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(ui): batch DOM mounts via DocumentFragment in renderMessages

The main message-build loop in renderMessages used to do
`inner.appendChild(row)` on every iteration — N reflows / style
recomputes for an N-message session.  For a 200-message conversation
that meant 200+ layout passes during a single session-switch.

This commit:
* Builds every message row, separator, assistant turn, and
  compression card into a detached DocumentFragment (`_frag`).
  Appending to a detached fragment is ~free — no layout, no paint,
  no style invalidation, no querySelector cost.
* Commits with `inner.replaceChildren(_frag)` — atomic empty +
  adopt in one operation.  Adopted nodes don't get re-parsed.
* Post-mount work (tool-card insertion, token-usage badge,
  highlightCode, addCopyButtons, lazy mermaid/katex registration)
  operates on the live `inner` and is unchanged.
* `_insertCompressionLikeNode`'s fallback path also routes to the
  fragment so a session that has no anchor still mounts atomically.

Cache write path (Task 2) still calls inner.cloneNode(true) on the
final mounted DOM — the fragment + replaceChildren combo collapses
to a clean parent before the clone, so no behaviour change there.

3 new grep tests pin the mechanism (createDocumentFragment present,
no inner.appendChild inside renderMessages, mount via
replaceChildren).  124 existing render/UI tests pass.

Refs: docs/plans/2026-04-28-session-switch-perf.md (Task 7)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(perf): operational guide + before/after results for session-switch overhaul

Final piece of the session-switch perf plan: documentation that future
contributors can use to understand the layered design without having
to chase 6 commits across api/ and static/.

* docs/PERFORMANCE.md (new): operational guide — describes the 4
  collaborating layers (backend pagination + ETag, client request-cache,
  client render-cache, fragment build + lazy heavy renders), operating
  principles, regression-test map, and 'when to revisit' criteria for
  the deferred Tasks 3 (windowed render) and 4 (markdown worker).
* docs/plans/PERF_RESULTS.md (new): before/after numbers captured by
  tests/test_perf_results_capture.py against the same synthetic 200-msg
  session used for the Task 0 baseline.  Headline:
    full payload    11.2ms baseline → 6.87ms  (38% faster)
    tail=30 (T1)    n/a            → 1.48ms / 8.7KB (vs 49KB full)
    ETag 304 (T6)   n/a            → 0.44ms / 0 B
* tests/test_perf_results_capture.py (new): one-shot perf snapshot test;
  prints a perf table when run with -s, used to refresh PERF_RESULTS.md.
* CHANGELOG.md: [Unreleased] entry under a new ### Performance section
  summarising all 5 perf commits.

Refs: docs/plans/2026-04-28-session-switch-perf.md (Task 8)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(title): preserve manual rename against LLM auto-title overwrite

Adds a 'manual' bool to /api/session/rename so the server can distinguish
explicit user renames from LLM-generated provisional/refined titles.
When manual=True, sets s.user_renamed_title=True; the background title
generator and refresher then short-circuit ('skipped: manual_title') at
every check point so user intent is never silently overwritten.

- /title slash command, sidebar context menu, titlebar dialog, duplicate
  -> manual:true
- first-message provisional title (auto), background LLM refine (auto)
  -> manual:false
- new helper _has_manual_title_override() uses 'is True' to avoid being
  fooled by MagicMock attribute access in tests
- Session.__init__ now accepts arbitrary kwargs so the new field
  round-trips through deserialization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(layout): remove leftover msg max-width caps for full-width chat

Follow-up to 6eba0c1 (feat(layout): full-width chat output container).
That commit removed the outer cap but several inner elements still
had 'max-width: var(--msg-max)', so message bodies, tool cards,
thinking cards, error rows and the message footer remained narrow.

- drop max-width from .msg-body, .tool-card, .thinking-card, .msg-foot,
  .msg-row[data-error], .messages-inner (and its 1400/1800px overrides)
- update test_full_width_chat assertions to enforce the absence of
  these caps so future regressions are caught

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ui): make Transcript markdown download robust against blob URL race

The #btnDownload click handler was revoking the blob URL synchronously on the
line after a.click(), and never appending the anchor to the DOM. The browser
download is asynchronous: it reads the blob URL after the click event returns,
so revoking immediately races the download. Combined with the detached anchor
(which Firefox/Safari/some WebViews refuse to .click()) the download could
hang or silently fail.

Fix follows the same pattern used by downloadFile() in workspace.js:

- append the <a> to document.body before .click()
- defer URL.revokeObjectURL + removeChild via setTimeout(..., 200)
- wrap everything in try/catch and surface failures via showToast
  (silent failure was the second-order bug — users had no clue what
  went wrong)
- also: a.style.display='none' so the temp anchor never flashes

Adds tests/test_transcript_download_robust.py to pin the corrected
pattern with grep-style assertions, matching the style of the existing
boot.js sanity tests in test_sprint9.py / test_sprint20.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(export): serve transcript Markdown via server endpoint, not blob URL

Prior fix (#3) made the blob-URL download more robust (DOM-attached anchor,
deferred URL revoke, try/catch) but did NOT solve the actual hang user reported:
the file would write completely to disk, yet the browser download UI never
reached the 'complete' state.

Root cause is a Chromium quirk with blob: URL downloads where the download
progress UI cannot reliably tell when the stream ends — the file is on disk
but the download bar shows it as still in-progress indefinitely.

Solution: bypass blob URLs entirely. Serve the markdown from the server
with proper Content-Disposition + Content-Length, mirroring the JSON
export path which works flawlessly.

Server side (api/routes.py)
- new _build_session_markdown(session) function mirrors the JS transcript()
  in messages.js: skip tool messages, join array text blocks, append
  attachments marker
- new _handle_session_export_markdown(handler, parsed) mirrors the JSON
  export structure: redact_session_data() before rendering, set
  Content-Type: text/markdown; charset=utf-8, Content-Disposition: attachment
  with filename, Content-Length, Cache-Control: no-store
- /api/session/export dispatcher now branches on ?format=md vs JSON default

Client side (static/boot.js)
- #btnDownload click handler now navigates to /api/session/export?format=md&...
- still uses the working pattern from downloadFile() in workspace.js:
  a.style.display='none' + appendChild + click() + setTimeout removeChild
- removed all blob URL machinery (no createObjectURL, no revokeObjectURL)
- still wrapped in try/catch with showToast on success and failure

Tests (tests/test_session_export_markdown.py, 9 tests)
- 400 on missing session_id, 404 on unknown session
- correct Content-Type / Content-Disposition / Content-Length headers
- markdown body renders user/assistant messages with correct heading levels
- tool messages are skipped (not exported)
- array content text blocks are joined, non-text blocks ignored
- attachments emit the _Files: a, b_ marker
- default JSON export still works (no regression)
- boot.js click handler is pinned to the server endpoint (no createObjectURL)

Replaces tests/test_transcript_download_robust.py from PR #3 — that file
asserted the blob-URL pattern which we're now removing entirely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: add mobile remote access design spec via frpc tunnel

* docs: add mobile remote access implementation plan

* feat(mobile): add visibilitychange SSE auto-reconnect for background recovery

When a mobile browser moves to background the OS suspends JS and kills
SSE connections. When the user returns the EventSource may be in a zombie
state. This listener detects foreground recovery and reconnects active
streams via the server-side status check API.

Test suite: 9 tests covering listener existence, visible state check,
active stream guard, status API call, _wireSSE reconnect, source close,
session restore fallback, delay mechanism, and ordering after pageshow.

---------

Co-authored-by: Albert Yang <yangzh0906@thundersoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: albert <albert@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants