feat(desktop): platforms.changed broadcast retires the Messaging page's 6s status poll

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-29 17:33:35 -05:00
parent 7142dc4580
commit f8e07a332e
5 changed files with 62 additions and 8 deletions

View file

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

View file

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

View file

@ -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. */

View file

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

View file

@ -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] = {}