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
+171 -2
View File
@@ -43,6 +43,177 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-turn"
for ev in (
{
"event": "user",
"chat_id": "t-turn",
"text": "q",
"turn_id": "turn-1",
"turn_phase": "user",
"turn_seq": 1,
},
{
"event": "reasoning_delta",
"chat_id": "t-turn",
"text": "think",
"turn_id": "turn-1",
"turn_phase": "reasoning",
"turn_seq": 2,
},
{
"event": "delta",
"chat_id": "t-turn",
"text": "a",
"turn_id": "turn-1",
"turn_phase": "answer",
"turn_seq": 3,
},
{
"event": "turn_end",
"chat_id": "t-turn",
"latency_ms": 12,
"turn_id": "turn-1",
"turn_phase": "complete",
"turn_seq": 4,
},
):
append_transcript_object(key, ev)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert msgs[0]["turnId"] == "turn-1"
assert msgs[0]["turnPhase"] == "user"
assert msgs[0]["turnSeq"] == 1
assert msgs[1]["turnId"] == "turn-1"
assert msgs[1]["turnPhase"] == "answer"
assert msgs[1]["turnSeq"] == 3
def test_replay_reused_turn_id_after_turn_end_starts_new_turn(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-reused-turn"
def event(
event: str,
phase: str,
seq: int,
text: str | None = None,
source: dict[str, str] | None = None,
) -> dict[str, object]:
out = {
"event": event,
"chat_id": "t-reused-turn",
"turn_id": "turn-1",
"turn_phase": phase,
"turn_seq": seq,
}
if text is not None:
out["text"] = text
if source is not None:
out["source"] = source
return out
for record in (
event("user", "user", 1, "remind me later"),
event("message", "answer", 2, "Reminder set."),
event("turn_end", "complete", 3),
event(
"message", "answer", 1, "Time to drink water.",
{"kind": "cron", "label": "drink water"},
),
event("turn_end", "complete", 2),
):
append_transcript_object(key, record)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert [m["content"] for m in msgs] == [
"remind me later",
"Reminder set.",
"Time to drink water.",
]
assert msgs[1]["turnId"] == "turn-1"
assert msgs[2]["turnId"].startswith("turn-1:replay:")
assert msgs[2]["turnId"] != msgs[1]["turnId"]
assert msgs[2]["source"] == {"kind": "cron", "label": "drink water"}
def test_build_response_restores_session_users_for_legacy_transcript(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:legacy-users"
append_transcript_object(
key,
{"event": "message", "chat_id": "legacy-users", "text": "assistant one"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "legacy-users"})
append_transcript_object(
key,
{"event": "message", "chat_id": "legacy-users", "text": "assistant two"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "legacy-users"})
out = build_webui_thread_response(
key,
session_messages=[
{"role": "user", "content": "prompt one", "timestamp": "2026-06-02T10:00:00"},
{"role": "assistant", "content": "assistant one"},
{"role": "user", "content": "prompt two", "timestamp": "2026-06-02T10:01:00"},
{"role": "assistant", "content": "assistant two"},
],
)
assert out is not None
assert [(m["role"], m["content"]) for m in out["messages"]] == [
("user", "prompt one"),
("assistant", "assistant one"),
("user", "prompt two"),
("assistant", "assistant two"),
]
def test_build_response_restores_session_users_without_duplicating_new_transcript_users(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:mixed-users"
append_transcript_object(
key,
{"event": "message", "chat_id": "mixed-users", "text": "old assistant"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "mixed-users"})
append_transcript_object(key, {"event": "user", "chat_id": "mixed-users", "text": "new prompt"})
append_transcript_object(
key,
{"event": "message", "chat_id": "mixed-users", "text": "new assistant"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "mixed-users"})
out = build_webui_thread_response(
key,
session_messages=[
{"role": "user", "content": "old prompt"},
{"role": "assistant", "content": "old assistant"},
{"role": "user", "content": "new prompt"},
{"role": "assistant", "content": "new assistant"},
],
)
assert out is not None
assert [(m["role"], m["content"]) for m in out["messages"]] == [
("user", "old prompt"),
("assistant", "old assistant"),
("user", "new prompt"),
("assistant", "new assistant"),
]
def test_replay_augments_assistant_text() -> None:
msgs = replay_transcript_to_ui_messages(
[
@@ -675,8 +846,6 @@ def test_replay_keeps_new_file_edit_after_reasoning_in_order(tmp_path, monkeypat
def test_build_response_schema(monkeypatch, tmp_path) -> None:
from nanobot.webui.transcript import build_webui_thread_response
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t3"
append_transcript_object(key, {"event": "user", "chat_id": "t3", "text": "x"})