feat(webui): preserve unread activity across reconnects

This commit is contained in:
Xubin Ren 2026-07-27 11:09:02 +08:00
parent b3d3a3e6c3
commit b7d411d9d3
10 changed files with 259 additions and 4 deletions

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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"
}

View File

@ -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];

View File

@ -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(() => {

View File

@ -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;
}

View File

@ -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",
},
});
});

View File

@ -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 = [
{