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

@ -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) => {