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
+173 -2
View File
@@ -104,6 +104,7 @@ def bus() -> MagicMock:
@pytest.fixture(autouse=True)
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
monkeypatch.setattr(
"nanobot.webui.workspaces.get_webui_dir",
lambda: tmp_path / "webui",
@@ -277,6 +278,8 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
@pytest.mark.asyncio
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
from nanobot.webui.transcript import read_transcript_lines
channel = _ch(bus)
conn = MagicMock()
conn.remote_address = ("127.0.0.1", 50123)
@@ -284,14 +287,30 @@ async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) ->
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
{
"type": "message",
"chat_id": "chat-1",
"content": "hello",
"webui": True,
"turn_id": "turn-1",
},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
assert msg.metadata["webui"] is True
assert msg.metadata["webui_turn_id"] == "turn-1"
assert msg.metadata["_wants_stream"] is True
lines = read_transcript_lines("websocket:chat-1")
assert lines == [{
"event": "user",
"chat_id": "chat-1",
"text": "hello",
"turn_id": "turn-1",
"turn_phase": "user",
"turn_seq": 1,
}]
@pytest.mark.asyncio
@@ -359,7 +378,7 @@ async def test_webui_user_transcript_append_failure_does_not_block_inbound(
def fail_append(_session_key: str, _obj: dict[str, Any]) -> None:
raise OSError("disk full")
monkeypatch.setattr("nanobot.channels.websocket.append_transcript_object", fail_append)
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", fail_append)
channel = _ch(bus)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@@ -664,6 +683,58 @@ async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp
assert sessions.read_session_file("websocket:chat-remote") is None
@pytest.mark.asyncio
async def test_native_webui_scope_allows_custom_scope_without_loopback(
bus: MagicMock,
tmp_path,
) -> None:
default_workspace = tmp_path / "default"
project = tmp_path / "project"
default_workspace.mkdir()
project.mkdir()
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(
bus,
session_manager=sessions,
workspace_path=default_workspace,
runtime_surface="native",
),
)
conn = AsyncMock()
conn.remote_address = None
await channel._dispatch_envelope(
conn,
"native-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-native",
"workspace_scope": {
"project_path": str(project),
"access_mode": "full",
},
},
)
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "session_updated"
assert payload["chat_id"] == "chat-native"
assert payload["workspace_scope"]["project_path"] == str(project.resolve())
assert payload["workspace_scope"]["project_name"] == "project"
assert payload["workspace_scope"]["access_mode"] == "full"
assert payload["workspace_scope"]["restrict_to_workspace"] is False
assert payload["workspace_scope"]["sandbox_status"]["restrict_to_workspace"] is False
assert payload["workspace_scope"]["sandbox_status"]["workspace_root"] == str(project.resolve())
saved = sessions.read_session_file("websocket:chat-native")
assert saved["metadata"]["workspace_scope"] == {
"project_path": str(project.resolve()),
"access_mode": "full",
}
@pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock()
@@ -799,6 +870,7 @@ async def test_send_progress_includes_structured_tool_events() -> None:
metadata={
"_progress": True,
"_tool_hint": True,
"webui_turn_id": "turn-1",
"_tool_events": [
{
"version": 1,
@@ -818,6 +890,9 @@ async def test_send_progress_includes_structured_tool_events() -> None:
payload = json.loads(mock_ws.send.await_args.args[0])
assert payload["event"] == "message"
assert payload["kind"] == "tool_hint"
assert payload["turn_id"] == "turn-1"
assert payload["turn_phase"] == "activity"
assert payload["turn_seq"] == 1
assert payload["tool_events"] == [
{
"version": 1,
@@ -1091,6 +1166,37 @@ async def test_send_reasoning_without_subscribers_is_noop() -> None:
assert channel._subs == {}
@pytest.mark.asyncio
async def test_stream_transcript_persists_without_subscribers() -> None:
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
gateway=_basic_handler(bus),
)
await channel.send_delta("chat-1", "hello", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", " world", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "s1"})
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "latency_ms": 42},
))
assert channel._subs == {}
lines = read_transcript_lines("websocket:chat-1")
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
body = build_webui_thread_response("websocket:chat-1")
assert body is not None
assert body["messages"][-1]["role"] == "assistant"
assert body["messages"][-1]["content"] == "hello world"
assert body["messages"][-1]["latencyMs"] == 42
@pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock()
@@ -2494,6 +2600,71 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
assert body["messages"][0]["content"] == "hi"
def test_handle_file_preview_returns_workspace_file(tmp_path) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
workspace = tmp_path / "workspace"
source = workspace / "nanobot" / "agent" / "hook.py"
source.parent.mkdir(parents=True)
source.write_text("print('hello')\n", encoding="utf-8")
gateway = _basic_handler(MagicMock(), workspace_path=workspace)
gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
key = "websocket:file-preview"
enc = quote(key, safe="")
path = quote("nanobot/agent/hook.py:12", safe="")
req = Request(
f"/api/sessions/{enc}/file-preview?path={path}",
Headers([("Authorization", "Bearer tok")]),
)
resp = gateway.http._handle_file_preview(req, enc)
assert resp.status_code == 200
body = json.loads(resp.body.decode())
assert body["display_path"] == "nanobot/agent/hook.py"
assert body["language"] == "python"
assert body["content"].splitlines() == ["print('hello')"]
assert body["truncated"] is False
def test_file_preview_normalizes_windows_file_url() -> None:
from nanobot.webui.file_preview import _clean_preview_path
assert _clean_preview_path("file:///C:/Users/me/project/app.py") == (
"C:/Users/me/project/app.py"
)
assert _clean_preview_path("file:///tmp/project/app.py") == "/tmp/project/app.py"
def test_handle_file_preview_rejects_paths_outside_workspace(tmp_path) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "secret.py"
outside.write_text("secret = True\n", encoding="utf-8")
gateway = _basic_handler(MagicMock(), workspace_path=workspace)
gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
key = "websocket:file-preview"
enc = quote(key, safe="")
req = Request(
f"/api/sessions/{enc}/file-preview?path={quote(str(outside), safe='')}",
Headers([("Authorization", "Bearer tok")]),
)
resp = gateway.http._handle_file_preview(req, enc)
assert resp.status_code == 403
def test_handle_webui_thread_get_backfills_legacy_missing_user_rows(
tmp_path,
monkeypatch,