v1.8.1: global agents + parser robustness + WS reconnect toast
Builtins move out of code into /data/AGENTS.md (always-on, mounted ro
into the container); per-project AGENTS.md is now an optional override.
agents.ts merges global + project entries with project-wins-by-name and
caches per-source mtimes (60s TTL). Parser switches to per-block
try/catch and returns AgentsResponse { agents, errors[] } so one
malformed block no longer fails the file. AgentPicker shows a
non-blocking amber chip listing skipped blocks and only fires a gray
toast when zero agents loaded.
WS reconnect UX (useUserEvents + useSessionStream) now silent on the
first disconnect; createWsReconnectToast escalates to gray after 3
failures or 15 s, then to red with a Retry Now action after 60 s.
useSessionStream also gained the exponential-backoff reconnect it was
missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,11 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
}
|
||||
}
|
||||
|
||||
// Matches useUserEvents — exponential backoff with the same ceiling so the
|
||||
// two channels reconnect on the same cadence after a network handoff.
|
||||
const RECONNECT_INITIAL_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
|
||||
export function useSessionStream(sessionId: string | undefined) {
|
||||
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -152,32 +157,52 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
|
||||
setState({ messages: [], connected: false, error: null });
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
let unmounted = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
|
||||
ws.onopen = () => {
|
||||
setState((s) => ({ ...s, connected: true, error: null }));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
|
||||
setState((s) => applyFrame(s, frame));
|
||||
} catch (err) {
|
||||
console.warn('bad ws frame', err);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
setState((s) => ({ ...s, error: 'websocket error' }));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setState((s) => ({ ...s, connected: false }));
|
||||
const connect = () => {
|
||||
if (unmounted) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
setState((s) => ({ ...s, connected: true, error: null }));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
|
||||
setState((s) => applyFrame(s, frame));
|
||||
} catch (err) {
|
||||
console.warn('bad ws frame', err);
|
||||
}
|
||||
};
|
||||
// v1.8.1: WS errors no longer surface as user-facing toasts here. The
|
||||
// user-channel hook (useUserEvents) owns the debounced "reconnecting…"
|
||||
// UI; this channel just reconnects silently on the same backoff.
|
||||
ws.onerror = () => {
|
||||
try { ws.close(); } catch {}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (unmounted) return;
|
||||
setState((s) => ({ ...s, connected: false }));
|
||||
const delay = reconnectDelay;
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
const ws = wsRef.current;
|
||||
wsRef.current = null;
|
||||
ws.close();
|
||||
if (ws) try { ws.close(); } catch {}
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import { createWsReconnectToast } from './wsReconnectToast';
|
||||
|
||||
const RECONNECT_INITIAL_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
@@ -11,6 +12,20 @@ export function useUserEvents(): void {
|
||||
let reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
let unmounted = false;
|
||||
|
||||
// v1.8.1: silent on the first disconnect; gray "reconnecting…" after 3
|
||||
// fails / 15 s; red "connection lost" with a Retry Now action after 60 s.
|
||||
const reconnectToast = createWsReconnectToast({
|
||||
label: 'Live updates',
|
||||
onRetryNow: () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
connect();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const connect = () => {
|
||||
if (unmounted) return;
|
||||
const url = new URL('/api/ws/user', window.location.href);
|
||||
@@ -19,6 +34,7 @@ export function useUserEvents(): void {
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
reconnectToast.onConnected();
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
@@ -34,6 +50,7 @@ export function useUserEvents(): void {
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted) return;
|
||||
reconnectToast.onFailure();
|
||||
const delay = reconnectDelay;
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
@@ -50,8 +67,8 @@ export function useUserEvents(): void {
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
reconnectToast.dispose();
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
// best-effort cleanup; ignore failure because the socket may already be closed
|
||||
if (ws) try { ws.close(); } catch {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
95
apps/web/src/hooks/wsReconnectToast.ts
Normal file
95
apps/web/src/hooks/wsReconnectToast.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// v1.8.1 thresholds. First disconnect is silent — mobile Authelia idle timeouts
|
||||
// and tab suspensions trip reconnects constantly and the old red "websocket
|
||||
// error" toast made the app feel broken. Only escalate once the failure is
|
||||
// sustained.
|
||||
const TOAST_AFTER_FAILS = 3;
|
||||
const TOAST_AFTER_MS = 15_000;
|
||||
const PERSISTENT_AFTER_MS = 60_000;
|
||||
|
||||
export interface WsReconnectToast {
|
||||
onFailure(): void;
|
||||
onConnected(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
label: string; // shown in the toast (e.g. "Live updates")
|
||||
onRetryNow: () => void; // user clicked the "Retry now" action
|
||||
}
|
||||
|
||||
// Per-connection toast wrapper. Caller drives it from the WS lifecycle:
|
||||
// onFailure — after each failed connection attempt
|
||||
// onConnected — after a successful onopen
|
||||
// dispose — on hook unmount
|
||||
// The wrapper itself runs no timers and does not change the caller's reconnect
|
||||
// cadence; it only decides when to show / dismiss the toast.
|
||||
export function createWsReconnectToast(opts: Options): WsReconnectToast {
|
||||
let firstFailureAt: number | null = null;
|
||||
let failureCount = 0;
|
||||
let reconnectingId: string | number | null = null;
|
||||
let persistentId: string | number | null = null;
|
||||
|
||||
function dismissReconnecting(): void {
|
||||
if (reconnectingId !== null) {
|
||||
toast.dismiss(reconnectingId);
|
||||
reconnectingId = null;
|
||||
}
|
||||
}
|
||||
function dismissPersistent(): void {
|
||||
if (persistentId !== null) {
|
||||
toast.dismiss(persistentId);
|
||||
persistentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onFailure() {
|
||||
if (firstFailureAt === null) firstFailureAt = Date.now();
|
||||
failureCount += 1;
|
||||
const elapsed = Date.now() - firstFailureAt;
|
||||
|
||||
// Escalate to red error + Retry button after PERSISTENT_AFTER_MS. Replaces
|
||||
// the gray toast if it's still showing.
|
||||
if (persistentId === null && elapsed >= PERSISTENT_AFTER_MS) {
|
||||
dismissReconnecting();
|
||||
persistentId = toast.error(`${opts.label}: connection lost`, {
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Retry now',
|
||||
onClick: () => {
|
||||
dismissReconnecting();
|
||||
dismissPersistent();
|
||||
opts.onRetryNow();
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Gray "reconnecting…" toast once we've crossed either threshold.
|
||||
if (
|
||||
reconnectingId === null &&
|
||||
persistentId === null &&
|
||||
(failureCount >= TOAST_AFTER_FAILS || elapsed >= TOAST_AFTER_MS)
|
||||
) {
|
||||
reconnectingId = toast.warning(`${opts.label}: reconnecting…`, {
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
},
|
||||
onConnected() {
|
||||
firstFailureAt = null;
|
||||
failureCount = 0;
|
||||
dismissReconnecting();
|
||||
dismissPersistent();
|
||||
},
|
||||
dispose() {
|
||||
firstFailureAt = null;
|
||||
failureCount = 0;
|
||||
dismissReconnecting();
|
||||
dismissPersistent();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user