Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions apps/desktop/src/app/messaging/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'

Expand All @@ -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'

Expand Down Expand Up @@ -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() {
Expand All @@ -160,7 +179,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
cancelled = true
window.clearInterval(id)
}
}, [refreshPlatforms])
}, [changeEventsAvailable, refreshPlatforms])

const selected = useMemo(() => {
if (!platforms) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { applyGoalStatusText } from '@/store/goals'
import {
notifyCronChanged,
notifyPetChanged,
notifyPlatformsChanged,
notifySessionsChanged,
type PetChangeMeta,
setChangeEventsAvailable
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/store/live-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down
10 changes: 10 additions & 0 deletions tests/tui_gateway/test_change_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 16 additions & 4 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -3080,12 +3090,14 @@ def _sessions_sig():
"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] = {}
Expand Down
Loading