mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-05 08:58:34 +00:00
feat(webui): preserve unread activity across reconnects
This commit is contained in:
parent
b3d3a3e6c3
commit
b7d411d9d3
@ -137,6 +137,13 @@ message. Copy the `nanobot trigger ...` command from the WebUI and replace
|
||||
Automation delivery is workspace-local. Scheduled jobs and local trigger
|
||||
deliveries use the same workspace as the gateway.
|
||||
|
||||
WebUI automation replies are written to the linked topic even when no browser
|
||||
is connected. When the WebUI is opened again, it replays the stored reply and
|
||||
compares the topic's durable activity time with its persisted read position to
|
||||
show **New activity**. A successful automation `lastStatus` means the agent turn
|
||||
completed; it does not mean a browser had a live WebSocket connection or that
|
||||
the user already read the reply.
|
||||
|
||||
Local trigger messages are written to a durable queue. If the gateway is not
|
||||
running yet, the message waits in that workspace. If the linked topic is
|
||||
already running a turn, the trigger waits until the session becomes idle instead
|
||||
|
||||
@ -1582,6 +1582,49 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
||||
assert body["messages"][-1]["latencyMs"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_reply_persists_for_replay_without_subscribers() -> None:
|
||||
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
||||
from nanobot.webui.transcript import build_webui_thread_response
|
||||
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
metadata = cron_proactive_delivery_metadata(
|
||||
"websocket",
|
||||
None,
|
||||
turn_seed="cron:daily-digest",
|
||||
source_label="Daily digest",
|
||||
)
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="cron-offline",
|
||||
content="The scheduled digest is ready.",
|
||||
metadata=metadata,
|
||||
))
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="cron-offline",
|
||||
content="",
|
||||
event=TurnEndEvent(),
|
||||
metadata=metadata,
|
||||
))
|
||||
|
||||
assert channel._subs == {}
|
||||
body = build_webui_thread_response("websocket:cron-offline")
|
||||
assert body is not None
|
||||
assert body["messages"][-1]["role"] == "assistant"
|
||||
assert body["messages"][-1]["content"] == "The scheduled digest is ready."
|
||||
assert body["messages"][-1]["source"] == {
|
||||
"kind": "cron",
|
||||
"label": "Daily digest",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||
bus = MagicMock()
|
||||
|
||||
@ -1891,11 +1891,15 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
||||
assert initial.status_code == 200
|
||||
assert initial.json()["schema_version"] == 1
|
||||
assert initial.json()["pinned_keys"] == []
|
||||
assert initial.json()["activity_seen_at_by_key"] == {}
|
||||
|
||||
payload = {
|
||||
"pinned_keys": ["websocket:sidebar"],
|
||||
"archived_keys": ["websocket:old"],
|
||||
"title_overrides": {"websocket:sidebar": "Pinned work"},
|
||||
"activity_seen_at_by_key": {
|
||||
"websocket:sidebar": "2026-07-27T08:30:00Z"
|
||||
},
|
||||
"view": {"density": "compact", "show_archived": True},
|
||||
}
|
||||
query = urlencode({"state": json.dumps(payload)})
|
||||
@ -1907,6 +1911,9 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
||||
body = updated.json()
|
||||
assert body["pinned_keys"] == ["websocket:sidebar"]
|
||||
assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
|
||||
assert body["activity_seen_at_by_key"] == {
|
||||
"websocket:sidebar": "2026-07-27T08:30:00Z"
|
||||
}
|
||||
assert body["view"]["density"] == "compact"
|
||||
|
||||
state_path = tmp_path / "webui" / "sidebar-state.json"
|
||||
@ -1914,6 +1921,9 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
||||
assert json.loads(state_path.read_text(encoding="utf-8"))["pinned_keys"] == [
|
||||
"websocket:sidebar"
|
||||
]
|
||||
assert json.loads(state_path.read_text(encoding="utf-8"))[
|
||||
"activity_seen_at_by_key"
|
||||
] == {"websocket:sidebar": "2026-07-27T08:30:00Z"}
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@ -41,6 +41,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
||||
"project_name_overrides": {},
|
||||
"tags_by_key": {},
|
||||
"collapsed_groups": {},
|
||||
"activity_seen_at_by_key": {},
|
||||
"view": {
|
||||
"density": "comfortable",
|
||||
"show_previews": False,
|
||||
@ -87,6 +88,19 @@ def _clean_bool_map(value: Any) -> dict[str, bool]:
|
||||
return out
|
||||
|
||||
|
||||
def _clean_activity_seen_at_by_key(value: Any) -> dict[str, str]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for key, raw_timestamp in list(value.items())[:_MAX_MAP_ITEMS]:
|
||||
cleaned_key = _clean_string(key)
|
||||
cleaned_timestamp = _clean_string(raw_timestamp, max_len=64)
|
||||
if cleaned_key is None or cleaned_timestamp is None:
|
||||
continue
|
||||
out[cleaned_key] = cleaned_timestamp
|
||||
return out
|
||||
|
||||
|
||||
def _clean_title_overrides(value: Any) -> dict[str, str]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
@ -142,6 +156,9 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||
)
|
||||
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
||||
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
||||
state["activity_seen_at_by_key"] = _clean_activity_seen_at_by_key(
|
||||
raw.get("activity_seen_at_by_key")
|
||||
)
|
||||
state["view"] = _clean_view(raw.get("view"))
|
||||
updated_at = raw.get("updated_at")
|
||||
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
||||
|
||||
@ -30,6 +30,11 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
||||
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
||||
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
||||
"collapsed_groups": {"Earlier": 1},
|
||||
"activity_seen_at_by_key": {
|
||||
"websocket:a": " 2026-07-27T08:30:00Z ",
|
||||
"empty": "",
|
||||
"invalid": 123,
|
||||
},
|
||||
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
||||
}
|
||||
),
|
||||
@ -45,6 +50,9 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
||||
assert state["collapsed_groups"] == {"Earlier": True}
|
||||
assert state["activity_seen_at_by_key"] == {
|
||||
"websocket:a": "2026-07-27T08:30:00Z"
|
||||
}
|
||||
assert state["view"] == {
|
||||
"density": "comfortable",
|
||||
"show_previews": False,
|
||||
@ -63,6 +71,9 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
||||
"archived_keys": ["websocket:b"],
|
||||
"title_overrides": {"websocket:a": "Release"},
|
||||
"project_name_overrides": {"/repo": "Core"},
|
||||
"activity_seen_at_by_key": {
|
||||
"websocket:a": "2026-07-27T08:30:00Z"
|
||||
},
|
||||
"view": {"density": "compact", "show_previews": True},
|
||||
}
|
||||
)
|
||||
@ -71,7 +82,14 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
||||
assert state["archived_keys"] == ["websocket:b"]
|
||||
assert state["title_overrides"] == {"websocket:a": "Release"}
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
assert state["activity_seen_at_by_key"] == {
|
||||
"websocket:a": "2026-07-27T08:30:00Z"
|
||||
}
|
||||
assert state["view"]["density"] == "compact"
|
||||
assert state["view"]["show_previews"] is True
|
||||
assert webui_sidebar_state_path().is_file()
|
||||
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
|
||||
persisted = read_webui_sidebar_state()
|
||||
assert persisted["pinned_keys"] == ["websocket:a"]
|
||||
assert persisted["activity_seen_at_by_key"] == {
|
||||
"websocket:a": "2026-07-27T08:30:00Z"
|
||||
}
|
||||
|
||||
@ -372,6 +372,15 @@ function writeSessionUpdateChatIds(chatIds: Set<string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isActivityNewer(updatedAt: string | null, seenAt: string | undefined): boolean {
|
||||
if (!updatedAt || !seenAt) return false;
|
||||
const updatedTime = Date.parse(updatedAt);
|
||||
const seenTime = Date.parse(seenAt);
|
||||
return Number.isFinite(updatedTime)
|
||||
&& Number.isFinite(seenTime)
|
||||
&& updatedTime > seenTime;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePayload {
|
||||
const accessMode = scope.access_mode === "restricted" ? "restricted" : "full";
|
||||
return {
|
||||
@ -1104,7 +1113,20 @@ function Shell({
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const updatedChatIdList = useMemo(() => {
|
||||
const combined = new Set(updatedChatIds);
|
||||
for (const session of sessions) {
|
||||
if (
|
||||
isActivityNewer(
|
||||
session.updatedAt,
|
||||
sidebarState.activity_seen_at_by_key[session.key],
|
||||
)
|
||||
) {
|
||||
combined.add(session.chatId);
|
||||
}
|
||||
}
|
||||
return Array.from(combined);
|
||||
}, [sessions, sidebarState.activity_seen_at_by_key, updatedChatIds]);
|
||||
const activeChatId = activeSession?.chatId ?? null;
|
||||
useEffect(() => {
|
||||
activeChatIdRef.current = activeChatId;
|
||||
@ -1115,7 +1137,26 @@ function Shell({
|
||||
next.delete(activeChatId);
|
||||
return next;
|
||||
});
|
||||
}, [activeChatId]);
|
||||
const activityAt = activeSession?.updatedAt;
|
||||
const sessionKey = activeSession?.key;
|
||||
if (!activityAt || !sessionKey) return;
|
||||
void updateSidebarState((current) => {
|
||||
const seenAt = current.activity_seen_at_by_key[sessionKey];
|
||||
if (seenAt && !isActivityNewer(activityAt, seenAt)) return current;
|
||||
return {
|
||||
...current,
|
||||
activity_seen_at_by_key: {
|
||||
...current.activity_seen_at_by_key,
|
||||
[sessionKey]: activityAt,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [
|
||||
activeChatId,
|
||||
activeSession?.key,
|
||||
activeSession?.updatedAt,
|
||||
updateSidebarState,
|
||||
]);
|
||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||
return workspaceOverrides[activeChatId];
|
||||
|
||||
@ -15,6 +15,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
||||
project_name_overrides: {},
|
||||
tags_by_key: {},
|
||||
collapsed_groups: {},
|
||||
activity_seen_at_by_key: {},
|
||||
view: {
|
||||
density: "comfortable",
|
||||
show_previews: false,
|
||||
@ -94,6 +95,7 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
|
||||
project_name_overrides: stringMap(value.project_name_overrides),
|
||||
tags_by_key: tagsMap(value.tags_by_key),
|
||||
collapsed_groups: boolMap(value.collapsed_groups),
|
||||
activity_seen_at_by_key: stringMap(value.activity_seen_at_by_key),
|
||||
view: {
|
||||
density,
|
||||
show_previews: Boolean(view.show_previews),
|
||||
@ -124,9 +126,24 @@ function pruneMissingSessions(
|
||||
archived_keys: filterKeys(state.archived_keys),
|
||||
title_overrides: filterMap(state.title_overrides),
|
||||
tags_by_key: filterMap(state.tags_by_key),
|
||||
activity_seen_at_by_key: filterMap(state.activity_seen_at_by_key),
|
||||
};
|
||||
}
|
||||
|
||||
function seedMissingActivitySeenAt(
|
||||
state: SidebarStatePayload,
|
||||
sessions: ChatSummary[],
|
||||
): SidebarStatePayload {
|
||||
const seenAt = { ...state.activity_seen_at_by_key };
|
||||
let changed = false;
|
||||
for (const session of sessions) {
|
||||
if (seenAt[session.key] || !session.updatedAt) continue;
|
||||
seenAt[session.key] = session.updatedAt;
|
||||
changed = true;
|
||||
}
|
||||
return changed ? { ...state, activity_seen_at_by_key: seenAt } : state;
|
||||
}
|
||||
|
||||
function sameState(a: SidebarStatePayload, b: SidebarStatePayload): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
@ -196,7 +213,10 @@ export function useSidebarState(
|
||||
|
||||
const pruned = useMemo(() => {
|
||||
if (!sessionsLoaded || loading) return state;
|
||||
return pruneMissingSessions(state, sessions);
|
||||
return seedMissingActivitySeenAt(
|
||||
pruneMissingSessions(state, sessions),
|
||||
sessions,
|
||||
);
|
||||
}, [loading, sessions, sessionsLoaded, state]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -302,6 +302,8 @@ export interface SidebarStatePayload {
|
||||
project_name_overrides: Record<string, string>;
|
||||
tags_by_key: Record<string, string[]>;
|
||||
collapsed_groups: Record<string, boolean>;
|
||||
/** Latest durable session activity the user has viewed, keyed by session key. */
|
||||
activity_seen_at_by_key: Record<string, string>;
|
||||
view: SidebarViewState;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
@ -847,6 +847,9 @@ describe("webui API helpers", () => {
|
||||
project_name_overrides: { "/Users/me/nanobot": "Core" },
|
||||
tags_by_key: {},
|
||||
collapsed_groups: {},
|
||||
activity_seen_at_by_key: {
|
||||
"websocket:chat-1": "2026-05-01T10:00:00Z",
|
||||
},
|
||||
view: {
|
||||
density: "compact" as const,
|
||||
show_previews: false,
|
||||
@ -881,6 +884,9 @@ describe("webui API helpers", () => {
|
||||
pinned_keys: ["websocket:chat-1"],
|
||||
title_overrides: { "websocket:chat-1": "Release" },
|
||||
project_name_overrides: { "/Users/me/nanobot": "Core" },
|
||||
activity_seen_at_by_key: {
|
||||
"websocket:chat-1": "2026-05-01T10:00:00Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1030,6 +1030,10 @@ describe("App layout", () => {
|
||||
title_overrides: { "websocket:chat-b": "Roadmap" },
|
||||
tags_by_key: {},
|
||||
collapsed_groups: {},
|
||||
activity_seen_at_by_key: {
|
||||
"websocket:chat-a": "2026-04-16T10:00:00Z",
|
||||
"websocket:chat-b": "2026-04-16T11:00:00Z",
|
||||
},
|
||||
view: {
|
||||
density: "comfortable",
|
||||
show_previews: false,
|
||||
@ -1118,6 +1122,11 @@ describe("App layout", () => {
|
||||
title_overrides: {},
|
||||
tags_by_key: {},
|
||||
collapsed_groups: {},
|
||||
activity_seen_at_by_key: {
|
||||
"websocket:zulu": "2026-04-16T12:00:00Z",
|
||||
"websocket:new": "2026-04-15T12:00:00Z",
|
||||
"websocket:alpha": "2026-04-14T12:00:00Z",
|
||||
},
|
||||
view: {
|
||||
density: "comfortable",
|
||||
show_previews: false,
|
||||
@ -1295,6 +1304,88 @@ describe("App layout", () => {
|
||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores unread activity from durable session timestamps after being offline", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "Already read",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-b",
|
||||
channel: "websocket",
|
||||
chatId: "chat-b",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T12:00:00Z",
|
||||
preview: "Scheduled reply arrived while offline",
|
||||
},
|
||||
];
|
||||
const initialState = {
|
||||
schema_version: 1,
|
||||
pinned_keys: [],
|
||||
archived_keys: [],
|
||||
title_overrides: {},
|
||||
project_name_overrides: {},
|
||||
tags_by_key: {},
|
||||
collapsed_groups: {},
|
||||
activity_seen_at_by_key: {
|
||||
"websocket:chat-a": "2026-04-16T10:00:00Z",
|
||||
"websocket:chat-b": "2026-04-16T11:00:00Z",
|
||||
},
|
||||
view: {
|
||||
density: "comfortable",
|
||||
show_previews: true,
|
||||
show_timestamps: true,
|
||||
show_archived: false,
|
||||
sort: "updated_desc",
|
||||
},
|
||||
updated_at: null,
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
||||
const href = String(url);
|
||||
if (href === "/api/webui/sidebar-state") {
|
||||
return jsonResponse(initialState);
|
||||
}
|
||||
if (href.startsWith("/api/webui/sidebar-state/update?")) {
|
||||
const encoded = new URLSearchParams(href.split("?", 2)[1]).get("state");
|
||||
return jsonResponse(JSON.parse(encoded ?? "{}"));
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
await waitFor(() =>
|
||||
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
within(sidebar).getByRole("button", {
|
||||
name: /^Scheduled reply arrived while offline/,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument(),
|
||||
);
|
||||
const updateUrls = vi.mocked(fetch).mock.calls
|
||||
.map(([url]) => String(url))
|
||||
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"));
|
||||
expect(updateUrls).toHaveLength(1);
|
||||
const encoded = new URLSearchParams(updateUrls[0].split("?", 2)[1]).get("state");
|
||||
expect(JSON.parse(encoded ?? "{}").activity_seen_at_by_key).toMatchObject({
|
||||
"websocket:chat-b": "2026-04-16T12:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores sidebar run indicators after a page reload", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user