Configure lefthook formatter checks (#231)

* Add lefthook formatter checks

* Fix SDK mode hydration

* Stabilize SDK mode integration test
This commit is contained in:
Nathan Flurry 2026-03-10 23:03:11 -07:00 committed by GitHub
parent 0471214d65
commit d2346bafb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
282 changed files with 5840 additions and 8399 deletions

View file

@ -32,9 +32,7 @@ import SessionSidebar from "./components/SessionSidebar";
import type { RequestLog } from "./types/requestLog";
import { buildCurl } from "./utils/http";
const flattenSelectOptions = (
options: ConfigSelectOption[] | Array<{ group: string; name: string; options: ConfigSelectOption[] }>
): ConfigSelectOption[] => {
const flattenSelectOptions = (options: ConfigSelectOption[] | Array<{ group: string; name: string; options: ConfigSelectOption[] }>): ConfigSelectOption[] => {
if (options.length === 0) return [];
if ("value" in options[0]) return options as ConfigSelectOption[];
return (options as Array<{ options: ConfigSelectOption[] }>).flatMap((g) => g.options);
@ -186,9 +184,7 @@ const getPersistedSessionModels = (): Record<string, string> => {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].length > 0
)
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].length > 0),
);
} catch {
return {};
@ -230,14 +226,12 @@ const getInitialConnection = () => {
}
}
const hasUrlParam = urlParam != null && urlParam.length > 0;
const defaultEndpoint = import.meta.env.DEV
? DEFAULT_ENDPOINT
: (getCurrentOriginEndpoint() ?? DEFAULT_ENDPOINT);
const defaultEndpoint = import.meta.env.DEV ? DEFAULT_ENDPOINT : (getCurrentOriginEndpoint() ?? DEFAULT_ENDPOINT);
return {
endpoint: hasUrlParam ? urlParam : defaultEndpoint,
token: tokenParam,
headers,
hasUrlParam
hasUrlParam,
};
};
@ -247,7 +241,7 @@ const agentDisplayNames: Record<string, string> = {
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
cursor: "Cursor"
cursor: "Cursor",
};
export default function App() {
@ -324,96 +318,94 @@ export default function App() {
});
}, []);
const createClient = useCallback(async (overrideEndpoint?: string) => {
const targetEndpoint = overrideEndpoint ?? endpoint;
const fetchWithLog: typeof fetch = async (input, init) => {
const method = init?.method ?? "GET";
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const bodyText = typeof init?.body === "string" ? init.body : undefined;
const curl = buildCurl(method, url, bodyText, token);
const logId = logIdRef.current++;
const createClient = useCallback(
async (overrideEndpoint?: string) => {
const targetEndpoint = overrideEndpoint ?? endpoint;
const fetchWithLog: typeof fetch = async (input, init) => {
const method = init?.method ?? "GET";
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const bodyText = typeof init?.body === "string" ? init.body : undefined;
const curl = buildCurl(method, url, bodyText, token);
const logId = logIdRef.current++;
const headers: Record<string, string> = {};
if (init?.headers) {
const h = new Headers(init.headers as HeadersInit);
h.forEach((v, k) => { headers[k] = v; });
}
const headers: Record<string, string> = {};
if (init?.headers) {
const h = new Headers(init.headers as HeadersInit);
h.forEach((v, k) => {
headers[k] = v;
});
}
const entry: RequestLog = {
id: logId,
method,
url,
headers,
body: bodyText,
time: new Date().toLocaleTimeString(),
curl
};
let logged = false;
const entry: RequestLog = {
id: logId,
method,
url,
headers,
body: bodyText,
time: new Date().toLocaleTimeString(),
curl,
};
let logged = false;
const fetchInit = {
...init,
targetAddressSpace: "loopback"
};
const fetchInit = {
...init,
targetAddressSpace: "loopback",
};
try {
const response = await fetch(input, fetchInit);
const acceptsStream = headers["accept"]?.includes("text/event-stream");
if (acceptsStream) {
const ct = response.headers.get("content-type") ?? "";
if (!ct.includes("text/event-stream")) {
throw new Error(
`Expected text/event-stream from ${method} ${url} but got ${ct || "(no content-type)"} (HTTP ${response.status})`
);
try {
const response = await fetch(input, fetchInit);
const acceptsStream = headers["accept"]?.includes("text/event-stream");
if (acceptsStream) {
const ct = response.headers.get("content-type") ?? "";
if (!ct.includes("text/event-stream")) {
throw new Error(`Expected text/event-stream from ${method} ${url} but got ${ct || "(no content-type)"} (HTTP ${response.status})`);
}
logRequest({ ...entry, status: response.status, responseBody: "(SSE stream)" });
logged = true;
return response;
}
const clone = response.clone();
const responseBody = await clone.text().catch(() => "");
logRequest({ ...entry, status: response.status, responseBody });
if (!response.ok && response.status >= 500) {
const messageText = getHttpErrorMessage(response.status, response.statusText, responseBody);
window.dispatchEvent(new CustomEvent<string>(HTTP_ERROR_EVENT, { detail: messageText }));
}
logRequest({ ...entry, status: response.status, responseBody: "(SSE stream)" });
logged = true;
return response;
} catch (error) {
const messageText = error instanceof Error ? error.message : "Request failed";
if (!logged) {
logRequest({ ...entry, status: 0, error: messageText });
}
throw error;
}
const clone = response.clone();
const responseBody = await clone.text().catch(() => "");
logRequest({ ...entry, status: response.status, responseBody });
if (!response.ok && response.status >= 500) {
const messageText = getHttpErrorMessage(response.status, response.statusText, responseBody);
window.dispatchEvent(new CustomEvent<string>(HTTP_ERROR_EVENT, { detail: messageText }));
}
logged = true;
return response;
} catch (error) {
const messageText = error instanceof Error ? error.message : "Request failed";
if (!logged) {
logRequest({ ...entry, status: 0, error: messageText });
}
throw error;
};
let persist: SessionPersistDriver;
try {
persist = new IndexedDbSessionPersistDriver({
databaseName: "sandbox-agent-inspector",
});
} catch {
persist = new InMemorySessionPersistDriver({
maxSessions: 512,
maxEventsPerSession: 5_000,
});
}
};
let persist: SessionPersistDriver;
try {
persist = new IndexedDbSessionPersistDriver({
databaseName: "sandbox-agent-inspector",
const client = await SandboxAgent.connect({
baseUrl: targetEndpoint,
token: token || undefined,
fetch: fetchWithLog,
headers: Object.keys(extraHeaders).length > 0 ? extraHeaders : undefined,
persist,
});
} catch {
persist = new InMemorySessionPersistDriver({
maxSessions: 512,
maxEventsPerSession: 5_000,
});
}
const client = await SandboxAgent.connect({
baseUrl: targetEndpoint,
token: token || undefined,
fetch: fetchWithLog,
headers: Object.keys(extraHeaders).length > 0 ? extraHeaders : undefined,
persist,
});
clientRef.current = client;
return client;
}, [endpoint, token, extraHeaders, logRequest]);
clientRef.current = client;
return client;
},
[endpoint, token, extraHeaders, logRequest],
);
const getClient = useCallback((): SandboxAgent => {
if (!clientRef.current) {
@ -433,22 +425,25 @@ export default function App() {
setErrorToasts((prev) => prev.filter((toast) => toast.id !== toastId));
}, []);
const scheduleErrorToastDismiss = useCallback((toastId: number, delayMs: number) => {
const existingTimeoutId = toastTimeoutsRef.current.get(toastId);
if (existingTimeoutId != null) {
window.clearTimeout(existingTimeoutId);
toastTimeoutsRef.current.delete(toastId);
}
const scheduleErrorToastDismiss = useCallback(
(toastId: number, delayMs: number) => {
const existingTimeoutId = toastTimeoutsRef.current.get(toastId);
if (existingTimeoutId != null) {
window.clearTimeout(existingTimeoutId);
toastTimeoutsRef.current.delete(toastId);
}
const clampedDelayMs = Math.max(0, delayMs);
const timeoutId = window.setTimeout(() => {
dismissErrorToast(toastId);
}, clampedDelayMs);
const clampedDelayMs = Math.max(0, delayMs);
const timeoutId = window.setTimeout(() => {
dismissErrorToast(toastId);
}, clampedDelayMs);
toastTimeoutsRef.current.set(toastId, timeoutId);
toastExpiryRef.current.set(toastId, Date.now() + clampedDelayMs);
toastRemainingMsRef.current.set(toastId, clampedDelayMs);
}, [dismissErrorToast]);
toastTimeoutsRef.current.set(toastId, timeoutId);
toastExpiryRef.current.set(toastId, Date.now() + clampedDelayMs);
toastRemainingMsRef.current.set(toastId, clampedDelayMs);
},
[dismissErrorToast],
);
const pauseErrorToastDismiss = useCallback((toastId: number) => {
const expiryMs = toastExpiryRef.current.get(toastId);
@ -464,125 +459,133 @@ export default function App() {
toastExpiryRef.current.delete(toastId);
}, []);
const resumeErrorToastDismiss = useCallback((toastId: number) => {
if (toastTimeoutsRef.current.has(toastId)) return;
const remainingMs = toastRemainingMsRef.current.get(toastId);
if (remainingMs == null) return;
scheduleErrorToastDismiss(toastId, remainingMs);
}, [scheduleErrorToastDismiss]);
const resumeErrorToastDismiss = useCallback(
(toastId: number) => {
if (toastTimeoutsRef.current.has(toastId)) return;
const remainingMs = toastRemainingMsRef.current.get(toastId);
if (remainingMs == null) return;
scheduleErrorToastDismiss(toastId, remainingMs);
},
[scheduleErrorToastDismiss],
);
const pushErrorToast = useCallback((error: unknown, fallback: string) => {
const messageText = getErrorMessage(error, fallback).trim() || fallback;
const toastId = toastIdRef.current++;
setErrorToasts((prev) => {
if (prev.some((toast) => toast.message === messageText)) {
return prev;
}
return [...prev, { id: toastId, message: messageText }].slice(-MAX_ERROR_TOASTS);
});
scheduleErrorToastDismiss(toastId, ERROR_TOAST_MS);
}, [scheduleErrorToastDismiss]);
// Subscribe to events for the current active session
const subscribeToSession = useCallback((session: Session) => {
const generation = ++subscriptionGenerationRef.current;
const isCurrentSubscription = (): boolean =>
subscriptionGenerationRef.current === generation
&& activeSessionRef.current?.id === session.id
&& selectedSessionIdRef.current === session.id;
// Unsubscribe from previous
if (eventUnsubRef.current) {
eventUnsubRef.current();
eventUnsubRef.current = null;
}
activeSessionRef.current = session;
const cachedEvents = sessionEventsCacheRef.current.get(session.id);
if (cachedEvents && isCurrentSubscription()) {
setEvents(cachedEvents);
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
} else if (isCurrentSubscription()) {
setHistoryLoadingSessionId(session.id);
}
// Hydrate existing events from persistence
const hydrateEvents = async () => {
const allEvents: SessionEvent[] = [];
let cursor: string | undefined;
while (true) {
const page = await getClient().getEvents({
sessionId: session.id,
cursor,
limit: 250,
});
allEvents.push(...page.items);
if (!page.nextCursor) break;
cursor = page.nextCursor;
}
sessionEventsCacheRef.current.set(session.id, allEvents);
if (!isCurrentSubscription()) return;
setEvents((prev) => (areEventsEqualById(prev, allEvents) ? prev : allEvents));
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
};
hydrateEvents().catch((error) => {
console.error("Failed to hydrate events:", error);
if (isCurrentSubscription()) {
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
}
});
// Subscribe to new events
const unsub = session.onEvent((event) => {
if (!isCurrentSubscription()) return;
setEvents((prev) => {
if (prev.some((existing) => existing.id === event.id)) {
const pushErrorToast = useCallback(
(error: unknown, fallback: string) => {
const messageText = getErrorMessage(error, fallback).trim() || fallback;
const toastId = toastIdRef.current++;
setErrorToasts((prev) => {
if (prev.some((toast) => toast.message === messageText)) {
return prev;
}
const next = [...prev, event];
sessionEventsCacheRef.current.set(session.id, next);
return next;
return [...prev, { id: toastId, message: messageText }].slice(-MAX_ERROR_TOASTS);
});
});
eventUnsubRef.current = unsub;
scheduleErrorToastDismiss(toastId, ERROR_TOAST_MS);
},
[scheduleErrorToastDismiss],
);
// Subscribe to permission requests
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
const permUnsub = session.onPermissionRequest((request: SessionPermissionRequest) => {
if (!isCurrentSubscription()) return;
pendingPermissionsRef.current.set(request.id, request);
if (request.toolCall?.toolCallId) {
permissionToolCallToIdRef.current.set(request.toolCall.toolCallId, request.id);
// Subscribe to events for the current active session
const subscribeToSession = useCallback(
(session: Session) => {
const generation = ++subscriptionGenerationRef.current;
const isCurrentSubscription = (): boolean =>
subscriptionGenerationRef.current === generation && activeSessionRef.current?.id === session.id && selectedSessionIdRef.current === session.id;
// Unsubscribe from previous
if (eventUnsubRef.current) {
eventUnsubRef.current();
eventUnsubRef.current = null;
}
setPendingPermissionIds((prev) => new Set([...prev, request.id]));
});
permissionUnsubRef.current = permUnsub;
}, [getClient]);
const handlePermissionReply = useCallback(async (permissionId: string, reply: PermissionReply) => {
const session = activeSessionRef.current;
if (!session) return;
try {
await session.respondPermission(permissionId, reply);
const request = pendingPermissionsRef.current.get(permissionId);
const selectedOption = request?.options.find((o) =>
reply === "always" ? o.kind === "allow_always" :
reply === "once" ? o.kind === "allow_once" :
o.kind === "reject_once" || o.kind === "reject_always"
);
setResolvedPermissions((prev) => new Map([...prev, [permissionId, selectedOption?.optionId ?? reply]]));
setPendingPermissionIds((prev) => {
const next = new Set(prev);
next.delete(permissionId);
return next;
activeSessionRef.current = session;
const cachedEvents = sessionEventsCacheRef.current.get(session.id);
if (cachedEvents && isCurrentSubscription()) {
setEvents(cachedEvents);
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
} else if (isCurrentSubscription()) {
setHistoryLoadingSessionId(session.id);
}
// Hydrate existing events from persistence
const hydrateEvents = async () => {
const allEvents: SessionEvent[] = [];
let cursor: string | undefined;
while (true) {
const page = await getClient().getEvents({
sessionId: session.id,
cursor,
limit: 250,
});
allEvents.push(...page.items);
if (!page.nextCursor) break;
cursor = page.nextCursor;
}
sessionEventsCacheRef.current.set(session.id, allEvents);
if (!isCurrentSubscription()) return;
setEvents((prev) => (areEventsEqualById(prev, allEvents) ? prev : allEvents));
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
};
hydrateEvents().catch((error) => {
console.error("Failed to hydrate events:", error);
if (isCurrentSubscription()) {
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
}
});
} catch (error) {
pushErrorToast(error, "Failed to respond to permission request");
}
}, [pushErrorToast]);
// Subscribe to new events
const unsub = session.onEvent((event) => {
if (!isCurrentSubscription()) return;
setEvents((prev) => {
if (prev.some((existing) => existing.id === event.id)) {
return prev;
}
const next = [...prev, event];
sessionEventsCacheRef.current.set(session.id, next);
return next;
});
});
eventUnsubRef.current = unsub;
// Subscribe to permission requests
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
const permUnsub = session.onPermissionRequest((request: SessionPermissionRequest) => {
if (!isCurrentSubscription()) return;
pendingPermissionsRef.current.set(request.id, request);
if (request.toolCall?.toolCallId) {
permissionToolCallToIdRef.current.set(request.toolCall.toolCallId, request.id);
}
setPendingPermissionIds((prev) => new Set([...prev, request.id]));
});
permissionUnsubRef.current = permUnsub;
},
[getClient],
);
const handlePermissionReply = useCallback(
async (permissionId: string, reply: PermissionReply) => {
const session = activeSessionRef.current;
if (!session) return;
try {
await session.respondPermission(permissionId, reply);
const request = pendingPermissionsRef.current.get(permissionId);
const selectedOption = request?.options.find((o) =>
reply === "always" ? o.kind === "allow_always" : reply === "once" ? o.kind === "allow_once" : o.kind === "reject_once" || o.kind === "reject_always",
);
setResolvedPermissions((prev) => new Map([...prev, [permissionId, selectedOption?.optionId ?? reply]]));
setPendingPermissionIds((prev) => {
const next = new Set(prev);
next.delete(permissionId);
return next;
});
} catch (error) {
pushErrorToast(error, "Failed to respond to permission request");
}
},
[pushErrorToast],
);
const connectToDaemon = async (reportError: boolean, overrideEndpoint?: string) => {
setConnecting(true);
@ -689,19 +692,20 @@ export default function App() {
}
};
const loadAgentConfig = useCallback(async (targetAgentId: string) => {
console.log("[loadAgentConfig] Loading config for agent:", targetAgentId);
try {
const info = await getClient().getAgent(targetAgentId, { config: true });
console.log("[loadAgentConfig] Got agent info:", info);
setAgents((prev) =>
prev.map((a) => (a.id === targetAgentId ? { ...a, configOptions: info.configOptions, configError: info.configError } : a))
);
} catch (error) {
console.error("[loadAgentConfig] Failed to load config:", error);
// Config loading is best-effort; the menu still works without it.
}
}, [getClient]);
const loadAgentConfig = useCallback(
async (targetAgentId: string) => {
console.log("[loadAgentConfig] Loading config for agent:", targetAgentId);
try {
const info = await getClient().getAgent(targetAgentId, { config: true });
console.log("[loadAgentConfig] Got agent info:", info);
setAgents((prev) => prev.map((a) => (a.id === targetAgentId ? { ...a, configOptions: info.configOptions, configError: info.configError } : a)));
} catch (error) {
console.error("[loadAgentConfig] Failed to load config:", error);
// Config loading is best-effort; the menu still works without it.
}
},
[getClient],
);
const fetchSessions = async () => {
setSessionsLoading(true);
@ -743,13 +747,7 @@ export default function App() {
// If the server already considers the session gone, still archive in local UI.
console.warn("Destroy session returned an error while archiving:", error);
}
setSessions((prev) =>
prev.map((session) =>
session.sessionId === targetSessionId
? { ...session, archived: true, ended: true }
: session
)
);
setSessions((prev) => prev.map((session) => (session.sessionId === targetSessionId ? { ...session, archived: true, ended: true } : session)));
setSessionModelById((prev) => {
if (!(targetSessionId in prev)) return prev;
const next = { ...prev };
@ -764,11 +762,7 @@ export default function App() {
const unarchiveSession = async (targetSessionId: string) => {
unarchiveSessionId(targetSessionId);
setSessions((prev) =>
prev.map((session) =>
session.sessionId === targetSessionId ? { ...session, archived: false } : session
)
);
setSessions((prev) => prev.map((session) => (session.sessionId === targetSessionId ? { ...session, archived: false } : session)));
await fetchSessions();
};
@ -883,7 +877,7 @@ export default function App() {
try {
const agentInfo = agents.find((agent) => agent.id === nextAgentId);
const modelOption = ((agentInfo?.configOptions ?? []) as ConfigOption[]).find(
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string"
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string",
);
if (modelOption && config.model !== modelOption.currentValue) {
await session.rawSend("session/set_config_option", {
@ -951,9 +945,12 @@ export default function App() {
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
fallbackCopy(text, onSuccess);
});
navigator.clipboard
.writeText(text)
.then(onSuccess)
.catch(() => {
fallbackCopy(text, onSuccess);
});
} else {
fallbackCopy(text, onSuccess);
}
@ -1222,18 +1219,15 @@ export default function App() {
}
if (event.sender === "agent" && method === "session/request_permission") {
const params = payload.params as {
options?: Array<{ optionId: string; name: string; kind: string }>;
toolCall?: { title?: string; toolCallId?: string; description?: string };
} | undefined;
const params = payload.params as
| {
options?: Array<{ optionId: string; name: string; kind: string }>;
toolCall?: { title?: string; toolCallId?: string; description?: string };
}
| undefined;
const toolCallId = params?.toolCall?.toolCallId;
const sdkPermissionId = toolCallId
? permissionToolCallToIdRef.current.get(toolCallId)
: undefined;
const permissionId = sdkPermissionId
?? (typeof payload.id === "number" || typeof payload.id === "string"
? String(payload.id)
: event.id);
const sdkPermissionId = toolCallId ? permissionToolCallToIdRef.current.get(toolCallId) : undefined;
const permissionId = sdkPermissionId ?? (typeof payload.id === "number" || typeof payload.id === "string" ? String(payload.id) : event.id);
const options = (params?.options ?? []).map((o) => ({
optionId: o.optionId,
name: o.name,
@ -1306,12 +1300,7 @@ export default function App() {
const shouldIgnoreCreateNoise = (value: unknown): boolean => {
if (Date.now() > createNoiseIgnoreUntilRef.current) return false;
const message = getErrorMessage(value, "").trim().toLowerCase();
return (
message.length === 0 ||
message === "request failed" ||
message.includes("request failed") ||
message.includes("unhandled promise rejection")
);
return message.length === 0 || message === "request failed" || message.includes("request failed") || message.includes("unhandled promise rejection");
};
const handleWindowError = (event: ErrorEvent) => {
@ -1427,18 +1416,22 @@ export default function App() {
const requestedSessionId = sessionId;
resumeInFlightSessionIdRef.current = requestedSessionId;
getClient().resumeSession(requestedSessionId).then((session) => {
if (selectedSessionIdRef.current !== requestedSessionId) return;
subscribeToSession(session);
}).catch((error) => {
if (selectedSessionIdRef.current !== requestedSessionId) return;
setSessionError(getErrorMessage(error, "Unable to resume session"));
setHistoryLoadingSessionId((current) => (current === requestedSessionId ? null : current));
}).finally(() => {
if (resumeInFlightSessionIdRef.current === requestedSessionId) {
resumeInFlightSessionIdRef.current = null;
}
});
getClient()
.resumeSession(requestedSessionId)
.then((session) => {
if (selectedSessionIdRef.current !== requestedSessionId) return;
subscribeToSession(session);
})
.catch((error) => {
if (selectedSessionIdRef.current !== requestedSessionId) return;
setSessionError(getErrorMessage(error, "Unable to resume session"));
setHistoryLoadingSessionId((current) => (current === requestedSessionId ? null : current));
})
.finally(() => {
if (resumeInFlightSessionIdRef.current === requestedSessionId) {
resumeInFlightSessionIdRef.current = null;
}
});
}, [connected, sessionId, sessions, getClient, subscribeToSession]);
useEffect(() => {
@ -1458,9 +1451,7 @@ export default function App() {
// If actively sending a prompt, show thinking
if (sendingSessionId === sessionId) return true;
// Check for in-progress tool calls
const hasInProgressTool = transcriptEntries.some(
(e) => e.kind === "tool" && e.toolStatus === "in_progress"
);
const hasInProgressTool = transcriptEntries.some((e) => e.kind === "tool" && e.toolStatus === "in_progress");
if (hasInProgressTool) return true;
// Check if last message was from user with no subsequent agent activity
const lastUserMessageIndex = [...transcriptEntries].reverse().findIndex((e) => e.kind === "message" && e.role === "user");
@ -1468,14 +1459,10 @@ export default function App() {
// If user message is the very last entry, we're waiting for response
if (lastUserMessageIndex === 0) return true;
// Check if there's any agent response after the user message
const entriesAfterUser = transcriptEntries.slice(-(lastUserMessageIndex));
const hasAgentResponse = entriesAfterUser.some(
(e) => e.kind === "message" && e.role === "assistant"
);
const entriesAfterUser = transcriptEntries.slice(-lastUserMessageIndex);
const hasAgentResponse = entriesAfterUser.some((e) => e.kind === "message" && e.role === "assistant");
// If no assistant message after user, but there are completed tools, not thinking
const hasCompletedTools = entriesAfterUser.some(
(e) => e.kind === "tool" && (e.toolStatus === "completed" || e.toolStatus === "failed")
);
const hasCompletedTools = entriesAfterUser.some((e) => e.kind === "tool" && (e.toolStatus === "completed" || e.toolStatus === "failed"));
if (!hasAgentResponse && !hasCompletedTools) return true;
return false;
}, [sessionId, sessionEnded, transcriptEntries, sendingSessionId]);
@ -1560,20 +1547,21 @@ export default function App() {
const update = params?.update as Record<string, unknown> | undefined;
if (update?.sessionUpdate !== "config_option_update") continue;
const category = (update.category as string | undefined)
?? ((update.option as Record<string, unknown> | undefined)?.category as string | undefined);
const category = (update.category as string | undefined) ?? ((update.option as Record<string, unknown> | undefined)?.category as string | undefined);
if (category && category !== "model") continue;
const optionId = (update.optionId as string | undefined)
?? (update.configOptionId as string | undefined)
?? ((update.option as Record<string, unknown> | undefined)?.id as string | undefined);
const optionId =
(update.optionId as string | undefined) ??
(update.configOptionId as string | undefined) ??
((update.option as Record<string, unknown> | undefined)?.id as string | undefined);
const seemsModelOption = !optionId || optionId.toLowerCase().includes("model");
if (!seemsModelOption) continue;
const candidate = (update.value as string | undefined)
?? (update.currentValue as string | undefined)
?? (update.selectedValue as string | undefined)
?? (update.modelId as string | undefined);
const candidate =
(update.value as string | undefined) ??
(update.currentValue as string | undefined) ??
(update.selectedValue as string | undefined) ??
(update.modelId as string | undefined);
if (candidate) {
latestModelId = candidate;
}
@ -1594,9 +1582,7 @@ export default function App() {
const optionId = params?.optionId as string | undefined;
const seemsModelOption = category === "model" || (typeof optionId === "string" && optionId.toLowerCase().includes("model"));
if (!seemsModelOption) continue;
const candidate = (params?.value as string | undefined)
?? (params?.currentValue as string | undefined)
?? (params?.modelId as string | undefined);
const candidate = (params?.value as string | undefined) ?? (params?.currentValue as string | undefined) ?? (params?.modelId as string | undefined);
if (candidate) {
latestModelId = candidate;
}
@ -1608,18 +1594,14 @@ export default function App() {
const modelPillLabel = useMemo(() => {
const sessionModelId =
currentSessionModelId
?? (sessionId ? sessionModelById[sessionId] : undefined)
?? (sessionId ? defaultModelByAgent[agentId] : undefined);
currentSessionModelId ?? (sessionId ? sessionModelById[sessionId] : undefined) ?? (sessionId ? defaultModelByAgent[agentId] : undefined);
if (!sessionModelId) return null;
return sessionModelId;
}, [agentId, currentSessionModelId, defaultModelByAgent, sessionId, sessionModelById]);
useEffect(() => {
if (!sessionId || !currentSessionModelId) return;
setSessionModelById((prev) =>
prev[sessionId] === currentSessionModelId ? prev : { ...prev, [sessionId]: currentSessionModelId }
);
setSessionModelById((prev) => (prev[sessionId] === currentSessionModelId ? prev : { ...prev, [sessionId]: currentSessionModelId }));
}, [currentSessionModelId, sessionId]);
useEffect(() => {
@ -1649,12 +1631,7 @@ export default function App() {
onFocus={() => pauseErrorToastDismiss(toast.id)}
onBlur={() => resumeErrorToastDismiss(toast.id)}
>
<button
type="button"
className="toast-close"
aria-label="Dismiss error"
onClick={() => dismissErrorToast(toast.id)}
>
<button type="button" className="toast-close" aria-label="Dismiss error" onClick={() => dismissErrorToast(toast.id)}>
×
</button>
<div className="toast-content">
@ -1690,7 +1667,7 @@ export default function App() {
<div className="app">
<header className="header">
<div className="header-left">
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: '20px', width: 'auto' }} />
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: "20px", width: "auto" }} />
<span className="header-endpoint">{endpoint}</span>
</div>
<div className="header-right">
@ -1699,11 +1676,15 @@ export default function App() {
Docs
</a>
<a className="header-link" href={discordUrl} target="_blank" rel="noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</a>
<a className="header-link" href={issueTrackerUrl} target="_blank" rel="noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
Issues
</a>
<button className="button secondary small" onClick={disconnect}>
@ -1720,12 +1701,16 @@ export default function App() {
onRefresh={fetchSessions}
onCreateSession={createNewSession}
onSelectAgent={loadAgentConfig}
agents={agents.length ? agents : defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))}
agents={
agents.length
? agents
: defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))
}
agentsLoading={agentsLoading}
agentsError={agentsError}
sessionsLoading={sessionsLoading}
@ -1746,12 +1731,16 @@ export default function App() {
onKeyDown={handleKeyDown}
onCreateSession={createNewSession}
onSelectAgent={loadAgentConfig}
agents={agents.length ? agents : defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))}
agents={
agents.length
? agents
: defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))
}
agentsLoading={agentsLoading}
agentsError={agentsError}
messagesEndRef={messagesEndRef}