Merge pull request #1675 from nesquena/feat/kanban-multiboard-and-sse

feat(kanban): multi-board management + SSE live event stream
This commit is contained in:
nesquena-hermes
2026-05-04 17:56:38 -07:00
committed by GitHub
12 changed files with 4912 additions and 8 deletions
+72
View File
@@ -1,5 +1,77 @@
# Hermes Web UI -- Changelog
## [v0.51.0] — 2026-05-04 — Kanban v1
### Added — Kanban v1: complete first-party Kanban for Hermes (closes #1645, #1646, #1647, #1649, #1654, #1655, #1660, #1675)
The full Kanban feature lands as a 12-commit stack giving the WebUI **first-party-compatible parity** with the Hermes Agent dashboard plugin's Kanban surface. A small team can now run their entire ticket-tracking flow directly inside the WebUI panel, sharing a single source of truth (`~/.hermes/kanban.db` + per-board `~/.hermes/kanban/boards/<slug>/kanban.db`) with the agent CLI, gateway slash commands, and dashboard.
**Stacked on previously-shipped foundation** (v0.50.275v0.50.297 introduced read-only Kanban panel, write semantics, task detail expansion, dashboard-parity core controls, UI parity polish, and review-feedback hardening). This release completes the picture with multi-board management and real-time event streaming.
**Multi-board management** (#1675, ~1900 LOC of new feature work):
- 5 new endpoints mirroring the agent dashboard plugin contract verbatim:
- `GET /api/kanban/boards` — list all boards with per-status task counts + active-board pointer
- `POST /api/kanban/boards` — create board (idempotent on slug)
- `PATCH /api/kanban/boards/<slug>` — rename / update display metadata (slug is immutable)
- `DELETE /api/kanban/boards/<slug>` — archive (default; reversible from `kanban/boards/_archived/`) or `?delete=1` hard-delete
- `POST /api/kanban/boards/<slug>/switch` — set active board (writes shared cross-process pointer at `<root>/kanban/current`)
- All existing per-board endpoints accept `?board=<slug>` query param (or `board` in JSON body); query takes precedence over body
- Frontend: `Default ▾` switcher pill in the panel header, click-anchored menu listing every board (current first) with per-status total badges + 3 actions (New / Rename / Archive). Modal handles both create and rename (slug auto-derives from name with manual override). 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 persists to `localStorage['hermes-kanban-active-board']` so a refresh stays put. The on-disk pointer is the cross-process source of truth, kept in sync via the switch endpoint.
- Default board is protected from deletion (would leave system without fallback active board).
- Slug normalisation goes through `kb._normalize_board_slug()` which rejects path-traversal patterns (`../etc/passwd`, `..\windows`) at validation time.
**Real-time SSE event stream** (#1675):
- New `GET /api/kanban/events/stream` long-lived Server-Sent Events endpoint mirroring the agent dashboard's WebSocket `/events` contract event-for-event
- 300ms server-side poll interval (matches agent dashboard's `_EVENT_POLL_SECONDS`), 200-event batch cap, 15s heartbeat keepalive
- Each `event: events` frame emits `id: <event_id>` so EventSource auto-stores `Last-Event-ID` and resumes from the right cursor on reconnect; server reads `Last-Event-ID` from request headers as a fallback when `?since=` is absent (cross-drop resume without re-streaming the backlog)
- Frontend uses `EventSource` by default with **automatic fallback to 30s HTTP polling** after 3 consecutive SSE failures (proxy strips `text/event-stream`, etc.)
- 250ms debounce on event bursts coalesces N events into a single board re-fetch
- SSE stream torn down cleanly when the user leaves the Kanban panel (no leaked threads on a long-running session)
- **Why SSE not WebSocket**: the WebUI's existing transport is synchronous `BaseHTTPServer`. WebSocket would require an async refactor or a hijack-the-socket hack. SSE is the right tool for unidirectional server-pushed event streams, matches the existing `/api/approval/stream` and `/api/clarify/stream` patterns, and gives identical write-to-receive latency (~300ms) versus the agent dashboard's WebSocket path.
**Bridge hardening** (#1660 + #1675 polish):
- `read_only` flag now reports honest state across all 4 payload sites (`_board_payload`, `_events_payload`, `_task_log_payload`, no-change short-circuit). Was hardcoded `True` from the read-only-bridge era of #1645; bridge has been writable since #1649.
- `ImportError` fallback: when `hermes_cli` isn't installed (webui-only deploy), all 4 verb handlers (GET/POST/PATCH/DELETE) return clean `503 kanban unavailable: <reason>` instead of bubbling 500s.
- **Dispatcher contract enforcement** (a39ec45): bridge rejects raw `PATCH status='running'` with 400 + clear error message. Direct status writes to `running` would bypass the `claim_lock`/`claim_expires`/`started_at`/`worker_pid` machinery, breaking dispatcher coordination. The frontend never sends `running` (button removed + drop-target disabled); the bridge is defense-in-depth. `_set_status_direct()` helper mirrors the agent dashboard's same-named function for legitimate non-running transitions, nulling claim fields and closing active runs with `outcome='reclaimed'` when leaving `running`.
- `blocked → ready` transitions route through `kb.unblock_task()` (fires `unblocked` event for live polling consumers), not raw UPDATE.
- `done → archived` transitions route through `kb.archive_task()`.
- **Archive race fix**: two-layer defense against `kb.connect(board=<slug>)` auto-materialising the directory + sqlite on first call, which would silently un-archive a board that was just removed. Frontend stops the SSE stream BEFORE the `DELETE` call (restarts on failure); bridge's `_kanban_sse_fetch_new` checks `kb.board_exists()` before `connect()`, returning empty results when the board is gone.
- **CSS injection fix** (60874db, caught during independent security audit): `b.color` was being interpolated into a `style=""` attribute via `esc()` which HTML-escapes but doesn't prevent CSS-context injection (e.g. `color="red;background:url('http://attacker/exfil')"`). New `_kanbanSafeColor()` helper allowlists only `^#[0-9a-fA-F]{3,8}$` hex codes or `^[a-zA-Z]{3,32}$` named colors; everything else collapses to empty and the renderer drops the rule entirely.
- **Routing-asymmetry fix** (Opus SHOULD-FIX #1): `PATCH/DELETE /api/kanban/boards/<slug>` now match the `/boards/<slug>` path BEFORE resolving `?board=`. A stray `?board=ghost` query param on a `PATCH /api/kanban/boards/experiments?board=ghost` no longer 404s on `ghost` — it correctly edits `experiments`. Mirrors the POST handler's structure.
**Mobile responsive**:
- 9 new rules under the existing `@media (max-width: 640px)` block covering the multi-board UI: switcher button (smaller padding/font), board-name truncation at 140px max-width, dropdown menu sized at `min(280px, 100vw - 24px)`, modal padding tightens, inline-row icon/color picker stacks vertically.
**Polish**:
- Accent-tinted Save button in the modal (was visually identical to Cancel before)
- Modal + dropdown menu now use the same `linear-gradient` panel + accent border pattern as the existing `app-dialog` overlay (was using undefined `var(--panel)` falling back to transparent)
- "Read-only view" banner now hidden by default in HTML and only shown when the bridge actually reports `read_only=true` (was permanently visible regardless of state)
### Tests
**4288 → 4356 passing** (+68 net).
- `tests/test_kanban_bridge.py`: 18 → 41 tests (+23 covering board CRUD, slug validation, default-board protection, dispatcher routing, board isolation via `connect()` spy, SSE backlog/error-recovery/integration with worker thread + threading.Event watchdog, SSE `id:` lines, Last-Event-ID resume, PATCH/DELETE routing-order regression)
- `tests/test_kanban_ui_static.py`: 15 → 27 tests (+12 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, debouncing, CSS-injection regression)
Total Kanban-specific test coverage: 33 → 68 tests (+35).
### Pre-release verification
- **Independent review (nesquena)**: APPROVED with one CSS-injection MUST-FIX caught and pushed before approval (60874db). Cross-tool checks against fresh `nousresearch/hermes-agent` tarball verified contract-for-contract parity with `plugins/kanban/dashboard/plugin_api.py` for all `/boards` endpoints + `/events` SSE wire format.
- **Opus advisor on PR #1675 stage diff**: SHIP verdict. Two SHOULD-FIX items applied with regression tests (PATCH/DELETE routing reorder + SSE `id:` lines / Last-Event-ID resume). MUST-FIX: 0.
- **Live end-to-end browser verification on port 8789**: Multi-board switcher, create/rename/archive flows, SSE 400ms live delivery, 5-task burst with 250ms debounce, `?board=` isolation across two boards, Last-Event-ID resume, CSS-injection fix renders safely. Zero JS errors throughout 11-step flow.
### Acknowledgments
This was a large stack of work. Massive thanks to **@ai-ag2026** for the full Kanban implementation across 12 commits. Reviewer security audit + CSS-injection fix by **@nesquena**. Multi-board + SSE design and integration by **@Michaelyklam** with AI-assist co-authorship.
## [v0.50.297] — 2026-05-04
### Fixed (3 PRs — closes #1658; refs #1458, #1652)
+1 -1
View File
@@ -2,7 +2,7 @@
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
> Last updated: v0.50.297 (May 04, 2026) — 4288 tests collected
> Last updated: v0.51.0 (May 04, 2026) — 4356 tests collected — Kanban v1 launch
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
+2 -2
View File
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
---
*Last updated: v0.50.297, May 04, 2026*
*Total automated tests collected: 4288*
*Last updated: v0.51.0, May 04, 2026 — Kanban v1 launch*
*Total automated tests collected: 4356*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*
+1217
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -1907,6 +1907,11 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/insights":
return _handle_insights(handler, parsed)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_get
return handle_kanban_get(handler, parsed)
if parsed.path == "/health":
return _handle_health(handler, parsed)
@@ -2622,6 +2627,11 @@ def handle_post(handler, parsed) -> bool:
body = read_body(handler)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_post
return handle_kanban_post(handler, parsed, body)
if parsed.path == "/api/session/new":
try:
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
@@ -3768,6 +3778,30 @@ def handle_post(handler, parsed) -> bool:
return False # 404
def handle_patch(handler, parsed) -> bool:
"""Handle all PATCH routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_patch
return handle_kanban_patch(handler, parsed, body)
return False
def handle_delete(handler, parsed) -> bool:
"""Handle all DELETE routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_delete
return handle_kanban_delete(handler, parsed, body)
return False
# ── GET route helpers ─────────────────────────────────────────────────────────
# MIME types for static file serving. Hoisted to module scope to avoid
+12 -3
View File
@@ -22,7 +22,7 @@ from api.auth import check_auth
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
from api.helpers import j, get_profile_cookie
from api.profiles import set_request_profile, clear_request_profile
from api.routes import handle_get, handle_post
from api.routes import handle_delete, handle_get, handle_patch, handle_post
from api.startup import auto_install_agent_deps, fix_credential_permissions
from api.updates import WEBUI_VERSION
@@ -137,7 +137,7 @@ class Handler(BaseHTTPRequestHandler):
finally:
clear_request_profile()
def do_POST(self) -> None:
def _handle_write(self, route_func) -> None:
self._req_t0 = time.time()
# Per-request profile context from cookie (issue #798)
cookie_profile = get_profile_cookie(self)
@@ -146,7 +146,7 @@ class Handler(BaseHTTPRequestHandler):
try:
parsed = urlparse(self.path)
if not check_auth(self, parsed): return
result = handle_post(self, parsed)
result = route_func(self, parsed)
if result is False:
return j(self, {'error': 'not found'}, status=404)
except Exception as e:
@@ -155,6 +155,15 @@ class Handler(BaseHTTPRequestHandler):
finally:
clear_request_profile()
def do_POST(self) -> None:
self._handle_write(handle_post)
def do_PATCH(self) -> None:
self._handle_write(handle_patch)
def do_DELETE(self) -> None:
self._handle_write(handle_delete)
def _raise_fd_soft_limit(target: int = 4096) -> dict:
"""Best-effort raise of RLIMIT_NOFILE for persistent WebUI hosts.
+464
View File
@@ -449,6 +449,64 @@ const LOCALES = {
tab_memory: 'Memory',
tab_workspaces: 'Spaces',
tab_profiles: 'Profiles',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
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_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_new_task: 'New task',
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
kanban_board_archived: 'Board archived',
kanban_board_name: 'Name',
kanban_board_slug: 'Slug (lowercase, hyphens)',
kanban_board_description: 'Description (optional)',
kanban_board_icon: 'Icon (emoji, optional)',
kanban_board_color: 'Color (optional)',
kanban_board_name_required: 'Name is required',
kanban_board_slug_required: 'Slug is required',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
kanban_card_archive: 'archive',
kanban_unassigned: 'unassigned',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Insights',
tab_settings: 'Settings',
@@ -1351,6 +1409,64 @@ const LOCALES = {
tab_memory: 'メモリ',
tab_workspaces: 'スペース',
tab_profiles: 'プロファイル',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
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_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_new_task: 'New task',
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
kanban_board_archived: 'Board archived',
kanban_board_name: 'Name',
kanban_board_slug: 'Slug (lowercase, hyphens)',
kanban_board_description: 'Description (optional)',
kanban_board_icon: 'Icon (emoji, optional)',
kanban_board_color: 'Color (optional)',
kanban_board_name_required: 'Name is required',
kanban_board_slug_required: 'Slug is required',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
kanban_card_archive: 'archive',
kanban_unassigned: 'unassigned',
kanban_status_archived: 'Archived',
tab_todos: 'ToDo',
tab_insights: 'インサイト',
tab_settings: '設定',
@@ -2095,6 +2211,64 @@ const LOCALES = {
tab_memory: 'Память',
tab_workspaces: 'Рабочие пространства',
tab_profiles: 'Профили',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
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_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_new_task: 'New task',
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
kanban_board_archived: 'Board archived',
kanban_board_name: 'Name',
kanban_board_slug: 'Slug (lowercase, hyphens)',
kanban_board_description: 'Description (optional)',
kanban_board_icon: 'Icon (emoji, optional)',
kanban_board_color: 'Color (optional)',
kanban_board_name_required: 'Name is required',
kanban_board_slug_required: 'Slug is required',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
kanban_card_archive: 'archive',
kanban_unassigned: 'unassigned',
kanban_status_archived: 'Archived',
tab_todos: 'Список дел',
tab_insights: 'Аналитика',
tab_settings: 'Настройки',
@@ -2933,6 +3107,64 @@ const LOCALES = {
tab_memory: 'Memoria',
tab_workspaces: 'Espacios',
tab_profiles: 'Perfiles',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
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_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_new_task: 'New task',
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
kanban_board_archived: 'Board archived',
kanban_board_name: 'Name',
kanban_board_slug: 'Slug (lowercase, hyphens)',
kanban_board_description: 'Description (optional)',
kanban_board_icon: 'Icon (emoji, optional)',
kanban_board_color: 'Color (optional)',
kanban_board_name_required: 'Name is required',
kanban_board_slug_required: 'Slug is required',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
kanban_card_archive: 'archive',
kanban_unassigned: 'unassigned',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Analíticas',
tab_settings: 'Ajustes',
@@ -3759,6 +3991,64 @@ const LOCALES = {
tab_memory: 'Gedächtnis',
tab_workspaces: 'Spaces',
tab_profiles: 'Profile',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
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_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_new_task: 'New task',
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
kanban_board_archived: 'Board archived',
kanban_board_name: 'Name',
kanban_board_slug: 'Slug (lowercase, hyphens)',
kanban_board_description: 'Description (optional)',
kanban_board_icon: 'Icon (emoji, optional)',
kanban_board_color: 'Color (optional)',
kanban_board_name_required: 'Name is required',
kanban_board_slug_required: 'Slug is required',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
kanban_card_archive: 'archive',
kanban_unassigned: 'unassigned',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Statistiken',
tab_settings: 'Einstellungen',
@@ -4606,6 +4896,64 @@ const LOCALES = {
tab_memory: '记忆',
tab_skills: '技能',
tab_tasks: '任务',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
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_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_new_task: 'New task',
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
kanban_board_archived: 'Board archived',
kanban_board_name: 'Name',
kanban_board_slug: 'Slug (lowercase, hyphens)',
kanban_board_description: 'Description (optional)',
kanban_board_icon: 'Icon (emoji, optional)',
kanban_board_color: 'Color (optional)',
kanban_board_name_required: 'Name is required',
kanban_board_slug_required: 'Slug is required',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
kanban_card_archive: 'archive',
kanban_unassigned: 'unassigned',
kanban_status_archived: 'Archived',
tab_todos: '待办',
tab_insights: '统计',
tab_workspaces: '工作区',
@@ -6454,6 +6802,64 @@ const LOCALES = {
tab_memory: 'Memória',
tab_workspaces: 'Spaces',
tab_profiles: 'Perfis',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
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_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_new_task: 'New task',
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
kanban_board_archived: 'Board archived',
kanban_board_name: 'Name',
kanban_board_slug: 'Slug (lowercase, hyphens)',
kanban_board_description: 'Description (optional)',
kanban_board_icon: 'Icon (emoji, optional)',
kanban_board_color: 'Color (optional)',
kanban_board_name_required: 'Name is required',
kanban_board_slug_required: 'Slug is required',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
kanban_card_archive: 'archive',
kanban_unassigned: 'unassigned',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Estatísticas',
tab_settings: 'Configurações',
@@ -7262,6 +7668,64 @@ const LOCALES = {
tab_memory: '메모리',
tab_workspaces: '공간',
tab_profiles: 'Agent 프로필',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
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_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_new_task: 'New task',
kanban_add_comment: 'Add comment',
kanban_only_mine: 'Only mine',
kanban_bulk_action: 'Bulk action',
kanban_nudge_dispatcher: 'Nudge dispatcher',
kanban_stats: 'Stats',
kanban_worker_log: 'Worker log',
kanban_block: 'Block',
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
kanban_board_archived: 'Board archived',
kanban_board_name: 'Name',
kanban_board_slug: 'Slug (lowercase, hyphens)',
kanban_board_description: 'Description (optional)',
kanban_board_icon: 'Icon (emoji, optional)',
kanban_board_color: 'Color (optional)',
kanban_board_name_required: 'Name is required',
kanban_board_slug_required: 'Slug is required',
kanban_card_start: 'start',
kanban_card_complete: 'complete',
kanban_card_archive: 'archive',
kanban_unassigned: 'unassigned',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: '통계',
tab_settings: '설정',
+93
View File
@@ -83,6 +83,7 @@
<nav class="rail" aria-label="Primary navigation">
<button class="rail-btn nav-tab active" data-panel="chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat" aria-label="Chat"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab" data-panel="tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks" aria-label="Tasks"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="rail-btn nav-tab" data-panel="kanban" onclick="switchPanel('kanban')" title="Kanban" data-i18n-title="tab_kanban" aria-label="Kanban"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 10h18"/></svg></button>
<button class="rail-btn nav-tab" data-panel="skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills" aria-label="Skills"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="rail-btn nav-tab" data-panel="memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory" aria-label="Memory"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="rail-btn nav-tab" data-panel="workspaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces" aria-label="Spaces"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
@@ -97,6 +98,7 @@
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="nav-tab" data-panel="kanban" data-label="Kanban" onclick="switchPanel('kanban')" title="Kanban" data-i18n-title="tab_kanban"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 10h18"/></svg></button>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
@@ -130,6 +132,35 @@
</div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Kanban panel -->
<div class="panel-view" id="panelKanban">
<div class="panel-head">
<span data-i18n="tab_kanban">Kanban</span>
<div class="panel-head-actions">
<button class="panel-head-btn" id="kanbanNewTaskBtn" onclick="createKanbanTask()" title="New task" data-i18n-title="kanban_new_task" aria-label="New task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-head-btn" id="kanbanRefreshBtn" onclick="loadKanban(true)" title="Refresh" data-i18n-title="kanban_refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
</div>
</div>
<div class="kanban-filter-stack">
<div class="sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="kanbanSearch" placeholder="Search tasks" data-i18n-placeholder="kanban_search_tasks" oninput="filterKanban()"></div>
<select id="kanbanAssigneeFilter" onchange="loadKanban(true)" aria-label="Assignee filter"></select>
<select id="kanbanTenantFilter" onchange="loadKanban(true)" aria-label="Tenant filter"></select>
<label class="kanban-check"><input id="kanbanIncludeArchived" type="checkbox" onchange="loadKanban(true)"> <span data-i18n="kanban_include_archived">Include archived</span></label>
<label class="kanban-check"><input id="kanbanOnlyMine" type="checkbox" onchange="loadKanban(true)"> <span data-i18n="kanban_only_mine">Only mine</span></label>
<div id="kanbanStats" class="kanban-stats" aria-live="polite"></div>
<div id="kanbanBulkBar" class="kanban-bulk-bar">
<select id="kanbanBulkStatus" aria-label="Bulk status"><option value="">Status</option><option value="ready">Ready</option><option value="running">Running</option><option value="blocked">Blocked</option><option value="done">Done</option><option value="archived">Archived</option></select>
<button class="btn secondary" onclick="bulkUpdateKanban()" data-i18n="kanban_bulk_action">Bulk action</button>
<button class="btn secondary" onclick="nudgeKanbanDispatcher()" data-i18n="kanban_nudge_dispatcher">Nudge dispatcher</button>
</div>
<div class="kanban-new-task-row">
<input id="kanbanNewTaskTitle" placeholder="New task" data-i18n-placeholder="kanban_new_task" onkeydown="if(event.key==='Enter')createKanbanTask()">
<button class="btn secondary" onclick="createKanbanTask()" data-i18n="kanban_new_task">New task</button>
</div>
</div>
<div class="kanban-summary" id="kanbanSummary"></div>
<div class="kanban-list" id="kanbanList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Skills panel -->
<div class="panel-view" id="panelSkills">
<div class="panel-head">
@@ -613,6 +644,32 @@
<div class="main-view-empty-sub" data-i18n="tasks_empty_sub">Pick a job from the sidebar to view its details and runs, or create a new one.</div>
</div>
</div>
<div id="mainKanban" class="main-view">
<div class="main-view-header">
<div>
<div class="main-view-title-row">
<div class="main-view-title" data-i18n="kanban_board">Board</div>
<div class="kanban-board-switcher" id="kanbanBoardSwitcher" hidden>
<button type="button" class="kanban-board-switcher-toggle" id="kanbanBoardSwitcherToggle" onclick="toggleKanbanBoardMenu(event)" aria-haspopup="menu" aria-expanded="false">
<span class="kanban-board-switcher-icon" id="kanbanBoardSwitcherIcon" aria-hidden="true"></span>
<span class="kanban-board-switcher-name" id="kanbanBoardSwitcherName">Default</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="kanban-board-switcher-menu" id="kanbanBoardSwitcherMenu" role="menu" hidden></div>
</div>
</div>
<div class="kanban-readonly" data-i18n="kanban_read_only" style="display:none">Read-only view</div>
</div>
<div class="main-view-actions">
<button class="panel-head-btn" id="btnKanbanCreateBoard" onclick="openKanbanCreateBoard()" title="New board" data-i18n-title="kanban_new_board" aria-label="New board"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><line x1="17.5" y1="14" x2="17.5" y2="21"/><line x1="14" y1="17.5" x2="21" y2="17.5"/></svg></button>
<button class="panel-head-btn" onclick="nudgeKanbanDispatcher()" title="Nudge dispatcher" data-i18n-title="kanban_nudge_dispatcher" aria-label="Nudge dispatcher"></button>
</div>
</div>
<div class="kanban-task-preview" id="kanbanTaskPreview" style="display:none"></div>
<div class="kanban-board-wrap">
<div class="kanban-board" id="kanbanBoard"><div style="padding:16px;color:var(--muted);font-size:13px" data-i18n="loading">Loading...</div></div>
</div>
</div>
<div id="mainWorkspaces" class="main-view">
<div class="main-view-header">
<div class="main-view-title" id="workspaceDetailTitle"></div>
@@ -1082,5 +1139,41 @@
<script src="static/panels.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/onboarding.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>
<!-- Kanban: create/rename board modal — used for both flows. -->
<div class="kanban-modal-overlay" id="kanbanBoardModal" hidden onclick="if(event.target===this)closeKanbanBoardModal()">
<div class="kanban-modal" role="dialog" aria-modal="true" aria-labelledby="kanbanBoardModalTitle">
<h3 id="kanbanBoardModalTitle" data-i18n="kanban_new_board">New board</h3>
<input type="hidden" id="kanbanBoardModalMode" value="create">
<input type="hidden" id="kanbanBoardModalSlug" value="">
<div class="kanban-modal-row">
<label for="kanbanBoardModalName" data-i18n="kanban_board_name">Name</label>
<input type="text" id="kanbanBoardModalName" maxlength="64" placeholder="e.g. Experiments" autocomplete="off">
</div>
<div class="kanban-modal-row" id="kanbanBoardModalSlugRow">
<label for="kanbanBoardModalSlugInput" data-i18n="kanban_board_slug">Slug (lowercase, hyphens)</label>
<input type="text" id="kanbanBoardModalSlugInput" maxlength="48" placeholder="experiments" autocomplete="off">
</div>
<div class="kanban-modal-row">
<label for="kanbanBoardModalDesc" data-i18n="kanban_board_description">Description (optional)</label>
<textarea id="kanbanBoardModalDesc" maxlength="200"></textarea>
</div>
<div class="kanban-modal-row-inline">
<div class="kanban-modal-row">
<label for="kanbanBoardModalIcon" data-i18n="kanban_board_icon">Icon (emoji, optional)</label>
<input type="text" id="kanbanBoardModalIcon" maxlength="4" placeholder="📋" autocomplete="off">
</div>
<div class="kanban-modal-row">
<label for="kanbanBoardModalColor" data-i18n="kanban_board_color">Color (optional)</label>
<input type="color" id="kanbanBoardModalColor" value="#7aa2ff">
</div>
</div>
<div class="kanban-modal-error" id="kanbanBoardModalError" aria-live="polite"></div>
<div class="kanban-modal-actions">
<button type="button" class="btn secondary" onclick="closeKanbanBoardModal()" data-i18n="cancel">Cancel</button>
<button type="button" class="btn primary" id="kanbanBoardModalSubmit" onclick="submitKanbanBoardModal()" data-i18n="save">Save</button>
</div>
</div>
</div>
</body>
</html>
+1011 -1
View File
File diff suppressed because it is too large Load Diff
+269 -1
View File
@@ -2145,14 +2145,16 @@ main.main > #mainSettings,
main.main > #mainSkills,
main.main > #mainMemory,
main.main > #mainTasks,
main.main > #mainKanban,
main.main > #mainWorkspaces,
main.main > #mainProfiles,
main.main > #mainInsights{display:none;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights) > #mainChat{display:flex;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights) > #mainChat{display:flex;}
main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
main.main.showing-skills > #mainSkills{display:flex;}
main.main.showing-memory > #mainMemory{display:flex;}
main.main.showing-tasks > #mainTasks{display:flex;}
main.main.showing-kanban > #mainKanban{display:flex;}
main.main.showing-workspaces > #mainWorkspaces{display:flex;}
main.main.showing-profiles > #mainProfiles{display:flex;}
#mainSettings{overflow-y:auto;}
@@ -3115,3 +3117,269 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.checkpoint-diff-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);}
.checkpoint-diff-body{padding:12px 16px;overflow-y:auto;flex:1;}
.checkpoint-diff-body pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-all;}
/* ── Kanban native board (read-only MVP) ── */
.kanban-filter-stack{display:flex;flex-direction:column;gap:8px;padding:8px 12px;border-bottom:1px solid var(--border);}
.kanban-filter-stack select{width:100%;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font-size:12px;}
.kanban-check{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;}
.kanban-summary{padding:8px 12px;color:var(--muted);font-size:12px;border-bottom:1px solid var(--border);}
.kanban-list{flex:1;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:6px;}
.kanban-list-item{display:flex;flex-direction:column;align-items:flex-start;gap:3px;width:100%;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--panel);color:var(--text);text-align:left;cursor:pointer;}
.kanban-list-item:hover{border-color:var(--accent);background:var(--hover);}
.kanban-list-status{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);}
.kanban-list-title{font-size:13px;font-weight:600;line-height:1.35;}
.kanban-board-wrap{flex:1;min-height:0;overflow:auto;padding:16px;background:var(--bg);}
.kanban-board{display:flex;gap:12px;min-height:100%;overflow-x:auto;padding-bottom:8px;}
.kanban-column{display:flex;flex-direction:column;min-width:260px;max-width:320px;flex:1;background:var(--panel);border:1px solid var(--border);border-radius:10px;min-height:240px;}
.kanban-column-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);font-size:13px;font-weight:600;color:var(--text);}
.kanban-count{font-size:11px;color:var(--muted);background:var(--input-bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;}
.kanban-column-body{display:flex;flex-direction:column;gap:8px;padding:10px;min-height:0;overflow-y:auto;}
.kanban-card{border:1px solid var(--border);border-radius:9px;background:var(--bg);padding:10px;cursor:pointer;box-shadow:var(--shadow-sm);}
.kanban-card:hover,.kanban-card.selected{border-color:var(--accent);}
.kanban-card-title{font-size:13px;font-weight:650;color:var(--text);line-height:1.35;margin-bottom:6px;}
.kanban-card-body{font-size:12px;color:var(--muted);line-height:1.45;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;margin-bottom:8px;}
.kanban-meta{font-size:11px;color:var(--muted);line-height:1.35;}
.kanban-readonly{font-size:11px;color:var(--muted);margin-top:6px;}
/* Multi-board switcher in the main panel header.
Renders next to the "Board" title as `Default ▾` when at least one
non-default board exists, opens a click-anchored menu listing all
boards, current first, with per-status total badges. */
.main-view-title-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.kanban-board-switcher{position:relative;display:inline-block;}
.kanban-board-switcher[hidden]{display:none;}
.kanban-board-switcher-toggle{
display:inline-flex;align-items:center;gap:6px;
padding:4px 10px;
border:1px solid var(--border);
background:var(--input-bg);
color:var(--text);
border-radius:8px;
font:inherit;font-size:12px;font-weight:550;
cursor:pointer;
transition:border-color .15s,color .15s,background .15s;
}
.kanban-board-switcher-toggle:hover{border-color:var(--accent);color:var(--accent);}
.kanban-board-switcher-toggle[aria-expanded="true"]{border-color:var(--accent);}
.kanban-board-switcher-icon{font-size:14px;line-height:1;display:inline-block;min-width:14px;text-align:center;}
.kanban-board-switcher-name{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.kanban-board-switcher-menu{
position:absolute;top:calc(100% + 4px);left:0;
min-width:240px;max-width:320px;
background:linear-gradient(180deg,rgba(21,31,45,.98),rgba(13,20,31,.98));
border:1px solid var(--accent-bg-strong, var(--border));
border-radius:10px;
box-shadow:0 8px 24px rgba(0,0,0,.45);
padding:6px;
z-index:150;
max-height:60vh;
overflow-y:auto;
}
:root:not(.dark) .kanban-board-switcher-menu{
background:linear-gradient(180deg,#fff,#f5f0e8);
border-color:rgba(0,0,0,.18);
}
.kanban-board-switcher-menu[hidden]{display:none;}
.kanban-board-switcher-item{
display:flex;align-items:center;gap:10px;width:100%;
padding:8px 10px;border:0;background:transparent;color:var(--text);
border-radius:6px;cursor:pointer;text-align:left;font:inherit;font-size:12px;
transition:background .15s;
}
.kanban-board-switcher-item:hover,
.kanban-board-switcher-item:focus{background:var(--accent-bg);outline:none;}
.kanban-board-switcher-item.is-current{font-weight:650;}
.kanban-board-switcher-item-icon{font-size:14px;line-height:1;flex-shrink:0;width:18px;text-align:center;}
.kanban-board-switcher-item-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.kanban-board-switcher-item-count{
flex-shrink:0;font-size:10px;color:var(--muted);
padding:2px 6px;border-radius:8px;background:var(--input-bg);
}
.kanban-board-switcher-item.is-current .kanban-board-switcher-item-count{
background:var(--accent-bg);color:var(--accent-text);
}
.kanban-board-switcher-divider{height:1px;background:var(--border);margin:6px 0;}
.kanban-board-switcher-action{
display:flex;align-items:center;gap:8px;width:100%;
padding:8px 10px;border:0;background:transparent;color:var(--muted);
border-radius:6px;cursor:pointer;text-align:left;font:inherit;font-size:11px;
transition:background .15s,color .15s;
}
.kanban-board-switcher-action:hover{background:var(--accent-bg);color:var(--accent-text);}
.kanban-board-switcher-action.danger:hover{background:rgba(255,95,95,.12);color:var(--danger);}
.kanban-board-switcher-action svg{width:14px;height:14px;flex-shrink:0;}
/* Modal forms for create/rename board — use the same visual language as
the app-dialog overlay (linear-gradient panel, accent border) so it
feels native to the WebUI rather than a one-off bridge UI. */
.kanban-modal-overlay{
position:fixed;inset:0;background:rgba(7,12,19,.62);backdrop-filter:blur(6px);
display:flex;align-items:center;justify-content:center;
z-index:1100;padding:24px;
}
.kanban-modal-overlay[hidden]{display:none;}
.kanban-modal{
width:min(460px,100%);
background:linear-gradient(180deg,rgba(21,31,45,.98),rgba(13,20,31,.98));
border:1px solid var(--accent-bg-strong, var(--border));
border-radius:18px;
box-shadow:0 18px 60px rgba(0,0,0,.45);
padding:18px 18px 16px;
color:var(--text);
box-sizing:border-box;
}
:root:not(.dark) .kanban-modal{
background:linear-gradient(180deg,#fff,#f5f0e8);
border-color:rgba(0,0,0,.18);
}
.kanban-modal h3{margin:0 0 14px;font-size:15px;font-weight:650;color:var(--text);}
.kanban-modal-row{margin-bottom:10px;}
.kanban-modal-row label{display:block;font-size:11px;color:var(--muted);margin-bottom:4px;font-weight:500;}
.kanban-modal-row input[type="text"],
.kanban-modal-row input[type="color"],
.kanban-modal-row textarea{
width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;
color:var(--text);padding:8px 10px;font:inherit;font-size:13px;box-sizing:border-box;
}
.kanban-modal-row input:focus,
.kanban-modal-row textarea:focus{
outline:none;border-color:var(--accent, #FFD700);
}
.kanban-modal-row textarea{min-height:60px;resize:vertical;}
.kanban-modal-row input[type="color"]{height:36px;padding:2px;cursor:pointer;}
.kanban-modal-row-inline{display:flex;gap:10px;}
.kanban-modal-row-inline > *{flex:1;min-width:0;}
.kanban-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;}
.kanban-modal-error{color:var(--danger);font-size:11px;margin-top:6px;min-height:14px;}
.kanban-empty{padding:12px;color:var(--muted);font-size:12px;text-align:center;border:1px dashed var(--border);border-radius:8px;}
.kanban-new-task-row{display:flex;gap:6px;align-items:center;}
.kanban-new-task-row input{flex:1;min-width:0;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 8px;font:inherit;font-size:12px;}
.kanban-task-preview{padding:12px 16px;border-bottom:1px solid var(--border);background:var(--panel);}
.kanban-task-preview-header{display:flex;align-items:center;gap:10px;margin-bottom:6px;}
.kanban-back-btn{flex-shrink:0;font-size:11px;padding:4px 8px;}
.kanban-task-preview-title{font-size:14px;font-weight:650;color:var(--text);margin-bottom:0;}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
.kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;}
.kanban-status-actions .btn{font-size:11px;padding:4px 8px;}
/* Generic styled buttons used throughout the Kanban panel. The Kanban PR
stack standardised on `.btn` / `.btn.secondary` class names but never
shipped the matching CSS, so without these rules buttons fall back to
the browser's default beveled appearance which clashes with the dark
theme. Scoped to kanban-* parent containers so the rules cannot affect
any other panel that happens to use those class names later. */
.kanban-pane .btn,
.kanban-bulk-bar .btn,
.kanban-new-task-row .btn,
.kanban-task-preview .btn,
.kanban-comment-form .btn,
.kanban-modal .btn{
border:1px solid var(--border);
background:var(--input-bg);
color:var(--text);
border-radius:6px;
padding:6px 12px;
font:inherit;
font-size:12px;
cursor:pointer;
transition:border-color .15s,background .15s,color .15s;
}
.kanban-pane .btn:hover,
.kanban-bulk-bar .btn:hover,
.kanban-new-task-row .btn:hover,
.kanban-task-preview .btn:hover,
.kanban-comment-form .btn:hover,
.kanban-modal .btn:hover{border-color:var(--accent);color:var(--accent);}
.kanban-pane .btn:disabled,
.kanban-bulk-bar .btn:disabled,
.kanban-new-task-row .btn:disabled,
.kanban-task-preview .btn:disabled,
.kanban-comment-form .btn:disabled,
.kanban-modal .btn:disabled{opacity:.5;cursor:default;}
.kanban-pane .btn.secondary,
.kanban-bulk-bar .btn.secondary,
.kanban-new-task-row .btn.secondary,
.kanban-task-preview .btn.secondary,
.kanban-comment-form .btn.secondary,
.kanban-modal .btn.secondary{background:transparent;}
.kanban-pane .btn.danger,
.kanban-task-preview .btn.danger,
.kanban-modal .btn.danger{color:var(--danger);border-color:rgba(255,95,95,.4);}
.kanban-pane .btn.danger:hover,
.kanban-task-preview .btn.danger:hover,
.kanban-modal .btn.danger:hover{background:rgba(255,95,95,.1);color:var(--danger);}
/* Primary CTA inside the kanban modal — accent-tinted to make Save vs.
Cancel visually distinct (was nearly identical before). */
.kanban-modal .btn.primary{
border-color:var(--accent, #FFD700);
background:var(--accent-bg, rgba(255,215,0,.12));
color:var(--accent-text, var(--accent, #FFD700));
font-weight:600;
}
.kanban-modal .btn.primary:hover{
background:var(--accent-bg-strong, rgba(255,215,0,.22));
border-color:var(--accent, #FFD700);
color:var(--accent-text, var(--accent, #FFD700));
}
.kanban-comment-form{display:flex;gap:8px;align-items:flex-end;margin-top:12px;}
.kanban-comment-form textarea{flex:1;min-height:42px;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:8px;font:inherit;font-size:12px;}
.kanban-detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-top:12px;}
.kanban-detail-section{border:1px solid var(--border);border-radius:8px;background:var(--bg);padding:10px;min-width:0;}
.kanban-detail-section h3{font-size:12px;font-weight:650;color:var(--text);margin:0 0 8px;}
.kanban-detail-row{padding:8px 0;border-top:1px solid var(--border);}
.kanban-detail-row:first-of-type{border-top:0;padding-top:0;}
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;white-space:pre-wrap;}
.kanban-detail-row-meta{font-size:10px;color:var(--muted);margin-top:4px;}
.kanban-detail-pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-word;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:6px;margin:6px 0 0;color:var(--muted);}
.kanban-detail-empty{font-size:12px;color:var(--muted);}
.kanban-detail-links-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:12px;color:var(--muted);}
.kanban-detail-links-grid code{display:inline-block;margin:4px 4px 0 0;padding:2px 5px;border-radius:5px;background:var(--input-bg);border:1px solid var(--border);color:var(--text);}
.kanban-stats{font-size:12px;color:var(--muted);padding:2px 0}
.kanban-stats-grid{display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
.kanban-stat-cell{display:inline-flex;gap:4px;align-items:center;border:1px solid var(--border);border-radius:999px;background:var(--input-bg);padding:2px 7px;}
.kanban-stat-cell.total{color:var(--text);}
.kanban-bulk-bar{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.kanban-bulk-bar select{flex:1;min-width:96px;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px}
.kanban-profile-lanes{display:flex;flex-direction:column;gap:18px;min-width:100%}
.kanban-profile-lane{border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.02);padding:10px}
.kanban-profile-lane-head{display:flex;justify-content:space-between;align-items:center;color:var(--text);font-size:13px;font-weight:600;margin-bottom:8px}
.kanban-board-in-lane{min-height:0;overflow-x:auto}
.kanban-card-topline{display:flex;gap:6px;align-items:center;margin-bottom:4px;font-size:10px;color:var(--muted)}
.kanban-card-id{font-family:var(--mono);opacity:.8}
.kanban-badge{border:1px solid var(--border);border-radius:999px;padding:1px 6px;font-size:10px;color:var(--muted)}
.kanban-badge.priority{color:var(--accent)}
.kanban-badge.tenant{color:var(--text)}
.kanban-card-meta{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:11px;color:var(--muted);margin-top:6px}
.kanban-card-assignee{color:var(--accent)}
.kanban-card-unassigned{opacity:.75}
.kanban-card-actions{display:flex;gap:4px;margin-top:8px;opacity:.85;flex-wrap:wrap}
.kanban-card-action{border:1px solid var(--border);background:var(--input-bg);color:var(--text);border-radius:6px;padding:2px 6px;font-size:10px;cursor:pointer}
.kanban-card-action.danger{color:var(--danger)}
.kanban-card-stale-amber{border-color:rgba(245,197,66,.55)}
.kanban-card-stale-red{border-color:rgba(255,95,95,.65)}
.kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px}
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}
@media (max-width: 640px){
.kanban-board{scroll-snap-type:x mandatory;}
.kanban-column{min-width:82vw;scroll-snap-align:start;}
.kanban-task-preview-header{align-items:flex-start;flex-direction:column;}
.kanban-comment-form{flex-direction:column;align-items:stretch;}
.kanban-stats-grid{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px;}
/* Multi-board: keep the switcher row tight on narrow screens, and
widen the dropdown menu so its action labels don't truncate. */
.main-view-title-row{gap:6px;}
.kanban-board-switcher-toggle{padding:3px 8px;font-size:11px;}
.kanban-board-switcher-name{max-width:140px;}
.kanban-board-switcher-menu{
min-width:min(280px, calc(100vw - 24px));
max-width:calc(100vw - 24px);
}
/* Modal scales to viewport width on phones with reasonable padding. */
.kanban-modal-overlay{padding:12px;}
.kanban-modal{padding:16px 16px 14px;border-radius:14px;}
.kanban-modal-row-inline{flex-direction:column;gap:0;}
}
File diff suppressed because it is too large Load Diff
+520
View File
@@ -0,0 +1,520 @@
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)
def test_kanban_has_native_sidebar_rail_and_mobile_tab():
assert 'data-panel="kanban"' in INDEX
assert 'data-i18n-title="tab_kanban"' in INDEX
assert 'onclick="switchPanel(\'kanban\')"' in INDEX
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():
assert "'kanban'" in re.search(r"\[[^\]]+\]\.forEach\(p => \{\s*mainEl\.classList", PANELS).group(0)
assert "if (nextPanel === 'kanban') await loadKanban();" in PANELS
assert "if (_currentPanel === 'kanban') await loadKanban();" in PANELS
def test_kanban_frontend_uses_relative_api_endpoints():
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
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")]
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
# 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
assert "method: 'POST'" in PANELS
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId)" in PANELS
assert "method: 'PATCH'" in PANELS
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/comments'" in PANELS
assert "kanban-status-actions" in PANELS
assert "kanban-comment-form" in PANELS
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
def test_kanban_i18n_keys_exist_in_every_locale_block():
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
assert len(locale_blocks) >= 8
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",
"kanban_comments_count",
"kanban_events_count",
"kanban_links",
"kanban_runs_count",
"kanban_no_comments",
"kanban_no_events",
"kanban_no_runs",
"kanban_new_task",
"kanban_add_comment",
]
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 == []
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
# 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"
assert "prompt(" not in PANELS
assert "confirm(" not in PANELS
def test_kanban_dashboard_parity_i18n_keys_exist():
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
required_keys = [
"kanban_only_mine",
"kanban_bulk_action",
"kanban_nudge_dispatcher",
"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 == []
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()
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
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
required_keys = ["kanban_lanes_by_profile", "kanban_card_start", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned"]
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 == []
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[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
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"
)
# ── 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}"
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
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
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