mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
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:
@@ -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.275–v0.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
@@ -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
@@ -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>/*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
@@ -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: '설정',
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+269
-1
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[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
|
||||
Reference in New Issue
Block a user