Files
fusion/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts
gsxdsm f157bf7460 fix(dashboard): harden visibility suspension, log caps, and mobile board UX
Suspend poll/SSE work when the tab is hidden, cap log buffers, restore board scroll more reliably, and improve list windowing/live tickers with related tests and a mobile-tab retention changeset.
2026-07-26 09:50:44 -07:00

182 lines
7.6 KiB
TypeScript

/**
* useRuntimeFallbackStatus — polls the lightweight `/api/tasks/:id/runtime-fallback`
* endpoint (FUX-022) and derives whether the runtime-fallback badge should be
* shown for a task, plus a one-shot toast trigger the first time a new
* fallback session is observed.
*
* ## Why polling instead of the existing badge WebSocket (useBadgeWebSocket)?
* `useBadgeWebSocket` is a GitHub/GitLab-specific protocol (`badge:updated`
* messages carrying `prInfo`/`issueInfo`). Runtime-fallback state changes at
* most once per agent session (session:runtime-resolved is written once per
* createResolvedAgentSession call), so a low-frequency poll is simpler and
* sufficient — extending the badge WS message protocol for a single new field
* would add cross-cutting server/socket surface for no material latency win.
* This hook only polls while `enabled` is true (callers should pass
* `isInViewport` so off-screen cards do not generate background traffic).
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchTaskRuntimeFallback, type TaskRuntimeFallbackResponse } from "../api/legacy";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
const POLL_INTERVAL_MS = 30_000;
// Toast dedupe must be shared across ALL hook instances in the process, not
// scoped per-instance: the same task/event can be observed simultaneously by
// multiple mounted badges (e.g. ActiveAgentsPanel + AgentsView board/list +
// TaskCard all rendering the same in-progress task at once), each running
// its own useRuntimeFallbackStatus() call. A per-instance ref only dedupes
// within one component instance's own poll history, so the same eventId
// would independently look "newly observed" to every instance and fire one
// toast each. Module-level state is shared across every call site because
// there is exactly one copy of this module per process/bundle.
//
// Keyed by `${taskId}:${eventId}` (not eventId alone) so ids are unambiguous
// even if two different tasks' audit logs ever produced colliding event ids.
// Bounded via a simple FIFO eviction (insertion order === Map iteration
// order) so a long-lived dashboard session touching many tasks over many
// hours cannot grow this unboundedly; runtime-fallback events are rare
// (at most one per agent session), so a few hundred entries comfortably
// covers realistic session lengths without needing TTL bookkeeping.
const MAX_TOASTED_EVENTS = 500;
const toastedEventKeys = new Map<string, true>();
function toastKey(taskId: string, eventId: string): string {
return `${taskId}:${eventId}`;
}
/**
* Returns true and records the key the first time it is seen; returns false
* on every subsequent call for the same key, regardless of which hook
* instance/component asks. This is the single shared gate all simultaneously
* mounted badge instances for the same task funnel through.
*/
function claimToastOnce(taskId: string, eventId: string): boolean {
const key = toastKey(taskId, eventId);
if (toastedEventKeys.has(key)) {
return false;
}
toastedEventKeys.set(key, true);
if (toastedEventKeys.size > MAX_TOASTED_EVENTS) {
const oldestKey = toastedEventKeys.keys().next().value;
if (oldestKey !== undefined) {
toastedEventKeys.delete(oldestKey);
}
}
return true;
}
/**
* Test-only escape hatch: clears the shared module-level dedupe store between
* test cases so one test's "already toasted" state cannot leak into the next.
* Guarded to a no-op outside the test build (import.meta.env.MODE) so it can
* never affect production code paths.
*/
export function __resetRuntimeFallbackToastDedupeStoreForTests(): void {
if (import.meta.env.MODE !== "test") return;
toastedEventKeys.clear();
}
export interface RuntimeFallbackStatus {
/** True only when the latest resolution has wasConfigured=false and a non-empty runtimeHint. */
showBadge: boolean;
/** The configured runtime hint that could not be resolved, when showBadge is true. */
runtimeHint: string | null;
/** FallbackReason ("not_found" | "factory_error" | "init_error") when available. */
reason: string | null;
/** Human-readable badge/toast message, or null when there is nothing to show. */
message: string | null;
/** True exactly once, on the render where a newly-observed fallback session should fire a toast. */
shouldToastNow: boolean;
}
const IDLE_STATUS: RuntimeFallbackStatus = {
showBadge: false,
runtimeHint: null,
reason: null,
message: null,
shouldToastNow: false,
};
export function formatRuntimeFallbackMessage(runtimeHint: string): string {
return `Runtime fallback: configured runtime '${runtimeHint}' unavailable, using default pi`;
}
/**
* @param taskId - Task to poll fallback status for. Pass undefined/empty to disable.
* @param enabled - Gate polling (e.g. isInViewport) to avoid background traffic for off-screen cards.
* @param projectId - Optional project scope for multi-project dashboards.
*/
export function useRuntimeFallbackStatus(
taskId: string | undefined,
enabled: boolean,
projectId?: string,
): RuntimeFallbackStatus {
const [status, setStatus] = useState<RuntimeFallbackStatus>(IDLE_STATUS);
const contextVersionRef = useRef(0);
const poll = useCallback(async () => {
if (!enabled || !taskId) return;
const versionAtStart = contextVersionRef.current;
let data: TaskRuntimeFallbackResponse;
try {
data = await fetchTaskRuntimeFallback(taskId, projectId);
} catch {
// Network hiccups shouldn't flip a shown badge back off; just skip this cycle.
return;
}
if (contextVersionRef.current !== versionAtStart) return;
if (!data.showFallbackBadge || !data.runtimeHint) {
setStatus(IDLE_STATUS);
return;
}
// Dedupe against the shared module-level store (not a per-instance ref)
// so a fallback event toasts exactly once across every simultaneously
// mounted badge instance for this task, not once per instance.
const isNewlyObserved = data.eventId !== null && claimToastOnce(taskId, data.eventId);
setStatus({
showBadge: true,
runtimeHint: data.runtimeHint,
reason: data.reason,
message: formatRuntimeFallbackMessage(data.runtimeHint),
shouldToastNow: isNewlyObserved,
});
}, [taskId, enabled, projectId]);
useEffect(() => {
if (!enabled || !taskId) {
setStatus(IDLE_STATUS);
return;
}
void poll();
return () => {
contextVersionRef.current += 1;
};
}, [taskId, enabled, poll]);
/*
FNXC:MobileTabRetention 2026-07-26-10:55:
Runtime-fallback badge polling is suspended while the document is hidden, on top of the existing viewport
`enabled` gate. Board cards run one of these each, so a backgrounded tab was issuing a burst of fetches
every 30s — the exact "page never goes idle" signal that makes iOS/Chrome Android discard the tab and
force a white-splash reload. Badges re-poll once on the hidden -> visible edge.
FNXC:MobileTabRetention 2026-07-26-14:20:
`priority: "background"` is stated EXPLICITLY here even though it is the helper default, because this is the
call site that makes the visible-edge stampede matter: one instance per in-viewport card means ~25 identical
-shaped requests fire off a single visibilitychange, against a 6-connection-per-origin browser cap and while
the SSE bus is trying to reopen. A runtime-fallback badge is ancillary decoration — it changes at most once
per agent session — so spreading it across the stagger window costs the operator nothing. Do not "promote"
this to `priority: "critical"`; that is what reintroduces the herd.
*/
useVisibilityAwarePoll(poll, POLL_INTERVAL_MS, {
enabled: enabled && Boolean(taskId),
priority: "background",
});
return status;
}