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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user