feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)

* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
+160 -1
View File
@@ -12,6 +12,8 @@ import httpx
import pytest
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
@@ -24,10 +26,12 @@ def _make_handler(
*,
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = Path.cwd()
workspace = workspace_path or Path.cwd()
return build_gateway_services(
config=config,
bus=bus,
@@ -38,6 +42,7 @@ def _make_handler(
runtime_model_name=runtime_model_name,
runtime_surface="browser",
runtime_capabilities_overrides=None,
cron_service=cron_service,
)
@@ -46,8 +51,10 @@ def _ch(
*,
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
port: int = _PORT,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -63,7 +70,9 @@ def _ch(
cfg, bus,
session_manager=session_manager,
static_dist_path=static_dist_path,
workspace_path=workspace_path,
runtime_model_name=runtime_model_name,
cron_service=cron_service,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -161,6 +170,156 @@ async def test_sessions_routes_require_bearer_token(
await server_task
@pytest.mark.asyncio
async def test_session_automations_route_filters_by_webui_session(
bus: MagicMock, tmp_path: Path
) -> None:
cron = CronService(tmp_path / "cron" / "jobs.json")
hourly = CronSchedule(kind="every", every_ms=3_600_000)
for name, message, to in (
("Morning check", "Check the project status", "abc"),
("Other session", "Do not show", "other"),
):
cron.add_job(
name=name,
schedule=hourly,
message=message,
channel="websocket",
to=to,
session_key=f"websocket:{to}",
)
cron.register_system_job(
CronJob(
id="heartbeat",
name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=60_000),
payload=CronPayload(kind="system_event"),
)
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
cron_service=cron,
port=29914,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get(
"http://127.0.0.1:29914/api/sessions/websocket:abc/automations"
)
assert deny.status_code == 401
boot = await _http_get("http://127.0.0.1:29914/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get(
"http://127.0.0.1:29914/api/sessions/websocket%3Aabc/automations",
headers=auth,
)
assert resp.status_code == 200
body = resp.json()
assert [job["name"] for job in body["jobs"]] == ["Morning check"]
job = body["jobs"][0]
assert job["schedule"]["kind"] == "every"
assert job["schedule"]["every_ms"] == 3_600_000
assert job["payload"]["message"] == "Check the project status"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_webui_skills_route_requires_token_and_hides_paths(
bus: MagicMock, tmp_path: Path
) -> None:
workspace_skill = tmp_path / "skills" / "workspace-skill"
workspace_skill.mkdir(parents=True)
(workspace_skill / "SKILL.md").write_text(
"---\nname: workspace-skill\ndescription: Workspace skill.\n---\n",
encoding="utf-8",
)
unavailable_skill = tmp_path / "skills" / "zz-unavailable-skill"
unavailable_skill.mkdir(parents=True)
(unavailable_skill / "SKILL.md").write_text(
"\n".join([
"---",
"name: zz-unavailable-skill",
"description: Missing CLI skill.",
"metadata:",
" nanobot:",
" requires:",
" bins:",
" - definitely-missing-nanobot-skill-cli",
" env:",
" - DEFINITELY_MISSING_NANOBOT_SKILL_ENV",
"---",
"Use the missing CLI and env var.",
]),
encoding="utf-8",
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=29920,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29920/api/webui/skills")
assert deny.status_code == 401
deny_detail = await _http_get("http://127.0.0.1:29920/api/webui/skills/workspace-skill")
assert deny_detail.status_code == 401
boot = await _http_get("http://127.0.0.1:29920/webui/bootstrap")
token = boot.json()["token"]
resp = await _http_get(
"http://127.0.0.1:29920/api/webui/skills",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
body = resp.json()
names = [skill["name"] for skill in body["skills"]]
assert names[0] == "workspace-skill"
assert "cron" in names
assert all("path" not in skill for skill in body["skills"])
workspace = body["skills"][0]
assert workspace == {
"name": "workspace-skill",
"description": "Workspace skill.",
"source": "workspace",
"available": True,
"unavailable_reason": "",
}
unavailable = next(skill for skill in body["skills"] if skill["name"] == "zz-unavailable-skill")
assert unavailable["available"] is False
assert unavailable["unavailable_reason"] == (
"CLI: definitely-missing-nanobot-skill-cli, "
"ENV: DEFINITELY_MISSING_NANOBOT_SKILL_ENV"
)
detail = await _http_get(
"http://127.0.0.1:29920/api/webui/skills/zz-unavailable-skill",
headers={"Authorization": f"Bearer {token}"},
)
assert detail.status_code == 200
detail_body = detail.json()
assert "path" not in detail_body
assert detail_body["requirements"] == {
"bins": ["definitely-missing-nanobot-skill-cli"],
"env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"],
"missing_bins": ["definitely-missing-nanobot-skill-cli"],
"missing_env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"],
}
assert "Use the missing CLI and env var." in detail_body["raw_markdown"]
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_cli_apps_routes_require_token_and_return_payload(
bus: MagicMock,