diff --git a/.env.example b/.env.example index 19dff7dc1c..768eca5009 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ # Port to listen on (default: 8787) # HERMES_WEBUI_PORT=8787 -# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp) +# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui) # HERMES_WEBUI_STATE_DIR=~/.hermes/webui # Default workspace directory shown on first launch diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b16b1d5737..2248ffa3b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pyyaml>=6.0 pytest pytest-timeout + pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio + # Install the `mcp` package so tests/test_mcp_server.py runs in CI. + # The package is an optional runtime dep of mcp_server.py — users + # who run the MCP integration install it themselves; CI installs + # it so test coverage exists. If mcp install fails (Python 3.13 + # wheel not yet available, etc.), tests/test_mcp_server.py uses + # importorskip and the matrix stays green. + pip install mcp || echo "mcp install failed — test_mcp_server.py will importorskip" - name: Run tests run: pytest tests/ -v --timeout=60 diff --git a/.gitignore b/.gitignore index b4ee8a54fb..2831628000 100644 --- a/.gitignore +++ b/.gitignore @@ -36,13 +36,20 @@ api/_version.py .DS_Store Thumbs.db -# Local reference clones — never committed (except tracked design/UI-UX reference pages) +# Local reference clones/artifacts — never committed by default. +# Markdown docs at docs/*.md are intentionally trackable for contributor docs. docs/* +!docs/*.md !docs/ui-ux/ !docs/ui-ux/** -!docs/docker.md -!docs/supervisor.md -!docs/troubleshooting.md +!docs/rfcs/ +!docs/rfcs/** + +# Local-only AI assistant context — never committed even under docs/. +docs/AGENTS.md +docs/CLAUDE.md +docs/.cursorrules +docs/.windsurfrules # Local-only PR review harness: rendering drivers, sample bank, fixtures. # Used by Claude during deep reviews; never shared in the repo. @@ -50,3 +57,5 @@ docs/* graphify-out/ .graphify_cached.json .graphify_uncached.txt + +.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f46f6ba7ac..9d737b2436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,1059 @@ # Hermes Web UI -- Changelog +## [Unreleased] + +### Fixed + +- **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior. + +### Added + +- **PR #2150** by @Jordan-SkyLF — "Refresh usage" button on the Provider quota card in Settings → Providers. Calls `/api/provider/quota?refresh=1&ts=` with `cache: 'no-store'` to bypass browser, service worker, and reverse-proxy caches that may have stamped a previous quota response, then re-renders just the quota card from the fresh response and shows a `Last checked ...` timestamp. Disabled `Refreshing…` state during the in-flight request; success toast on completion or failure toast if the refresh fails. Note: the `refresh=1` query param is a no-op at the server today (`get_provider_quota()` has no in-process cache layer), so the win is strictly browser-side cache-bust + the `no-store` fetch option. A future maintainer follow-up may add server-side TTL caching of OAuth account-limit fetches, at which point the `refresh=1` param becomes load-bearing on both sides. + +## [v0.51.51] — 2026-05-12 — Release AA (stage-344 — 16-PR contributor batch — i18n + insights bucketing/mobile + manual-compress async + workspace recovery + iOS PWA scroll + Cloudflare login health + fr locale) + +### Added + +- **PR #2130** by @dso2ng — Lazy lineage-report fetch on sidebar segment-badge expand. The sidebar already showed `N segments` for collapsed compression lineage rows (refs #1906, #1943) and the backend report endpoint is now shipped (refs #2012), but some rows only had a backend `_compression_segment_count` from `/api/sessions` while the browser hadn't materialized the older segment rows — clicking the badge couldn't reveal the full bounded list. Adds a small per-sidebar-cache lineage-report cache/inflight map in `static/sessions.js`, invalidates it on each fresh `/api/sessions` refresh, and on expand fetches `GET /api/session/lineage/report?session_id=` only when `_sessionSegmentCount(s)` exceeds the locally-materialized `_lineage_segments` count. Merges returned report `segments` by `session_id` with existing client segments, skipping the visible tip and `child_session` rows. Leaves report `children` out of the compression-segment list so subagent/fork child semantics remain separate. 132-line regression suite covering fetch-needed detection, report-segment merging/dedup, endpoint construction, and inflight cache de-duping. + +- **PR #2142** by @legeantbleu — French (`fr`) locale. ~938 UI strings translated via Google Translate then sanitized for JS string escaping. Inserted at the end of `static/i18n.js`'s `LOCALES` map (insertion-order convention used by every locale since `it` landed). Stage-344 maintainer fix added the matching tuple entries in `tests/test_issue1488_composer_voice_buttons.py:TestComposerVoiceButtonI18n.LOCALES` + sibling `TestVoiceModePreferenceGate.LOCALES`, plus the matching `_LOGIN_LOCALE['fr']` block in `api/routes.py` so the login page localizes for French users (issue #1442 parity contract), plus an inverted `_resolve_login_locale_key('fr')` assertion in `tests/test_login_locale_parity.py` that previously assumed fr falls back to en. Mirrors the stage-340 fix for the `it` locale (PR #2067). + +### Fixed + +- **PR #2120** by @Michaelyklam (closes #2103) — Daily Tokens chart no longer overflows its card on 90/365 day ranges. Adds `_bucketDailyTokensForChart()` in `static/panels.js` that keeps ≤30 rows per-day and buckets longer ranges into summed chart rows (90→45 bars at 2-day buckets, 365→46 bars at 8-day buckets, ≤52 ceiling). Updates the Daily Tokens render loop to use bucketed chart rows, date-range labels, and summed tooltip values. Switched chart columns to shrink-safe `minmax(0,1fr)` so the bars stay inside the card. Backend `/api/insights` payload unchanged. 130-line regression suite covering short-range preservation, long-range bounding, label/title shape on bucketed rows, render-loop usage, and shrink-safe CSS. + +- **PR #2121** by @Michaelyklam (refs #2104) — Token Breakdown + Models row stacks on mobile instead of forcing horizontal page overflow. New `insights-usage-grid` class wraps the row with a scoped `@media (max-width: 640px)` rule that flips it to `grid-template-columns: 1fr`. Contains remaining model-table overflow inside the card. 27-line regression suite covering the mobile breakpoint, single-column layout, contained `overflow-x`, and presence of the scoped rule. + +- **PR #2123** by @Michaelyklam (closes #2112) — Portuguese (`pt`) locale parity: 5 missing session-management keys (bulk delete/archive, select mode, select all, selected count, no-selection text) added so Portuguese users stop silently falling back to English. Extended `tests/test_login_locale_parity.py` with a session-management key parity check across all locale blocks. + +- **PR #2125** by @Michaelyklam (closes #2093) — Renamed `_patch_skill_home_modules` → `patch_skill_home_modules` in `api/profiles.py` since the helper is imported by streaming code and asserted by tests across modules. Updated streaming import/fallback/call sites in `api/streaming.py` and the env-lock regression test expectations. Expanded `api/compression_anchor.py`'s module docstring to explain manual vs automatic compression anchoring and `auto_compression=True` behavior. Documentation/rename-only — no runtime behavior change. + +- **PR #2128** by @franksong2702 (closes #2087) — Manual `/compress` no longer fails behind reverse proxies that time out long synchronous requests. Adds `POST /api/session/compress/start` (start or reuse an in-process manual compression job keyed by `session_id`) + `GET /api/session/compress/status?session_id=...` (poll `running`/`done`/`error`/`idle`). Reuses the existing `_handle_session_compress` implementation inside the worker so the save path, provider resolution, sanitization, and the legacy synchronous endpoint stay aligned. Adds a stream-state guard before save so a compression worker can't overwrite a session that started another stream while compression was running. 10-minute cleanup for terminal job results, with successful `done` payloads released after first status consumption. `static/commands.js` `/compress` and `/compact` now start, poll, and apply the saved compressed session; session-load resume wiring picks up in-flight compression on page reload. + +- **PR #2129** by @Michaelyklam (closes #2092) — `_purgeStaleInflightEntries()` now iterates `INFLIGHT` keys and explicitly drops ids absent from the current session list. Pre-fix the cleanup only removed entries for sessions still present in `_allSessions` and marked non-streaming, so deleted/archived/filtered-out sessions left ghost entries indefinitely. Preserves still-streaming sessions. 124-line regression suite covering absent/present-non-streaming/present-streaming cases. + +- **PR #2135** by @franksong2702 (closes #2126, refs #2131) — `/api/models/live?provider=custom:` now only returns models from the requested named provider entry instead of every `custom_providers[].model`. Direct `/v1/models` fallback uses the matched named provider's `base_url`+`api_key` pair instead of the active profile's `model.base_url`/`model.api_key`. `custom:` reads only the matching named entry; bare `custom` reads only unnamed entries. Includes model IDs from both singular `model` and plural `models` config forms. Cache key behavior preserved (already provider-scoped). Regression coverage for named-provider scoping, bare-custom scoping, and direct fetch endpoint/key selection. + +- **PR #2137** by @franksong2702 (closes #2122) — Login page health probe now sends `credentials: 'same-origin'` instead of `credentials: 'omit'`. Cloudflare Access and similar same-origin reverse proxies need the access cookie to reach the proxy, so the prior omit caused WebUI to falsely disable login before `/health` ever resolved. Keeps the health URL mount-relative (`health`) for subpath deployments. Static regression test pins same-origin credentials and forbids the omit variant. + +- **PR #2138** by @dobby-d-elf — Live Hermes WebUI chats no longer get stuck with `Error: Path does not exist: ...` when the session points at a deleted workspace. Workspace fallback now looks up the live `DEFAULT_WORKSPACE` instead of using a stale import-time snapshot. Old sessions with deleted implicit workspaces are repaired to the current valid workspace during chat start, so the next send recovers instead of erroring. 71-line regression suite for both the stale-fallback and missing-session-workspace recovery paths. + +- **PR #2139** by @Michaelyklam (refs #2097) — Turn-journal terminal-collision audit slice. `derive_turn_journal_states()` now returns `(states, terminal_collisions)`; collisions carry the `turn_id` plus terminal events in timestamp order when a turn records more than one terminal event (completed + interrupted both fire). Latest-by-timestamp derived state behavior preserved for existing callers; session recovery audit and existing tests updated to unpack the new tuple. Audit-only: no multi-process append safety in this PR. + +- **PR #2140** by @franksong2702 (closes #2133) — WebUI fallback activation now passes `api_key` and `key_env` in the normalized fallback entry to `AIAgent`, matching what the CLI path preserves. Hermes Agent fallback resolution already knew how to use these — WebUI was dropping them, leaving env-backed fallback providers unauthenticated after a primary provider 401. Legacy single-dict `fallback_model` and list-form `fallback_providers` selection behavior unchanged. + +- **PR #2141** by @franksong2702 (closes #2102) — Settings → System header no longer clips off the right edge on phones. Section header now stacks vertically under the existing Settings mobile breakpoint; the System update/version control group wraps to use available width; individual version badges keep their text intact while the group wraps. CSS-only change inside the existing breakpoint scope. Mobile layout static regression added. + +- **PR #2143** by @dobby-d-elf — iPhone PWA chat bottom-scroll stutter fixed. Removed the Start/End scroll controls from the transcript scroll layout — they were sticky children inside `#messages`, which on iOS momentum/elastic scrolling perturbed the scroll surface at the bottom boundary. Now the transcript is wrapped in a `.messages-shell` and the controls render as absolute overlays outside `#messages`, so `#messages` is back to a plain native scrolling container. Adds a small visibility dead zone for the down-arrow button so elastic bottom pulls don't flash the button while already at the bottom. + +- **PR #2132** by @Michaelyklam (refs #2096) — Docs-only: added `Synchronous durability design rationale` to `docs/rfcs/turn-journal.md`. Documents why submitted-event journaling stays synchronously fsync-backed today, qualitative fsync latency expectations for SSD/HDD/Docker-overlay filesystems, and maintainer benchmark guidance for measuring p50/p95/p99 append/fsync latency before any future async lifecycle journaling. + +### Stage-344 maintainer fixes + +- **`api/routes.py:_handle_session_compress_start/status` (#2128 polish)** — Opus SHOULD-FIX from stage-344 review. Two related UX bugs in the new async manual-compression flow: (1) `compress/status` popped the `done` job entry on first read, which left a second open tab with `{status:"idle"}` and a "Compression job is no longer available" toast — fixed by letting the existing 10-minute TTL handle eviction so all tabs see the same terminal payload; (2) re-invoking `compress/start` within the 10-minute TTL returned the stale prior `done` payload instead of running a new compression — fixed by always dropping the existing entry and starting a fresh worker, so a user closing a tab mid-compress and re-running `/compress` on a fresh open gets a new result. Both are 1-block tweaks; existing `tests/test_sprint46.py` 10/10 still passes. The third Opus SHOULD-FIX (#2135 `cfg["model"]` fallback when `provider=custom:X` doesn't match any entry) is deferred to a follow-up — it's strictly no-worse-than-master behavior, but worth tightening to skip the URL probe when no entry matched. + +## [v0.51.50] — 2026-05-12 — Release Z (stage-343 — single-PR — ctl.sh bash 3.2 macOS compat fix + regression test suite) + +### Fixed + +- **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh. + +## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback) + +### Added + +- **PR #2109** by @franksong2702 — Read-only worktree status endpoint for the #2057 lifecycle tracker. `GET /api/session/worktree/status?session_id=...` returns the session-owned worktree path, filesystem existence, dirty/untracked state, ahead/behind counts when an upstream is configured, and live stream/embedded-terminal lock flags. Uses `git worktree list --porcelain`, `git status --porcelain --untracked-files=normal`, and `rev-list --left-right --count HEAD...@{u}` only — no mutating git state, 2-second per-call timeouts (tightened from PR-submitted 5s during stage review). Session-id scoped (rejects non-worktree sessions with 400), does not accept arbitrary filesystem paths. This is the non-destructive status surface Nathan requested as the next slice before any future explicit remove-worktree action. 221-line regression suite covering clean/dirty/untracked/missing-path/live-stream-lock/embedded-terminal-lock/endpoint-success/non-worktree-rejection cases. + +### Fixed + +- **PR #2113** by @franksong2702 (closes #2111) — Session archive/delete success toasts now prefer the backend `worktree_retained` response over the cached session-sidebar snapshot. Pre-fix a stale sidebar snapshot (other browser tab archived the session, server-side mutation moved the worktree, etc.) could make the success toast say "worktree preserved on disk" even when the backend response said no worktree was retained. Frontend now treats `response.worktree_retained: true/false` as source of truth and falls back to `session.worktree_path` only when the backend doesn't return the flag (older-server compatibility). Both single-session and batch (Promise.all) archive/delete paths updated; batch retained-count derived from per-response flags instead of the pre-POST cached `_worktreeSessionCount`. The pre-flight confirm dialog still uses the cached snapshot (it renders before the POST exists), but the post-POST toast now reflects backend truth. + +- **PR #2116** by @starship-s — OpenAI Codex provider quota card no longer reports "unavailable" when Codex chat requests actually work. Runtime requests authenticate via the modern `agent.credential_pool`, but the account-usage probe only tried the legacy singleton Codex token path. Adds a Codex-only credential-pool fallback inside the existing isolated `_account_usage_subprocess`: when `agent.account_usage.fetch_account_usage()` returns no available snapshot, the fallback selects the active `openai-codex` credential-pool entry, derives the Codex usage endpoint from the runtime base URL (handles `/backend-api/codex` → `/wham/usage` and custom bases → `/api/codex/usage`), and serializes the existing snapshot shape expected by the WebUI. Stays inside the child process so active Hermes profile context remains isolated; legacy unavailable diagnostics preserved when the pool fallback can't produce a usable result; non-Codex providers unchanged. Returns only quota display data — never credential labels, access tokens, or raw exception strings. 151-line regression suite covers the success path, both URL-resolution branches, and the unavailable-fallthrough case. + + +### Stage-342 maintainer fixes + +- **`api/worktrees.py:_run_git` default timeout 5s → 2s** — Opus SHOULD-FIX from stage-342 review: PR #2109's new `/api/session/worktree/status` endpoint runs up to four `git` subprocess calls per request, each defaulting to a 5-second timeout. Worst case 20 seconds per polling request piling up on the `ThreadingHTTPServer` thread pool is risky given today's `_cron_env_lock` near-miss on production 8787. Status probes should fail fast — a worktree that takes longer than 2 seconds to enumerate is already in trouble, and the client can retry. Mechanical 1-LOC default-arg change; all four call sites already pass `cwd` positionally and rely on the default. ~1 LOC. + +## [v0.51.48] — 2026-05-12 — Release X (stage-341 — 3 contributor PRs — Hermes run adapter RFC + title-retry loop fix on reasoning-only models + worktree archive/delete confirm copy) + +### Added + +- **PR #2105** by @Michaelyklam — Hermes run adapter contract RFC at `docs/rfcs/hermes-run-adapter-contract.md` (refs #1925). 315-line spec/gap matrix that defines the event/control compatibility contract WebUI needs before browser-originated chat turns can be routed to Hermes-owned runtime execution. Documents the ownership boundary (Hermes Agent owns run creation, lifecycle, event ordering, replay, terminal state, approvals, clarify, cancellation; WebUI owns browser auth, transcript rendering, tool cards, approval/clarify widgets, workspace UX), the minimum `start_run`/`observe_run`/`get_run`/`cancel_run`/`queue_or_continue`/`respond_approval`/`respond_clarify` IPC surface, and a gap matrix mapping current `STREAMS`/`CANCEL_FLAGS`/`AGENT_INSTANCES`/callback queues to Hermes-owned targets with explicit "no private callback queue" / "no runtime surrogate" non-goals. First success criterion is restart/reattach (start a non-trivial run, restart hermes-webui, browser reconnects, replays from last cursor, cancels with Hermes-emitted terminal state) — not "basic chat streamed once." Status: Proposed. + +### Fixed + +- **PR #2107** (self-built, closes #2083) — Title-generation budget-doubling retry loop on reasoning-only model responses. Reporter @darkopetrovic on LM Studio with Qwen3.6-35B-A3B (and the broader class: DeepSeek-R1, Kimi-K2, other Qwen3-thinking variants) saw GPU never going idle after each prompt — the chat turn finished cleanly but the auto-title generation request burned its 500-token budget on hidden `reasoning_content`, emitted `content=""` with `finish_reason=length`, got classified as `llm_length`, retried at 1024 tokens, returned the same shape, then iterated through `_title_prompts()`'s two prompts for ~3000 reasoning tokens per new chat. The agent-side `is_lmstudio` classifier in `run_agent.py:9468` misses `custom:` providers pointing at LM Studio, so the `reasoning_effort: "none"` adapter never fires for that route. WebUI-side belt-and-braces fix: (1) `_extract_title_response()` reorders the empty-response classification to check `reasoning_content` first regardless of `finish_reason` — reasoning presence is the diagnostic signal, not finish_reason; (2) `_title_retry_status()` drops `llm_empty_reasoning{,_aux}` from the retry set (length-without-reasoning still retries — legitimate budget-truncation case); (3) new `_title_should_skip_remaining_attempts()` short-circuits the prompt-iteration loop, both aux and agent routes break to `_fallback_title_from_exchange` for a local-summary title. Net: 4 calls → 1 call per chat. `tests/test_title_aux_routing.py` inverts the old reasoning-retry assertions and adds two new tests for the legitimate length-without-reasoning retry path. nesquena APPROVED with 200-line end-to-end trace + behavioral harness confirming the 4→1 call reduction. + +- **PR #2064** by @franksong2702 — Worktree session archive/delete confirm copy now reassures users that the underlying worktree directory remains on disk (refs #2057). Pre-fix the confirm dialogs said only "Delete this conversation?" / "Archive this conversation?" without clarifying that worktree-backed conversations preserve the worktree files even when the conversation row is removed — users were reasonably afraid of losing local work. Adds an explicit `worktree_retained` boolean on the `/api/session` payload that the frontend reads to surface "The worktree at /path will remain on disk." (single) and "N worktree-backed conversation(s) will keep their worktree directories on disk." (bulk) variants in both archive and delete dialogs. 81-line i18n update across all 9 locales (en/it/ja/ru/es/de/zh/pt/ko) with an English-bundle locale-leak fix caught during screenshot capture (several worktree strings had landed under Russian in error). Regression coverage in `tests/test_issue2057_worktree_lifecycle.py` + `tests/test_issue2057_worktree_ui_static.py`. UX-gate cleared with 5 viewports (4×1280px desktop covering single + bulk archive/delete confirms, 1×390px mobile of single-delete confirming dialog fits without overflow). + +### Stage-341 maintainer fixes + +- **`docs/rfcs/README.md`** — Added a single bullet to the conventions block clarifying that RFCs are design directions, not invitations to file implementation PRs against fragments. Implementation slices need maintainer confirmation in the tracking issue first. Applied alongside PR #2105 to head off the speculative-fragment pattern we just had to put on hold with PR #2071 (well-written 651-LOC collector with no callers). ~6 LOC. + +- **`static/i18n.js:it` block** — Opus SHOULD-FIX from stage-341 review: PR #2064 was branched before stage-340 landed the `it` locale (#2067), so the 9 new `session_*worktree*` keys were missing for Italian users. Mechanical add inside the `it:` block at the parallel position to en/ja. Falls back to English silently without this fix; with this fix, Italian users see the worktree-retention reassurance copy in their locale. Parallels the stage-340 `cron_toast_notifications_*` fix exactly. ~9 LOC. + +- **`api/streaming.py` short-circuit observability** — Opus SHOULD-FIX from stage-341 review: PR #2107's new `_title_should_skip_remaining_attempts` short-circuit `break` was silent in both the aux and agent title-generation paths. Added a `logger.debug` call before each `break` so production logs surface why the prompt-iteration loop exited early (nesquena flagged this as non-blocking; landed as polish in the same release). Also expanded the function's docstring to document the membership criterion explicitly so future additions (`llm_safety_blocked`, `llm_oauth_quota`, etc.) have a clear inclusion test. ~16 LOC. + + +## [v0.51.47] — 2026-05-11 — Release W (4-PR contributor batch — per-cron toast toggle + Italian locale + stale-gateway agent-health fix + CI/console hygiene) + +### Added + +- **PR #2100** by @ai-ag2026 — Per-cron toast notification toggle. New `toast_notifications` boolean on cron job payloads (default-true for legacy preservation) wired through `_renderCronForm`, `_renderCronDetail`, `openCronCreate`, `openCronEdit`, `duplicateCurrentCron`, and `saveCronForm`. The polling loop in `startCronPolling()` gates `showToast(...)` on `c.toast_notifications !== false` so muted jobs still update the Tasks badge and new-run marker but skip the toast. Full i18n parity (9 locales: en/it/ja/ru/es/de/zh/pt/ko after PR #2067 landed) and 158-line regression suite in `tests/test_cron_toast_notifications.py`. + +- **PR #2067** by @samuelgudi — Italian (`it`) locale. ~280 UI strings translated covering boot, messages, MCP, commands, goals, settings, sessions, kanban, panels, and the offline state. Inserted alphabetically (`en → it → ja`) in `static/i18n.js`'s `LOCALES` map and mirrored in the `LOGIN_LOCALES` server-rendered table in `api/routes.py`. Updated `TestComposerVoiceButtonI18n.LOCALES` to include `"it"`; sibling `TestVoiceModePreferenceGate` also gets the tuple so its newly-adaptive `len(self.LOCALES)` count assert resolves. + +### Fixed + +- **PR #2075** by @LumenYoung — Stale `gateway_state == "running"` runtime status is now reported as `alive: null` (unknown) instead of `alive: false` (refs #1879). In multi-container WebUI+gateway deployments the older gateway builds only refresh `gateway_state.json` on lifecycle changes, not every tick — so a stale `running` file means "WebUI cannot see the gateway" rather than "gateway is down". New `_runtime_status_is_stale_running()` helper sits in front of the existing `_runtime_status_is_stale_stopped()` branch in `build_agent_health_payload()` so the heartbeat banner no longer flips to a confirmed-outage state when the gateway is actually fine but PID-checking across containers is impossible. 52 LOC including the inversion of the matching assertion in `test_issue1879_cross_container_gateway_liveness.py`. + +- **PR #2070** by @ai-ag2026 — CI and console-noise hygiene. (1) Quoted `"pyyaml>=6.0"` in `.github/workflows/tests.yml` install step so the shell stops parsing the unquoted `>` as stdout redirection. (2) Registered the `integration` pytest marker in a new `pytest.ini` to suppress collection-time warnings on tests that hit the live test server. (3) Lowered the live-model success diagnostic in `_fetchLiveModels()` from `console.log` to `console.debug` so model-fetch chatter no longer floods the default browser console. New `tests/test_ci_hygiene.py` (29 LOC) pins all three regressions. + +### Stage-340 maintainer fixes + +- **`tests/test_issue1488_composer_voice_buttons.py:TestVoiceModePreferenceGate`** — Defined `LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")` on the class. PR #2067 made `test_settings_pane_has_voice_mode_i18n_keys` count adaptive via `len(self.LOCALES)` but only defined `LOCALES` on the sibling `TestComposerVoiceButtonI18n`, so CI failed with `AttributeError`. Mirroring the tuple is the surgical fix; the alternative (back to a hard-coded `9`) would have rotted next time someone adds a locale. ~2 LOC. + +- **`static/i18n.js:it` block** — Opus SHOULD-FIX from stage-340 review: added the four `cron_toast_notifications_*` keys (label, hint, enabled, disabled) inside the `it:` block. PR #2067 inserted the `it` locale between `en` and `ja`; PR #2100 added those keys to the other 8 locales but missed `it`. ~4 LOC, mechanical add immediately after `cron_profile_server_default_hint` to mirror the en/ja position. + +## [v0.51.46] — 2026-05-11 — Release V (5-PR contributor batch — CSP report-only + logs panel polish + plugin slash commands + turn-journal crash-safe writer + lifecycle events) + +### Added + +- **PR #2059** by @ai-ag2026 — Append-only WebUI turn journal helper at `api/turn_journal.py` (new file, ~128 LOC). Writes one JSONL file per session under `_turn_journal/` and fsyncs `submitted`-turn events before the worker thread starts via `/api/chat/start` (after pending session state is saved and before `threading.Thread(...)` starts). `recovery_audit` extended to report non-terminal journal turns as `turn_journal_pending_turn` when the submitted user message is not present in the sidecar. Intentionally the minimal slice from `docs/rfcs/turn-journal.md` (RFC #2042): writer + reader + state derivation + audit-only reporting. No replay or repair yet. + +- **PR #2062** by @ai-ag2026 — Turn-journal lifecycle events on top of #2059's submitted-event writer. Records `worker_started` when the streaming worker begins, `assistant_started` before the final session save once an assistant message exists, `completed` after the final save, and `interrupted` on the provider-error path. `append_turn_journal_event_for_stream(...)` reuses the `turn_id` associated with the stream's submitted event. Still audit-only / journaling-only — does not replay turns or repair assistant output. The little WAL goblin remains on a leash. + +- **PR #2089** by @plerohellec — Plugin-defined slash commands now surface in the WebUI command picker and execute via a new `/api/commands/exec` route (closes #1935). `list_commands()` in `api/commands.py` merges `hermes_cli.plugins.get_plugin_commands()` into the `/api/commands` payload with `category: "Plugin"`; the frontend intercepts plugin commands in `static/messages.js` and `static/commands.js` to route through the plugin execution endpoint instead of falling through to the agent. Pre-fix the WebUI only learned slash commands from `hermes_cli.commands.COMMAND_REGISTRY` (commands.py:23), so plugin-registered commands were invisible to the picker, autocomplete, and routing — they fell through to the agent as raw text and the agent's response was about an unknown command. This is the WebUI half of the parity fix; the corresponding agent-side plumbing already existed in `hermes_cli/plugins.py:1424` (`get_plugin_commands()`). + +### Fixed + +- **PR #2085** by @bergeouss — Logs panel: clipboard `_copyText()` fallback + severity filter (closes #2081). Pre-fix `copyLogsAll()` called `navigator.clipboard.writeText()` directly with no fallback — failed silently on large payloads / non-secure contexts / unfocused pages, leaving users with a useless error toast. Now routes through `_copyText()` from `ui.js` which already has a ` @@ -863,6 +883,17 @@

