From f8e07a332e3e75a3b717db1fc0e0d622dfbb8601 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 17:33:35 -0500 Subject: [PATCH] feat(desktop): platforms.changed broadcast retires the Messaging page's 6s status poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The change watcher (#73673) missed one always-on-while-mounted timer: the Messaging page polled /api/messaging/platforms every 6s for connection status. The gateway already persists platform connect/disconnect/health to gateway_state.json, so watch that file's mtime and broadcast platforms.changed (floored to 5s — the gateway also rewrites the file for in-flight-count bookkeeping), route it through live-sync like its siblings, and refresh the page on the tick. Older backends keep the legacy visible-tab poll verbatim. Finishes the always-on poll sweep for #73618. --- apps/desktop/src/app/messaging/index.tsx | 25 ++++++++++++++++--- .../hooks/use-message-stream/gateway-event.ts | 10 +++++++- apps/desktop/src/store/live-sync.ts | 5 ++++ tests/tui_gateway/test_change_watcher.py | 10 ++++++++ tui_gateway/server.py | 20 ++++++++++++--- 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 75ed0b556cf..08ae2a44404 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -1,3 +1,4 @@ +import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -20,6 +21,7 @@ import { openExternalLink } from '@/lib/external-link' import { ExternalLink, Save, Trash2 } from '@/lib/icons' import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' +import { $changeEventsAvailable, $platformsChangeTick } from '@/store/live-sync' import { notify, notifyError } from '@/store/notifications' import { runGatewayRestart } from '@/store/system-actions' @@ -141,9 +143,26 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, . void refreshPlatforms() }, [refreshPlatforms]) - // Auto-poll while the user is on the messaging page so connection status - // updates without a manual "check" click. Pause when the tab is hidden. + const changeEventsAvailable = useStore($changeEventsAvailable) + const platformsChangeTick = useStore($platformsChangeTick) + + // Connection status updates without a manual "check" click. platforms.changed + // (the gateway persisting connect/disconnect/health to gateway_state.json) + // drives the refresh on event-capable backends — no timer; older backends + // keep the legacy visible-tab poll. useEffect(() => { + if (!changeEventsAvailable || platformsChangeTick === 0 || document.hidden) { + return + } + + void refreshPlatforms(true) + }, [changeEventsAvailable, platformsChangeTick, refreshPlatforms]) + + useEffect(() => { + if (changeEventsAvailable) { + return + } + let cancelled = false function tick() { @@ -160,7 +179,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, . cancelled = true window.clearInterval(id) } - }, [refreshPlatforms]) + }, [changeEventsAvailable, refreshPlatforms]) const selected = useMemo(() => { if (!platforms) { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index dfdbdff87b6..f8b232a8831 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -27,6 +27,7 @@ import { applyGoalStatusText } from '@/store/goals' import { notifyCronChanged, notifyPetChanged, + notifyPlatformsChanged, notifySessionsChanged, type PetChangeMeta, setChangeEventsAvailable @@ -298,7 +299,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } return - } else if (event.type === 'pet.changed' || event.type === 'cron.changed' || event.type === 'sessions.changed') { + } else if ( + event.type === 'pet.changed' || + event.type === 'cron.changed' || + event.type === 'sessions.changed' || + event.type === 'platforms.changed' + ) { // Change-watcher broadcasts (server._broadcast_watched_changes): the // backend's on-disk signature moved. Route to the live-sync ticks the // former pollers now subscribe to. Only the active profile's changes @@ -311,6 +317,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { notifyPetChanged(payload as PetChangeMeta | undefined) } else if (event.type === 'cron.changed') { notifyCronChanged() + } else if (event.type === 'platforms.changed') { + notifyPlatformsChanged() } else { notifySessionsChanged() } diff --git a/apps/desktop/src/store/live-sync.ts b/apps/desktop/src/store/live-sync.ts index 2464eb96dbd..cab1f5a8a99 100644 --- a/apps/desktop/src/store/live-sync.ts +++ b/apps/desktop/src/store/live-sync.ts @@ -18,6 +18,7 @@ export const $changeEventsAvailable = atom(false) export const $cronChangeTick = atom(0) export const $sessionsChangeTick = atom(0) +export const $platformsChangeTick = atom(0) /** `pet.info.meta`-shaped payload carried on `pet.changed` — lets the pet skip * the heavy sprite refetch when the broadcast already says enabled=false. */ @@ -47,6 +48,10 @@ export function notifySessionsChanged(): void { $sessionsChangeTick.set($sessionsChangeTick.get() + 1) } +export function notifyPlatformsChanged(): void { + $platformsChangeTick.set($platformsChangeTick.get() + 1) +} + /** Reset on gateway wipe/reconnect — a new backend re-advertises capability on * its own gateway.ready, and stale ticks must not fire refreshes into stores * the wipe just cleared. */ diff --git a/tests/tui_gateway/test_change_watcher.py b/tests/tui_gateway/test_change_watcher.py index 275e54127fe..b4b6558418e 100644 --- a/tests/tui_gateway/test_change_watcher.py +++ b/tests/tui_gateway/test_change_watcher.py @@ -62,6 +62,16 @@ def test_state_db_move_broadcasts_sessions_changed(watcher_home): assert ("sessions.changed", {}) in events +def test_gateway_state_move_broadcasts_platforms_changed(watcher_home): + home, events = watcher_home + server._broadcast_watched_changes(now=0.0) + + (home / "gateway_state.json").write_text('{"platforms": {}}') + server._broadcast_watched_changes(now=10.0) + + assert ("platforms.changed", {}) in events + + def test_sessions_floor_coalesces_burst_but_keeps_trailing_edge(watcher_home): home, events = watcher_home server._broadcast_watched_changes(now=0.0) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 611a8f872c9..5cc0d87742f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3072,6 +3072,16 @@ def _sessions_sig(): return sig +def _platforms_sig(): + """mtime of gateway_state.json — the messaging gateway process persists + platform connect/disconnect/health there, so its movement is the + "connection status changed" signal for the Messaging page.""" + try: + return (_watcher_home() / "gateway_state.json").stat().st_mtime_ns + except OSError: + return None + + # Watched change signals: event → (check interval, signature fn, payload fn). # Signatures are stat/dict-lookup cheap, same bar as the skin watcher; the # check interval keeps the pricier probes (pet resolves the active sheet off @@ -3080,12 +3090,14 @@ _CHANGE_WATCHES: dict[str, tuple[float, Any, Any]] = { "pet.changed": (2.0, _pet_sig, _pet_changed_payload), "cron.changed": (1.0, _cron_sig, lambda: {}), "sessions.changed": (0.5, _sessions_sig, lambda: {}), + "platforms.changed": (2.0, _platforms_sig, lambda: {}), } -# state.db moves on every message append during a streaming turn; the floor -# coalesces that burst to one broadcast per window (trailing edge included — -# a floored change keeps its old signature and re-fires next tick). -_CHANGE_BROADCAST_FLOOR_S = {"sessions.changed": 2.0} +# state.db moves on every message append during a streaming turn, and the +# gateway rewrites gateway_state.json for in-flight-count bookkeeping; the +# floor coalesces those bursts to one broadcast per window (trailing edge +# included — a floored change keeps its old signature and re-fires next tick). +_CHANGE_BROADCAST_FLOOR_S = {"sessions.changed": 2.0, "platforms.changed": 5.0} _change_sigs: dict[str, Any] = {} _change_checked_at: dict[str, float] = {}