feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
"""
|
|
|
|
|
Sprint 16 Tests: safe HTML rendering in renderMd(), active session styling,
|
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
|
|
|
session sidebar polish (SVG icons, dropdown actions).
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
"""
|
|
|
|
|
import html as _html
|
|
|
|
|
import pathlib
|
|
|
|
|
import re
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
2026-04-14 19:04:48 +00:00
|
|
|
from tests._pytest_port import BASE
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def get_text(path):
|
|
|
|
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
|
|
|
|
return r.read().decode("utf-8"), r.status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def esc(s):
|
|
|
|
|
"""Mirror of esc() in ui.js — HTML-escapes a string."""
|
|
|
|
|
return _html.escape(str(s), quote=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SAFE_TAGS = re.compile(
|
|
|
|
|
r"^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td"
|
|
|
|
|
r"|hr|blockquote|p|br|a|div)([\s>]|$)",
|
|
|
|
|
re.I,
|
|
|
|
|
)
|
|
|
|
|
SAFE_INLINE = re.compile(r"^<\/?(strong|em|code|a)([\s>]|$)", re.I)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inline_md(t):
|
|
|
|
|
"""Mirror of inlineMd() in ui.js — for use inside list items / blockquotes."""
|
|
|
|
|
t = re.sub(r"\*\*\*(.+?)\*\*\*", lambda m: "<strong><em>" + esc(m.group(1)) + "</em></strong>", t)
|
|
|
|
|
t = re.sub(r"\*\*(.+?)\*\*", lambda m: "<strong>" + esc(m.group(1)) + "</strong>", t)
|
|
|
|
|
t = re.sub(r"\*([^*\n]+)\*", lambda m: "<em>" + esc(m.group(1)) + "</em>", t)
|
|
|
|
|
t = re.sub(r"`([^`\n]+)`", lambda m: "<code>" + esc(m.group(1)) + "</code>", t)
|
|
|
|
|
t = re.sub(
|
|
|
|
|
r"\[([^\]]+)\]\((https?://[^\)]+)\)",
|
|
|
|
|
lambda m: f'<a href="{esc(m.group(2))}" target="_blank" rel="noopener">{esc(m.group(1))}</a>',
|
|
|
|
|
t,
|
|
|
|
|
)
|
|
|
|
|
t = re.sub(r"</?[a-zA-Z][^>]*>", lambda m: m.group() if SAFE_INLINE.match(m.group()) else esc(m.group()), t)
|
|
|
|
|
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_md(raw):
|
|
|
|
|
"""
|
|
|
|
|
Python mirror of renderMd() in static/ui.js.
|
|
|
|
|
Kept in sync with the JS implementation so tests catch regressions
|
|
|
|
|
if the JS logic drifts from the documented behaviour.
|
|
|
|
|
"""
|
|
|
|
|
s = raw or ""
|
|
|
|
|
|
|
|
|
|
# Pre-pass: stash code blocks/spans, convert safe HTML → markdown equivalents
|
|
|
|
|
fence_stash = []
|
|
|
|
|
|
|
|
|
|
def stash(m):
|
|
|
|
|
fence_stash.append(m.group())
|
|
|
|
|
return "\x00F" + str(len(fence_stash) - 1) + "\x00"
|
|
|
|
|
|
fix(renderer): line-anchor fence regex to prevent mid-line ``` corruption (#1438)
The markdown fence regex /```([\s\S]*?)```/g had no line anchoring. A literal
triple backtick inside code block content (e.g. a regex with ``` in a lookbehind,
or a script that documents fences) terminated the outer fence at the wrong place.
The leaked tail then went through bold/italic/inline-code passes, eating `*`
characters as italic markers and emitting literal </strong> tags into the
rendered output.
CommonMark §4.5 requires that an opening code fence be the first non-whitespace
content of a line (up to 3 spaces of indent allowed) and that the closing fence
also start a line. This patch updates 3 sites + the Python mirror to use that
invariant:
static/ui.js:1559 renderMd() fenced-block stash (assistant messages)
static/ui.js:66 _renderUserFencedBlocks() (user messages)
static/ui.js:2599 _stripForTTS() (TTS speech pre-strip)
tests/test_sprint16.py Python mirror
Pattern: (^|\n)[ ]{0,3}```(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)
The non-capturing (?:...\n)? group keeps empty fences (```\n```) working;
without it, a body+\n is required and the closing fence on the very next line
no longer matches. The lead group (^|\n) is prefixed back to the stash token
so paragraphs above don't bleed into the <pre> block.
20 regression tests in tests/test_issue1438_fence_anchoring.py cover:
- Cygnus's exact repro from Discord (May 1 2026)
- Inline ``` mid-paragraph (must not open fence)
- Partial/streaming fence with no close (must not eat content)
- Empty fences with and without language tag
- 3-space indented fences (allowed) vs 4-space (not a fence)
- Multiple adjacent blocks
- Bold/italic/inline-code surviving after a fence
- Source-level guards on all 3 patched sites + lead-prefix invariant
Empirical browser verification (live JS, on bug repro):
Before fix: </code></pre>[^\n]<em>|%%[ \t]</em>... ← truncated, italic leak
After fix: <pre><code>...```[^\n]*|%%...</code></pre> ← intact, regex preserved
Tests: 3678 passed (+20 from new test file, was 3658), 0 failures.
Reported-By: Cygnus (Discord)
Relayed-By: @AvidFuturist
Closes #1438
2026-05-02 02:30:20 +00:00
|
|
|
# Fence regex line-anchored to match JS fix for #1438 (allows empty fence)
|
|
|
|
|
s = re.sub(r"(?:^|\n)[ ]{0,3}```(?:[\s\S]*?\n)?[ ]{0,3}```(?=\n|$)|`[^`\n]+`", stash, s)
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
s = re.sub(r"<strong>([\s\S]*?)</strong>", lambda m: "**" + m.group(1) + "**", s, flags=re.I)
|
|
|
|
|
s = re.sub(r"<b>([\s\S]*?)</b>", lambda m: "**" + m.group(1) + "**", s, flags=re.I)
|
|
|
|
|
s = re.sub(r"<em>([\s\S]*?)</em>", lambda m: "*" + m.group(1) + "*", s, flags=re.I)
|
|
|
|
|
s = re.sub(r"<i>([\s\S]*?)</i>", lambda m: "*" + m.group(1) + "*", s, flags=re.I)
|
|
|
|
|
s = re.sub(r"<code>([^<]*?)</code>", lambda m: "`" + m.group(1) + "`", s, flags=re.I)
|
|
|
|
|
s = re.sub(r"<br\s*/?>", "\n", s, flags=re.I)
|
2026-05-04 17:15:19 +00:00
|
|
|
# Glued-bold-heading lift (issue #1446) — must mirror static/ui.js behavior:
|
|
|
|
|
# protected code/pre placeholders stay hidden while a sentence-glued bold
|
|
|
|
|
# "stub heading" is lifted into its own paragraph when followed by a blank line.
|
v0.50.264 polish followups: i18n parity + assistant-output readability
Closes #1442 (server-side _LOGIN_LOCALE missing ja/pt/ko)
Closes #1443 (promote _isImeEnter helper to 6 other Safari Enter guards)
Closes #1446 (glued-bold-heading lift for LLM thinking-block output)
Closes #1447 (markdown heading visual hierarchy in chat messages)
All four issues were filed by the Opus pre-release advisor on the v0.50.264 batch
or by Cygnus via Discord (relayed by @AvidFuturist, May 1 2026). They share a
common shape — narrow, well-scoped, independent of each other, all adding
regression tests.
== #1442: _LOGIN_LOCALE parity (api/routes.py + static/i18n.js) ==
Added entries for ja/pt/ko to the server-side _LOGIN_LOCALE dict that renders
the localized login page BEFORE the JS i18n bundle loads. With v0.50.264
shipping Japanese as the 8th built-in locale, ja/pt/ko users were seeing the
English login page even with their language preference set.
While auditing static/i18n.js for English leakage, also fixed:
- ko: 10 user-facing login/sign-out/password keys still in English
- es: 3 sign-out/auth-disabled keys still in English
Tests: tests/test_login_locale_parity.py (20 tests) — pins both invariants:
(a) every locale in i18n.js LOCALES has a matching _LOGIN_LOCALE entry
(b) every locale's login-flow keys (13 of them) are translated, not English
== #1443: window._isImeEnter promotion ==
PR #1441 fixed the Safari IME-composition Enter race in the chat composer
(`#msg`) by widening the guard from `e.isComposing` to a `_isImeEnter(e)`
helper that combines three signals (isComposing || keyCode===229 ||
_imeComposing flag). Six other Enter-input handlers were left on the original
narrow guard and would still drop IME composition Enters on Safari for
Japanese/Chinese/Korean users.
Promoted the helper to `window._isImeEnter` (defined in static/boot.js) and
replaced the `e.isComposing` guards at all six sites:
- static/sessions.js: session rename, project create, project rename
- static/ui.js: app dialog (confirm/prompt), message edit, workspace rename
The state-free part of the helper (`isComposing || keyCode===229`) handles
Safari's race for any focused input without needing per-input composition
listeners — only `#msg` keeps the local `_imeComposing` flag.
Tests:
- tests/test_issue1443_ime_helper_promotion.py (9 tests) — pins each site
+ verifies no raw `e.isComposing` Enter-guards remain in sessions.js/ui.js
- tests/test_ime_composition.py — alternation regex extended to accept
the windowed helper form (loosen-test-on-shape-change pattern from
v0.50.264 reflection notes)
== #1446: glued-bold-heading lift (static/ui.js renderMd + Python mirror) ==
LLMs in thinking/reasoning mode emit "section headers" glued to the end of the
previous paragraph with no whitespace:
Para 1 text.**Heading to Para 2**
Para 2 text.**Heading to Para 3**
The renderer correctly produces inline `<strong>` per CommonMark, but it looks
like trailing emphasis on the body text rather than a section break. Cygnus
reported this as "Markdown feedback 2 of 3."
Added a single regex pre-pass in renderMd():
s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g, '$1\n\n**$2**\n\n')
Constraints chosen to avoid false positives:
- Trigger only on `[.!?]` IMMEDIATELY before `**` (no space) — almost always
an LLM-glued heading, not intentional emphasis
- Inner text ≤80 chars, no `*` or newline (single-line only)
- Trailing `\n\n` required — preserves "this is **important** to know."
mid-paragraph emphasis untouched
- Position: after rawPreStash restore, before fence_stash restore — fenced
code blocks stay protected (their content is `\x00P` / `\x00F` tokens
when the lift runs)
Mirrored in tests/test_sprint16.py render_md() so both stay in sync.
Tests: tests/test_issue1446_glued_heading_lift.py (17 tests, 5 of which drive
the actual ui.js renderMd via node) — covers all 3 trigger forms (.!?), all 4
preserve-emphasis cases the issue spec'd, fenced/inline code protection,
chained glued headings, source-level position pin, regex shape pin.
== #1447: markdown heading visual hierarchy (static/style.css) ==
Pre-fix sizes in `.msg-body`:
h1 18px, h2 16px, h3 14px (= body), h4 13px, h5 12px, h6 11px
So h3 was indistinguishable from body and h4/h5/h6 were SMALLER than body.
Cygnus's report: "Markdown feedback 3 of 3 — Headings seem to be missing
across the board in Hermes. They're there, but all plaintext."
New sizes:
h1 24px (border-bottom) h2 20px (border-bottom) h3 17px h4 15px
h5 14px (uppercase, tracked) h6 13px (uppercase, tracked, muted)
All headings now `font-weight:700` + `color:var(--strong)` for stronger ink.
h5/h6 use uppercase + letter-spacing for "label-style" affordance instead
of being smaller-than-body.
Synced .preview-md (file preview pane) to match exactly so a markdown file
preview and a chat message render identically. Added missing h4/h5/h6 rules
to .preview-md (it only had h1-h3 before).
Updated data-font-size="small"/"large" h1-h6 overrides to scale
proportionally with the new defaults. Hierarchy preserved at all three
font-size settings.
Tests: tests/test_issue1447_heading_hierarchy.py (9 tests) — pins the size
hierarchy, the bottom borders on h1/h2, the uppercase affordance on h5/h6,
the .preview-md sync, and the small/large override scaling.
== Verification ==
pytest tests/ -q → 3748 passed (+56 new)
bash ~/WebUI/scripts/run-browser-tests.sh → 20 + 11 PASS
bash ~/WebUI/scripts/webui_qa_agent.sh 8789 → 23/23 PASS
Visual confirmation in browser at port 8789:
- Heading hierarchy clearly visible at all 6 levels
- Glued-bold lift produces separate paragraphs as designed
- window._isImeEnter accessible from any module after boot.js
- Login page renders ja/pt/ko strings correctly (curl -s /login)
2026-05-02 04:04:23 +00:00
|
|
|
s = re.sub(r"([.!?])\*\*([^*\n]{1,80})\*\*\n\n", r"\1\n\n**\2**\n\n", s)
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
s = re.sub(r"\x00F(\d+)\x00", lambda m: fence_stash[int(m.group(1))], s)
|
|
|
|
|
|
|
|
|
|
# Fenced code blocks
|
|
|
|
|
def fenced(m):
|
fix(renderer): line-anchor fence regex to prevent mid-line ``` corruption (#1438)
The markdown fence regex /```([\s\S]*?)```/g had no line anchoring. A literal
triple backtick inside code block content (e.g. a regex with ``` in a lookbehind,
or a script that documents fences) terminated the outer fence at the wrong place.
The leaked tail then went through bold/italic/inline-code passes, eating `*`
characters as italic markers and emitting literal </strong> tags into the
rendered output.
CommonMark §4.5 requires that an opening code fence be the first non-whitespace
content of a line (up to 3 spaces of indent allowed) and that the closing fence
also start a line. This patch updates 3 sites + the Python mirror to use that
invariant:
static/ui.js:1559 renderMd() fenced-block stash (assistant messages)
static/ui.js:66 _renderUserFencedBlocks() (user messages)
static/ui.js:2599 _stripForTTS() (TTS speech pre-strip)
tests/test_sprint16.py Python mirror
Pattern: (^|\n)[ ]{0,3}```(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)
The non-capturing (?:...\n)? group keeps empty fences (```\n```) working;
without it, a body+\n is required and the closing fence on the very next line
no longer matches. The lead group (^|\n) is prefixed back to the stash token
so paragraphs above don't bleed into the <pre> block.
20 regression tests in tests/test_issue1438_fence_anchoring.py cover:
- Cygnus's exact repro from Discord (May 1 2026)
- Inline ``` mid-paragraph (must not open fence)
- Partial/streaming fence with no close (must not eat content)
- Empty fences with and without language tag
- 3-space indented fences (allowed) vs 4-space (not a fence)
- Multiple adjacent blocks
- Bold/italic/inline-code surviving after a fence
- Source-level guards on all 3 patched sites + lead-prefix invariant
Empirical browser verification (live JS, on bug repro):
Before fix: </code></pre>[^\n]<em>|%%[ \t]</em>... ← truncated, italic leak
After fix: <pre><code>...```[^\n]*|%%...</code></pre> ← intact, regex preserved
Tests: 3678 passed (+20 from new test file, was 3658), 0 failures.
Reported-By: Cygnus (Discord)
Relayed-By: @AvidFuturist
Closes #1438
2026-05-02 02:30:20 +00:00
|
|
|
lang, code = m.group(1), (m.group(2) or "").rstrip("\n")
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
h = f'<div class="pre-header">{esc(lang)}</div>' if lang else ""
|
|
|
|
|
return h + "<pre><code>" + esc(code) + "</code></pre>"
|
fix(renderer): line-anchor fence regex to prevent mid-line ``` corruption (#1438)
The markdown fence regex /```([\s\S]*?)```/g had no line anchoring. A literal
triple backtick inside code block content (e.g. a regex with ``` in a lookbehind,
or a script that documents fences) terminated the outer fence at the wrong place.
The leaked tail then went through bold/italic/inline-code passes, eating `*`
characters as italic markers and emitting literal </strong> tags into the
rendered output.
CommonMark §4.5 requires that an opening code fence be the first non-whitespace
content of a line (up to 3 spaces of indent allowed) and that the closing fence
also start a line. This patch updates 3 sites + the Python mirror to use that
invariant:
static/ui.js:1559 renderMd() fenced-block stash (assistant messages)
static/ui.js:66 _renderUserFencedBlocks() (user messages)
static/ui.js:2599 _stripForTTS() (TTS speech pre-strip)
tests/test_sprint16.py Python mirror
Pattern: (^|\n)[ ]{0,3}```(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)
The non-capturing (?:...\n)? group keeps empty fences (```\n```) working;
without it, a body+\n is required and the closing fence on the very next line
no longer matches. The lead group (^|\n) is prefixed back to the stash token
so paragraphs above don't bleed into the <pre> block.
20 regression tests in tests/test_issue1438_fence_anchoring.py cover:
- Cygnus's exact repro from Discord (May 1 2026)
- Inline ``` mid-paragraph (must not open fence)
- Partial/streaming fence with no close (must not eat content)
- Empty fences with and without language tag
- 3-space indented fences (allowed) vs 4-space (not a fence)
- Multiple adjacent blocks
- Bold/italic/inline-code surviving after a fence
- Source-level guards on all 3 patched sites + lead-prefix invariant
Empirical browser verification (live JS, on bug repro):
Before fix: </code></pre>[^\n]<em>|%%[ \t]</em>... ← truncated, italic leak
After fix: <pre><code>...```[^\n]*|%%...</code></pre> ← intact, regex preserved
Tests: 3678 passed (+20 from new test file, was 3658), 0 failures.
Reported-By: Cygnus (Discord)
Relayed-By: @AvidFuturist
Closes #1438
2026-05-02 02:30:20 +00:00
|
|
|
# Fenced code blocks (line-anchored, fixes #1438; allows empty fence)
|
|
|
|
|
s = re.sub(r"(?:^|\n)[ ]{0,3}```([\w+-]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)", fenced, s)
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
s = re.sub(r"`([^`\n]+)`", lambda m: "<code>" + esc(m.group(1)) + "</code>", s)
|
|
|
|
|
|
|
|
|
|
# Inline formatting (top-level, outside list items)
|
|
|
|
|
s = re.sub(r"\*\*\*(.+?)\*\*\*", lambda m: "<strong><em>" + esc(m.group(1)) + "</em></strong>", s)
|
|
|
|
|
s = re.sub(r"\*\*(.+?)\*\*", lambda m: "<strong>" + esc(m.group(1)) + "</strong>", s)
|
|
|
|
|
s = re.sub(r"\*([^*\n]+)\*", lambda m: "<em>" + esc(m.group(1)) + "</em>", s)
|
|
|
|
|
|
|
|
|
|
# Block elements using inlineMd for their content
|
|
|
|
|
s = re.sub(r"^### (.+)$", lambda m: "<h3>" + inline_md(m.group(1)) + "</h3>", s, flags=re.M)
|
|
|
|
|
s = re.sub(r"^## (.+)$", lambda m: "<h2>" + inline_md(m.group(1)) + "</h2>", s, flags=re.M)
|
|
|
|
|
s = re.sub(r"^# (.+)$", lambda m: "<h1>" + inline_md(m.group(1)) + "</h1>", s, flags=re.M)
|
|
|
|
|
s = re.sub(r"^---+$", "<hr>", s, flags=re.M)
|
|
|
|
|
s = re.sub(r"^> (.+)$", lambda m: "<blockquote>" + inline_md(m.group(1)) + "</blockquote>", s, flags=re.M)
|
|
|
|
|
|
|
|
|
|
def handle_ul(block):
|
|
|
|
|
lines = block.strip().split("\n")
|
|
|
|
|
out = "<ul>"
|
|
|
|
|
for l in lines:
|
|
|
|
|
indent = bool(re.match(r"^ {2,}", l))
|
|
|
|
|
text = re.sub(r"^ {0,4}[-*+] ", "", l)
|
|
|
|
|
style = ' style="margin-left:16px"' if indent else ""
|
|
|
|
|
out += f"<li{style}>{inline_md(text)}</li>"
|
|
|
|
|
return out + "</ul>"
|
|
|
|
|
|
|
|
|
|
s = re.sub(r"((?:^(?: )?[-*+] .+\n?)+)", lambda m: handle_ul(m.group()), s, flags=re.M)
|
|
|
|
|
|
|
|
|
|
def handle_ol(block):
|
|
|
|
|
lines = block.strip().split("\n")
|
|
|
|
|
out = "<ol>"
|
|
|
|
|
for l in lines:
|
|
|
|
|
text = re.sub(r"^ {0,4}\d+\. ", "", l)
|
|
|
|
|
out += f"<li>{inline_md(text)}</li>"
|
|
|
|
|
return out + "</ol>"
|
|
|
|
|
|
|
|
|
|
s = re.sub(r"((?:^(?: )?\d+\. .+\n?)+)", lambda m: handle_ol(m.group()), s, flags=re.M)
|
|
|
|
|
|
|
|
|
|
# Safety net: escape unknown tags in remaining text
|
|
|
|
|
s = re.sub(r"</?[a-zA-Z][^>]*>", lambda m: m.group() if SAFE_TAGS.match(m.group()) else esc(m.group()), s)
|
|
|
|
|
|
|
|
|
|
# Paragraph wrap
|
|
|
|
|
parts = s.split("\n\n")
|
|
|
|
|
def wrap(p):
|
|
|
|
|
p = p.strip()
|
|
|
|
|
if not p: return ""
|
|
|
|
|
if re.match(r"^<(h[1-6]|ul|ol|pre|hr|blockquote)", p): return p
|
|
|
|
|
return "<p>" + p.replace("\n", "<br>") + "</p>"
|
|
|
|
|
s = "\n".join(wrap(p) for p in parts)
|
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Static analysis: verify key structures exist in ui.js ────────────────────
|
|
|
|
|
|
|
|
|
|
def test_render_md_pre_pass_converts_strong(cleanup_test_sessions):
|
|
|
|
|
"""ui.js renderMd() must have pre-pass that converts <strong> to **."""
|
|
|
|
|
src = REPO_ROOT / "static" / "ui.js"
|
|
|
|
|
code = src.read_text()
|
|
|
|
|
assert "<strong>" in code and "**" in code, "pre-pass for <strong> not found"
|
|
|
|
|
# Verify the specific conversion pattern
|
|
|
|
|
assert re.search(r"<strong>.*?\*\*", code, re.S), \
|
|
|
|
|
"renderMd pre-pass should convert <strong>...</strong> to **...**"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_has_safety_net(cleanup_test_sessions):
|
|
|
|
|
"""ui.js must have a safety-net that escapes unknown HTML tags after the pipeline."""
|
|
|
|
|
src = REPO_ROOT / "static" / "ui.js"
|
|
|
|
|
code = src.read_text()
|
|
|
|
|
assert "SAFE_TAGS" in code, "SAFE_TAGS allowlist regex not found in ui.js"
|
|
|
|
|
assert "esc(tag)" in code, "safety-net esc(tag) call not found in ui.js"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_stashes_code_blocks(cleanup_test_sessions):
|
|
|
|
|
"""ui.js pre-pass must stash code blocks before replacing safe HTML tags."""
|
|
|
|
|
src = REPO_ROOT / "static" / "ui.js"
|
|
|
|
|
code = src.read_text()
|
|
|
|
|
assert "fence_stash" in code, "fence_stash not found in renderMd pre-pass"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_handles_br_tag(cleanup_test_sessions):
|
|
|
|
|
"""ui.js must convert <br> to newline in pre-pass."""
|
|
|
|
|
src = REPO_ROOT / "static" / "ui.js"
|
|
|
|
|
code = src.read_text()
|
|
|
|
|
assert re.search(r"<br\\s\*", code) or "<br" in code, "<br> handling not found"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_no_placeholder_remnants(cleanup_test_sessions):
|
|
|
|
|
"""Old Unicode placeholder approach (\\uE001-\\uE005) must be gone."""
|
|
|
|
|
src = REPO_ROOT / "static" / "ui.js"
|
|
|
|
|
code = src.read_text()
|
|
|
|
|
for old_ph in ["\\uE001", "\\uE002", "\\uE003", "\\uE004", "\\uE005"]:
|
|
|
|
|
assert old_ph not in code, \
|
|
|
|
|
f"Old placeholder {old_ph} still present — broken implementation not cleaned up"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_safe_tag_allowlist_complete(cleanup_test_sessions):
|
|
|
|
|
"""SAFE_TAGS allowlist must include all tags the pipeline emits."""
|
|
|
|
|
src = REPO_ROOT / "static" / "ui.js"
|
|
|
|
|
code = src.read_text()
|
|
|
|
|
required = ["strong", "em", "code", "pre", "ul", "ol", "li",
|
|
|
|
|
"table", "blockquote", "hr", "br", "a", "div"]
|
|
|
|
|
safe_tags_match = re.search(r"SAFE_TAGS\s*=\s*/(.+?)/i", code)
|
|
|
|
|
assert safe_tags_match, "SAFE_TAGS regex not found"
|
|
|
|
|
pattern = safe_tags_match.group(1)
|
|
|
|
|
for tag in required:
|
|
|
|
|
assert tag in pattern, f"Tag '{tag}' missing from SAFE_TAGS allowlist"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Behavioural: renderMd logic via Python mirror ─────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_render_md_markdown_bold(cleanup_test_sessions):
|
|
|
|
|
"""**word** markdown renders as <strong>word</strong>."""
|
|
|
|
|
out = render_md("Hello **world**")
|
|
|
|
|
assert "<strong>world</strong>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_html_strong_passthrough(cleanup_test_sessions):
|
|
|
|
|
"""<strong>word</strong> in AI output renders as bold."""
|
|
|
|
|
out = render_md("Hello <strong>world</strong>")
|
|
|
|
|
assert "<strong>world</strong>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_html_b_tag(cleanup_test_sessions):
|
|
|
|
|
"""<b>word</b> renders as <strong>word</strong>."""
|
|
|
|
|
out = render_md("Hello <b>world</b>")
|
|
|
|
|
assert "<strong>world</strong>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_html_em_passthrough(cleanup_test_sessions):
|
|
|
|
|
"""<em>word</em> renders as italic."""
|
|
|
|
|
out = render_md("Hello <em>world</em>")
|
|
|
|
|
assert "<em>world</em>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_html_i_tag(cleanup_test_sessions):
|
|
|
|
|
"""<i>word</i> renders as <em>word</em>."""
|
|
|
|
|
out = render_md("Hello <i>word</i>")
|
|
|
|
|
assert "<em>word</em>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_html_code_passthrough(cleanup_test_sessions):
|
|
|
|
|
"""<code>text</code> renders as inline code."""
|
|
|
|
|
out = render_md("use <code>print()</code>")
|
|
|
|
|
assert "<code>print()</code>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_html_br_becomes_newline(cleanup_test_sessions):
|
|
|
|
|
"""<br> in AI output becomes a newline (rendered as <br> inside <p> later)."""
|
|
|
|
|
out = render_md("line one<br>line two")
|
|
|
|
|
assert "line one\nline two" in out or "line one<br>line two" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_mixed_markdown_and_html(cleanup_test_sessions):
|
|
|
|
|
"""Markdown and HTML formatting can coexist in the same response."""
|
|
|
|
|
out = render_md("**markdown** and <strong>html</strong>")
|
|
|
|
|
assert "<strong>markdown</strong>" in out
|
|
|
|
|
assert "<strong>html</strong>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_html_strong_in_list_item(cleanup_test_sessions):
|
|
|
|
|
"""THE SCREENSHOT BUG: <strong> tags inside list items must render as bold,
|
|
|
|
|
not as escaped literal text like <strong>."""
|
|
|
|
|
out = render_md(
|
|
|
|
|
"- <strong>All items</strong> get `border-radius: 0 8px 8px 0`\n"
|
|
|
|
|
"- <strong>Active item</strong> uses <code>#e8a030</code>\n"
|
|
|
|
|
"- <strong>Project items</strong> show their color\n"
|
|
|
|
|
"- <strong>Regular items</strong> stay muted"
|
|
|
|
|
)
|
|
|
|
|
assert "<strong>" not in out, \
|
|
|
|
|
"Escaped <strong> literal found in list output — bold not rendering"
|
|
|
|
|
assert "<strong>All items</strong>" in out
|
|
|
|
|
assert "<strong>Active item</strong>" in out
|
|
|
|
|
assert "<code>border-radius: 0 8px 8px 0</code>" in out
|
|
|
|
|
assert "<code>#e8a030</code>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_exact_screenshot_content(cleanup_test_sessions):
|
|
|
|
|
"""Exact text from the ui-changes-unrendered-html-tags.png screenshot.
|
|
|
|
|
This is the canonical regression test for the inlineMd fix.
|
|
|
|
|
All four bullet points must render <strong> and <code> as HTML, not literal text."""
|
|
|
|
|
out = render_md(
|
|
|
|
|
"- <strong>All items</strong> now have <code>border-radius: 0 8px 8px 0</code>"
|
|
|
|
|
" \u2014 straight left edge everywhere, rounded on the right\n"
|
|
|
|
|
"- <strong>Active item</strong> is now gold/amber (<code>#e8a030</code>)"
|
|
|
|
|
" \u2014 same warm gold used in the logo \u2014 instead of blue,"
|
|
|
|
|
" so it stands out distinctly from everything else\n"
|
|
|
|
|
"- <strong>Project items</strong> still show their project color on the left"
|
|
|
|
|
" border, but only when they're not the active item (active always wins with gold)\n"
|
|
|
|
|
"- <strong>Regular items</strong> (no project) still have no left border color"
|
|
|
|
|
)
|
|
|
|
|
# None of the safe tags should appear as literal escaped text
|
|
|
|
|
assert "<strong>" not in out, \
|
|
|
|
|
"Literal <strong> found — <strong> is not rendering as bold"
|
|
|
|
|
assert "</strong>" not in out, \
|
|
|
|
|
"Literal </strong> found — closing tag is not rendering"
|
|
|
|
|
assert "<code>" not in out, \
|
|
|
|
|
"Literal <code> found — <code> is not rendering as inline code"
|
|
|
|
|
# Each item's bold label must render correctly
|
|
|
|
|
assert "<strong>All items</strong>" in out
|
|
|
|
|
assert "<strong>Active item</strong>" in out
|
|
|
|
|
assert "<strong>Project items</strong>" in out
|
|
|
|
|
assert "<strong>Regular items</strong>" in out
|
|
|
|
|
# The code spans in items 1 and 2 must render correctly
|
|
|
|
|
assert "<code>border-radius: 0 8px 8px 0</code>" in out
|
|
|
|
|
assert "<code>#e8a030</code>" in out
|
|
|
|
|
# The surrounding prose text must be preserved
|
|
|
|
|
assert "straight left edge everywhere" in out
|
|
|
|
|
assert "same warm gold used in the logo" in out
|
|
|
|
|
assert "active always wins with gold" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_markdown_bold_in_list_item(cleanup_test_sessions):
|
|
|
|
|
"""**bold** markdown inside list items must render as <strong>."""
|
|
|
|
|
out = render_md("- **First** item\n- **Second** item with `code`")
|
|
|
|
|
assert "<strong>First</strong>" in out
|
|
|
|
|
assert "<strong>Second</strong>" in out
|
|
|
|
|
assert "<code>code</code>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_html_strong_in_blockquote(cleanup_test_sessions):
|
|
|
|
|
"""<strong> inside blockquote must render as bold."""
|
|
|
|
|
out = render_md("> <strong>Note:</strong> pay attention")
|
|
|
|
|
assert "<strong>" not in out
|
|
|
|
|
assert "<strong>Note:</strong>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_html_strong_in_heading(cleanup_test_sessions):
|
|
|
|
|
"""<strong> inside a heading must render as bold."""
|
|
|
|
|
out = render_md("## <strong>Important</strong> Section")
|
|
|
|
|
assert "<strong>" not in out
|
|
|
|
|
assert "<strong>Important</strong>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_xss_in_list_still_blocked(cleanup_test_sessions):
|
|
|
|
|
"""XSS attempts in list items must still be escaped."""
|
|
|
|
|
out = render_md("- <img src=x onerror=alert(1)> bad")
|
|
|
|
|
assert "<img" not in out
|
|
|
|
|
assert "<img" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_xss_in_blockquote_still_blocked(cleanup_test_sessions):
|
|
|
|
|
"""XSS in blockquote must still be escaped."""
|
|
|
|
|
out = render_md("> <script>alert(1)</script>")
|
|
|
|
|
assert "<script>" not in out
|
|
|
|
|
assert "<script" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_code_span_in_list_protected(cleanup_test_sessions):
|
|
|
|
|
"""Backtick code span in list item must escape its content."""
|
|
|
|
|
out = render_md("- Use `<br>` for breaks")
|
|
|
|
|
assert "<code><br></code>" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_code_block_protects_html(cleanup_test_sessions):
|
|
|
|
|
"""HTML inside a backtick code span must NOT be converted — shown as literal."""
|
|
|
|
|
out = render_md("keep `<strong>literal</strong>` safe")
|
|
|
|
|
assert "<strong>" in out, "HTML inside code span should be escaped"
|
|
|
|
|
assert "<strong>literal</strong>" not in out, "HTML inside code span should NOT render as bold"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_fenced_code_protects_html(cleanup_test_sessions):
|
|
|
|
|
"""HTML inside a fenced code block must not be converted by the pre-pass.
|
|
|
|
|
The fenced block is stashed before tag replacement runs, so the raw HTML
|
|
|
|
|
is preserved intact for the pipeline's esc() to escape when rendering
|
|
|
|
|
the <pre><code> block. We verify the stash/restore mechanism works by
|
|
|
|
|
checking the content is unchanged after the pre-pass (i.e. still contains
|
|
|
|
|
the original tag text, not converted to **not bold**)."""
|
|
|
|
|
src = "```\n<strong>not bold</strong>\n```"
|
|
|
|
|
out = render_md(src)
|
|
|
|
|
# Pre-pass stash preserves the raw content -- it should NOT have been
|
|
|
|
|
# converted to **not bold** (which would render as bold outside the fence)
|
|
|
|
|
assert "**not bold**" not in out, \
|
|
|
|
|
"Fenced code content was incorrectly converted to markdown by the pre-pass"
|
|
|
|
|
# The raw content should still be present (stash/restore worked)
|
|
|
|
|
assert "<strong>not bold</strong>" in out or "<strong>" in out, \
|
|
|
|
|
"Fenced code content was lost after stash/restore"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Security: XSS must be blocked ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_render_md_xss_img_tag_escaped(cleanup_test_sessions):
|
|
|
|
|
"""<img src=x onerror=alert(1)> must be HTML-escaped, not rendered."""
|
|
|
|
|
out = render_md("<img src=x onerror=alert(1)>")
|
|
|
|
|
assert "<img" not in out, "Raw <img> tag must not appear in output"
|
|
|
|
|
assert "<img" in out, "<img> must be HTML-escaped"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_xss_script_tag_escaped(cleanup_test_sessions):
|
|
|
|
|
"""<script>alert(1)</script> must be HTML-escaped."""
|
|
|
|
|
out = render_md("<script>alert(1)</script>")
|
|
|
|
|
assert "<script>" not in out, "Raw <script> tag must not appear in output"
|
|
|
|
|
assert "<script" in out, "<script> must be HTML-escaped"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_xss_iframe_escaped(cleanup_test_sessions):
|
|
|
|
|
"""<iframe> must be HTML-escaped."""
|
|
|
|
|
out = render_md("<iframe src='evil.com'></iframe>")
|
|
|
|
|
assert "<iframe" not in out
|
|
|
|
|
assert "<iframe" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_xss_svg_onerror_escaped(cleanup_test_sessions):
|
|
|
|
|
"""<svg onload=...> must be HTML-escaped."""
|
|
|
|
|
out = render_md("<svg onload=alert(1)>")
|
|
|
|
|
assert "<svg" not in out
|
|
|
|
|
assert "<svg" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_xss_in_bold_text_escaped(cleanup_test_sessions):
|
|
|
|
|
"""**<img onerror=...>** — XSS inside markdown bold must be escaped."""
|
|
|
|
|
out = render_md("**<img src=x onerror=alert(1)>**")
|
|
|
|
|
assert "<img" not in out, "XSS inside **bold** must be escaped"
|
|
|
|
|
assert "<img" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_xss_in_html_strong_escaped(cleanup_test_sessions):
|
|
|
|
|
"""<strong><img ...></strong> — nested XSS inside HTML strong must be escaped."""
|
|
|
|
|
out = render_md("<strong><img src=x onerror=alert(1)></strong>")
|
|
|
|
|
# <strong> converts to ** which then escapes the inner content via esc()
|
|
|
|
|
assert "<img" not in out, "XSS nested inside <strong> must be escaped"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_md_xss_object_tag_escaped(cleanup_test_sessions):
|
|
|
|
|
"""<object data=...> must be HTML-escaped."""
|
|
|
|
|
out = render_md("<object data='evil.swf'></object>")
|
|
|
|
|
assert "<object" not in out
|
|
|
|
|
assert "<object" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Sprint 16 sidebar: static structure checks ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
# ── Exhaustive inlineMd / renderMd edge-case tests ───────────────────────────
|
|
|
|
|
|
|
|
|
|
# --- Unordered list variants ---
|
|
|
|
|
|
|
|
|
|
def test_list_bold_only(cleanup_test_sessions):
|
|
|
|
|
"""Single bold word in list item."""
|
|
|
|
|
out = render_md("- **bold**")
|
|
|
|
|
assert "<strong>bold</strong>" in out
|
|
|
|
|
assert "<" not in out
|
|
|
|
|
|
|
|
|
|
def test_list_italic_only(cleanup_test_sessions):
|
|
|
|
|
"""Single italic word in list item."""
|
|
|
|
|
out = render_md("- *italic*")
|
|
|
|
|
assert "<em>italic</em>" in out
|
|
|
|
|
|
|
|
|
|
def test_list_code_only(cleanup_test_sessions):
|
|
|
|
|
"""Single code span in list item."""
|
|
|
|
|
out = render_md("- `code`")
|
|
|
|
|
assert "<code>code</code>" in out
|
|
|
|
|
|
|
|
|
|
def test_list_bold_and_code_mixed(cleanup_test_sessions):
|
|
|
|
|
"""Bold and code together in one list item."""
|
|
|
|
|
out = render_md("- **run** `pip install foo`")
|
|
|
|
|
assert "<strong>run</strong>" in out
|
|
|
|
|
assert "<code>pip install foo</code>" in out
|
|
|
|
|
|
|
|
|
|
def test_list_html_strong_and_code_mixed(cleanup_test_sessions):
|
|
|
|
|
"""HTML <strong> and <code> together — the exact screenshot scenario."""
|
|
|
|
|
out = render_md("- <strong>Key</strong>: use <code>value</code>")
|
|
|
|
|
assert "<strong>Key</strong>" in out
|
|
|
|
|
assert "<code>value</code>" in out
|
|
|
|
|
assert "<strong>" not in out
|
|
|
|
|
assert "<code>" not in out
|
|
|
|
|
|
|
|
|
|
def test_list_html_em(cleanup_test_sessions):
|
|
|
|
|
"""HTML <em> in list item renders as italic."""
|
|
|
|
|
out = render_md("- <em>emphasized</em> text")
|
|
|
|
|
assert "<em>emphasized</em>" in out
|
|
|
|
|
assert "<em>" not in out
|
|
|
|
|
|
|
|
|
|
def test_list_html_b_tag(cleanup_test_sessions):
|
|
|
|
|
"""HTML <b> in list item renders as bold."""
|
|
|
|
|
out = render_md("- <b>bold via b tag</b>")
|
|
|
|
|
assert "<strong>bold via b tag</strong>" in out
|
|
|
|
|
assert "<b>" not in out
|
|
|
|
|
|
|
|
|
|
def test_list_html_i_tag(cleanup_test_sessions):
|
|
|
|
|
"""HTML <i> in list item renders as italic."""
|
|
|
|
|
out = render_md("- <i>italic via i tag</i>")
|
|
|
|
|
assert "<em>italic via i tag</em>" in out
|
|
|
|
|
assert "<i>" not in out
|
|
|
|
|
|
|
|
|
|
def test_list_multiple_items_each_formatted(cleanup_test_sessions):
|
|
|
|
|
"""Multiple list items each with different formatting."""
|
|
|
|
|
out = render_md(
|
|
|
|
|
"- **bold item**\n"
|
|
|
|
|
"- *italic item*\n"
|
|
|
|
|
"- `code item`\n"
|
|
|
|
|
"- plain item"
|
|
|
|
|
)
|
|
|
|
|
assert "<strong>bold item</strong>" in out
|
|
|
|
|
assert "<em>italic item</em>" in out
|
|
|
|
|
assert "<code>code item</code>" in out
|
|
|
|
|
assert "<li>plain item</li>" in out
|
|
|
|
|
|
|
|
|
|
def test_list_item_bold_mid_sentence(cleanup_test_sessions):
|
|
|
|
|
"""Bold in middle of a list item sentence."""
|
|
|
|
|
out = render_md("- Set the **timeout** to 30 seconds")
|
|
|
|
|
assert "<strong>timeout</strong>" in out
|
|
|
|
|
assert "Set the" in out
|
|
|
|
|
assert "to 30 seconds" in out
|
|
|
|
|
|
|
|
|
|
def test_list_item_multiple_bold_spans(cleanup_test_sessions):
|
|
|
|
|
"""Multiple bold spans in one list item."""
|
|
|
|
|
out = render_md("- **A** and **B** are both important")
|
|
|
|
|
assert "<strong>A</strong>" in out
|
|
|
|
|
assert "<strong>B</strong>" in out
|
|
|
|
|
|
|
|
|
|
def test_ordered_list_bold(cleanup_test_sessions):
|
|
|
|
|
"""Bold text inside ordered list items."""
|
|
|
|
|
out = render_md("1. **First** step\n2. **Second** step\n3. Plain step")
|
|
|
|
|
assert "<ol>" in out
|
|
|
|
|
assert "<strong>First</strong>" in out
|
|
|
|
|
assert "<strong>Second</strong>" in out
|
|
|
|
|
assert "<li>Plain step</li>" in out
|
|
|
|
|
|
|
|
|
|
def test_ordered_list_html_strong(cleanup_test_sessions):
|
|
|
|
|
"""HTML <strong> inside ordered list items renders correctly."""
|
|
|
|
|
out = render_md("1. <strong>Install</strong> the package\n2. <strong>Configure</strong> the settings")
|
|
|
|
|
assert "<ol>" in out
|
|
|
|
|
assert "<strong>Install</strong>" in out
|
|
|
|
|
assert "<strong>Configure</strong>" in out
|
|
|
|
|
assert "<strong>" not in out
|
|
|
|
|
|
|
|
|
|
def test_ordered_list_code_spans(cleanup_test_sessions):
|
|
|
|
|
"""Code spans inside ordered list items."""
|
|
|
|
|
out = render_md("1. Run `npm install`\n2. Run `npm start`")
|
|
|
|
|
assert "<code>npm install</code>" in out
|
|
|
|
|
assert "<code>npm start</code>" in out
|
|
|
|
|
|
|
|
|
|
def test_indented_list_item_bold(cleanup_test_sessions):
|
|
|
|
|
"""Bold inside indented (nested) list item."""
|
|
|
|
|
out = render_md("- top level\n - **nested bold**")
|
|
|
|
|
assert "<strong>nested bold</strong>" in out
|
|
|
|
|
assert "margin-left:16px" in out
|
|
|
|
|
|
|
|
|
|
# --- Blockquote variants ---
|
|
|
|
|
|
|
|
|
|
def test_blockquote_plain(cleanup_test_sessions):
|
|
|
|
|
"""Plain blockquote wraps in <blockquote>."""
|
|
|
|
|
out = render_md("> simple quote")
|
|
|
|
|
assert "<blockquote>simple quote</blockquote>" in out
|
|
|
|
|
|
|
|
|
|
def test_blockquote_bold(cleanup_test_sessions):
|
|
|
|
|
"""**bold** inside blockquote renders correctly."""
|
|
|
|
|
out = render_md("> **important** note")
|
|
|
|
|
assert "<strong>important</strong>" in out
|
|
|
|
|
|
|
|
|
|
def test_blockquote_html_strong(cleanup_test_sessions):
|
|
|
|
|
"""<strong> inside blockquote renders as bold."""
|
|
|
|
|
out = render_md("> <strong>Warning:</strong> read this")
|
|
|
|
|
assert "<strong>Warning:</strong>" in out
|
|
|
|
|
assert "<strong>" not in out
|
|
|
|
|
|
|
|
|
|
def test_blockquote_code_span(cleanup_test_sessions):
|
|
|
|
|
"""Code span inside blockquote renders correctly."""
|
|
|
|
|
out = render_md("> Use `git commit` to save")
|
|
|
|
|
assert "<code>git commit</code>" in out
|
|
|
|
|
|
|
|
|
|
def test_blockquote_mixed_formatting(cleanup_test_sessions):
|
|
|
|
|
"""Mixed bold and code in blockquote."""
|
|
|
|
|
out = render_md("> **Note:** run `pip install foo` first")
|
|
|
|
|
assert "<strong>Note:</strong>" in out
|
|
|
|
|
assert "<code>pip install foo</code>" in out
|
|
|
|
|
|
|
|
|
|
def test_blockquote_xss_blocked(cleanup_test_sessions):
|
|
|
|
|
"""XSS in blockquote content must be escaped."""
|
|
|
|
|
out = render_md("> <img src=x onerror=alert(1)>")
|
|
|
|
|
assert "<img" in out
|
|
|
|
|
assert "<img" not in out
|
|
|
|
|
|
|
|
|
|
# --- Heading variants ---
|
|
|
|
|
|
|
|
|
|
def test_heading_h1_bold(cleanup_test_sessions):
|
|
|
|
|
"""Bold inside h1 renders correctly."""
|
|
|
|
|
out = render_md("# **Main** Title")
|
|
|
|
|
assert "<h1><strong>Main</strong> Title</h1>" in out
|
|
|
|
|
|
|
|
|
|
def test_heading_h2_html_strong(cleanup_test_sessions):
|
|
|
|
|
"""HTML <strong> inside h2 renders correctly."""
|
|
|
|
|
out = render_md("## <strong>Section</strong> Name")
|
|
|
|
|
assert "<h2><strong>Section</strong> Name</h2>" in out
|
|
|
|
|
assert "<strong>" not in out
|
|
|
|
|
|
|
|
|
|
def test_heading_h3_code(cleanup_test_sessions):
|
|
|
|
|
"""Code span inside h3 renders correctly."""
|
|
|
|
|
out = render_md("### The `renderMd` function")
|
|
|
|
|
assert "<h3>The <code>renderMd</code> function</h3>" in out
|
|
|
|
|
|
|
|
|
|
def test_heading_xss_blocked(cleanup_test_sessions):
|
|
|
|
|
"""XSS attempt in heading must be escaped."""
|
|
|
|
|
out = render_md("## <script>alert(1)</script>")
|
|
|
|
|
assert "<script>" not in out
|
|
|
|
|
assert "<script" in out
|
|
|
|
|
|
|
|
|
|
# --- Paragraph / top-level formatting ---
|
|
|
|
|
|
|
|
|
|
def test_paragraph_bold_renders(cleanup_test_sessions):
|
|
|
|
|
"""Bold in a plain paragraph renders correctly."""
|
|
|
|
|
out = render_md("The **quick brown fox** jumps.")
|
|
|
|
|
assert "<strong>quick brown fox</strong>" in out
|
|
|
|
|
|
|
|
|
|
def test_paragraph_html_strong_renders(cleanup_test_sessions):
|
|
|
|
|
"""HTML <strong> in a plain paragraph renders correctly."""
|
|
|
|
|
out = render_md("The <strong>quick brown fox</strong> jumps.")
|
|
|
|
|
assert "<strong>quick brown fox</strong>" in out
|
|
|
|
|
assert "<strong>" not in out
|
|
|
|
|
|
|
|
|
|
def test_paragraph_html_code_renders(cleanup_test_sessions):
|
|
|
|
|
"""HTML <code> in a plain paragraph renders correctly."""
|
|
|
|
|
out = render_md("Call <code>foo()</code> to start.")
|
|
|
|
|
assert "<code>foo()</code>" in out
|
|
|
|
|
assert "<code>" not in out
|
|
|
|
|
|
|
|
|
|
def test_paragraph_br_creates_line_break(cleanup_test_sessions):
|
|
|
|
|
"""<br> in paragraph becomes a line break inside <p>."""
|
|
|
|
|
out = render_md("Line one<br>Line two")
|
|
|
|
|
# br converts to \n which inside <p> becomes <br>
|
|
|
|
|
assert "Line one" in out and "Line two" in out
|
|
|
|
|
|
|
|
|
|
def test_multiple_paragraphs_separated(cleanup_test_sessions):
|
|
|
|
|
"""Double newline creates separate <p> elements."""
|
|
|
|
|
out = render_md("First paragraph.\n\nSecond paragraph.")
|
|
|
|
|
assert out.count("<p>") == 2
|
|
|
|
|
|
|
|
|
|
# --- Table variants ---
|
|
|
|
|
|
|
|
|
|
def test_table_structure_in_ui_js(cleanup_test_sessions):
|
|
|
|
|
"""ui.js must contain table rendering logic with thead/tbody structure."""
|
|
|
|
|
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
|
|
|
|
assert "<table>" in src or "table>" in src, "table rendering not found in ui.js"
|
|
|
|
|
assert "thead" in src, "thead not found in table renderer"
|
|
|
|
|
assert "tbody" in src, "tbody not found in table renderer"
|
|
|
|
|
assert "parseRow" in src, "parseRow helper not found in table renderer"
|
|
|
|
|
|
|
|
|
|
# --- br tag specifically ---
|
|
|
|
|
|
|
|
|
|
def test_br_in_list_item(cleanup_test_sessions):
|
|
|
|
|
"""<br> inside a list item becomes a newline."""
|
|
|
|
|
out = render_md("- Line one<br>Line two")
|
|
|
|
|
assert "Line one" in out
|
|
|
|
|
assert "Line two" in out
|
|
|
|
|
|
|
|
|
|
def test_br_self_closing_in_paragraph(cleanup_test_sessions):
|
|
|
|
|
"""<br/> self-closing form is also handled."""
|
|
|
|
|
out = render_md("Before<br/>After")
|
|
|
|
|
assert "Before" in out and "After" in out
|
|
|
|
|
|
|
|
|
|
# --- No double-escaping ---
|
|
|
|
|
|
|
|
|
|
def test_no_double_escaping_ampersand(cleanup_test_sessions):
|
|
|
|
|
"""A literal & in text must become & exactly once, not &amp;."""
|
|
|
|
|
out = render_md("foo & bar")
|
|
|
|
|
assert "&amp;" not in out
|
|
|
|
|
assert "&" in out or "foo & bar" in out # either fine (paragraph wrap may not escape)
|
|
|
|
|
|
|
|
|
|
def test_no_double_escaping_lt_in_code(cleanup_test_sessions):
|
|
|
|
|
"""< inside a code span must become < exactly once."""
|
|
|
|
|
out = render_md("`a < b`")
|
|
|
|
|
assert "<lt;" not in out
|
|
|
|
|
assert "<" in out
|
|
|
|
|
|
|
|
|
|
def test_strong_text_not_double_escaped(cleanup_test_sessions):
|
|
|
|
|
"""Content of <strong> must not be double-escaped."""
|
|
|
|
|
out = render_md("<strong>hello & world</strong>")
|
|
|
|
|
# The & inside strong content should be escaped once
|
|
|
|
|
assert "&amp;" not in out
|
|
|
|
|
assert "<strong>" in out
|
|
|
|
|
|
|
|
|
|
# --- inlineMd helper present in source ---
|
|
|
|
|
|
|
|
|
|
def test_inline_md_helper_in_ui_js(cleanup_test_sessions):
|
|
|
|
|
"""ui.js must define inlineMd() helper function."""
|
|
|
|
|
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
|
|
|
|
assert "function inlineMd(" in src, "inlineMd() helper not found in ui.js"
|
|
|
|
|
|
|
|
|
|
def test_inline_md_used_in_list_handler(cleanup_test_sessions):
|
|
|
|
|
"""List handler in ui.js must call inlineMd() not esc() for item text."""
|
|
|
|
|
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
|
|
|
|
# Find the list block handler
|
|
|
|
|
ul_idx = src.find("html+='<ul>'") or src.find('html+=`<ul>`') or src.find("let html='<ul>'")
|
|
|
|
|
assert ul_idx >= 0 or "inlineMd(text)" in src, "inlineMd not called in list handler"
|
|
|
|
|
# Verify inlineMd is called, not bare esc
|
|
|
|
|
assert "inlineMd(text)" in src, "inlineMd(text) call not found — list items may not render formatting"
|
|
|
|
|
|
|
|
|
|
def test_inline_md_used_in_blockquote_handler(cleanup_test_sessions):
|
|
|
|
|
"""Blockquote handler in ui.js must call inlineMd() not esc() for content."""
|
|
|
|
|
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
|
|
|
|
assert "inlineMd(t)" in src, "inlineMd not called in blockquote/heading handler"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sessions_js_has_svg_icons(cleanup_test_sessions):
|
|
|
|
|
"""sessions.js must define ICONS object with SVG strings for sidebar buttons."""
|
|
|
|
|
src = REPO_ROOT / "static" / "sessions.js"
|
|
|
|
|
code = src.read_text()
|
|
|
|
|
assert "const ICONS=" in code or "const ICONS =" in code, "ICONS constant not found"
|
|
|
|
|
for icon in ["pin", "folder", "archive", "trash", "dup"]:
|
|
|
|
|
assert icon + ":" in code or f"'{icon}'" in code, f"ICONS.{icon} not found"
|
|
|
|
|
assert "<svg" in code, "SVG content not found in ICONS"
|
|
|
|
|
|
|
|
|
|
|
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
|
|
|
def test_sessions_js_has_dropdown_actions(cleanup_test_sessions):
|
|
|
|
|
"""sessions.js must use a single trigger button and dropdown for session actions."""
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
src = REPO_ROOT / "static" / "sessions.js"
|
|
|
|
|
code = src.read_text()
|
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
|
|
|
assert "session-actions-trigger" in code, "session action trigger button not found in sessions.js"
|
|
|
|
|
assert "session-action-menu" in code, "session action dropdown menu not found in sessions.js"
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
|
|
|
|
|
|
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
|
|
|
def test_style_css_has_session_actions_dropdown(cleanup_test_sessions):
|
|
|
|
|
"""style.css must define trigger and dropdown styles for session actions."""
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
src = REPO_ROOT / "static" / "style.css"
|
|
|
|
|
code = src.read_text()
|
|
|
|
|
assert ".session-actions" in code, ".session-actions not found in style.css"
|
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
|
|
|
assert ".session-action-menu" in code, ".session-action-menu not found in style.css"
|
|
|
|
|
assert "position:fixed" in code or "position: fixed" in code, \
|
|
|
|
|
".session-action-menu must use position:fixed to avoid sidebar clipping"
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
|
|
|
|
|
|
2026-04-18 06:37:09 +00:00
|
|
|
def test_style_css_active_session_uses_accent(cleanup_test_sessions):
|
|
|
|
|
"""Active session style should use accent color variable, not hardcoded hex."""
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
src = REPO_ROOT / "static" / "style.css"
|
|
|
|
|
code = src.read_text()
|
2026-04-18 06:37:09 +00:00
|
|
|
assert "var(--accent" in code and ".session-item.active" in code, \
|
|
|
|
|
"Active session must use var(--accent) variables in style.css"
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
|
|
|
|
|
|
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
|
|
|
def test_sessions_js_uses_action_menu_not_per_row_buttons(cleanup_test_sessions):
|
|
|
|
|
"""sessions.js must use the single ⋯ action menu instead of per-row buttons.
|
|
|
|
|
|
|
|
|
|
The per-row button overlay was replaced with a single ⋯ trigger that opens a
|
|
|
|
|
positioned dropdown (session-action-menu). This removes the borderLeftColor
|
|
|
|
|
project colour override that the old code applied, which was the original
|
|
|
|
|
concern this test guarded. The new design uses a dot indicator for project
|
|
|
|
|
membership instead.
|
|
|
|
|
"""
|
feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
2026-04-03 00:27:43 +00:00
|
|
|
src = REPO_ROOT / "static" / "sessions.js"
|
|
|
|
|
code = src.read_text()
|
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
|
|
|
assert "session-actions-trigger" in code, "session-actions-trigger not found in sessions.js"
|
|
|
|
|
assert "_openSessionActionMenu" in code, "_openSessionActionMenu not found in sessions.js"
|
|
|
|
|
assert "closeSessionActionMenu" in code, "closeSessionActionMenu not found in sessions.js"
|
|
|
|
|
# The old per-row buttons must not be present (they were replaced by the menu)
|
|
|
|
|
assert "act-pin" not in code, "old act-pin per-row button still in sessions.js"
|
|
|
|
|
assert "act-archive" not in code, "old act-archive per-row button still in sessions.js"
|