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:
2026-05-16 23:16:02 +00:00
parent 2bce4d85fa
commit 12d91c9a12
8 changed files with 352 additions and 339 deletions

View File

@@ -30,7 +30,10 @@ export interface Session {
agent_id: string | null;
}
export type AgentSource = 'builtin' | 'file';
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
// override at <root>/AGENTS.md. In-code builtins were retired; the seed file
// lives at /data/AGENTS.md.
export type AgentSource = 'global' | 'project';
export interface Agent {
id: string;
@@ -43,9 +46,14 @@ export interface Agent {
source: AgentSource;
}
export interface AgentParseError {
agent_name: string;
reason: string;
}
export interface AgentsResponse {
agents: Agent[];
parse_error: string | null;
errors: AgentParseError[];
}
export const CHAT_STATUSES = ['open', 'archived'] as const;

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Agent } from '@/api/types';
import type { Agent, AgentParseError } from '@/api/types';
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,23 +19,28 @@ interface Props {
export function AgentPicker({ projectId, value, onChange }: Props) {
const [agents, setAgents] = useState<Agent[] | null>(null);
const [parseErrors, setParseErrors] = useState<AgentParseError[]>([]);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
// Load on mount (and on projectId change) so the trigger shows the agent
// name immediately, not the raw id. AGENTS.md parse errors surface as a
// toast once per load.
// v1.8.1: per-agent parse errors are non-blocking. Silent if any agents
// loaded successfully; a gray warning toast fires only when EVERY agent
// in AGENTS.md failed to parse. Server logs a console.warn either way.
useEffect(() => {
let cancelled = false;
setAgents(null);
setParseErrors([]);
setError(null);
api.agents
.list(projectId)
.then((res) => {
if (cancelled) return;
setAgents(res.agents);
if (res.parse_error) {
toast.error(`AGENTS.md parse error: ${res.parse_error}`);
setParseErrors(res.errors);
if (res.errors.length > 0 && res.agents.length === 0) {
toast.warning(
`AGENTS.md: ${res.errors.length} agent${res.errors.length === 1 ? '' : 's'} failed to parse, none loaded`,
);
}
})
.catch((err) => {
@@ -100,6 +105,14 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
)}
</DropdownMenuItem>
))}
{parseErrors.length > 0 && (
<div
className="px-2 py-1.5 mt-1 text-xs text-amber-500 border-t border-border"
title={parseErrors.map((e) => `${e.agent_name}: ${e.reason}`).join('\n')}
>
{parseErrors.length} agent{parseErrors.length === 1 ? '' : 's'} skipped
</div>
)}
</>
)}
</DropdownMenuContent>

View File

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

View File

@@ -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 {}
};
}, []);

View 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();
},
};
}