What can I help with?

When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.
+
+ +
Show floating Start and End buttons while reading long session histories.
+ + Load older messages while scrolling up + +
When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.
+
@@ -1118,7 +1149,7 @@

What can I help with?

+ + diff --git a/static/login.js b/static/login.js index 72c47a5b49..28fdfe80d5 100644 --- a/static/login.js +++ b/static/login.js @@ -69,7 +69,8 @@ document.addEventListener('DOMContentLoaded', function () { // On page load, probe the server so we can distinguish "can't reach server" // (Tailscale off, wrong network) from "session expired / need to log in". - // Uses /health — a public endpoint, no auth required. + // Uses /health — public for WebUI auth, but deployment access proxies may + // require same-origin cookies before the request reaches WebUI. // If unreachable, retries every 3 s and auto-reloads once the server is back. (function checkConnectivity() { var retryTimer = null; @@ -81,7 +82,7 @@ document.addEventListener('DOMContentLoaded', function () { } function probe() { - fetch('health', { method: 'GET', credentials: 'omit' }) + fetch('health', { method: 'GET', credentials: 'same-origin' }) .then(function (r) { if (r.ok) { // Server is reachable — if we were in retry mode, reload so the diff --git a/static/manifest.json b/static/manifest.json index 2e337271ef..caa9570f92 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -4,8 +4,8 @@ "description": "Hermes AI Agent Web UI", "start_url": "./", "display": "standalone", - "background_color": "#1a1a1a", - "theme_color": "#1a1a1a", + "background_color": "#0D0D1A", + "theme_color": "#0D0D1A", "orientation": "portrait-primary", "icons": [ { diff --git a/static/messages.js b/static/messages.js index 7d2f5075d3..579d039b9f 100644 --- a/static/messages.js +++ b/static/messages.js @@ -32,6 +32,19 @@ function _markActiveSessionViewedOnReturn() { if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); } +function _deferStreamErrorIfOffline(){ + if(typeof isOfflineBannerVisible==='function' && isOfflineBannerVisible()){ + setComposerStatus(t('offline_stream_waiting')); + return true; + } + if(typeof showOfflineBanner==='function' && navigator.onLine===false){ + showOfflineBanner('browser'); + setComposerStatus(t('offline_stream_waiting')); + return true; + } + return false; +} + document.addEventListener('visibilitychange', _markActiveSessionViewedOnReturn); window.addEventListener('focus', _markActiveSessionViewedOnReturn); // TTS: pause speech synthesis when user focuses the composer (#499) @@ -63,7 +76,7 @@ async function send(){ // cmdSteer / cmdInterrupt say "No active task to stop." if(text.startsWith('/')){ const _pc=typeof parseCommand==='function'&&parseCommand(text); - if(_pc&&['steer','interrupt','queue','terminal'].includes(_pc.name)){ + if(_pc&&['steer','interrupt','queue','terminal','goal'].includes(_pc.name)){ const _bc=COMMANDS.find(c=>c.name===_pc.name); if(_bc){ $('msg').value='';autoResize(); @@ -153,6 +166,21 @@ async function send(){ renderMessages(); $('msg').value='';autoResize();hideCmdDropdown();return; } + if(_agentCmd&&_agentCmd.category==='Plugin'){ + if(!S.session){await newSession();await renderSessionList();} + S.messages.push({role:'user',content:text,_ts:Date.now()/1000}); + let _pluginOutput='(no output)'; + try{ + _pluginOutput=typeof executeAgentPluginCommand==='function' + ? await executeAgentPluginCommand(text,_agentCmd) + : 'Plugin command runtime unavailable in WebUI.'; + }catch(e){ + _pluginOutput=`Plugin command error: ${e&&e.message||e}`; + } + S.messages.push({role:'assistant',content:String(_pluginOutput||'(no output)'),_ts:Date.now()/1000}); + renderMessages(); + $('msg').value='';autoResize();hideCmdDropdown();return; + } } } if(!S.session){await newSession();await renderSessionList();} @@ -176,6 +204,8 @@ async function send(){ if(!msgText){setComposerStatus('Nothing to send');return;} $('msg').value='';autoResize(); + // Clear persisted composer draft since message was sent. + if (activeSid && typeof _clearComposerDraft === 'function') _clearComposerDraft(activeSid); const displayText=text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)'); const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000}; S.toolCalls=[]; // clear tool calls from previous turn @@ -195,6 +225,10 @@ async function send(){ startClarifyPolling(activeSid); _fetchYoloState(activeSid); // sync YOLO pill with backend state S.activeStreamId = null; // will be set after stream starts + if(typeof updateSendBtn==='function') updateSendBtn(); + // AbortController for the pre-stream window so Stop can cancel the pending request. + const _startAbort=new AbortController(); + window._abortPendingChatStart=()=>{try{_startAbort.abort();}catch(_){}}; // Set provisional title from user message immediately so session appears // in the sidebar right away with a meaningful name (server may refine later) @@ -221,13 +255,14 @@ async function send(){ // Start the agent via POST, get a stream_id back let streamId; try{ - const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({ + const startData=await api('/api/chat/start',{method:'POST',signal:_startAbort.signal,body:JSON.stringify({ session_id:activeSid,message:msgText, model:S.session.model||$('modelSelect').value,workspace:S.session.workspace, model_provider:S.session.model_provider||null, profile:S.activeProfile||S.session.profile||'default', attachments:uploaded.length?uploaded:undefined })}); + if(startData.effective_model && S.session){ S.session.model=startData.effective_model; S.session.model_provider=startData.effective_model_provider||S.session.model_provider||null; @@ -244,6 +279,11 @@ async function send(){ } streamId=startData.stream_id; S.activeStreamId = streamId; + window._abortPendingChatStart=null; // pre-stream window closed + // setBusy(true) already ran with activeStreamId=null; updateSendBtn is now a + // no-op since the button already shows stop (pre-stream gap fix), but keep the + // call so any other callers that read activeStreamId get the right state. + if(typeof updateSendBtn==='function') updateSendBtn(); if(S.session&&typeof startData.pending_started_at==='number'){ S.session.pending_started_at=startData.pending_started_at; } @@ -265,6 +305,16 @@ async function send(){ void renderSessionList(); } }catch(e){ + // Abort = user clicked Stop during the pre-stream gap. cancelStream() already + // cleared S.busy and removed the thinking indicator; just clean up INFLIGHT. + if(e&&e.name==='AbortError'){ + delete INFLIGHT[activeSid]; + if(typeof clearInflightState==='function') clearInflightState(activeSid); + stopApprovalPolling();stopClarifyPolling(); + if(typeof renderSessionList==='function') void renderSessionList(); + return; + } + window._abortPendingChatStart=null; const errMsg=String((e&&e.message)||''); const conflictActiveStream=/session already has an active stream/i.test(errMsg); if(conflictActiveStream){ @@ -336,6 +386,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let assistantText=''; let reasoningText=''; let liveReasoningText=''; + let _latestGoalStatus=null; + let _pendingGoalContinuation=null; let assistantRow=null; let assistantBody=null; let segmentStart=0; // char offset in assistantText where current segment begins @@ -367,11 +419,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return _clarifySessionId===activeSid||(!_clarifySessionId&&_isActiveSession()); } function _clearApprovalForOwner(){ + _clearApprovalPendingForSession(activeSid); if(!_approvalBelongsToOwner()) return; stopApprovalPolling(); hideApprovalCard(true); } function _clearClarifyForOwner(reason){ + _clearClarifyPendingForSession(activeSid); if(!_clarifyBelongsToOwner()) return; stopClarifyPolling(); hideClarifyCard(true, reason||'terminal'); @@ -643,6 +697,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('src');n.setAttribute('data-blocked-scheme','1');} } } + function _resetAssistantSegment(){ + assistantRow=null; + assistantBody=null; + segmentStart=assistantText.length; + _freshSegment=true; + _smdEndParser(); + } + let _lastRenderMs=0; function _scheduleRender(){ if(_renderPending) return; @@ -723,6 +785,26 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _scheduleRender(); }); + source.addEventListener('interim_assistant',e=>{ + if(!S.session||S.session.session_id!==activeSid) return; + const d=JSON.parse(e.data); + const visible=String(d&&d.text?d.text:'').trim(); + const alreadyStreamed=!!(d&&d.already_streamed); + if(!visible){ + return; + } + if(alreadyStreamed){ + _resetAssistantSegment(); + return; + } + assistantText+=visible; + syncInflightAssistantMessage(); + if(!S.session||S.session.session_id!==activeSid) return; + const parsed=_parseStreamState(); + if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow(); + _scheduleRender(); + }); + source.addEventListener('reasoning',e=>{ const d=JSON.parse(e.data); reasoningText += d.text || ''; @@ -766,11 +848,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Reset the live assistant row reference so that any text tokens arriving // after this tool call create a NEW segment appended below the tool card, // rather than updating the old segment that sits above it in the DOM. - assistantRow=null; - assistantBody=null; - segmentStart=assistantText.length; // new segment starts at current text length - _freshSegment=true; // prevent reuse of old DOM node - _smdEndParser(); // finalize current smd parser; new one created on next token + _freshSegment=true; + _smdEndParser(); + _resetAssistantSegment(); scrollIfPinned(); }); @@ -806,16 +886,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('approval',e=>{ const d=JSON.parse(e.data); - d._session_id=activeSid; - showApprovalCard(d, 1); + showApprovalForSession(activeSid, d, 1); playNotificationSound(); sendBrowserNotification('Approval required',d.description||'Tool approval needed'); }); source.addEventListener('clarify',e=>{ const d=JSON.parse(e.data); - d._session_id=activeSid; - showClarifyCard(d); + showClarifyForSession(activeSid, d); playNotificationSound(); sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed'); }); @@ -853,6 +931,56 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }catch(_){} }); + function _resolveGoalMessage(d){ + const key=String(d && d.message_key ? d.message_key : '').trim(); + const args=Array.isArray(d && d.message_args) ? d.message_args : []; + const raw=String(d&&d.message||'').trim(); + if(key && typeof t==='function'){ + try{ + const translated=String(t(key,...args)); + if(translated && translated!==key)return translated; + }catch(_){} + } + return raw; + } + + source.addEventListener('goal',e=>{ + try{ + const d=JSON.parse(e.data||'{}'); + if((d.session_id||activeSid)!==activeSid) return; + const goalState=String(d.state||'').trim(); + const goalEvaluatingMessage=t('goal_evaluating_progress'); + if(goalState==='evaluating'){ + setComposerStatus(goalEvaluatingMessage); + return; + } + const msg=_resolveGoalMessage(d); + if(!msg)return; + _latestGoalStatus={message:msg,decision:d.decision||null,state:goalState||null}; + setComposerStatus(msg); + showToast(msg.split('\n')[0],2600); + }catch(_){} + }); + + source.addEventListener('goal_continue',e=>{ + try{ + const d=JSON.parse(e.data||'{}'); + const sid=d.session_id||activeSid; + const continuation_prompt=String(d.continuation_prompt||d.text||'').trim(); + if(!continuation_prompt||sid!==activeSid)return; + _pendingGoalContinuation={ + sid, + text:continuation_prompt, + model:S.session&&S.session.model||'', + model_provider:S.session&&S.session.model_provider||null, + profile:S.activeProfile||'default', + }; + const toast=t('goal_continuing_toast'); + const cmsg=_resolveGoalMessage(d); + showToast((toast&&cmsg&&cmsg!==toast)?cmsg.split('\n')[0]:toast,2200); + }catch(_){} + }); + source.addEventListener('done',e=>{ _terminalStateReached=true; if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} @@ -889,6 +1017,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } _clearApprovalForOwner(); _clearClarifyForOwner('terminal'); + const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function') + ? _shouldFollowMessagesOnDomReplace() + : (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200))); if(isActiveSession){ S.activeStreamId=null; } @@ -963,15 +1094,38 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const lastUser=[...S.messages].reverse().find(m=>m.role==='user'); if(lastUser)lastUser.attachments=uploaded; } + if(_latestGoalStatus&&_latestGoalStatus.message){ + S.messages.push({ + role:'assistant', + content:String(_latestGoalStatus.message), + _ts:Date.now()/1000, + _goalStatus:true, + _transient:true, + }); + } clearLiveToolCards(); S.busy=false; // No-reply guard (#373): if agent returned nothing, show inline error if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});} if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); - syncTopbar();renderMessages({preserveScroll:true});loadDir('.'); + syncTopbar();renderMessages({preserveScroll:true}); + if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom(); + loadDir('.'); // TTS auto-read: speak the last assistant response if enabled (#499) if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300); } + if(isActiveSession&&_pendingGoalContinuation&&typeof queueSessionMessage==='function'){ + const _goalNext=_pendingGoalContinuation; + _pendingGoalContinuation=null; + queueSessionMessage(_goalNext.sid,{ + text:_goalNext.text, + files:[], + model:_goalNext.model, + model_provider:_goalNext.model_provider, + profile:_goalNext.profile, + }); + if(typeof updateQueueBadge==='function')updateQueueBadge(_goalNext.sid); + } if(isActiveSession) _queueDrainSid=activeSid; renderSessionList(); _setActivePaneIdleIfOwner(); @@ -1011,6 +1165,24 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }catch(_){} }); + source.addEventListener('compressing',e=>{ + // Context auto-compression is starting. Surface the same calm running + // compression card as manual /compress while the summarizer LLM call runs. + if(!S.session||S.session.session_id!==activeSid) return; + let d={}; + try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; } + if(d.session_id&&d.session_id!==activeSid) return; + if(typeof setCompressionUi==='function'){ + setCompressionUi({ + sessionId:activeSid, + phase:'running', + automatic:true, + message:d.message||'Auto-compressing context...', + }); + } + if(typeof renderMessages==='function') renderMessages({preserveScroll:true}); + }); + source.addEventListener('compressed',e=>{ // Context was auto-compressed during this turn. Render it through the // same transient compression-card path as manual /compress, without @@ -1030,13 +1202,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); if(!S.busy&&typeof renderMessages==='function') renderMessages(); - showToast(message||'Context compressed'); + showToast(message||'Context compressed', 8000); }); source.addEventListener('metering',e=>{ try{ const d=JSON.parse(e.data||'{}'); if((d.session_id||activeSid)!==activeSid) return; + if(d.usage&&typeof _syncCtxIndicator==='function'){ + S.lastUsage={...(S.lastUsage||{}),...d.usage}; + _syncCtxIndicator(S.lastUsage); + } if(d.estimated===true||d.tps_available!==true||typeof d.tps!=='number'||d.tps<=0){ if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null); return; @@ -1100,6 +1276,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('error',async e=>{ source.close(); + if(_deferStreamErrorIfOffline()) return; if(_terminalStateReached || _streamFinalized){ _closeSource(); return; @@ -1116,13 +1293,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true})); return; } - }catch(_){} + }catch(_){ + if(_deferStreamErrorIfOffline()) return; + } if(await _restoreSettledSession()) return; + if(_deferStreamErrorIfOffline()) return; _handleStreamError(); },1500); return; } if(await _restoreSettledSession()) return; + if(_deferStreamErrorIfOffline()) return; _handleStreamError(); }); @@ -1383,8 +1564,50 @@ function hideApprovalCard(force=false) { // Track session_id of the active approval so respond goes to the right session let _approvalSessionId = null; let _approvalCurrentId = null; // approval_id of the card currently shown +let _approvalPendingBySession = new Map(); + +function _promptActiveSessionId() { + return (S.session && S.session.session_id) || null; +} + +function _approvalPromptBelongsToActiveSession(sid) { + return !!(sid && _promptActiveSessionId() === sid); +} + +function _rememberApprovalPending(pending, pendingCount) { + if (!pending) return null; + const sid = pending._session_id || _promptActiveSessionId(); + if (!sid) return null; + const nextPending = {...pending, _session_id: sid}; + _approvalPendingBySession.set(sid, {pending: nextPending, pendingCount: pendingCount || 1}); + return sid; +} + +function _clearApprovalPendingForSession(sid) { + if (sid) _approvalPendingBySession.delete(sid); +} + +function _hideApprovalCardIfOwner(sid, force=false) { + if (!sid || _approvalSessionId === sid) hideApprovalCard(force); +} + +function _renderPendingApprovalForActiveSession() { + const sid = _promptActiveSessionId(); + if (!sid) return; + if (_approvalSessionId && _approvalSessionId !== sid) hideApprovalCard(true); + const entry = _approvalPendingBySession.get(sid); + if (entry) showApprovalCard(entry.pending, entry.pendingCount); +} + +function showApprovalForSession(sid, pending, pendingCount) { + if (!pending) return; + pending._session_id = sid; + showApprovalCard(pending, pendingCount); +} function showApprovalCard(pending, pendingCount) { + const sid = _rememberApprovalPending(pending, pendingCount); + if (!_approvalPromptBelongsToActiveSession(sid)) return; const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []); const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : ""); const cmd = pending.command || ""; @@ -1393,7 +1616,7 @@ function showApprovalCard(pending, pendingCount) { const sameApproval = card.classList.contains("visible") && _approvalSignature === sig; $("approvalDesc").textContent = desc; $("approvalCmd").textContent = cmd; - _approvalSessionId = pending._session_id || (S.session && S.session.session_id) || null; + _approvalSessionId = sid; _approvalCurrentId = pending.approval_id || null; _approvalSignature = sig; // Show "1 of N" counter when multiple approvals are queued @@ -1431,6 +1654,7 @@ async function respondApproval(choice) { }); _approvalSessionId = null; _approvalCurrentId = null; + _clearApprovalPendingForSession(sid); hideApprovalCard(true); try { await api("/api/approval/respond", { @@ -1450,14 +1674,14 @@ function startApprovalPolling(sid) { es.addEventListener('initial', e => { const d = JSON.parse(e.data); - if (d.pending) { d.pending._session_id = sid; showApprovalCard(d.pending, d.pending_count || 1); } - else { hideApprovalCard(); } + if (d.pending) { showApprovalForSession(sid, d.pending, d.pending_count || 1); } + else { _clearApprovalPendingForSession(sid); _hideApprovalCardIfOwner(sid); } }); es.addEventListener('approval', e => { const d = JSON.parse(e.data); - if (d.pending) { d.pending._session_id = sid; showApprovalCard(d.pending, d.pending_count || 1); } - else { hideApprovalCard(); } + if (d.pending) { showApprovalForSession(sid, d.pending, d.pending_count || 1); } + else { _clearApprovalPendingForSession(sid); _hideApprovalCardIfOwner(sid); } }); es.onerror = () => { @@ -1472,7 +1696,7 @@ function startApprovalPolling(sid) { // We detect this via a periodic check (cheap — no network request). _approvalSSEHealthTimer = setInterval(() => { if (!S.busy || !S.session || S.session.session_id !== sid) { - stopApprovalPolling(); hideApprovalCard(true); + stopApprovalPolling(); _hideApprovalCardIfOwner(sid, true); } }, 5000); @@ -1490,12 +1714,12 @@ let _approvalPollingSessionId = null; function _startApprovalFallbackPoll(sid) { _approvalPollTimer = setInterval(async () => { if (!S.busy || !S.session || S.session.session_id !== sid) { - stopApprovalPolling(); hideApprovalCard(true); return; + stopApprovalPolling(); _hideApprovalCardIfOwner(sid, true); return; } try { const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid)); - if (data.pending) { data.pending._session_id=sid; showApprovalCard(data.pending, data.pending_count||1); } - else { hideApprovalCard(); } + if (data.pending) { showApprovalForSession(sid, data.pending, data.pending_count||1); } + else { _clearApprovalPendingForSession(sid); _hideApprovalCardIfOwner(sid); } } catch(e) { /* ignore poll errors */ } }, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness } @@ -1521,8 +1745,49 @@ let _clarifySessionId = null; let _clarifyMissingEndpointWarned = false; let _clarifyCountdownTimer = null; let _clarifyExpiresAt = 0; +let _clarifyPendingBySession = new Map(); const CLARIFY_MIN_VISIBLE_MS = 30000; +function _clarifyPromptBelongsToActiveSession(sid) { + return !!(sid && _promptActiveSessionId() === sid); +} + +function _rememberClarifyPending(pending) { + if (!pending) return null; + const sid = pending._session_id || _promptActiveSessionId(); + if (!sid) return null; + const nextPending = {...pending, _session_id: sid}; + _clarifyPendingBySession.set(sid, {pending: nextPending}); + return sid; +} + +function _clearClarifyPendingForSession(sid) { + if (sid) _clarifyPendingBySession.delete(sid); +} + +function _hideClarifyCardIfOwner(sid, force=false, reason="dismissed") { + if (!sid || _clarifySessionId === sid) hideClarifyCard(force, reason); +} + +function _renderPendingClarifyForActiveSession() { + const sid = _promptActiveSessionId(); + if (!sid) return; + if (_clarifySessionId && _clarifySessionId !== sid) hideClarifyCard(true, 'session'); + const entry = _clarifyPendingBySession.get(sid); + if (entry) showClarifyCard(entry.pending); +} + +function showClarifyForSession(sid, pending) { + if (!pending) return; + pending._session_id = sid; + showClarifyCard(pending); +} + +function _renderPendingPromptsForActiveSession() { + _renderPendingApprovalForActiveSession(); + _renderPendingClarifyForActiveSession(); +} + function _ensureClarifyCardDom() { let card = $("clarifyCard"); if (card) return card; @@ -1698,6 +1963,8 @@ function _clarifySetControlsDisabled(disabled, loading=false) { } function showClarifyCard(pending) { + const sid = _rememberClarifyPending(pending); + if (!_clarifyPromptBelongsToActiveSession(sid)) return; const question = pending.question || pending.description || ''; const choices = Array.isArray(pending.choices_offered) ? pending.choices_offered @@ -1713,7 +1980,7 @@ function showClarifyCard(pending) { const choicesEl = $("clarifyChoices"); const input = $("clarifyInput"); const sameClarify = card.classList.contains("visible") && _clarifySignature === sig; - _clarifySessionId = pending._session_id || (S.session && S.session.session_id) || null; + _clarifySessionId = sid; _clarifySignature = sig; _startClarifyCountdown(pending); if (!sameClarify) { @@ -1794,6 +2061,7 @@ async function respondClarify(response) { return; } _clarifySessionId = null; + _clearClarifyPendingForSession(sid); _clarifySetControlsDisabled(true, true); hideClarifyCard(true, 'sent'); try { @@ -1825,16 +2093,16 @@ function startClarifyPolling(sid) { _clarifyEventSource.addEventListener('initial', function(ev) { try { var d = JSON.parse(ev.data); - if (d.pending) { d.pending._session_id = sid; showClarifyCard(d.pending); } - else { hideClarifyCard(false, 'expired'); } + if (d.pending) { showClarifyForSession(sid, d.pending); } + else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); } } catch(e) {} }); _clarifyEventSource.addEventListener('clarify', function(ev) { try { var d = JSON.parse(ev.data); - if (d.pending) { d.pending._session_id = sid; showClarifyCard(d.pending); } - else { hideClarifyCard(false, 'expired'); } + if (d.pending) { showClarifyForSession(sid, d.pending); } + else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); } } catch(e) {} }); @@ -1871,12 +2139,12 @@ function startClarifyPolling(sid) { function _startClarifyFallbackPoll(sid) { _clarifyFallbackTimer = setInterval(async () => { if (!S.session || S.session.session_id !== sid) { - stopClarifyPolling(); hideClarifyCard(true, 'session'); return; + stopClarifyPolling(); _hideClarifyCardIfOwner(sid, true, 'session'); return; } try { const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid)); - if (data.pending) { data.pending._session_id=sid; showClarifyCard(data.pending); } - else { hideClarifyCard(false, 'expired'); } + if (data.pending) { showClarifyForSession(sid, data.pending); } + else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); } } catch(e) { const msg = String((e && e.message) || ""); if (!_clarifyMissingEndpointWarned && /(^|\b)(404|not found)(\b|$)/i.test(msg)) { diff --git a/static/panels.js b/static/panels.js index be20e7244e..d942fac0fe 100644 --- a/static/panels.js +++ b/static/panels.js @@ -12,6 +12,7 @@ let _kanbanLanesByProfile = false; let _kanbanCurrentBoard = null; let _kanbanBoardsList = null; let _kanbanBoardMenuOpen = false; +let _kanbanIsDispatching = false; // SSE event stream — replaces the 30s polling cadence with a long-lived // /api/kanban/events/stream connection. Falls back to polling when the // EventSource fails to connect (proxy that strips text/event-stream, etc). @@ -31,6 +32,7 @@ let _profilePreFormDetail = null; let _pendingSettingsTargetPanel = null; // destination selected while settings had unsaved changes let _logsAutoRefreshTimer = null; let _lastLogsLines = []; +let _logsSeverityFilter = 'all'; // Map of panel names → i18n keys for the app titlebar label. const APP_TITLEBAR_KEYS = { @@ -182,6 +184,26 @@ function _consumeSettingsTargetPanel(fallback = 'chat') { async function switchPanel(name, opts = {}) { const nextPanel = name || 'chat'; const prevPanel = _currentPanel; + // ── Desktop sidebar collapse toggle (rail-click only) ── + // If the click came from a rail icon AND we're on desktop, the rail icon + // does double duty: clicking the already-active panel collapses the sidebar; + // clicking any panel while collapsed expands first. Programmatic switches + // (no opts.fromRailClick) are unaffected so legacy callers preserve + // behaviour exactly. + if (opts.fromRailClick && typeof _isSidebarCollapsed === 'function' + && typeof _isDesktopWidth === 'function' && _isDesktopWidth()) { + if (_isSidebarCollapsed()) { + // Expand first, then continue to the normal panel switch below so + // the clicked panel becomes (or stays) active in the same gesture. + expandSidebar(); + } else if (prevPanel === nextPanel) { + // Same panel clicked while sidebar is open → collapse and short-circuit. + // Skip the guard/cleanup work below; nothing about the active panel + // is changing, only the visibility of the panel container. + toggleSidebar(true); + return false; + } + } if (!opts.bypassSettingsGuard && !_beforePanelSwitch(nextPanel)) return false; if (prevPanel !== 'settings' && nextPanel === 'settings') _beginSettingsPanelSession(); // Close any long-lived Kanban SSE stream when leaving the kanban panel @@ -192,6 +214,8 @@ async function switchPanel(name, opts = {}) { _currentPanel = nextPanel; // Update nav tabs (rail + mobile sidebar-nav share data-panel) document.querySelectorAll('[data-panel]').forEach(t => t.classList.toggle('active', t.dataset.panel === nextPanel)); + // Refresh aria-expanded on the newly-active rail button to mirror sidebar state. + if (typeof _syncSidebarAria === 'function') _syncSidebarAria(); // Update panel views document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active')); const panelEl = $('panel' + nextPanel.charAt(0).toUpperCase() + nextPanel.slice(1)); @@ -230,6 +254,26 @@ function _isRecurringCronJob(job) { return kind === 'cron' || kind === 'interval'; } +function _cronScheduleKindForInput(value) { + const schedule = String(value || '').trim(); + if (!schedule) return ''; + const lower = schedule.toLowerCase(); + if (lower.startsWith('every ')) return 'interval'; + if (lower.startsWith('@')) return 'cron'; + const parts = schedule.split(/\s+/); + if (parts.length >= 5 && parts.slice(0, 5).every(p => /^[\d*\-,/]+$/.test(p))) return 'cron'; + if (schedule.includes('T') || /^\d{4}-\d{2}-\d{2}/.test(schedule)) return 'once'; + if (/^\d+\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i.test(schedule)) return 'once'; + return ''; +} + +function _syncCronScheduleWarning() { + const input = $('cronFormSchedule'); + const warning = $('cronFormScheduleOnceWarning'); + if (!input || !warning) return; + warning.style.display = _cronScheduleKindForInput(input.value) === 'once' ? '' : 'none'; +} + function _hasUnlimitedRepeat(job) { return !!(job && job.repeat && job.repeat.times == null); } @@ -422,6 +466,9 @@ function _renderCronDetail(job){ const schedule = job.schedule_display || (job.schedule && job.schedule.expression) || ''; const skills = Array.isArray(job.skills) && job.skills.length ? job.skills.join(', ') : '—'; const deliver = job.deliver || 'local'; + const isNoAgent = !!job.no_agent; + const cronJobMode = isNoAgent ? 'no-agent' : 'agent'; + const script = job.script || ''; const profileLabel = _cronProfileLabel(job.profile); const profileTitle = _cronProfileTitle(job.profile); const lastError = job.last_error ? `
${esc(t('error_prefix').replace(/:\s*$/,''))}
${esc(job.last_error)}
` : ''; @@ -440,6 +487,7 @@ function _renderCronDetail(job){ ` : ''; + const toastNotifications = job.toast_notifications !== false; body.innerHTML = `
${attentionBanner} @@ -450,7 +498,10 @@ function _renderCronDetail(job){
${esc(t('cron_next'))}
${esc(nextRun)}
${esc(t('cron_last'))}
${esc(lastRun)}
Deliver
${esc(deliver)}
+
Mode
${esc(cronJobMode)}
+ ${isNoAgent ? `
No-agent script
${esc(script || '—')}
` : ''}
${esc(t('cron_profile_label') || 'Profile')}
${esc(profileLabel)}
+
${esc(t('cron_toast_notifications_label') || 'Completion toasts')}
${esc(toastNotifications ? (t('cron_toast_notifications_enabled') || 'Enabled') : (t('cron_toast_notifications_disabled') || 'Disabled'))}
Skills
${esc(skills)}
${lastError}
@@ -634,6 +685,7 @@ function duplicateCurrentCron(){ prompt: job.prompt || '', deliver: job.deliver || 'local', profile: job.profile || '', + toast_notifications: job.toast_notifications !== false, isEdit: false, }); if (!_cronSkillsCache) { @@ -667,7 +719,7 @@ function openCronCreate(){ _cronMode = 'create'; _cronIsDuplicate = false; _cronSelectedSkills = []; - _renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', profile:'', isEdit:false }); + _renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', profile:'', toast_notifications:true, isEdit:false }); _cronSkillsCache = null; api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{}); loadCronProfiles().then(()=>_refreshCronProfileSelect('')).catch(()=>{}); @@ -685,6 +737,9 @@ function openCronEdit(job){ prompt: job.prompt || '', deliver: job.deliver || 'local', profile: job.profile || '', + toast_notifications: job.toast_notifications !== false, + no_agent: !!job.no_agent, + script: job.script || '', isEdit: true, }); if (!_cronSkillsCache) { @@ -695,11 +750,13 @@ function openCronEdit(job){ loadCronProfiles().then(()=>_refreshCronProfileSelect(job.profile || '')).catch(()=>{}); } -function _renderCronForm({ name, schedule, prompt, deliver, profile, isEdit }){ +function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notifications=true, no_agent=false, script='', isEdit }){ const title = $('taskDetailTitle'); const body = $('taskDetailBody'); const empty = $('taskDetailEmpty'); if (!body || !title) return; + const isNoAgent = !!no_agent; + const toastNotifications = toast_notifications !== false; title.textContent = isEdit ? (t('edit') + ' · ' + (name || schedule || t('scheduled_jobs'))) : t('new_job'); const deliverOpt = (v,l) => ``; body.innerHTML = ` @@ -713,10 +770,12 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, isEdit }){
${esc(t('cron_schedule_hint') || "Cron expression or shorthand like 'every 1h'.")}
+ -
+
- + + ${isNoAgent ? `
No-agent mode runs the configured script directly; Prompt is unused. No-agent script: ${esc(script || '—')}
` : ''}
@@ -724,6 +783,7 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, isEdit }){ ${deliverOpt('local', t('cron_deliver_local') || 'Local (save output only)')} ${deliverOpt('discord','Discord')} ${deliverOpt('telegram','Telegram')} + ${deliverOpt('slack','Slack')}
@@ -733,6 +793,13 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, isEdit }){
${esc(t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time')}
+
+ + +
@@ -749,6 +816,12 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, isEdit }){ if (empty) empty.style.display = 'none'; _setCronHeaderButtons(isEdit ? 'edit' : 'create'); _renderCronSkillTags(); + const scheduleEl = $('cronFormSchedule'); + if (scheduleEl) { + scheduleEl.addEventListener('input', _syncCronScheduleWarning); + scheduleEl.addEventListener('change', _syncCronScheduleWarning); + _syncCronScheduleWarning(); + } const focusEl = $('cronFormName'); if (focusEl) focusEl.focus(); } @@ -818,6 +891,7 @@ async function saveCronForm(){ const promptEl=$('cronFormPrompt'); const delivEl=$('cronFormDeliver'); const profileEl=$('cronFormProfile'); + const toastEl=$('cronFormToastNotifications'); const errEl=$('cronFormError'); if(!schEl||!promptEl||!errEl) return; const name=(nameEl?nameEl.value:'').trim(); @@ -825,12 +899,15 @@ async function saveCronForm(){ const prompt=promptEl.value.trim(); const deliver=delivEl?delivEl.value:'local'; const profile=profileEl?profileEl.value:''; + const toastNotifications=toastEl?!!toastEl.checked:true; + const isNoAgent = !!(_cronPreFormDetail && _cronPreFormDetail.no_agent); errEl.style.display='none'; if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;} - if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;} + if(!isNoAgent && !prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;} try{ if (_editingCronId) { - const updates = {job_id: _editingCronId, schedule, prompt, profile: profile}; + const updates = {job_id: _editingCronId, schedule, profile: profile, toast_notifications: toastNotifications}; + if (!isNoAgent) updates.prompt = prompt; if (name) updates.name = name; await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)}); const editedId = _editingCronId; @@ -842,7 +919,7 @@ async function saveCronForm(){ if (job) openCronDetail(editedId); return; } - const body={schedule,prompt,deliver,profile: profile}; + const body={schedule,prompt,deliver,profile: profile, toast_notifications: toastNotifications}; if(_cronIsDuplicate) body.enabled=false; if(name)body.name=name; if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills; @@ -1085,10 +1162,9 @@ function _kanbanCardStalenessClass(task){ function _kanbanCardQuickActions(task){ const id = esc(task.id || ''); const status = task.status || ''; - const start = status !== 'running' && status !== 'done' && status !== 'archived' ? `` : ''; const complete = status !== 'done' && status !== 'archived' ? `` : ''; const archive = status !== 'archived' ? `` : ''; - return `
${start}${complete}${archive}
`; + return `
${complete}${archive}
`; } async function quickKanbanCardAction(event, taskId, status){ @@ -1157,17 +1233,25 @@ function _kanbanRenderProfileLanes(columns){ }).join('')}
`; } +function _kanbanEmptyBoardHtml(){ + return `
${esc(t('kanban_no_data'))}
${esc(t('kanban_work_queue_hint'))}
`; +} + function _kanbanRenderBoard(){ const board = $('kanbanBoard'); if (!board) return; if (!_kanbanBoard || !_kanbanBoard.columns) { - board.innerHTML = `
${esc(t('kanban_no_data'))}
`; + board.innerHTML = _kanbanEmptyBoardHtml(); return; } const columns = _kanbanVisibleTasks(); const total = columns.reduce((n, col) => n + (col.tasks || []).length, 0); if ($('kanbanSummary')) $('kanbanSummary').textContent = String(t('kanban_visible_tasks')).replace('{0}', total); _kanbanRenderSidebar(columns); + if (total === 0) { + board.innerHTML = _kanbanEmptyBoardHtml(); + return; + } board.innerHTML = _kanbanLanesByProfile ? _kanbanRenderProfileLanes(columns) : columns.map(_kanbanRenderColumn).join(''); } @@ -1189,11 +1273,49 @@ function _kanbanCard(task, status){ `; } +async function hardRefreshWebUIClient(){ + try { + if (navigator.serviceWorker) { + const regs = await navigator.serviceWorker.getRegistrations(); + await Promise.all(regs.map(r => r.unregister())); + } + } catch(_) {} + try { + if (window.caches) { + const keys = await caches.keys(); + await Promise.all(keys.map(k => caches.delete(k))); + } + } catch(_) {} + window.location.reload(); +} + +function _kanbanLooksLikeStaleClientError(err){ + const msg = String((err && err.message) || err || '').toLowerCase(); + return !!(err && err.status === 404 && ( + msg === 'not found' || + msg.includes('unknown kanban endpoint') || + msg.includes('stale cached bundle') + )); +} + +function _kanbanUnavailableHtml(err){ + const raw = String((err && err.message) || err || ''); + if (_kanbanLooksLikeStaleClientError(err)) { + return `
Kanban needs a hard refresh
The server rejected an obsolete Kanban endpoint. This usually means the browser or Mac app is still running a stale cached WebUI bundle after an update.
Original error: ${esc(raw || 'not found')}
`; + } + const msg = `${esc(t('kanban_unavailable'))}: ${esc(raw)}`; + return `
${msg}
`; +} + async function loadKanban(animate){ const board = $('kanbanBoard'); const list = $('kanbanList'); try { if (animate && board) board.innerHTML = `
${esc(t('loading'))}
`; + // Resolve the active board before board-scoped requests. If another CLI or + // tab archived the previous board, /boards can fall back to default instead + // of leaving config/board pinned to a ghost slug. + await loadKanbanBoards(); const config = await api('/api/kanban/config' + _kanbanBoardQuery()); let assignees = null; try { assignees = await api('/api/kanban/assignees' + _kanbanBoardQuery()); } catch(e) { assignees = null; } @@ -1223,16 +1345,19 @@ async function loadKanban(animate){ _kanbanSetSelectOptions($('kanbanAssigneeFilter'), _kanbanBoard.assignees || (assignees && assignees.assignees) || (config && config.assignees), 'kanban_all_assignees'); _kanbanSetSelectOptions($('kanbanTenantFilter'), _kanbanBoard.tenants, 'kanban_all_tenants'); await loadKanbanStats(); - // Refresh the multi-board switcher (and resolve which board to show - // from localStorage / server state). Best-effort — failures hide the - // switcher rather than blocking the panel from rendering. - await loadKanbanBoards(); + // Note: PR #1828 (v0.51.20) moved the boards refresh to the start of + // loadKanban() so the active board is resolved BEFORE board-scoped + // requests fire. The previous tail-of-function refresh has been removed + // to avoid doubling /api/kanban/boards traffic during SSE-driven + // refreshes (debounced at 250ms via _scheduleKanbanRefresh). The + // 30-second poll started by _kanbanStartPolling() picks up any board + // state changes that arrive after this render. _kanbanStartPolling(); _kanbanRenderBoard(); } catch(e) { - const msg = `${esc(t('kanban_unavailable'))}: ${esc(e.message || e)}`; - if (board) board.innerHTML = `
${msg}
`; - if (list) list.innerHTML = `
${msg}
`; + const html = _kanbanUnavailableHtml(e); + if (board) board.innerHTML = html; + if (list) list.innerHTML = html; } } @@ -1365,12 +1490,97 @@ function _kanbanBoardQuery(extra){ } async function nudgeKanbanDispatcher(){ + if (_kanbanIsDispatching) return; + // Dry-run dispatch: show what WOULD be spawned, without actually spawning + // workers. Uses ?dry_run=1 so the dispatcher reports its plan without + // mutating the board. The result shape includes spawned/skipped_unassigned/ + // skipped_nonspawnable/promoted/auto_blocked so users can diagnose why a + // Ready task isn't being picked up before they commit to a real run. + _kanbanIsDispatching = true; + _setKanbanDispatcherButtonsDisabled(true); try { const dispatchEndpoint = '/api/kanban/dispatch'; - await api(dispatchEndpoint + '?dry_run=1&max=1' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), {method: 'POST'}); - showToast(t('kanban_nudge_dispatcher')); + const result = await api( + dispatchEndpoint + '?dry_run=1&max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), + {method: 'POST'}, + ); + showToast(_kanbanFormatDispatchResult(result, true), 'info', 6000); await loadKanban(true); - } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } + } catch(e) { + showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); + } finally { + _kanbanIsDispatching = false; + _setKanbanDispatcherButtonsDisabled(false); + } +} + +async function runKanbanDispatcher(){ + if (_kanbanIsDispatching) return; + // Real dispatch: claims Ready tasks and spawns worker subprocesses + // (one `hermes -p ` per claimed row, up to max=8 per call). + // Confirmation dialog first because this actually consumes API budget on + // each spawned worker. Result toast surfaces what happened so users see + // the dispatcher actually doing work. + if (!_kanbanCurrentBoard) { + showToast(t('kanban_unavailable') || 'Kanban unavailable', 'error'); + return; + } + + _kanbanIsDispatching = true; + _setKanbanDispatcherButtonsDisabled(true); + try { + const ok = await showConfirmDialog({ + title: t('kanban_run_dispatcher') || 'Run dispatcher', + message: t('kanban_run_dispatcher_confirm') + || 'This will claim Ready tasks on this board and spawn worker subprocesses (one per task, up to 8 per click). Continue?', + confirmLabel: t('kanban_run_dispatcher') || 'Run dispatcher', + }); + if (!ok) return; + const dispatchEndpoint = '/api/kanban/dispatch'; + const result = await api( + dispatchEndpoint + '?max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), + {method: 'POST'}, + ); + showToast(_kanbanFormatDispatchResult(result, false), 'info', 8000); + await loadKanban(true); + } catch(e) { + showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); + } finally { + _kanbanIsDispatching = false; + _setKanbanDispatcherButtonsDisabled(false); + } +} + +function _setKanbanDispatcherButtonsDisabled(disabled){ + document.querySelectorAll('.kanban-run-dispatch-btn, .kanban-nudge-dispatch-btn').forEach((btn) => { + btn.disabled = !!disabled; + btn.classList.toggle('disabled', !!disabled); + }); +} + +function _kanbanFormatDispatchResult(result, dryRun){ + // Produce a human-readable one-line summary of dispatch_once's output so + // users can see exactly what happened rather than a generic "OK" toast. + const r = result || {}; + const spawned = (r.spawned || []).length; + const promoted = r.promoted || 0; + const reclaimed = r.reclaimed || 0; + const skippedUnassigned = (r.skipped_unassigned || []).length; + const skippedNonspawnable = (r.skipped_nonspawnable || []).length; + const autoBlocked = (r.auto_blocked || []).length; + const timedOut = (r.timed_out || []).length; + const crashed = (r.crashed || []).length; + const verb = dryRun ? (t('kanban_dispatch_preview_prefix') || 'Preview:') : (t('kanban_dispatch_run_prefix') || 'Dispatched:'); + const parts = []; + parts.push(spawned + ' ' + (t('kanban_dispatch_spawned') || 'spawned')); + if (promoted) parts.push(promoted + ' ' + (t('kanban_dispatch_promoted') || 'promoted')); + if (reclaimed) parts.push(reclaimed + ' ' + (t('kanban_dispatch_reclaimed') || 'reclaimed')); + if (skippedUnassigned) parts.push(skippedUnassigned + ' ' + (t('kanban_dispatch_skipped_unassigned') || 'skipped (no assignee)')); + if (skippedNonspawnable) parts.push(skippedNonspawnable + ' ' + (t('kanban_dispatch_skipped_nonspawnable') || 'skipped (unknown profile)')); + if (autoBlocked) parts.push(autoBlocked + ' ' + (t('kanban_dispatch_auto_blocked') || 'auto-blocked')); + if (timedOut) parts.push(timedOut + ' ' + (t('kanban_dispatch_timed_out') || 'timed out')); + if (crashed) parts.push(crashed + ' ' + (t('kanban_dispatch_crashed') || 'crashed')); + return verb + ' ' + parts.join(', '); } function _kanbanSelectedTaskIds(){ @@ -1507,7 +1717,14 @@ function _kanbanLinksHtml(links){ async function createKanbanTask(){ const input = document.getElementById('kanbanNewTaskTitle'); const title = input ? input.value.trim() : ''; - if (!title) return; + if (!title) { + // Empty inline input (or a click on the panel-head "+" via openKanbanCreate) + // — open the full create-task modal so the user has somewhere obvious to + // type and configure the task. Mirrors the cron / skills pattern of routing + // header "+" clicks through to a clearly-modal create surface. + openKanbanCreate(); + return; + } try { const created = await api('/api/kanban/tasks' + _kanbanBoardQuery(), { method: 'POST', @@ -1519,6 +1736,447 @@ async function createKanbanTask(){ } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } +// ──────────────────────────────────────────────────────────────────────────── +// Kanban: create-task modal (panel-head "+" button entry point). +// +// Same `.kanban-modal-overlay` shell as openKanbanCreateBoard() so the two +// flows look and behave identically (centered card, dim backdrop, ESC closes, +// click-on-backdrop closes). The modal markup lives in static/index.html as +// #kanbanTaskModal — see the section just above . Submit hits the +// existing /api/kanban/tasks POST endpoint (which already accepts title, body, +// assignee, tenant, priority, status — see api/kanban_bridge.py:306). +// ──────────────────────────────────────────────────────────────────────────── + +// ──────────────────────────────────────────────────────────────────────────── +// Kanban: create-task / edit-task modal (panel-head "+" + task-detail Edit +// button entry points). +// +// Single modal serves both flows. Title + submit-button labels and the +// underlying submit verb (POST vs PATCH) flip based on `_kanbanTaskModalMode`. +// +// Same `.kanban-modal-overlay` shell as openKanbanCreateBoard() so the two +// flows look and behave identically (centered card, dim backdrop, ESC closes, +// click-on-backdrop closes). The modal markup lives in static/index.html as +// #kanbanTaskModal — see the section just above . +// +// The assignee field auto-completes against the union of (a) live Hermes +// profile names from /api/profiles and (b) historical assignees on the +// active board, with an inline hint that explains the dispatcher claim +// contract — most users will pick a profile name from the dropdown rather +// than type one. +// ──────────────────────────────────────────────────────────────────────────── + +let _kanbanTaskModalMode = 'create'; // 'create' | 'edit' +let _kanbanTaskModalEditingId = null; // task id when mode === 'edit' +let _kanbanProfileNamesCache = null; // populated lazily on first modal open +let _kanbanProfileNamesCacheAt = 0; +const _KANBAN_PROFILE_NAMES_CACHE_TTL_MS = 30000; +function _invalidateKanbanProfileCache() { + _kanbanProfileNamesCache = null; + _kanbanProfileNamesCacheAt = 0; +} +let _kanbanTaskModalFocusCleanup = null; +// Status the modal *displayed* on edit-mode open. If the user doesn't touch +// the dropdown, we must NOT send `status` in the PATCH payload — otherwise +// editing a task whose real status is non-editable in this dropdown +// (running/blocked/done/archived → mapped to 'triage' for display) would +// silently demote the task on save. See the regression caught during PR +// review: editing a 'running' task without touching status was reclaiming +// the worker and moving the task back to triage. +let _kanbanTaskModalInitialDisplayedStatus = null; +let _kanbanBoardModalFocusCleanup = null; + +async function _kanbanLoadProfileNames(){ + // Hit /api/profiles once per session and cache for a short TTL. + // Returns an array of profile names (sorted, default first if present). + const hasFreshCache = ( + Array.isArray(_kanbanProfileNamesCache) && + (Date.now() - _kanbanProfileNamesCacheAt) < _KANBAN_PROFILE_NAMES_CACHE_TTL_MS + ); + if (hasFreshCache) return _kanbanProfileNamesCache; + try { + const data = await api('/api/profiles'); + const profiles = Array.isArray(data && data.profiles) ? data.profiles : []; + const names = profiles.map(p => p && p.name).filter(Boolean); + // Stable order: default first, then alphabetical. + names.sort((a, b) => { + if (a === 'default') return -1; + if (b === 'default') return 1; + return a.localeCompare(b); + }); + _kanbanProfileNamesCache = names; + _kanbanProfileNamesCacheAt = Date.now(); + return names; + } catch(_) { + _kanbanProfileNamesCache = []; + _kanbanProfileNamesCacheAt = Date.now(); + return []; + } +} + +async function _kanbanPopulateAssigneeSelect(currentValue){ + const sel = document.getElementById('kanbanTaskModalAssignee'); + if (!sel) return; + // Profile names: the canonical set the dispatcher can claim. + const profileNames = await _kanbanLoadProfileNames(); + // Historical assignees from the active board: include them so users who + // assigned to a CLI lane (e.g. orion-cc) before still see those values. + const historicalAssignees = (_kanbanBoard && Array.isArray(_kanbanBoard.assignees)) + ? _kanbanBoard.assignees + : []; + // Build a final ordered list, deduping. Profiles come first, then any + // historical assignees that aren't profiles (rare but keeps round-tripping + // correct for tasks created via CLI). + const seen = new Set(); + const profiles = []; + for (const name of profileNames) { + if (!seen.has(name)) { profiles.push(name); seen.add(name); } + } + const extras = []; + for (const name of historicalAssignees) { + if (name && !seen.has(name)) { extras.push(name); seen.add(name); } + } + // If the current value isn't in either bucket (e.g. an old CLI-created + // assignee that's since been deleted), preserve it as a final option so + // editing the task doesn't silently change its assignee. + if (currentValue && !seen.has(currentValue)) { + extras.push(currentValue); + seen.add(currentValue); + } + // The empty value maps to null on submit (intentionally unassigned). Keep + // it last so the default-selected option is the first profile, not "no one". + let html = ''; + if (profiles.length) { + html += ``; + html += profiles.map(v => ``).join(''); + html += ''; + } + if (extras.length) { + html += ``; + html += extras.map(v => ``).join(''); + html += ''; + } + // Final "no assignee" fallthrough — explicit so users know what they're choosing. + html += ``; + sel.innerHTML = html; +} + +function openKanbanCreate(){ + // Make sure the user is on the kanban panel so the resulting board reload is + // visible behind the modal. + if (typeof switchPanel === 'function' && _currentPanel !== 'kanban') switchPanel('kanban'); + const modal = document.getElementById('kanbanTaskModal'); + if (!modal) return; + _kanbanTaskModalMode = 'create'; + _kanbanTaskModalEditingId = null; + _kanbanTaskModalInitialDisplayedStatus = null; // create mode: always send status + // Default new tasks to "ready" so they're immediately claimable by the + // dispatcher (assuming the user picks an assignee). Triage is for staging + // tasks that need human review before being marked actionable; users who + // want it can still pick it from the status dropdown. + _kanbanResetTaskModalFields({status: 'ready'}); + _kanbanSetTaskModalStatusHint(null); + _kanbanSetTaskModalLabels('create'); + _kanbanPopulateAssigneeSelect('').then(() => { + // After the dropdown is populated, default-select the first profile (not + // the "Unassigned" fallthrough). This is the right hint: most users want + // to assign to *something* — they can pick "Unassigned" deliberately. + const sel = document.getElementById('kanbanTaskModalAssignee'); + if (sel && sel.options.length > 0 && sel.value === '') { + const firstProfile = Array.from(sel.options).find(opt => opt.value !== ''); + if (firstProfile) sel.value = firstProfile.value; + } + }); + _kanbanPopulateTenantDatalist(); + modal.hidden = false; + if (_kanbanTaskModalFocusCleanup) { + _kanbanTaskModalFocusCleanup(); + _kanbanTaskModalFocusCleanup = null; + } + _kanbanTaskModalFocusCleanup = _trapModalFocus(modal); + setTimeout(() => { + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + if (titleEl) titleEl.focus(); + }, 50); + document.addEventListener('keydown', _kanbanTaskModalKey); +} + +async function openKanbanEdit(taskId){ + // Triggered by the Edit button on the task detail view. Fetches the task + // (rather than relying on whatever's cached locally) so the modal always + // reflects authoritative server state. + if (!taskId) return; + if (typeof switchPanel === 'function' && _currentPanel !== 'kanban') switchPanel('kanban'); + const modal = document.getElementById('kanbanTaskModal'); + if (!modal) return; + let task = null; + try { + const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + _kanbanBoardQuery()); + task = data && data.task; + } catch(e) { + showToast((t('kanban_unavailable') || 'Kanban unavailable') + ': ' + (e.message || e), 'error'); + return; + } + if (!task) return; + _kanbanTaskModalMode = 'edit'; + _kanbanTaskModalEditingId = task.id; + // Track the displayed status so submitKanbanTaskModal can detect whether + // the user actually picked a new value vs. the dropdown's mapped default. + // Without this, editing a 'running'/'blocked'/'done'/'archived' task whose + // real status maps to 'triage' for display would silently demote the task + // (the mapped 'triage' would land in the PATCH payload, and _patch_task + // would call _set_status_direct → reclaim worker → move to triage). + const initialDisplayedStatus = _kanbanEditableStatusFor(task.status); + const originalStatus = task.status || initialDisplayedStatus; + _kanbanTaskModalInitialDisplayedStatus = initialDisplayedStatus; + _kanbanResetTaskModalFields({ + title: task.title || '', + body: task.body || '', + status: initialDisplayedStatus, + tenant: task.tenant || '', + priority: typeof task.priority === 'number' ? task.priority : 0, + }); + // Populate the assignee select AFTER reset so the option exists when we + // call sel.value = currentAssignee. + await _kanbanPopulateAssigneeSelect(task.assignee || ''); + _kanbanSetTaskModalStatusHint(originalStatus, initialDisplayedStatus); + _kanbanSetTaskModalLabels('edit'); + _kanbanPopulateTenantDatalist(); + modal.hidden = false; + if (_kanbanTaskModalFocusCleanup) { + _kanbanTaskModalFocusCleanup(); + _kanbanTaskModalFocusCleanup = null; + } + _kanbanTaskModalFocusCleanup = _trapModalFocus(modal); + setTimeout(() => { + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + if (titleEl) { titleEl.focus(); titleEl.select(); } + }, 50); + document.addEventListener('keydown', _kanbanTaskModalKey); +} + +function _kanbanEditableStatusFor(status){ + // The modal's status select only offers triage/todo/ready (the user-writable + // states). blocked/running/done/archived are reached via the detail-view + // status buttons or the dispatcher. Map non-editable states to a sensible + // default so the user can still change them via the buttons after saving. + const editable = new Set(['triage', 'todo', 'ready']); + return editable.has(status) ? status : 'triage'; +} + +function _kanbanResetTaskModalFields(values){ + const v = values || {}; + const set = (id, val) => { + const el = document.getElementById(id); + if (el) el.value = (val == null ? '' : String(val)); + }; + set('kanbanTaskModalTitleInput', v.title || ''); + set('kanbanTaskModalBody', v.body || ''); + set('kanbanTaskModalStatus', v.status || 'triage'); + // Assignee handled separately by _kanbanPopulateAssigneeSelect() because + // it's a