diff --git a/README.md b/README.md
index 54552606..4c3a11b0 100644
--- a/README.md
+++ b/README.md
@@ -361,7 +361,7 @@ Need help with `PATH`, API keys, provider/model matching, or JSON errors? See th
## 🌐 WebUI
-The WebUI ships **inside the published wheel** — no extra build step. Just enable the WebSocket channel and open it in your browser.
+The WebUI ships **inside the published wheel** — no extra build step. It is the browser workbench for chat sessions, workspace controls, Apps, Skills, Automations, and settings. For the full user guide, see [`docs/webui.md`](./docs/webui.md).
@@ -383,12 +383,12 @@ nanobot gateway
**3. Open the WebUI**
-Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs → LAN access](./webui/README.md#access-from-another-device-lan).
+Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
> [!TIP]
-> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the Vite dev server (HMR) workflow.
+> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the source-tree, Vite dev server, build, and test workflow.
## 🏗️ Architecture
diff --git a/docs/README.md b/docs/README.md
index 53281a45..9b4ef7b9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,7 +16,7 @@ If you find a docs mistake, outdated command, or confusing step, please open an
|---|---|---|
| New to terminals and config files | [`start-without-technical-background.md`](./start-without-technical-background.md) | [`troubleshooting.md`](./troubleshooting.md) if the first reply fails |
| Comfortable pasting commands and JSON | [`quick-start.md`](./quick-start.md) | [`provider-cookbook.md`](./provider-cookbook.md) for pasteable provider setups |
-| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`../webui/README.md`](../webui/README.md), and [`deployment.md`](./deployment.md) |
+| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`webui.md`](./webui.md), and [`deployment.md`](./deployment.md) |
| Integrating or extending nanobot | [`architecture.md`](./architecture.md) | [`configuration.md`](./configuration.md), [`openai-api.md`](./openai-api.md), [`python-sdk.md`](./python-sdk.md), [`development.md`](./development.md), and [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
## Start Here
@@ -38,7 +38,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
| Next goal | Read | First check |
|---|---|---|
-| Use nanobot in a browser | [`../webui/README.md`](../webui/README.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` |
+| Use nanobot in a browser | [`webui.md`](./webui.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` |
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
| Understand before operating long-term | [`concepts.md`](./concepts.md) | Know what config, workspace, gateway, sessions, memory, and tools mean |
@@ -48,7 +48,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
| Goal | Read | Outcome |
|---|---|---|
-| Open the bundled browser UI | [`../webui/README.md`](../webui/README.md) | WebUI on port `8765`, or Vite HMR when developing the frontend |
+| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
| Use slash commands and periodic tasks | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, heartbeat tasks, and chat-side controls |
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
diff --git a/docs/architecture.md b/docs/architecture.md
index 665fad1c..97c7afe5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -108,7 +108,8 @@ WebUI source lives in `webui/`. The production build is written to `nanobot/web/
Useful docs:
-- [`../webui/README.md`](../webui/README.md) for WebUI use and development;
+- [`webui.md`](./webui.md) for the WebUI user guide;
+- [`../webui/README.md`](../webui/README.md) for frontend source development;
- [`websocket.md`](./websocket.md) for protocol details.
## Tools
diff --git a/docs/deployment.md b/docs/deployment.md
index e076a8f1..0c398880 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -54,7 +54,7 @@ Restart the deployed process after editing `config.json`. Long-running processes
> }
> ```
>
-> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured — see [`webui/README.md`](../webui/README.md) for details.
+> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
### Docker Compose
diff --git a/docs/quick-start.md b/docs/quick-start.md
index 7ba5dcc7..819b8a4b 100644
--- a/docs/quick-start.md
+++ b/docs/quick-start.md
@@ -273,7 +273,7 @@ Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
| Understand config, workspace, gateway, channels, memory, and tools | [`concepts.md`](./concepts.md) |
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
| Understand provider/model matching | [`providers.md`](./providers.md) |
-| Open the bundled browser UI | [`../webui/README.md`](../webui/README.md) |
+| Open the bundled browser UI | [`webui.md`](./webui.md) |
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [`chat-apps.md`](./chat-apps.md) |
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
diff --git a/docs/start-without-technical-background.md b/docs/start-without-technical-background.md
index 7e23e139..32cdc095 100644
--- a/docs/start-without-technical-background.md
+++ b/docs/start-without-technical-background.md
@@ -401,7 +401,7 @@ nanobot gateway
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
-If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`../webui/README.md`](../webui/README.md).
+If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
### Connect a Chat App
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 6ea134ac..ee374422 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -205,7 +205,7 @@ http://127.0.0.1:8765
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
-See [`../webui/README.md`](../webui/README.md) for LAN and development setup.
+See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
## Chat App Problems
diff --git a/docs/webui.md b/docs/webui.md
new file mode 100644
index 00000000..82eb12dd
--- /dev/null
+++ b/docs/webui.md
@@ -0,0 +1,168 @@
+# WebUI
+
+The WebUI is nanobot's browser workbench. Use it after a basic CLI reply already
+works, when you want a persistent chat workspace, visible agent activity,
+workspace controls, Apps, Skills, settings, and Automations in one place.
+
+The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
+the `webui/` source directory when you are changing the frontend itself.
+
+## Open the WebUI
+
+First confirm your provider and model can answer:
+
+```bash
+nanobot agent -m "Hello!"
+```
+
+Then merge the WebSocket channel into your existing `~/.nanobot/config.json`:
+
+```json
+{ "channels": { "websocket": { "enabled": true } } }
+```
+
+If you are new to JSON snippets, see
+[`start-without-technical-background.md#how-to-merge-json-snippets`](./start-without-technical-background.md#how-to-merge-json-snippets).
+
+Start the gateway:
+
+```bash
+nanobot gateway
+```
+
+Leave the gateway running and open
+[`http://127.0.0.1:8765`](http://127.0.0.1:8765). The WebUI is served by the
+WebSocket channel on port `8765` by default. The gateway health endpoint,
+`18790` by default, is not the browser UI.
+
+## What It Is For
+
+| Area | Use it for |
+|---|---|
+| Chat | Start, switch, search, fork, and delete browser sessions |
+| Agent activity | See thinking, tool calls, file activity, command output, and generated artifacts in context |
+| Workspace | Pick the project workspace before asking for file or shell work |
+| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
+| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
+| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
+| Skills | Inspect available built-in and workspace skills before relying on them |
+| Automations | Review, search, run, pause, edit, and delete scheduled agent turns |
+| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
+
+## Chat Workspace
+
+The sidebar is the session switcher. A session keeps its own history, title,
+workspace metadata, and linked automations. Use a new session when you want a
+separate context; use fork when you want to continue from an existing point
+without changing the original thread.
+
+The message timeline shows both user-visible replies and agent activity. Long
+tool or reasoning sections can be expanded when you need the details.
+
+## Workspace and Access
+
+Use the workspace picker before starting project-specific work. This gives the
+agent the right project context for file paths, shell commands, and session
+metadata.
+
+The access control in the composer controls the local capability level for the
+chat. It does not bypass your gateway, provider, shell sandbox, or operating
+system configuration; it only selects among the capabilities that are already
+available to this WebUI session.
+
+## Composer
+
+The composer supports plain messages, image attachments, voice input when
+transcription is configured, slash commands, and `@` mentions for installed Apps
+or MCP presets. The model badge shows the current model or preset and links back
+to model settings when setup is incomplete.
+
+For image generation, configure an image provider first and then use the WebUI
+image mode from the composer. See [`image-generation.md`](./image-generation.md)
+for provider setup and output behavior.
+
+## Apps
+
+Open Apps from the sidebar or settings navigation to manage integrations that
+nanobot can call from a chat. CLI Apps install local adapters that nanobot runs
+on your machine; they do not modify the native apps themselves. MCP presets add
+predefined MCP server configurations.
+
+After an App or MCP preset is available, mention it from the composer with `@`
+to attach that capability to the next message.
+
+## Skills
+
+The Skills view shows the skill instructions available to the agent, including
+built-in skills and workspace-provided skills. Check this view when you want to
+know whether nanobot already has a focused workflow for a task before you ask it
+to perform that task.
+
+## Automations
+
+Automations are scheduled agent turns. They should be created from the chat,
+channel, or session where they are supposed to run so nanobot keeps the correct
+target context.
+
+Use the Automations view to:
+
+- Filter by all, active, paused, needs-attention, or system jobs.
+- Search by task name, message, linked chat, schedule, or status.
+- Sort by next run, last run, updated time, or name.
+- Run now, pause or resume, edit, or delete user-created automations.
+- Inspect protected system automations without changing them.
+
+Search accepts plain text and field filters such as `name:backup`,
+`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, and `status:paused`.
+
+An automation without a linked chat cannot be enabled or run from the WebUI,
+because nanobot would not know where to deliver the scheduled turn. Recreate it
+from the target chat or channel so the automation has complete context.
+
+## Settings
+
+Settings is the control surface for the browser session and gateway-backed
+runtime configuration. Use it to review or adjust model presets, provider
+visibility, image generation, voice transcription, web tools, Apps, Automations,
+Skills, runtime identity, and advanced safety controls.
+
+Some settings take effect immediately. Runtime settings that affect the gateway
+or agent process may require a restart; the WebUI shows that requirement next to
+the relevant control.
+
+## LAN Access
+
+To open the WebUI from another device on the same network, bind the WebSocket
+channel to all interfaces and set a token or token issue secret:
+
+```json
+{
+ "channels": {
+ "websocket": {
+ "enabled": true,
+ "host": "0.0.0.0",
+ "port": 8765,
+ "tokenIssueSecret": "your-secret-here"
+ }
+ }
+}
+```
+
+The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
+`tokenIssueSecret` is configured. After the gateway starts, open
+`http://:8765` from the other device and enter the secret in the login
+form.
+
+## Troubleshooting
+
+If the page does not open, check these in order:
+
+1. `nanobot agent -m "Hello!"` works in the same Python environment.
+2. The WebSocket channel is enabled in `~/.nanobot/config.json`.
+3. `nanobot gateway` is still running.
+4. You are opening port `8765`, not the gateway health port.
+5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
+
+For detailed diagnostics, see
+[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
+For frontend development, see [`../webui/README.md`](../webui/README.md).
diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py
index 3c18d8e9..3e5b6783 100644
--- a/nanobot/channels/websocket.py
+++ b/nanobot/channels/websocket.py
@@ -896,6 +896,7 @@ class WebSocketChannel(BaseChannel):
goal_state=gs_blob,
metadata=msg.metadata,
)
+ await self.send_session_updated(msg.chat_id, scope="thread")
return
if msg.metadata.get("_session_updated"):
if conns:
@@ -1146,8 +1147,8 @@ class WebSocketChannel(BaseChannel):
await self._safe_send_to(connection, raw, label=" goal_status ")
async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None:
- """Notify clients that session metadata changed outside the main turn."""
- conns = list(self._subs.get(chat_id, ()))
+ """Notify WebUI clients that a session row should refresh."""
+ conns = list(self._conn_chats)
if not conns:
return
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index 16fab16b..23426bec 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -136,6 +136,10 @@ class CronService:
"""Service for managing and executing scheduled jobs."""
_MAX_RUN_HISTORY = 20
+ _UNBOUND_AGENT_JOB_REASON = (
+ "agent cron payload is missing bound session delivery context; "
+ "recreate it from a chat session"
+ )
def __init__(
self,
@@ -154,6 +158,42 @@ class CronService:
self._timer_active = False
self.max_sleep_ms = max_sleep_ms
+ def _is_unbound_agent_job(self, job: CronJob) -> bool:
+ return job.payload.kind == "agent_turn" and not is_bound_cron_job(job)
+
+ def _enforce_agent_binding(self, job: CronJob) -> bool:
+ """Disable user cron jobs that cannot be routed to a concrete session."""
+ if not self._is_unbound_agent_job(job):
+ return False
+ if (
+ not job.enabled
+ and job.state.next_run_at_ms is None
+ and job.state.last_status == "error"
+ and job.state.last_error
+ ):
+ return False
+
+ job.enabled = False
+ job.state.next_run_at_ms = None
+ job.state.last_status = "error"
+ job.state.last_error = self._UNBOUND_AGENT_JOB_REASON
+ job.updated_at_ms = max(job.updated_at_ms, _now_ms())
+ logger.warning(
+ "Cron: disabled unbound agent job '{}' ({}): {}",
+ job.name,
+ job.id,
+ self._UNBOUND_AGENT_JOB_REASON,
+ )
+ return True
+
+ def _enforce_store_agent_bindings(self) -> bool:
+ if not self._store:
+ return False
+ changed = False
+ for job in self._store.jobs:
+ changed = self._enforce_agent_binding(job) or changed
+ return changed
+
def _load_jobs(self) -> tuple[list[CronJob], int] | None:
"""Load jobs from disk.
@@ -312,6 +352,8 @@ class CronService:
jobs, version = loaded
self._store = CronStore(version=version, jobs=jobs)
self._merge_action()
+ if self._enforce_store_agent_bindings() and self._running:
+ self._save_store()
return self._store
@@ -456,6 +498,8 @@ class CronService:
return
now = _now_ms()
for job in self._store.jobs:
+ if self._enforce_agent_binding(job):
+ continue
if job.enabled:
job.state.next_run_at_ms = _compute_next_run(job.schedule, now)
@@ -638,6 +682,7 @@ class CronService:
delete_after_run=delete_after_run,
)
_normalize_agent_turn_job(job)
+ self._enforce_agent_binding(job)
if self._running:
store = self._load_store()
store.jobs.append(job)
@@ -695,7 +740,8 @@ class CronService:
if job.id == job_id:
job.enabled = enabled
job.updated_at_ms = _now_ms()
- if enabled:
+ self._enforce_agent_binding(job)
+ if job.enabled:
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
else:
job.state.next_run_at_ms = None
@@ -747,10 +793,13 @@ class CronService:
if delete_after_run is not None:
job.delete_after_run = delete_after_run
_normalize_agent_turn_job(job)
+ self._enforce_agent_binding(job)
job.updated_at_ms = _now_ms()
if job.enabled:
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
+ else:
+ job.state.next_run_at_ms = None
if self._running:
self._save_store()
@@ -769,6 +818,10 @@ class CronService:
store = self._load_store()
for job in store.jobs:
if job.id == job_id:
+ if self._is_unbound_agent_job(job):
+ self._enforce_agent_binding(job)
+ self._save_store()
+ return False
if not force and not job.enabled:
return False
await self._execute_job(job)
diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py
index c87dfd09..55e748d6 100644
--- a/nanobot/webui/session_automations.py
+++ b/nanobot/webui/session_automations.py
@@ -1,14 +1,18 @@
-"""Session-scoped automation payloads for the embedded WebUI."""
+"""Automation payloads for the embedded WebUI."""
from __future__ import annotations
from collections.abc import Collection
from typing import Any, Protocol
+from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.cron.types import CronJob
+from nanobot.session.manager import _message_preview_text
class _CronServiceLike(Protocol):
+ def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: ...
+
def list_bound_cron_jobs_for_session(
self,
session_key: str,
@@ -17,6 +21,10 @@ class _CronServiceLike(Protocol):
) -> list[CronJob]: ...
+class _SessionManagerLike(Protocol):
+ def read_session_file(self, key: str) -> dict[str, Any] | None: ...
+
+
def session_automation_jobs(
cron_service: _CronServiceLike | None,
session_key: str,
@@ -45,16 +53,50 @@ def session_automations_payload(
}
+def all_automations_payload(
+ cron_service: _CronServiceLike | None,
+ *,
+ session_manager: _SessionManagerLike | None = None,
+ pending_job_ids: Collection[str] | None = None,
+) -> dict[str, Any]:
+ """Return all cron jobs visible to the WebUI automation manager."""
+ jobs = cron_service.list_jobs(include_disabled=True) if cron_service is not None else []
+ return {
+ "jobs": serialize_automation_jobs(
+ jobs,
+ pending_job_ids=pending_job_ids,
+ include_details=True,
+ session_manager=session_manager,
+ )
+ }
+
+
def serialize_automation_jobs(
jobs: list[CronJob],
*,
pending_job_ids: Collection[str] | None = None,
+ include_details: bool = False,
+ session_manager: _SessionManagerLike | None = None,
) -> list[dict[str, Any]]:
- return [_serialize_job(job, pending=job.id in (pending_job_ids or ())) for job in jobs]
+ return [
+ _serialize_job(
+ job,
+ pending=job.id in (pending_job_ids or ()),
+ include_details=include_details,
+ session_manager=session_manager,
+ )
+ for job in jobs
+ ]
-def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]:
- return {
+def _serialize_job(
+ job: CronJob,
+ *,
+ pending: bool = False,
+ include_details: bool = False,
+ session_manager: _SessionManagerLike | None = None,
+) -> dict[str, Any]:
+ payload = {
"id": job.id,
"name": job.name,
"enabled": job.enabled,
@@ -74,3 +116,80 @@ def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]:
"pending": pending,
},
}
+ if not include_details:
+ return payload
+
+ payload["protected"] = job.payload.kind == "system_event"
+ payload["delete_after_run"] = job.delete_after_run
+ payload["created_at_ms"] = job.created_at_ms
+ payload["updated_at_ms"] = job.updated_at_ms
+ payload["payload"].update({"kind": job.payload.kind})
+ payload["state"].update(
+ {
+ "last_run_at_ms": job.state.last_run_at_ms,
+ "last_error": job.state.last_error,
+ "run_history": [
+ {
+ "run_at_ms": record.run_at_ms,
+ "status": record.status,
+ "duration_ms": record.duration_ms,
+ "error": record.error,
+ }
+ for record in job.state.run_history[-5:]
+ ],
+ }
+ )
+ payload["origin"] = _origin_payload(job, session_manager)
+ return payload
+
+
+def _origin_payload(
+ job: CronJob,
+ session_manager: _SessionManagerLike | None,
+) -> dict[str, Any] | None:
+ channel = job.payload.origin_channel
+ chat_id = job.payload.origin_chat_id
+ if not channel or not chat_id:
+ return None
+ title = ""
+ preview = ""
+ if channel != "websocket":
+ return {
+ "channel": channel,
+ "title": title,
+ "preview": preview,
+ }
+
+ session_key = f"{channel}:{chat_id}"
+ if session_manager is not None:
+ data = session_manager.read_session_file(session_key)
+ if isinstance(data, dict):
+ title = str(data.get("title") or "")
+ preview = _session_preview(data.get("messages"))
+
+ return {
+ "session_key": session_key,
+ "channel": channel,
+ "chat_id": chat_id,
+ "title": title,
+ "preview": preview,
+ }
+
+
+def _session_preview(messages: Any) -> str:
+ if not isinstance(messages, list):
+ return ""
+ fallback_preview = ""
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+ if message.get(CRON_HISTORY_META) is True:
+ continue
+ text = _message_preview_text(message)
+ if not text:
+ continue
+ if message.get("role") == "user":
+ return text
+ if not fallback_preview and message.get("role") == "assistant":
+ fallback_preview = text
+ return fallback_preview
diff --git a/nanobot/webui/session_list_index.py b/nanobot/webui/session_list_index.py
index 4fba2ca1..aaadc97a 100644
--- a/nanobot/webui/session_list_index.py
+++ b/nanobot/webui/session_list_index.py
@@ -9,11 +9,13 @@ from __future__ import annotations
import json
import os
+from datetime import datetime
from pathlib import Path
from typing import Any
from loguru import logger
+from nanobot.config.paths import get_webui_dir
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.session.manager import (
_SESSION_LIST_PREVIEW_MAX_CHARS,
@@ -26,6 +28,8 @@ from nanobot.session.manager import (
_INDEX_VERSION = 1
_INDEX_FILENAME = ".webui_session_index.json"
+_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
+_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
@@ -117,7 +121,13 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path) -> bool:
signature = _file_signature(path)
except OSError:
return False
- return row.get("mtime_ns") == signature["mtime_ns"] and row.get("size") == signature["size"]
+ activity_signature = _webui_activity_signature(str(row.get("key")))
+ return (
+ row.get("mtime_ns") == signature["mtime_ns"]
+ and row.get("size") == signature["size"]
+ and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS]
+ and row.get(_WEBUI_ACTIVITY_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE]
+ )
def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
@@ -155,17 +165,69 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
return fallback_preview
+def _webui_activity_paths(session_key: str) -> list[Path]:
+ stem = SessionManager.safe_key(session_key)
+ webui_dir = get_webui_dir()
+ return [
+ webui_dir / f"{stem}.jsonl",
+ webui_dir / f"{stem}.json",
+ ]
+
+
+def _webui_activity_signature(session_key: str) -> dict[str, int]:
+ latest_mtime_ns = 0
+ total_size = 0
+ for path in _webui_activity_paths(session_key):
+ try:
+ stat = path.stat()
+ except OSError:
+ continue
+ if not path.is_file():
+ continue
+ latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns)
+ total_size += stat.st_size
+ return {
+ _WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns,
+ _WEBUI_ACTIVITY_SIZE: total_size,
+ }
+
+
+def _webui_activity_updated_at(signature: dict[str, int]) -> str | None:
+ mtime_ns = signature.get(_WEBUI_ACTIVITY_MTIME_NS, 0)
+ if mtime_ns <= 0:
+ return None
+ return datetime.fromtimestamp(mtime_ns / 1_000_000_000).isoformat()
+
+
+def _timestamp(value: str | None) -> float:
+ if not value:
+ return 0.0
+ try:
+ return datetime.fromisoformat(value).timestamp()
+ except ValueError:
+ return 0.0
+
+
+def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
+ if _timestamp(activity) > _timestamp(stored):
+ return activity
+ return stored
+
+
def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
signature = _file_signature(path)
+ activity_signature = _webui_activity_signature(session.key)
+ activity_updated_at = _webui_activity_updated_at(activity_signature)
return {
"key": session.key,
"created_at": session.created_at.isoformat(),
- "updated_at": session.updated_at.isoformat(),
+ "updated_at": _latest_updated_at(session.updated_at.isoformat(), activity_updated_at),
"title": _metadata_title(session.metadata),
"preview": _preview_from_messages(session.messages),
"file": path.name,
"mtime_ns": signature["mtime_ns"],
"size": signature["size"],
+ **activity_signature,
}
@@ -207,15 +269,19 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
if not fallback_preview and item.get("role") == "assistant":
fallback_preview = text
signature = _file_signature(path)
+ key = data.get("key") or fallback_key
+ activity_signature = _webui_activity_signature(key)
+ activity_updated_at = _webui_activity_updated_at(activity_signature)
return {
- "key": data.get("key") or fallback_key,
+ "key": key,
"created_at": data.get("created_at"),
- "updated_at": data.get("updated_at"),
+ "updated_at": _latest_updated_at(data.get("updated_at"), activity_updated_at),
"title": _metadata_title(data.get("metadata", {})),
"preview": preview or fallback_preview,
"file": path.name,
"mtime_ns": signature["mtime_ns"],
"size": signature["size"],
+ **activity_signature,
}
except Exception:
repaired = session_manager._repair(fallback_key)
diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py
index 8d2694f6..9e2981f9 100644
--- a/nanobot/webui/ws_http.py
+++ b/nanobot/webui/ws_http.py
@@ -17,12 +17,15 @@ import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
+from urllib.parse import unquote
from loguru import logger
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
+from nanobot.cron.session_turns import is_bound_cron_job
+from nanobot.cron.types import CronJob, CronSchedule
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
@@ -64,6 +67,7 @@ from nanobot.webui.http_utils import (
)
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.session_automations import (
+ all_automations_payload,
serialize_automation_jobs,
session_automation_jobs,
session_automations_payload,
@@ -79,6 +83,7 @@ from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
+_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus
@@ -87,8 +92,6 @@ if TYPE_CHECKING:
def _decode_api_key(raw_key: str) -> str | None:
- from urllib.parse import unquote
-
key = unquote(raw_key)
_api_key_re = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
if _api_key_re.match(key) is None:
@@ -236,6 +239,11 @@ class GatewayHTTPHandler:
if response is not None:
return response
+ # Automation routes
+ response = await self._dispatch_automation_routes(request, got)
+ if response is not None:
+ return response
+
# Misc routes
response = await self._dispatch_misc_routes(connection, request, got)
if response is not None:
@@ -514,6 +522,112 @@ class GatewayHTTPHandler:
delete_webui_thread(decoded_key)
return _http_json_response({"deleted": bool(deleted)})
+ # -- Automation routes --------------------------------------------------
+
+ async def _dispatch_automation_routes(
+ self,
+ request: WsRequest,
+ got: str,
+ ) -> Response | None:
+ if got == "/api/webui/automations":
+ return self._handle_webui_automations(request)
+ m = re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", got)
+ if m:
+ return await self._handle_webui_automation_action(request, m.group(1))
+ return None
+
+ def _pending_cron_job_ids_for_all(self) -> set[str]:
+ if self.cron_service is None or self.cron_pending_job_ids is None:
+ return set()
+ pending: set[str] = set()
+ for job in self.cron_service.list_jobs(include_disabled=True):
+ session_key = job.payload.session_key
+ if not session_key and job.payload.origin_channel and job.payload.origin_chat_id:
+ session_key = f"{job.payload.origin_channel}:{job.payload.origin_chat_id}"
+ if session_key:
+ pending.update(self.cron_pending_job_ids(session_key))
+ return pending
+
+ def _handle_webui_automations(self, request: WsRequest) -> Response:
+ if not self.check_api_token(request):
+ return _http_error(401, "Unauthorized")
+ return _http_json_response(
+ all_automations_payload(
+ self.cron_service,
+ session_manager=self.session_manager,
+ pending_job_ids=self._pending_cron_job_ids_for_all(),
+ )
+ )
+
+ async def _handle_webui_automation_action(
+ self,
+ request: WsRequest,
+ action: str,
+ ) -> Response:
+ if not self.check_api_token(request):
+ return _http_error(401, "Unauthorized")
+ if self.cron_service is None:
+ return _http_error(503, "cron service unavailable")
+
+ query = _parse_query(request.path)
+ job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
+ if not job_id:
+ return _http_error(400, "missing automation id")
+ job = self.cron_service.get_job(job_id)
+ if job is None:
+ return _http_error(404, "automation not found")
+ if job.payload.kind == "system_event":
+ return _http_error(403, "system automation is protected")
+ if action in {"enable", "run"} and not is_bound_cron_job(job):
+ return _http_error(409, "automation has no linked chat")
+
+ if action == "enable":
+ if self.cron_service.enable_job(job_id, enabled=True) is None:
+ return _http_error(404, "automation not found")
+ elif action == "disable":
+ if self.cron_service.enable_job(job_id, enabled=False) is None:
+ return _http_error(404, "automation not found")
+ elif action == "delete":
+ result = self.cron_service.remove_job(job_id)
+ if result == "not_found":
+ return _http_error(404, "automation not found")
+ if result == "protected":
+ return _http_error(403, "system automation is protected")
+ elif action == "run":
+ if not job.enabled:
+ return _http_error(409, "automation is disabled")
+ task = asyncio.create_task(self.cron_service.run_job(job_id, force=False))
+ task.add_done_callback(self._log_automation_run_result)
+ elif action == "update":
+ values = _automation_values_from_request(request)
+ if values is None:
+ return _http_error(400, "invalid automation update payload")
+ parsed = _parse_automation_update(values, current_job=job)
+ if isinstance(parsed, str):
+ return _http_error(400, parsed)
+ try:
+ result = self.cron_service.update_job(job_id, **parsed)
+ except ValueError as exc:
+ return _http_error(400, str(exc))
+ if result == "not_found":
+ return _http_error(404, "automation not found")
+ if result == "protected":
+ return _http_error(403, "system automation is protected")
+ else:
+ return _http_error(404, "unknown automation action")
+
+ return self._handle_webui_automations(request)
+
+ @staticmethod
+ def _log_automation_run_result(task: asyncio.Task[bool]) -> None:
+ try:
+ ran = task.result()
+ except Exception:
+ logger.exception("WebUI automation run-now task failed")
+ return
+ if not ran:
+ logger.warning("WebUI automation run-now task did not execute")
+
# -- Media routes -------------------------------------------------------
def _dispatch_media_routes(self, request: WsRequest, got: str) -> Response | None:
@@ -662,5 +776,132 @@ class GatewayHTTPHandler:
extra_headers=[("Cache-Control", cache)],
)
+
+def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
+ raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
+ if not raw:
+ return {}
+ try:
+ values = json.loads(raw)
+ except Exception:
+ try:
+ values = json.loads(unquote(raw))
+ except Exception:
+ return None
+ return values if isinstance(values, dict) else None
+
+
+def _parse_automation_update(
+ values: dict[str, Any],
+ *,
+ current_job: CronJob | None = None,
+) -> dict[str, Any] | str:
+ update: dict[str, Any] = {}
+ if "name" in values:
+ raw_name = values.get("name")
+ if not isinstance(raw_name, str):
+ return "name must be a string"
+ name = raw_name.strip()
+ if not name:
+ return "name cannot be empty"
+ update["name"] = name
+ if "message" in values:
+ raw_message = values.get("message")
+ if not isinstance(raw_message, str):
+ return "message must be a string"
+ message = raw_message.strip()
+ if not message:
+ return "message cannot be empty"
+ update["message"] = message
+ if "schedule" in values:
+ raw_schedule = values.get("schedule")
+ if not isinstance(raw_schedule, dict):
+ return "schedule must be an object"
+ parsed_schedule = _parse_automation_schedule(raw_schedule)
+ if isinstance(parsed_schedule, str):
+ return parsed_schedule
+ if current_job is not None and _schedule_matches_job(parsed_schedule, current_job):
+ return update
+ schedule_error = _validate_automation_schedule(parsed_schedule)
+ if schedule_error:
+ return schedule_error
+ update["schedule"] = parsed_schedule
+ update["delete_after_run"] = parsed_schedule.kind == "at"
+ return update
+
+
+def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
+ raw_kind = values.get("kind")
+ if not isinstance(raw_kind, str):
+ return "schedule kind must be a string"
+ kind = raw_kind.strip()
+ if kind == "every":
+ every_ms = _positive_int(values.get("every_ms"))
+ if every_ms is None:
+ return "every schedule requires positive every_ms"
+ return CronSchedule(kind="every", every_ms=every_ms)
+ if kind == "cron":
+ raw_expr = values.get("expr")
+ if not isinstance(raw_expr, str):
+ return "cron schedule requires expr"
+ expr = raw_expr.strip()
+ if not expr:
+ return "cron schedule requires expr"
+ raw_tz = values.get("tz")
+ if raw_tz is not None and not isinstance(raw_tz, str):
+ return "cron schedule timezone must be a string"
+ tz = raw_tz.strip() if isinstance(raw_tz, str) else ""
+ return CronSchedule(kind="cron", expr=expr, tz=tz or None)
+ if kind == "at":
+ at_ms = _positive_int(values.get("at_ms"))
+ if at_ms is None:
+ return "one-time schedule requires positive at_ms"
+ return CronSchedule(kind="at", at_ms=at_ms)
+ return "unknown schedule kind"
+
+
+def _schedule_matches_job(schedule: CronSchedule, job: CronJob) -> bool:
+ current = job.schedule
+ if schedule.kind != current.kind:
+ return False
+ if schedule.kind == "at":
+ return schedule.at_ms == current.at_ms
+ if schedule.kind == "every":
+ return schedule.every_ms == current.every_ms
+ if schedule.kind == "cron":
+ return (schedule.expr or "") == (current.expr or "") and (
+ schedule.tz or None
+ ) == (current.tz or None)
+ return False
+
+
+def _validate_automation_schedule(schedule: CronSchedule) -> str | None:
+ if schedule.kind == "at":
+ if not schedule.at_ms or schedule.at_ms <= int(time.time() * 1000):
+ return "one-time schedule must be in the future"
+ return None
+ if schedule.kind != "cron":
+ return None
+
+ try:
+ from datetime import datetime
+ from zoneinfo import ZoneInfo
+
+ from croniter import croniter
+
+ tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
+ base = datetime.now(tz=tz)
+ croniter(schedule.expr, base).get_next(datetime)
+ except Exception:
+ return "cron schedule is invalid"
+ return None
+
+
+def _positive_int(value: Any) -> int | None:
+ if isinstance(value, bool) or not isinstance(value, int):
+ return None
+ return value if value > 0 else None
+
+
def _is_websocket_channel_session_key(key: str) -> bool:
return key.startswith("websocket:")
diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py
index b695665c..8fb80fcd 100644
--- a/tests/channels/test_websocket_channel.py
+++ b/tests/channels/test_websocket_channel.py
@@ -119,6 +119,42 @@ async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Re
)
+@pytest.mark.asyncio
+async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
+ class Conn:
+ remote_address = None
+
+ def __init__(self) -> None:
+ self.sent: list[str] = []
+
+ async def send(self, raw: str) -> None:
+ self.sent.append(raw)
+
+ channel = _ch(bus)
+ active_conn = Conn()
+ other_conn = Conn()
+ channel._attach(active_conn, "chat-a")
+ channel._attach(other_conn, "chat-b")
+ assert sorted(channel._subs) == ["chat-a", "chat-b"]
+ assert sum(len(conns) for conns in channel._subs.values()) == 2
+
+ await channel.send_session_updated("chat-a", scope="thread")
+
+ active_events = [json.loads(raw)["event"] for raw in active_conn.sent]
+ other_events = [json.loads(raw)["event"] for raw in other_conn.sent]
+
+ assert (active_events, other_events) == (
+ ["session_updated"],
+ ["session_updated"],
+ )
+ payload = json.loads(other_conn.sent[0])
+ assert payload == {
+ "event": "session_updated",
+ "chat_id": "chat-a",
+ "scope": "thread",
+ }
+
+
async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]:
"""Receive until a specific websocket event appears."""
for _ in range(10):
@@ -128,6 +164,10 @@ async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]:
raise AssertionError(f"websocket event {event!r} was not received")
+def _sent_ws_payloads(mock_ws: AsyncMock) -> list[dict[str, Any]]:
+ return [json.loads(call.args[0]) for call in mock_ws.send.await_args_list]
+
+
def test_normalize_http_path_strips_trailing_slash_except_root() -> None:
assert _normalize_http_path("/chat/") == "/chat"
assert _normalize_http_path("/chat?x=1") == "/chat"
@@ -1234,9 +1274,10 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
metadata={"_turn_end": True},
))
- mock_ws.send.assert_awaited_once()
- body = json.loads(mock_ws.send.await_args.args[0])
- assert body == {"event": "turn_end", "chat_id": "chat-1"}
+ assert _sent_ws_payloads(mock_ws) == [
+ {"event": "turn_end", "chat_id": "chat-1"},
+ {"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
+ ]
@pytest.mark.asyncio
@@ -1253,9 +1294,10 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
metadata={"_turn_end": True, "latency_ms": 1500},
))
- mock_ws.send.assert_awaited_once()
- body = json.loads(mock_ws.send.await_args.args[0])
- assert body == {"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500}
+ assert _sent_ws_payloads(mock_ws) == [
+ {"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500},
+ {"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
+ ]
@pytest.mark.asyncio
@@ -1273,9 +1315,10 @@ async def test_send_turn_end_includes_goal_state_when_present() -> None:
metadata={"_turn_end": True, "goal_state": blob},
))
- mock_ws.send.assert_awaited_once()
- body = json.loads(mock_ws.send.await_args.args[0])
- assert body == {"event": "turn_end", "chat_id": "chat-1", "goal_state": blob}
+ assert _sent_ws_payloads(mock_ws) == [
+ {"event": "turn_end", "chat_id": "chat-1", "goal_state": blob},
+ {"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
+ ]
@pytest.mark.asyncio
diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py
index fe74666c..b23d1097 100644
--- a/tests/channels/test_websocket_http_routes.py
+++ b/tests/channels/test_websocket_http_routes.py
@@ -3,12 +3,14 @@
import asyncio
import functools
import json
+import random
+import socket
import threading
import time
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
-from urllib.parse import urlencode
+from urllib.parse import quote, urlencode
import httpx
import pytest
@@ -23,6 +25,18 @@ from nanobot.webui.gateway_services import GatewayServices, build_gateway_servic
_PORT = 29900
+def _free_port() -> int:
+ for _ in range(100):
+ port = random.randint(30_000, 60_000)
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ try:
+ sock.bind(("127.0.0.1", port))
+ except OSError:
+ continue
+ return port
+ raise RuntimeError("could not find a free localhost port")
+
+
def _make_handler(
cfg: dict[str, Any] | WebSocketConfig,
bus: Any,
@@ -813,6 +827,255 @@ async def test_session_delete_removes_file(
await server_task
+@pytest.mark.asyncio
+async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
+ bus: MagicMock, tmp_path: Path
+) -> None:
+ port = _free_port()
+ base_url = f"http://127.0.0.1:{port}"
+ cron = CronService(tmp_path / "cron" / "jobs.json")
+ user_job = cron.add_job(
+ name="Daily repo check",
+ schedule=CronSchedule(kind="every", every_ms=86_400_000),
+ message="Check the repo status",
+ session_key="websocket:abc",
+ origin_channel="websocket",
+ origin_chat_id="abc",
+ )
+ incomplete_job = cron.add_job(
+ name="english-quiz",
+ schedule=CronSchedule(kind="every", every_ms=3_600_000),
+ message="Practice English",
+ session_key="unified:default",
+ )
+ external_job = cron.add_job(
+ name="WeChat quiz",
+ schedule=CronSchedule(kind="every", every_ms=3_600_000),
+ message="Send a quiz",
+ session_key="weixin:wx-chat",
+ origin_channel="weixin",
+ origin_chat_id="wx-chat",
+ )
+ past_one_shot_job = cron.add_job(
+ name="Past one-shot",
+ schedule=CronSchedule(kind="at", at_ms=1),
+ message="Old one-shot message",
+ session_key="websocket:abc",
+ origin_channel="websocket",
+ origin_chat_id="abc",
+ delete_after_run=True,
+ )
+ cron.register_system_job(
+ CronJob(
+ id="heartbeat",
+ name="heartbeat",
+ schedule=CronSchedule(kind="every", every_ms=60_000),
+ payload=CronPayload(kind="system_event"),
+ )
+ )
+ session_manager = _seed_session(tmp_path, key="websocket:abc")
+ external_session = Session(key="weixin:wx-chat")
+ external_session.add_message("user", "Scheduled cron job triggered")
+ session_manager.save(external_session)
+ channel = _ch(
+ bus,
+ session_manager=session_manager,
+ cron_service=cron,
+ cron_pending_job_ids=lambda key: {user_job.id} if key == "websocket:abc" else set(),
+ port=port,
+ )
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ deny = await _http_get(f"{base_url}/api/webui/automations")
+ assert deny.status_code == 401, deny.text
+
+ boot = await _http_get(f"{base_url}/webui/bootstrap")
+ token = boot.json()["token"]
+ auth = {"Authorization": f"Bearer {token}"}
+ resp = await _http_get(
+ f"{base_url}/api/webui/automations",
+ headers=auth,
+ )
+ assert resp.status_code == 200
+ assert "wx-chat" not in resp.text
+ assert "unified:default" not in resp.text
+ body = resp.json()
+ by_id = {job["id"]: job for job in body["jobs"]}
+ assert by_id[user_job.id]["protected"] is False
+ assert by_id[user_job.id]["state"]["pending"] is True
+ assert by_id[user_job.id]["state"]["run_history"] == []
+ assert by_id[user_job.id]["origin"]["session_key"] == "websocket:abc"
+ assert by_id[user_job.id]["origin"]["preview"] == "hi"
+ assert "session_key" not in by_id[incomplete_job.id]["payload"]
+ assert "origin_channel" not in by_id[incomplete_job.id]["payload"]
+ assert "origin_chat_id" not in by_id[incomplete_job.id]["payload"]
+ assert by_id[incomplete_job.id]["origin"] is None
+ assert "session_key" not in by_id[external_job.id]["payload"]
+ assert "origin_channel" not in by_id[external_job.id]["payload"]
+ assert "origin_chat_id" not in by_id[external_job.id]["payload"]
+ assert by_id[external_job.id]["origin"]["channel"] == "weixin"
+ assert "session_key" not in by_id[external_job.id]["origin"]
+ assert "chat_id" not in by_id[external_job.id]["origin"]
+ assert by_id[external_job.id]["origin"]["preview"] == ""
+ assert by_id["heartbeat"]["protected"] is True
+
+ updated = await _http_get(
+ f"{base_url}/api/webui/automations/update?id={user_job.id}",
+ headers={
+ **auth,
+ "X-Nanobot-Automation-Values": json.dumps(
+ {
+ "name": "Daily quiz",
+ "message": "Ask the daily quiz",
+ "schedule": {
+ "kind": "cron",
+ "expr": "0 9 * * *",
+ "tz": "UTC",
+ },
+ }
+ ),
+ },
+ )
+ assert updated.status_code == 200
+ by_id = {job["id"]: job for job in updated.json()["jobs"]}
+ assert by_id[user_job.id]["name"] == "Daily quiz"
+ assert by_id[user_job.id]["payload"]["message"] == "Ask the daily quiz"
+ assert by_id[user_job.id]["schedule"]["kind"] == "cron"
+ assert by_id[user_job.id]["schedule"]["expr"] == "0 9 * * *"
+ assert by_id[user_job.id]["schedule"]["tz"] == "UTC"
+
+ unicode_update = await _http_get(
+ f"{base_url}/api/webui/automations/update?id={user_job.id}",
+ headers={
+ **auth,
+ "X-Nanobot-Automation-Values": quote(
+ json.dumps(
+ {
+ "name": "每日测验",
+ "message": "问今日测验",
+ },
+ ensure_ascii=False,
+ ),
+ safe="",
+ ),
+ },
+ )
+ assert unicode_update.status_code == 200
+ assert cron.get_job(user_job.id).name == "每日测验"
+ assert cron.get_job(user_job.id).payload.message == "问今日测验"
+
+ malformed_update = await _http_get(
+ f"{base_url}/api/webui/automations/update?id={user_job.id}",
+ headers={
+ **auth,
+ "X-Nanobot-Automation-Values": json.dumps({"message": ["bad"]}),
+ },
+ )
+ assert malformed_update.status_code == 400
+ assert cron.get_job(user_job.id).payload.message == "问今日测验"
+
+ invalid_cron_update = await _http_get(
+ f"{base_url}/api/webui/automations/update?id={user_job.id}",
+ headers={
+ **auth,
+ "X-Nanobot-Automation-Values": json.dumps(
+ {"schedule": {"kind": "cron", "expr": "not a cron", "tz": "UTC"}}
+ ),
+ },
+ )
+ assert invalid_cron_update.status_code == 400
+ assert cron.get_job(user_job.id).schedule.expr == "0 9 * * *"
+
+ past_one_shot_update = await _http_get(
+ f"{base_url}/api/webui/automations/update?id={past_one_shot_job.id}",
+ headers={
+ **auth,
+ "X-Nanobot-Automation-Values": json.dumps(
+ {
+ "message": "Updated one-shot message",
+ "schedule": {"kind": "at", "at_ms": 1},
+ }
+ ),
+ },
+ )
+ assert past_one_shot_update.status_code == 200
+ assert cron.get_job(past_one_shot_job.id).payload.message == "Updated one-shot message"
+ assert cron.get_job(past_one_shot_job.id).schedule.at_ms == 1
+
+ protected_update = await _http_get(
+ f"{base_url}/api/webui/automations/update?id=heartbeat",
+ headers={
+ **auth,
+ "X-Nanobot-Automation-Values": json.dumps({"name": "bad"}),
+ },
+ )
+ assert protected_update.status_code == 403
+
+ disabled = await _http_get(
+ f"{base_url}/api/webui/automations/disable?id={user_job.id}",
+ headers=auth,
+ )
+ assert disabled.status_code == 200
+ by_id = {job["id"]: job for job in disabled.json()["jobs"]}
+ assert by_id[user_job.id]["enabled"] is False
+
+ disabled_run = await _http_get(
+ f"{base_url}/api/webui/automations/run?id={user_job.id}",
+ headers=auth,
+ )
+ assert disabled_run.status_code == 409
+
+ unbound_run = await _http_get(
+ f"{base_url}/api/webui/automations/run?id={incomplete_job.id}",
+ headers=auth,
+ )
+ assert unbound_run.status_code == 409
+ assert "no linked chat" in unbound_run.text
+
+ unbound_enable = await _http_get(
+ f"{base_url}/api/webui/automations/enable?id={incomplete_job.id}",
+ headers=auth,
+ )
+ assert unbound_enable.status_code == 409
+ assert "no linked chat" in unbound_enable.text
+
+ protected_delete = await _http_get(
+ f"{base_url}/api/webui/automations/delete?id=heartbeat",
+ headers=auth,
+ )
+ assert protected_delete.status_code == 403
+ protected_disable = await _http_get(
+ f"{base_url}/api/webui/automations/disable?id=heartbeat",
+ headers=auth,
+ )
+ assert protected_disable.status_code == 403
+ protected_run = await _http_get(
+ f"{base_url}/api/webui/automations/run?id=heartbeat",
+ headers=auth,
+ )
+ assert protected_run.status_code == 403
+
+ enabled = await _http_get(
+ f"{base_url}/api/webui/automations/enable?id={user_job.id}",
+ headers=auth,
+ )
+ assert enabled.status_code == 200
+ by_id = {job["id"]: job for job in enabled.json()["jobs"]}
+ assert by_id[user_job.id]["enabled"] is True
+
+ deleted = await _http_get(
+ f"{base_url}/api/webui/automations/delete?id={user_job.id}",
+ headers=auth,
+ )
+ assert deleted.status_code == 200
+ assert user_job.id not in {job["id"] for job in deleted.json()["jobs"]}
+ assert "heartbeat" in {job["id"] for job in deleted.json()["jobs"]}
+ finally:
+ await channel.stop()
+ await server_task
+
+
@pytest.mark.asyncio
async def test_session_delete_blocks_when_bound_automation_exists(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py
index d81d4121..2072e063 100644
--- a/tests/cron/test_cron_service.py
+++ b/tests/cron/test_cron_service.py
@@ -17,6 +17,14 @@ async def _wait_until(predicate, *, timeout: float = 1.0, interval: float = 0.01
assert predicate()
+def _bound_chat(chat_id: str = "chat-1") -> dict[str, str]:
+ return {
+ "session_key": f"websocket:{chat_id}",
+ "origin_channel": "websocket",
+ "origin_chat_id": chat_id,
+ }
+
+
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
@@ -37,12 +45,74 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None:
name="tz ok",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"),
message="hello",
+ **_bound_chat(),
)
assert job.schedule.tz == "America/Vancouver"
assert job.state.next_run_at_ms is not None
+@pytest.mark.asyncio
+async def test_unbound_agent_jobs_are_disabled_on_add(tmp_path) -> None:
+ called: list[str] = []
+
+ async def on_job(job):
+ called.append(job.id)
+
+ service = CronService(
+ tmp_path / "cron" / "jobs.json",
+ on_job=on_job,
+ )
+ job = service.add_job(
+ name="unbound",
+ schedule=CronSchedule(kind="every", every_ms=60_000),
+ message="hello",
+ )
+
+ assert job.enabled is False
+ assert job.state.next_run_at_ms is None
+ assert job.state.last_status == "error"
+ assert "missing bound session delivery context" in (job.state.last_error or "")
+ assert await service.run_job(job.id, force=True) is False
+ assert called == []
+
+
+def test_unbound_agent_jobs_are_disabled_on_load(tmp_path) -> None:
+ store_path = tmp_path / "cron" / "jobs.json"
+ store_path.parent.mkdir(parents=True)
+ store_path.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "jobs": [
+ {
+ "id": "unbound-1",
+ "name": "Unbound reminder",
+ "enabled": True,
+ "schedule": {"kind": "every", "everyMs": 60_000},
+ "payload": {
+ "kind": "agent_turn",
+ "message": "check status",
+ },
+ "state": {"nextRunAtMs": 1},
+ "createdAtMs": 1,
+ "updatedAtMs": 1,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ job = CronService(store_path).get_job("unbound-1")
+
+ assert job is not None
+ assert job.enabled is False
+ assert job.state.next_run_at_ms is None
+ assert job.state.last_status == "error"
+ assert "missing bound session delivery context" in (job.state.last_error or "")
+
+
def test_add_job_migrates_legacy_delivery_context(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
@@ -263,6 +333,7 @@ async def test_execute_job_records_run_history(tmp_path) -> None:
name="hist",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
await service.run_job(job.id)
@@ -287,6 +358,7 @@ async def test_run_history_records_errors(tmp_path) -> None:
name="fail",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
await service.run_job(job.id)
@@ -308,6 +380,7 @@ async def test_run_history_records_skipped_jobs(tmp_path) -> None:
name="skip",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
await service.run_job(job.id)
@@ -332,7 +405,7 @@ async def test_run_history_records_job_cancellation(tmp_path) -> None:
name="cancel",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
- session_key="websocket:chat-1",
+ **_bound_chat(),
)
assert await service.run_job(job.id) is True
@@ -355,6 +428,7 @@ async def test_run_history_trimmed_to_max(tmp_path) -> None:
name="trim",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
for _ in range(25):
await service.run_job(job.id)
@@ -371,6 +445,7 @@ async def test_run_history_persisted_to_disk(tmp_path) -> None:
name="persist",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
await service.run_job(job.id)
@@ -395,6 +470,7 @@ async def test_run_job_disabled_does_not_flip_running_state(tmp_path) -> None:
name="disabled",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
service.enable_job(job.id, enabled=False)
@@ -413,6 +489,7 @@ async def test_run_job_preserves_running_service_state(tmp_path) -> None:
name="manual",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
result = await service.run_job(job.id, force=True)
@@ -435,6 +512,7 @@ async def test_running_service_honors_external_disable(tmp_path) -> None:
name="external-disable",
schedule=CronSchedule(kind="every", every_ms=200),
message="hello",
+ **_bound_chat(),
)
await service.start()
try:
@@ -483,6 +561,7 @@ async def test_start_server_not_jobs(tmp_path):
name="hist",
schedule=CronSchedule(kind="every", every_ms=100),
message="hello",
+ **_bound_chat(),
)
assert len(service.list_jobs()) == 1
await _wait_until(lambda: bool(called), timeout=0.8)
@@ -503,6 +582,7 @@ async def test_subsecond_job_not_delayed_to_one_second(tmp_path):
name="fast",
schedule=CronSchedule(kind="every", every_ms=100),
message="hello",
+ **_bound_chat(),
)
await service.start()
try:
@@ -526,6 +606,7 @@ async def test_running_service_picks_up_external_add(tmp_path):
name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=100),
message="tick",
+ **_bound_chat("heartbeat"),
)
await service.start()
try:
@@ -536,6 +617,7 @@ async def test_running_service_picks_up_external_add(tmp_path):
name="external",
schedule=CronSchedule(kind="every", every_ms=100),
message="ping",
+ **_bound_chat("external"),
)
await _wait_until(lambda: "external" in called, timeout=0.8)
@@ -557,6 +639,7 @@ async def test_add_job_during_jobs_exec(tmp_path):
name="test",
schedule=CronSchedule(kind="every", every_ms=150),
message="tick",
+ **_bound_chat("test"),
)
run_once = False
@@ -565,6 +648,7 @@ async def test_add_job_during_jobs_exec(tmp_path):
name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=100),
message="tick",
+ **_bound_chat("heartbeat"),
)
assert len(service.list_jobs()) == 1
await service.start()
@@ -585,6 +669,7 @@ async def test_external_update_preserves_run_history_records(tmp_path):
name="history",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
await service.run_job(job.id, force=True)
@@ -626,6 +711,7 @@ async def test_timer_execution_is_not_rolled_back_by_list_jobs_reload(tmp_path):
name="race",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
job.state.next_run_at_ms = max(1, int(time.time() * 1000) - 1_000)
service._save_store()
@@ -650,6 +736,7 @@ def test_update_job_changes_name(tmp_path) -> None:
name="old name",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
result = service.update_job(job.id, name="new name")
assert isinstance(result, CronJob)
@@ -663,6 +750,7 @@ def test_update_job_changes_schedule(tmp_path) -> None:
name="sched",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
old_next = job.state.next_run_at_ms
@@ -679,6 +767,7 @@ def test_update_job_changes_message(tmp_path) -> None:
name="msg",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="old message",
+ **_bound_chat(),
)
result = service.update_job(job.id, message="new message")
assert isinstance(result, CronJob)
@@ -691,6 +780,7 @@ def test_update_job_changes_cron_expression(tmp_path) -> None:
name="cron-job",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
message="hello",
+ **_bound_chat(),
)
result = service.update_job(
job.id,
@@ -726,6 +816,7 @@ def test_update_job_validates_schedule(tmp_path) -> None:
name="validate",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
with pytest.raises(ValueError, match="unknown timezone"):
service.update_job(
@@ -743,6 +834,7 @@ async def test_update_job_preserves_run_history(tmp_path) -> None:
name="hist",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
await service.run_job(job.id)
@@ -758,6 +850,7 @@ def test_update_job_offline_writes_action(tmp_path) -> None:
name="offline",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
+ **_bound_chat(),
)
service.update_job(job.id, name="updated-offline")
@@ -811,6 +904,7 @@ async def test_list_jobs_during_on_job_does_not_cause_stale_reload(tmp_path) ->
name=name,
schedule=CronSchedule(kind="every", every_ms=3_600_000),
message="test",
+ **_bound_chat(name),
)
# Force next_run to the past so _on_timer picks them up
for job in service._store.jobs:
diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py
index bcf518ed..d2099d45 100644
--- a/tests/cron/test_cron_tool_list.py
+++ b/tests/cron/test_cron_tool_list.py
@@ -20,6 +20,14 @@ def _make_tool_with_tz(tmp_path, tz: str) -> CronTool:
return CronTool(service, default_timezone=tz)
+def _bound_chat(chat_id: str = "chat-1") -> dict[str, str]:
+ return {
+ "session_key": f"websocket:{chat_id}",
+ "origin_channel": "websocket",
+ "origin_chat_id": chat_id,
+ }
+
+
# -- _format_timing tests --
@@ -146,6 +154,7 @@ def test_list_cron_job_shows_expression_and_timezone(tmp_path) -> None:
name="Morning scan",
schedule=CronSchedule(kind="cron", expr="0 9 * * 1-5", tz="America/Denver"),
message="scan",
+ **_bound_chat(),
)
result = tool._list_jobs()
assert "cron: 0 9 * * 1-5 (America/Denver)" in result
@@ -157,6 +166,7 @@ def test_list_every_job_shows_human_interval(tmp_path) -> None:
name="Frequent check",
schedule=CronSchedule(kind="every", every_ms=1_800_000),
message="check",
+ **_bound_chat(),
)
result = tool._list_jobs()
assert "every 30m" in result
@@ -168,6 +178,7 @@ def test_list_every_job_hours(tmp_path) -> None:
name="Hourly check",
schedule=CronSchedule(kind="every", every_ms=7_200_000),
message="check",
+ **_bound_chat(),
)
result = tool._list_jobs()
assert "every 2h" in result
@@ -179,6 +190,7 @@ def test_list_every_job_seconds(tmp_path) -> None:
name="Fast check",
schedule=CronSchedule(kind="every", every_ms=30_000),
message="check",
+ **_bound_chat(),
)
result = tool._list_jobs()
assert "every 30s" in result
@@ -190,6 +202,7 @@ def test_list_every_job_non_minute_seconds(tmp_path) -> None:
name="Ninety-second check",
schedule=CronSchedule(kind="every", every_ms=90_000),
message="check",
+ **_bound_chat(),
)
result = tool._list_jobs()
assert "every 90s" in result
@@ -201,6 +214,7 @@ def test_list_every_job_milliseconds(tmp_path) -> None:
name="Sub-second check",
schedule=CronSchedule(kind="every", every_ms=200),
message="check",
+ **_bound_chat(),
)
result = tool._list_jobs()
assert "every 200ms" in result
@@ -212,6 +226,7 @@ def test_list_at_job_shows_iso_timestamp(tmp_path) -> None:
name="One-shot",
schedule=CronSchedule(kind="at", at_ms=1773684000000),
message="fire",
+ **_bound_chat(),
)
result = tool._list_jobs()
assert "at 2026-" in result
@@ -226,6 +241,7 @@ async def test_list_shows_last_run_state(tmp_path) -> None:
name="Stateful job",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
message="test",
+ **_bound_chat(),
)
# Simulate a completed run by updating state in the store
job.state.last_run_at_ms = 1773673200000
@@ -245,6 +261,7 @@ async def test_list_shows_error_message(tmp_path) -> None:
name="Failed job",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
message="test",
+ **_bound_chat(),
)
job.state.last_run_at_ms = 1773673200000
job.state.last_status = "error"
@@ -262,6 +279,7 @@ def test_list_shows_next_run(tmp_path) -> None:
name="Upcoming job",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
message="test",
+ **_bound_chat(),
)
result = tool._list_jobs()
assert "Next run:" in result
diff --git a/tests/webui/test_session_list_index.py b/tests/webui/test_session_list_index.py
index c8b0adf6..cc10f8c1 100644
--- a/tests/webui/test_session_list_index.py
+++ b/tests/webui/test_session_list_index.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import os
+from datetime import datetime
from pathlib import Path
import nanobot.webui.session_list_index as session_list_index
@@ -86,5 +88,86 @@ def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) ->
assert list_webui_sessions(manager)[0]["preview"] == "提醒已经到期。"
+def test_webui_session_list_uses_webui_transcript_activity_for_sort(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ webui_dir = tmp_path / "webui"
+ webui_dir.mkdir()
+ monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
+
+ manager = SessionManager(tmp_path)
+ old_session = manager.get_or_create("websocket:old-metadata")
+ old_session.created_at = datetime(2026, 6, 15, 10, 0, 0)
+ old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
+ old_session.add_message("user", "old metadata")
+ old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
+ manager.save(old_session)
+
+ newer_metadata = manager.get_or_create("websocket:newer-metadata")
+ newer_metadata.created_at = datetime(2026, 6, 15, 11, 0, 0)
+ newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0)
+ newer_metadata.add_message("user", "newer metadata")
+ newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0)
+ manager.save(newer_metadata)
+
+ transcript = webui_dir / "websocket_old-metadata.jsonl"
+ transcript.write_text(
+ '{"event":"turn_end","chat_id":"old-metadata"}\n',
+ encoding="utf-8",
+ )
+ activity_ns = int(datetime(2026, 6, 15, 12, 0, 0).timestamp() * 1_000_000_000)
+ os.utime(transcript, ns=(activity_ns, activity_ns))
+
+ rows = list_webui_sessions(manager)
+
+ assert [row["key"] for row in rows] == [
+ "websocket:old-metadata",
+ "websocket:newer-metadata",
+ ]
+ assert rows[0]["updated_at"].startswith("2026-06-15T12:00:00")
+
+
+def test_webui_session_list_rescans_when_transcript_changes(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ webui_dir = tmp_path / "webui"
+ webui_dir.mkdir()
+ monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
+
+ manager = SessionManager(tmp_path)
+ session = manager.get_or_create("websocket:transcript-change")
+ session.created_at = datetime(2026, 6, 15, 10, 0, 0)
+ session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
+ session.add_message("user", "preview")
+ session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
+ manager.save(session)
+
+ assert list_webui_sessions(manager)[0]["preview"] == "preview"
+
+ transcript = webui_dir / "websocket_transcript-change.jsonl"
+ transcript.write_text(
+ '{"event":"turn_end","chat_id":"transcript-change"}\n',
+ encoding="utf-8",
+ )
+ activity_ns = int(datetime(2026, 6, 15, 12, 30, 0).timestamp() * 1_000_000_000)
+ os.utime(transcript, ns=(activity_ns, activity_ns))
+
+ original_scan = session_list_index._scan_session_row
+ scanned: list[str] = []
+
+ def record_scan(session_manager: SessionManager, path: Path) -> dict | None:
+ scanned.append(path.name)
+ return original_scan(session_manager, path)
+
+ monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan)
+
+ rows = list_webui_sessions(manager)
+
+ assert scanned == [manager._get_session_path("websocket:transcript-change").name]
+ assert rows[0]["updated_at"].startswith("2026-06-15T12:30:00")
+
+
def list_webui_sessions(manager: SessionManager) -> list[dict]:
return session_list_index.list_webui_sessions(manager)
diff --git a/webui/README.md b/webui/README.md
index 2730a272..998ea02b 100644
--- a/webui/README.md
+++ b/webui/README.md
@@ -1,6 +1,10 @@
-# nanobot WebUI
+# nanobot WebUI Source
-The WebUI is the browser workbench served by `nanobot gateway`. If you installed `nanobot-ai` from PyPI, the WebUI bundle is already included; this `webui/` source tree is only needed when you are changing the frontend.
+This directory contains the React/TypeScript source for the nanobot WebUI. If
+you installed `nanobot-ai` from PyPI and only want to use the bundled browser UI,
+read the user guide in [`docs/webui.md`](../docs/webui.md). You do not need
+Node.js, Bun, Vite, or anything in this directory unless you are changing the
+frontend.
For the project overview, install guide, and general docs map, see the root [`README.md`](../README.md) and [`docs/README.md`](../docs/README.md).
@@ -8,46 +12,14 @@ For the project overview, install guide, and general docs map, see the root [`RE
| Goal | Start with | Opens at |
|---|---|---|
-| Use the bundled browser UI | [Just want to use the WebUI?](#just-want-to-use-the-webui) | `http://127.0.0.1:8765` |
-| Use the WebUI from another device | [Access from another device (LAN)](#access-from-another-device-lan) | `http://:8765` |
+| Use the bundled browser UI | [`docs/webui.md`](../docs/webui.md) | `http://127.0.0.1:8765` |
+| Use the WebUI from another device | [`docs/webui.md#lan-access`](../docs/webui.md#lan-access) | `http://:8765` |
| Change WebUI source code | [Develop the WebUI (Vite HMR)](#develop-the-webui-vite-hmr) | `http://127.0.0.1:5173` |
| Debug setup failures | [`docs/troubleshooting.md#webui-problems`](../docs/troubleshooting.md#webui-problems) | Diagnosis order and common fixes |
-## Just want to use the WebUI?
-
-If you installed nanobot via `python -m pip install nanobot-ai`, the WebUI is **already bundled** in the wheel. You do **not** need Node.js, Bun, Vite, or anything in this directory unless you are changing the WebUI source code.
-
-First prove the provider path:
-
-```bash
-nanobot agent -m "Hello!"
-```
-
-If the shell cannot find `nanobot`, use the module form from the same Python environment:
-
-```bash
-python -m nanobot agent -m "Hello!"
-```
-
-Then merge this WebSocket snippet into your existing `~/.nanobot/config.json` instead of replacing the whole file:
-
-```json
-{ "channels": { "websocket": { "enabled": true } } }
-```
-
-If you are new to JSON snippets, see [`docs/start-without-technical-background.md#how-to-merge-json-snippets`](../docs/start-without-technical-background.md#how-to-merge-json-snippets).
-
-Start the gateway:
-
-```bash
-nanobot gateway
-```
-
-Leave this terminal running while you use the WebUI. Closing it stops the browser UI and WebSocket connection.
-
-Open [`http://127.0.0.1:8765`](http://127.0.0.1:8765). The gateway's `18790` port is only the health endpoint, not the browser UI. For setup failures, use [`docs/troubleshooting.md`](../docs/troubleshooting.md#webui-problems).
-
-This `webui/` tree is for people **changing the WebUI source code**. It is built with Vite + React 18 + TypeScript + Tailwind 3 + shadcn/ui, talks to the gateway over the WebSocket multiplex protocol, and reads session metadata from the embedded REST surface on the same port.
+The source app is built with Vite + React 18 + TypeScript + Tailwind 3 +
+shadcn/ui. It talks to the gateway over the WebSocket multiplex protocol and
+reads session metadata from the embedded REST surface on the same port.
## Layout
@@ -104,27 +76,6 @@ If your gateway listens on a non-default port, point the dev server at it:
NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
```
-### Access from another device (LAN)
-
-To use the WebUI from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
-
-```json
-{
- "channels": {
- "websocket": {
- "enabled": true,
- "host": "0.0.0.0",
- "port": 8765,
- "tokenIssueSecret": "your-secret-here"
- }
- }
-}
-```
-
-The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
-
-Then open `http://:8765` on the other device. The WebUI will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
-
## Build for packaged runtime
You usually do not need to run this by hand: `python -m build` invokes the WebUI build automatically when packaging the wheel.
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index 722a6e55..ee035c2f 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -65,14 +65,15 @@ type BootState =
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
-const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
+const SESSION_UPDATES_STORAGE_KEY = "nanobot-webui.sidebar.session-updates.v1";
+const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
-type ShellView = "chat" | "settings" | "apps" | "skills";
+type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
type ShellRoute = {
view: ShellView;
activeKey: string | null;
@@ -87,6 +88,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"voice",
"browser",
"apps",
+ "automations",
"skills",
"runtime",
"advanced",
@@ -101,7 +103,7 @@ function defaultShellRoute(): ShellRoute {
}
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
- if (section === "apps" || section === "skills") return section;
+ if (section === "apps" || section === "automations" || section === "skills") return section;
return "settings";
}
@@ -130,6 +132,9 @@ function readShellRoute(): ShellRoute {
if (path === "/apps") {
return { view: "apps", activeKey, settingsSection: "apps" };
}
+ if (path === "/automations") {
+ return { view: "automations", activeKey, settingsSection: "automations" };
+ }
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
@@ -255,10 +260,12 @@ function readSidebarOpen(): boolean {
}
}
-function readCompletedRunChatIds(): Set {
+function readSessionUpdateChatIds(): Set {
if (typeof window === "undefined") return new Set();
try {
- const raw = window.localStorage.getItem(COMPLETED_RUNS_STORAGE_KEY);
+ const raw =
+ window.localStorage.getItem(SESSION_UPDATES_STORAGE_KEY)
+ ?? window.localStorage.getItem(LEGACY_COMPLETED_RUNS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((item): item is string => typeof item === "string"));
@@ -267,10 +274,10 @@ function readCompletedRunChatIds(): Set {
}
}
-function writeCompletedRunChatIds(chatIds: Set): void {
+function writeSessionUpdateChatIds(chatIds: Set): void {
try {
window.localStorage.setItem(
- COMPLETED_RUNS_STORAGE_KEY,
+ SESSION_UPDATES_STORAGE_KEY,
JSON.stringify(Array.from(chatIds)),
);
} catch {
@@ -570,7 +577,7 @@ function Shell({
const [restartToast, setRestartToast] = useState(null);
const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState>(() => new Set());
- const [completedChatIds, setCompletedChatIds] = useState>(readCompletedRunChatIds);
+ const [updatedChatIds, setUpdatedChatIds] = useState>(readSessionUpdateChatIds);
const [workspaces, setWorkspaces] = useState(null);
const skills = useSkills(token);
const [settingsSnapshot, setSettingsSnapshot] = useState(null);
@@ -638,20 +645,20 @@ function Shell({
}, [hostSidebarOpen]);
useEffect(() => {
- writeCompletedRunChatIds(completedChatIds);
- }, [completedChatIds]);
+ writeSessionUpdateChatIds(updatedChatIds);
+ }, [updatedChatIds]);
const activeSession = useMemo(() => {
if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
- const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
+ const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
useEffect(() => {
activeChatIdRef.current = activeChatId;
if (!activeChatId) return;
- setCompletedChatIds((current) => {
+ setUpdatedChatIds((current) => {
if (!current.has(activeChatId)) return current;
const next = new Set(current);
next.delete(activeChatId);
@@ -691,7 +698,7 @@ function Shell({
useEffect(() => {
if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId));
- setCompletedChatIds((current) => {
+ setUpdatedChatIds((current) => {
const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
);
@@ -719,12 +726,25 @@ function Shell({
}, [activeKey, loading, navigate, sessions]);
useEffect(() => {
- return client.onSessionUpdate((_chatId, _scope, workspaceScope) => {
+ return client.onSessionUpdate((chatId, scope, workspaceScope) => {
+ if (scope === "thread") {
+ setUpdatedChatIds((current) => {
+ const next = new Set(current);
+ if (activeChatIdRef.current === chatId) {
+ next.delete(chatId);
+ } else {
+ next.add(chatId);
+ }
+ return next.size === current.size && next.has(chatId) === current.has(chatId)
+ ? current
+ : next;
+ });
+ }
if (!workspaceScope) return;
const next = normalizeWorkspaceScope(workspaceScope);
setWorkspaceOverrides((current) => ({
...current,
- [_chatId]: next,
+ [chatId]: next,
}));
setDraftWorkspaceScope(next);
setWorkspaceError(null);
@@ -761,7 +781,7 @@ function Shell({
runningChatIdsRef.current = next;
return next;
});
- setCompletedChatIds((current) => {
+ setUpdatedChatIds((current) => {
let changed = false;
const next = new Set(current);
for (const chatId of activeRunIds) {
@@ -958,7 +978,7 @@ function Shell({
const selected = sessions.find((session) => session.key === key);
const selectedChatId = selected?.chatId;
if (selectedChatId) {
- setCompletedChatIds((current) => {
+ setUpdatedChatIds((current) => {
if (!current.has(selectedChatId)) return current;
const next = new Set(current);
next.delete(selectedChatId);
@@ -1166,6 +1186,12 @@ function Shell({
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
+ const onOpenAutomations = useCallback(() => {
+ setSessionSearchOpen(false);
+ navigate({ view: "automations", activeKey, settingsSection: "automations" });
+ setMobileSidebarOpen(false);
+ }, [activeKey, navigate]);
+
const onOpenSkills = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "skills", activeKey, settingsSection: "skills" });
@@ -1223,7 +1249,7 @@ function Shell({
nextRunning.add(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
- setCompletedChatIds((current) => {
+ setUpdatedChatIds((current) => {
if (!current.has(chatId)) return current;
const next = new Set(current);
next.delete(chatId);
@@ -1237,7 +1263,7 @@ function Shell({
nextRunning.delete(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
- setCompletedChatIds((current) => {
+ setUpdatedChatIds((current) => {
const next = new Set(current);
if (activeChatIdRef.current === chatId) {
next.delete(chatId);
@@ -1341,6 +1367,12 @@ function Shell({
});
return;
}
+ if (view === "automations") {
+ document.title = t("app.documentTitle.chat", {
+ title: t("settings.nav.automations", { defaultValue: "Automations" }),
+ });
+ return;
+ }
if (view === "skills") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.skills", { defaultValue: "Skills" }),
@@ -1367,9 +1399,10 @@ function Shell({
onNewChatInProject,
onOpenSettings,
onOpenApps,
+ onOpenAutomations,
onOpenSkills,
onOpenSearch: onOpenSessionSearch,
- activeUtility: view === "apps" || view === "skills" ? view : null,
+ activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
onToggleArchived,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
@@ -1377,7 +1410,7 @@ function Shell({
projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList,
- completedChatIds: completedChatIdList,
+ updatedChatIds: updatedChatIdList,
viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length,
diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx
index ccc44a45..889e1a10 100644
--- a/webui/src/components/ChatList.tsx
+++ b/webui/src/components/ChatList.tsx
@@ -60,7 +60,7 @@ interface ChatListProps {
projectNameOverrides?: Record;
collapsedGroups?: Record;
runningChatIds?: string[];
- completedChatIds?: string[];
+ updatedChatIds?: string[];
density?: SidebarDensity;
showPreviews?: boolean;
showTimestamps?: boolean;
@@ -89,7 +89,7 @@ export const ChatList = memo(function ChatList({
projectNameOverrides = {},
collapsedGroups = {},
runningChatIds = [],
- completedChatIds = [],
+ updatedChatIds = [],
density = "comfortable",
showPreviews = false,
showTimestamps = false,
@@ -175,7 +175,7 @@ export const ChatList = memo(function ChatList({
const pinned = new Set(pinnedKeys);
const archived = new Set(archivedKeys);
const running = new Set(runningChatIds);
- const completed = new Set(completedChatIds);
+ const updated = new Set(updatedChatIds);
const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
@@ -245,8 +245,8 @@ export const ChatList = memo(function ChatList({
const projectMode = group.kind === "project";
const activityState = running.has(s.chatId)
? "running"
- : completed.has(s.chatId) && !active
- ? "complete"
+ : updated.has(s.chatId) && !active
+ ? "updated"
: null;
return (
+ {jobs.length
+ ? tx("settings.automations.noMatches", "No automations match this view.")
+ : tx("settings.automations.empty", "No automations yet.")}
+
+ {!jobs.length ? (
+
+ {tx(
+ "settings.automations.emptyHint",
+ "Create one from where it should run so nanobot keeps the right context.",
+ )}
+