diff --git a/docs/automations.md b/docs/automations.md index 56d779bd5..1a1dda839 100644 --- a/docs/automations.md +++ b/docs/automations.md @@ -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 diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 92eb84bb7..eaf481ce1 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -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() diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index a1f51ce0a..70a1bcad3 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -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 diff --git a/nanobot/webui/sidebar_state.py b/nanobot/webui/sidebar_state.py index 0a2f4cfcc..f9d88272d 100644 --- a/nanobot/webui/sidebar_state.py +++ b/nanobot/webui/sidebar_state.py @@ -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 diff --git a/tests/utils/test_webui_sidebar_state.py b/tests/utils/test_webui_sidebar_state.py index 6294a0d5d..d7b108f27 100644 --- a/tests/utils/test_webui_sidebar_state.py +++ b/tests/utils/test_webui_sidebar_state.py @@ -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" + } diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 2e9e74e95..b9600e0b2 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -372,6 +372,15 @@ function writeSessionUpdateChatIds(chatIds: Set): 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(() => { if (activeChatId && workspaceOverrides[activeChatId]) { return workspaceOverrides[activeChatId]; diff --git a/webui/src/hooks/useSidebarState.ts b/webui/src/hooks/useSidebarState.ts index bf0a93b52..a2209912d 100644 --- a/webui/src/hooks/useSidebarState.ts +++ b/webui/src/hooks/useSidebarState.ts @@ -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(() => { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 1db55490d..00c1d7cbf 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -302,6 +302,8 @@ export interface SidebarStatePayload { project_name_overrides: Record; tags_by_key: Record; collapsed_groups: Record; + /** Latest durable session activity the user has viewed, keyed by session key. */ + activity_seen_at_by_key: Record; view: SidebarViewState; updated_at?: string | null; } diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index e2e6103a3..187f62bbc 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -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", + }, }); }); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index f6a513568..26e621ce9 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -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(); + + 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 = [ {