feat: add turn streaming and inspector updates

This commit is contained in:
Nathan Flurry 2026-01-27 06:18:43 -08:00
parent bf58891edf
commit 34d4f3693e
49 changed files with 4629 additions and 1146 deletions

View file

@ -383,7 +383,7 @@
flex-direction: column;
border-right: 1px solid var(--border);
background: var(--surface-2);
overflow: hidden;
overflow: visible;
}
.sidebar-header {
@ -394,12 +394,15 @@
align-items: center;
justify-content: space-between;
flex-shrink: 0;
overflow: visible;
}
.sidebar-header-actions {
display: flex;
align-items: center;
gap: 8px;
position: relative;
overflow: visible;
}
.sidebar-icon-btn {
@ -449,6 +452,53 @@
background: var(--accent-hover);
}
.sidebar-add-menu-wrapper {
position: relative;
}
.sidebar-add-menu {
position: absolute;
top: 30px;
right: 0;
min-width: 140px;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 60;
}
.sidebar-add-option {
background: transparent;
border: 1px solid transparent;
color: var(--text);
text-align: left;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all var(--transition);
}
.sidebar-add-option:hover {
background: var(--accent);
color: #fff;
}
.sidebar-add-status {
padding: 6px 8px;
font-size: 11px;
color: var(--muted);
}
.sidebar-add-status.error {
color: var(--danger);
}
.session-list {
flex: 1;
overflow-y: auto;
@ -520,6 +570,10 @@
font-size: 11px;
}
.sidebar-empty.error {
color: var(--danger);
}
/* Chat Panel */
.chat-panel {
display: flex;
@ -560,6 +614,21 @@
color: var(--text-secondary);
}
.session-agent-display {
font-size: 11px;
font-weight: 600;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, transparent);
padding: 2px 6px;
border-radius: 999px;
}
.panel-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.messages-container {
flex: 1;
overflow-y: auto;
@ -947,6 +1016,13 @@
width: 50px;
}
.setup-select-small:disabled,
.setup-select:disabled,
.setup-input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.setup-stream-btn {
display: flex;
align-items: center;
@ -973,6 +1049,16 @@
color: var(--accent);
}
.setup-stream-btn:disabled {
cursor: default;
opacity: 0.6;
}
.setup-stream-btn:disabled:hover {
border-color: var(--border-2);
color: var(--muted);
}
.setup-stream-btn.active {
background: var(--accent);
border-color: var(--accent);

View file

@ -62,6 +62,12 @@ export default function App() {
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [modesByAgent, setModesByAgent] = useState<Record<string, AgentModeInfo[]>>({});
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [agentsLoading, setAgentsLoading] = useState(false);
const [agentsError, setAgentsError] = useState<string | null>(null);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsError, setSessionsError] = useState<string | null>(null);
const [modesLoadingByAgent, setModesLoadingByAgent] = useState<Record<string, boolean>>({});
const [modesErrorByAgent, setModesErrorByAgent] = useState<Record<string, string | null>>({});
const [agentId, setAgentId] = useState("claude");
const [agentMode, setAgentMode] = useState("");
@ -75,10 +81,12 @@ export default function App() {
const [events, setEvents] = useState<UniversalEvent[]>([]);
const [offset, setOffset] = useState(0);
const offsetRef = useRef(0);
const [eventsLoading, setEventsLoading] = useState(false);
const [polling, setPolling] = useState(false);
const pollTimerRef = useRef<number | null>(null);
const [streamMode, setStreamMode] = useState<"poll" | "sse">("sse");
const [turnStreaming, setTurnStreaming] = useState(false);
const [streamMode, setStreamMode] = useState<"poll" | "sse" | "turn">("sse");
const [eventError, setEventError] = useState<string | null>(null);
const [questionSelections, setQuestionSelections] = useState<Record<string, string[][]>>({});
@ -95,6 +103,7 @@ export default function App() {
const clientRef = useRef<SandboxAgent | null>(null);
const sseAbortRef = useRef<AbortController | null>(null);
const turnAbortRef = useRef<AbortController | null>(null);
const logRequest = useCallback((entry: RequestLog) => {
setRequestLog((prev) => {
@ -200,9 +209,18 @@ export default function App() {
setEventError(null);
stopPolling();
stopSse();
stopTurnStream();
setAgents([]);
setSessions([]);
setAgentsLoading(false);
setSessionsLoading(false);
setAgentsError(null);
setSessionsError(null);
};
const refreshAgents = async () => {
setAgentsLoading(true);
setAgentsError(null);
try {
const data = await getClient().listAgents();
const agentList = data.agents ?? [];
@ -213,17 +231,23 @@ export default function App() {
}
}
} catch (error) {
setConnectError(getErrorMessage(error, "Unable to refresh agents"));
setAgentsError(getErrorMessage(error, "Unable to refresh agents"));
} finally {
setAgentsLoading(false);
}
};
const fetchSessions = async () => {
setSessionsLoading(true);
setSessionsError(null);
try {
const data = await getClient().listSessions();
const sessionList = data.sessions ?? [];
setSessions(sessionList);
} catch {
// Silently fail - sessions list is supplementary
setSessionsError("Unable to load sessions.");
} finally {
setSessionsLoading(false);
}
};
@ -237,22 +261,32 @@ export default function App() {
};
const loadModes = async (targetId: string) => {
setModesLoadingByAgent((prev) => ({ ...prev, [targetId]: true }));
setModesErrorByAgent((prev) => ({ ...prev, [targetId]: null }));
try {
const data = await getClient().getAgentModes(targetId);
const modes = data.modes ?? [];
setModesByAgent((prev) => ({ ...prev, [targetId]: modes }));
} catch {
// Silently fail - modes are optional
setModesErrorByAgent((prev) => ({ ...prev, [targetId]: "Unable to load modes." }));
} finally {
setModesLoadingByAgent((prev) => ({ ...prev, [targetId]: false }));
}
};
const sendMessage = async () => {
if (!message.trim()) return;
const prompt = message.trim();
if (!prompt || !sessionId || turnStreaming) return;
setSessionError(null);
try {
await getClient().postMessage(sessionId, { message });
setMessage("");
setMessage("");
if (streamMode === "turn") {
await startTurnStream(prompt);
return;
}
try {
await getClient().postMessage(sessionId, { message: prompt });
if (!polling) {
if (streamMode === "poll") {
startPolling();
@ -266,6 +300,7 @@ export default function App() {
};
const selectSession = (session: SessionInfo) => {
stopTurnStream();
setSessionId(session.sessionId);
setAgentId(session.agent);
setAgentMode(session.agentMode);
@ -278,7 +313,12 @@ export default function App() {
setSessionError(null);
};
const createNewSession = async () => {
const createNewSession = async (nextAgentId?: string) => {
stopTurnStream();
const selectedAgent = nextAgentId ?? agentId;
if (nextAgentId) {
setAgentId(nextAgentId);
}
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "session-";
for (let i = 0; i < 8; i++) {
@ -297,7 +337,7 @@ export default function App() {
permissionMode?: string;
model?: string;
variant?: string;
} = { agent: agentId };
} = { agent: selectedAgent };
if (agentMode) body.agentMode = agentMode;
if (permissionMode) body.permissionMode = permissionMode;
if (model) body.model = model;
@ -320,6 +360,7 @@ export default function App() {
const fetchEvents = useCallback(async () => {
if (!sessionId) return;
setEventsLoading(true);
try {
const response = await getClient().getEvents(sessionId, {
offset: offsetRef.current,
@ -330,6 +371,8 @@ export default function App() {
setEventError(null);
} catch (error) {
setEventError(getErrorMessage(error, "Unable to fetch events"));
} finally {
setEventsLoading(false);
}
}, [appendEvents, getClient, sessionId]);
@ -394,6 +437,48 @@ export default function App() {
setPolling(false);
};
const startTurnStream = async (prompt: string) => {
stopPolling();
stopSse();
if (turnAbortRef.current) return;
if (!sessionId) {
setEventError("Select or create a session first.");
return;
}
setEventError(null);
setTurnStreaming(true);
const controller = new AbortController();
turnAbortRef.current = controller;
try {
for await (const event of getClient().streamTurn(
sessionId,
{ message: prompt },
undefined,
controller.signal
)) {
appendEvents([event]);
}
} catch (error) {
if (controller.signal.aborted) {
return;
}
setEventError(getErrorMessage(error, "Turn stream error."));
} finally {
if (turnAbortRef.current === controller) {
turnAbortRef.current = null;
setTurnStreaming(false);
}
}
};
const stopTurnStream = () => {
if (turnAbortRef.current) {
turnAbortRef.current.abort();
turnAbortRef.current = null;
}
setTurnStreaming(false);
};
const resetEvents = () => {
setEvents([]);
setOffset(0);
@ -580,6 +665,7 @@ export default function App() {
return () => {
stopPolling();
stopSse();
stopTurnStream();
};
}, []);
@ -604,6 +690,7 @@ export default function App() {
useEffect(() => {
if (!connected || !sessionId || polling) return;
if (streamMode === "turn") return;
const hasSession = sessions.some((session) => session.sessionId === sessionId);
if (!hasSession) return;
if (streamMode === "poll") {
@ -613,6 +700,15 @@ export default function App() {
}
}, [connected, sessionId, polling, streamMode, sessions]);
useEffect(() => {
if (streamMode === "turn") {
stopPolling();
stopSse();
} else if (turnStreaming) {
stopTurnStream();
}
}, [streamMode, turnStreaming]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [transcriptEntries]);
@ -633,6 +729,16 @@ export default function App() {
const availableAgents = agents.length ? agents.map((agent) => agent.id) : defaultAgents;
const currentAgent = agents.find((agent) => agent.id === agentId);
const activeModes = modesByAgent[agentId] ?? [];
const modesLoading = modesLoadingByAgent[agentId] ?? false;
const modesError = modesErrorByAgent[agentId] ?? null;
const agentDisplayNames: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
mock: "Mock"
};
const agentLabel = agentDisplayNames[agentId] ?? agentId;
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
@ -642,6 +748,9 @@ export default function App() {
};
const toggleStream = () => {
if (streamMode === "turn") {
return;
}
if (polling) {
if (streamMode === "poll") {
stopPolling();
@ -695,11 +804,17 @@ export default function App() {
onSelectSession={selectSession}
onRefresh={fetchSessions}
onCreateSession={createNewSession}
availableAgents={availableAgents}
agentsLoading={agentsLoading}
agentsError={agentsError}
sessionsLoading={sessionsLoading}
sessionsError={sessionsError}
/>
<ChatPanel
sessionId={sessionId}
polling={polling}
turnStreaming={turnStreaming}
transcriptEntries={transcriptEntries}
sessionError={sessionError}
message={message}
@ -708,22 +823,24 @@ export default function App() {
onKeyDown={handleKeyDown}
onCreateSession={createNewSession}
messagesEndRef={messagesEndRef}
agentId={agentId}
agentLabel={agentLabel}
agentMode={agentMode}
permissionMode={permissionMode}
model={model}
variant={variant}
streamMode={streamMode}
availableAgents={availableAgents}
activeModes={activeModes}
currentAgentVersion={currentAgent?.version ?? null}
onAgentChange={setAgentId}
modesLoading={modesLoading}
modesError={modesError}
onAgentModeChange={setAgentMode}
onPermissionModeChange={setPermissionMode}
onModelChange={setModel}
onVariantChange={setVariant}
onStreamModeChange={setStreamMode}
onToggleStream={toggleStream}
hasSession={Boolean(sessionId)}
eventError={eventError}
questionRequests={questionRequests}
permissionRequests={permissionRequests}
questionSelections={questionSelections}
@ -740,6 +857,8 @@ export default function App() {
offset={offset}
onFetchEvents={fetchEvents}
onResetEvents={resetEvents}
eventsLoading={eventsLoading}
eventsError={eventError}
requestLog={requestLog}
copiedLogId={copiedLogId}
onClearRequestLog={() => setRequestLog([])}
@ -749,6 +868,8 @@ export default function App() {
modesByAgent={modesByAgent}
onRefreshAgents={refreshAgents}
onInstallAgent={installAgent}
agentsLoading={agentsLoading}
agentsError={agentsError}
/>
</main>
</div>

View file

@ -1,4 +1,5 @@
import { Plus, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { SessionInfo } from "sandbox-agent";
const SessionSidebar = ({
@ -6,14 +7,47 @@ const SessionSidebar = ({
selectedSessionId,
onSelectSession,
onRefresh,
onCreateSession
onCreateSession,
availableAgents,
agentsLoading,
agentsError,
sessionsLoading,
sessionsError
}: {
sessions: SessionInfo[];
selectedSessionId: string;
onSelectSession: (session: SessionInfo) => void;
onRefresh: () => void;
onCreateSession: () => void;
onCreateSession: (agentId: string) => void;
availableAgents: string[];
agentsLoading: boolean;
agentsError: string | null;
sessionsLoading: boolean;
sessionsError: string | null;
}) => {
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!showMenu) return;
const handler = (event: MouseEvent) => {
if (!menuRef.current) return;
if (!menuRef.current.contains(event.target as Node)) {
setShowMenu(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [showMenu]);
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
mock: "Mock"
};
return (
<div className="session-sidebar">
<div className="sidebar-header">
@ -22,14 +56,46 @@ const SessionSidebar = ({
<button className="sidebar-icon-btn" onClick={onRefresh} title="Refresh sessions">
<RefreshCw size={14} />
</button>
<button className="sidebar-add-btn" onClick={onCreateSession} title="New session">
<Plus size={14} />
</button>
<div className="sidebar-add-menu-wrapper" ref={menuRef}>
<button
className="sidebar-add-btn"
onClick={() => setShowMenu((value) => !value)}
title="New session"
>
<Plus size={14} />
</button>
{showMenu && (
<div className="sidebar-add-menu">
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>}
{!agentsLoading && !agentsError && availableAgents.length === 0 && (
<div className="sidebar-add-status">No agents available.</div>
)}
{!agentsLoading && !agentsError &&
availableAgents.map((id) => (
<button
key={id}
className="sidebar-add-option"
onClick={() => {
onCreateSession(id);
setShowMenu(false);
}}
>
{agentLabels[id] ?? id}
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="session-list">
{sessions.length === 0 ? (
{sessionsLoading ? (
<div className="sidebar-empty">Loading sessions...</div>
) : sessionsError ? (
<div className="sidebar-empty error">{sessionsError}</div>
) : sessions.length === 0 ? (
<div className="sidebar-empty">No sessions yet.</div>
) : (
sessions.map((session) => (

View file

@ -5,10 +5,12 @@ import type { TimelineEntry } from "./types";
const ChatMessages = ({
entries,
sessionError,
eventError,
messagesEndRef
}: {
entries: TimelineEntry[];
sessionError: string | null;
eventError: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
}) => {
return (
@ -67,6 +69,7 @@ const ChatMessages = ({
);
})}
{sessionError && <div className="message-error">{sessionError}</div>}
{eventError && <div className="message-error">{eventError}</div>}
<div ref={messagesEndRef} />
</div>
);

View file

@ -1,4 +1,4 @@
import { MessageSquare, Plus, Terminal } from "lucide-react";
import { MessageSquare, PauseCircle, PlayCircle, Plus, Terminal } from "lucide-react";
import type { AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent";
import ApprovalsTab from "../debug/ApprovalsTab";
import ChatInput from "./ChatInput";
@ -9,6 +9,7 @@ import type { TimelineEntry } from "./types";
const ChatPanel = ({
sessionId,
polling,
turnStreaming,
transcriptEntries,
sessionError,
message,
@ -17,22 +18,24 @@ const ChatPanel = ({
onKeyDown,
onCreateSession,
messagesEndRef,
agentId,
agentLabel,
agentMode,
permissionMode,
model,
variant,
streamMode,
availableAgents,
activeModes,
currentAgentVersion,
onAgentChange,
hasSession,
modesLoading,
modesError,
onAgentModeChange,
onPermissionModeChange,
onModelChange,
onVariantChange,
onStreamModeChange,
onToggleStream,
eventError,
questionRequests,
permissionRequests,
questionSelections,
@ -43,6 +46,7 @@ const ChatPanel = ({
}: {
sessionId: string;
polling: boolean;
turnStreaming: boolean;
transcriptEntries: TimelineEntry[];
sessionError: string | null;
message: string;
@ -51,22 +55,24 @@ const ChatPanel = ({
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCreateSession: () => void;
messagesEndRef: React.RefObject<HTMLDivElement>;
agentId: string;
agentLabel: string;
agentMode: string;
permissionMode: string;
model: string;
variant: string;
streamMode: "poll" | "sse";
availableAgents: string[];
streamMode: "poll" | "sse" | "turn";
activeModes: AgentModeInfo[];
currentAgentVersion?: string | null;
onAgentChange: (value: string) => void;
hasSession: boolean;
modesLoading: boolean;
modesError: string | null;
onAgentModeChange: (value: string) => void;
onPermissionModeChange: (value: string) => void;
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
onStreamModeChange: (value: "poll" | "sse") => void;
onStreamModeChange: (value: "poll" | "sse" | "turn") => void;
onToggleStream: () => void;
eventError: string | null;
questionRequests: QuestionEventData[];
permissionRequests: PermissionEventData[];
questionSelections: Record<string, string[][]>;
@ -76,16 +82,57 @@ const ChatPanel = ({
onReplyPermission: (requestId: string, reply: "once" | "always" | "reject") => void;
}) => {
const hasApprovals = questionRequests.length > 0 || permissionRequests.length > 0;
const isTurnMode = streamMode === "turn";
const isStreaming = isTurnMode ? turnStreaming : polling;
const turnLabel = turnStreaming ? "Streaming" : "On Send";
return (
<div className="chat-panel">
<div className="panel-header">
<div className="panel-header-left">
<MessageSquare className="button-icon" />
<span className="panel-title">Session</span>
<span className="panel-title">{sessionId ? "Session" : "No Session"}</span>
{sessionId && <span className="session-id-display">{sessionId}</span>}
{sessionId && <span className="session-agent-display">{agentLabel}</span>}
</div>
<div className="panel-header-right">
<div className="setup-stream">
<select
className="setup-select-small"
value={streamMode}
onChange={(e) => onStreamModeChange(e.target.value as "poll" | "sse" | "turn")}
title="Stream Mode"
disabled={!sessionId}
>
<option value="poll">Poll</option>
<option value="sse">SSE</option>
<option value="turn">Turn</option>
</select>
<button
className={`setup-stream-btn ${isStreaming ? "active" : ""}`}
onClick={onToggleStream}
title={isTurnMode ? "Turn streaming starts on send" : polling ? "Stop streaming" : "Start streaming"}
disabled={!sessionId || isTurnMode}
>
{isTurnMode ? (
<>
<PlayCircle size={14} />
<span>{turnLabel}</span>
</>
) : polling ? (
<>
<PauseCircle size={14} />
<span>Pause</span>
</>
) : (
<>
<PlayCircle size={14} />
<span>Resume</span>
</>
)}
</button>
</div>
</div>
{polling && <span className="pill accent">Live</span>}
</div>
<div className="messages-container">
@ -109,6 +156,7 @@ const ChatPanel = ({
<ChatMessages
entries={transcriptEntries}
sessionError={sessionError}
eventError={eventError}
messagesEndRef={messagesEndRef}
/>
)}
@ -135,27 +183,24 @@ const ChatPanel = ({
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId}
disabled={!sessionId || turnStreaming}
/>
<ChatSetup
agentId={agentId}
agentLabel={agentLabel}
agentMode={agentMode}
permissionMode={permissionMode}
model={model}
variant={variant}
streamMode={streamMode}
polling={polling}
availableAgents={availableAgents}
activeModes={activeModes}
currentAgentVersion={currentAgentVersion}
onAgentChange={onAgentChange}
modesLoading={modesLoading}
modesError={modesError}
onAgentModeChange={onAgentModeChange}
onPermissionModeChange={onPermissionModeChange}
onModelChange={onModelChange}
onVariantChange={onVariantChange}
onStreamModeChange={onStreamModeChange}
onToggleStream={onToggleStream}
hasSession={hasSession}
/>
</div>
);

View file

@ -1,60 +1,53 @@
import { PauseCircle, PlayCircle } from "lucide-react";
import type { AgentModeInfo } from "sandbox-agent";
const ChatSetup = ({
agentId,
agentLabel,
agentMode,
permissionMode,
model,
variant,
streamMode,
polling,
availableAgents,
activeModes,
currentAgentVersion,
onAgentChange,
hasSession,
modesLoading,
modesError,
onAgentModeChange,
onPermissionModeChange,
onModelChange,
onVariantChange,
onStreamModeChange,
onToggleStream
onVariantChange
}: {
agentId: string;
agentLabel: string;
agentMode: string;
permissionMode: string;
model: string;
variant: string;
streamMode: "poll" | "sse";
polling: boolean;
availableAgents: string[];
activeModes: AgentModeInfo[];
currentAgentVersion?: string | null;
onAgentChange: (value: string) => void;
hasSession: boolean;
modesLoading: boolean;
modesError: string | null;
onAgentModeChange: (value: string) => void;
onPermissionModeChange: (value: string) => void;
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
onStreamModeChange: (value: "poll" | "sse") => void;
onToggleStream: () => void;
}) => {
const agentVersionLabel = currentAgentVersion
? `${agentLabel} v${currentAgentVersion}`
: agentLabel;
return (
<div className="setup-row">
<select className="setup-select" value={agentId} onChange={(e) => onAgentChange(e.target.value)} title="Agent">
{availableAgents.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
<select
className="setup-select"
value={agentMode}
onChange={(e) => onAgentModeChange(e.target.value)}
title="Mode"
disabled={!hasSession || modesLoading || Boolean(modesError)}
>
{activeModes.length > 0 ? (
{modesLoading ? (
<option value="">Loading modes...</option>
) : modesError ? (
<option value="">{modesError}</option>
) : activeModes.length > 0 ? (
activeModes.map((mode) => (
<option key={mode.id} value={mode.id}>
{mode.name || mode.id}
@ -70,6 +63,7 @@ const ChatSetup = ({
value={permissionMode}
onChange={(e) => onPermissionModeChange(e.target.value)}
title="Permission Mode"
disabled={!hasSession}
>
<option value="default">Default</option>
<option value="plan">Plan</option>
@ -82,6 +76,7 @@ const ChatSetup = ({
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
title="Model"
disabled={!hasSession}
/>
<input
@ -90,40 +85,12 @@ const ChatSetup = ({
onChange={(e) => onVariantChange(e.target.value)}
placeholder="Variant"
title="Variant"
disabled={!hasSession}
/>
<div className="setup-stream">
<select
className="setup-select-small"
value={streamMode}
onChange={(e) => onStreamModeChange(e.target.value as "poll" | "sse")}
title="Stream Mode"
>
<option value="poll">Poll</option>
<option value="sse">SSE</option>
</select>
<button
className={`setup-stream-btn ${polling ? "active" : ""}`}
onClick={onToggleStream}
title={polling ? "Stop streaming" : "Start streaming"}
>
{polling ? (
<>
<PauseCircle size={14} />
<span>Pause</span>
</>
) : (
<>
<PlayCircle size={14} />
<span>Resume</span>
</>
)}
</button>
</div>
{currentAgentVersion && (
<span className="setup-version" title="Installed version">
v{currentAgentVersion}
{hasSession && (
<span className="setup-version" title="Session agent">
{agentVersionLabel}
</span>
)}
</div>

View file

@ -8,23 +8,31 @@ const AgentsTab = ({
defaultAgents,
modesByAgent,
onRefresh,
onInstall
onInstall,
loading,
error
}: {
agents: AgentInfo[];
defaultAgents: string[];
modesByAgent: Record<string, AgentModeInfo[]>;
onRefresh: () => void;
onInstall: (agentId: string, reinstall: boolean) => void;
loading: boolean;
error: string | null;
}) => {
return (
<>
<div className="inline-row" style={{ marginBottom: 16 }}>
<button className="button secondary small" onClick={onRefresh}>
<button className="button secondary small" onClick={onRefresh} disabled={loading}>
<RefreshCw className="button-icon" /> Refresh
</button>
</div>
{agents.length === 0 && <div className="card-meta">No agents reported. Click refresh to check.</div>}
{error && <div className="banner error">{error}</div>}
{loading && <div className="card-meta">Loading agents...</div>}
{!loading && agents.length === 0 && (
<div className="card-meta">No agents reported. Click refresh to check.</div>
)}
{(agents.length
? agents

View file

@ -14,6 +14,8 @@ const DebugPanel = ({
offset,
onFetchEvents,
onResetEvents,
eventsLoading,
eventsError,
requestLog,
copiedLogId,
onClearRequestLog,
@ -22,7 +24,9 @@ const DebugPanel = ({
defaultAgents,
modesByAgent,
onRefreshAgents,
onInstallAgent
onInstallAgent,
agentsLoading,
agentsError
}: {
debugTab: DebugTab;
onDebugTabChange: (tab: DebugTab) => void;
@ -30,6 +34,8 @@ const DebugPanel = ({
offset: number;
onFetchEvents: () => void;
onResetEvents: () => void;
eventsLoading: boolean;
eventsError: string | null;
requestLog: RequestLog[];
copiedLogId: number | null;
onClearRequestLog: () => void;
@ -39,6 +45,8 @@ const DebugPanel = ({
modesByAgent: Record<string, AgentModeInfo[]>;
onRefreshAgents: () => void;
onInstallAgent: (agentId: string, reinstall: boolean) => void;
agentsLoading: boolean;
agentsError: string | null;
}) => {
return (
<div className="debug-panel">
@ -69,7 +77,14 @@ const DebugPanel = ({
)}
{debugTab === "events" && (
<EventsTab events={events} offset={offset} onFetch={onFetchEvents} onClear={onResetEvents} />
<EventsTab
events={events}
offset={offset}
onFetch={onFetchEvents}
onClear={onResetEvents}
loading={eventsLoading}
error={eventsError}
/>
)}
{debugTab === "agents" && (
@ -79,6 +94,8 @@ const DebugPanel = ({
modesByAgent={modesByAgent}
onRefresh={onRefreshAgents}
onInstall={onInstallAgent}
loading={agentsLoading}
error={agentsError}
/>
)}
</div>

View file

@ -8,12 +8,16 @@ const EventsTab = ({
events,
offset,
onFetch,
onClear
onClear,
loading,
error
}: {
events: UniversalEvent[];
offset: number;
onFetch: () => void;
onClear: () => void;
loading: boolean;
error: string | null;
}) => {
const [collapsedEvents, setCollapsedEvents] = useState<Record<string, boolean>>({});
@ -28,8 +32,8 @@ const EventsTab = ({
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">Offset: {offset}</span>
<div className="inline-row">
<button className="button ghost small" onClick={onFetch}>
Fetch
<button className="button ghost small" onClick={onFetch} disabled={loading}>
{loading ? "Loading..." : "Fetch"}
</button>
<button className="button ghost small" onClick={onClear}>
Clear
@ -37,8 +41,12 @@ const EventsTab = ({
</div>
</div>
{error && <div className="banner error">{error}</div>}
{events.length === 0 ? (
<div className="card-meta">No events yet. Start streaming to receive events.</div>
<div className="card-meta">
{loading ? "Loading events..." : "No events yet. Start streaming to receive events."}
</div>
) : (
<div className="event-list">
{[...events].reverse().map((event) => {