mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 05:00:20 +00:00
chore: sync workspace changes
This commit is contained in:
parent
d24f983e2c
commit
bf58891edf
139 changed files with 5454 additions and 8986 deletions
|
|
@ -757,6 +757,26 @@
|
|||
}
|
||||
|
||||
/* Input Area */
|
||||
.approvals-inline {
|
||||
padding: 12px 16px;
|
||||
background: var(--surface-2);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.approvals-inline-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.approvals-inline .card {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg);
|
||||
|
|
@ -931,8 +951,10 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
width: auto;
|
||||
height: 24px;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: 4px;
|
||||
|
|
@ -941,6 +963,11 @@
|
|||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.setup-stream-btn span {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.setup-stream-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
|
|
@ -1123,14 +1150,68 @@
|
|||
background: var(--surface);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
.event-item.expanded {
|
||||
box-shadow: 0 0 0 1px rgba(255, 79, 0, 0.12);
|
||||
}
|
||||
|
||||
.event-summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.event-summary:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-summary-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.event-summary:hover .event-chevron {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.event-type {
|
||||
|
|
@ -1173,17 +1254,70 @@
|
|||
color: var(--purple);
|
||||
}
|
||||
|
||||
.event-icon.session,
|
||||
.event-icon.session-started,
|
||||
.event-icon.session-ended {
|
||||
color: var(--success);
|
||||
border-color: rgba(48, 209, 88, 0.35);
|
||||
background: rgba(48, 209, 88, 0.12);
|
||||
}
|
||||
|
||||
.event-icon.item,
|
||||
.event-icon.item-started,
|
||||
.event-icon.item-completed {
|
||||
color: var(--accent);
|
||||
border-color: rgba(255, 79, 0, 0.35);
|
||||
background: rgba(255, 79, 0, 0.12);
|
||||
}
|
||||
|
||||
.event-icon.item-delta {
|
||||
color: var(--cyan);
|
||||
border-color: rgba(100, 210, 255, 0.35);
|
||||
background: rgba(100, 210, 255, 0.12);
|
||||
}
|
||||
|
||||
.event-icon.error,
|
||||
.event-icon.agent-unparsed {
|
||||
color: var(--danger);
|
||||
border-color: rgba(255, 59, 48, 0.35);
|
||||
background: rgba(255, 59, 48, 0.12);
|
||||
}
|
||||
|
||||
.event-icon.question,
|
||||
.event-icon.question-requested,
|
||||
.event-icon.question-resolved {
|
||||
color: var(--warning);
|
||||
border-color: rgba(255, 159, 10, 0.35);
|
||||
background: rgba(255, 159, 10, 0.12);
|
||||
}
|
||||
|
||||
.event-icon.permission,
|
||||
.event-icon.permission-requested,
|
||||
.event-icon.permission-resolved {
|
||||
color: var(--purple);
|
||||
border-color: rgba(191, 90, 242, 0.35);
|
||||
background: rgba(191, 90, 242, 0.12);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.event-id {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
.event-payload {
|
||||
margin: 0 12px 12px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
101
frontend/packages/inspector/src/components/ConnectScreen.tsx
Normal file
101
frontend/packages/inspector/src/components/ConnectScreen.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { Zap } from "lucide-react";
|
||||
|
||||
const ConnectScreen = ({
|
||||
endpoint,
|
||||
token,
|
||||
connectError,
|
||||
connecting,
|
||||
onEndpointChange,
|
||||
onTokenChange,
|
||||
onConnect,
|
||||
reportUrl
|
||||
}: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
connectError: string | null;
|
||||
connecting: boolean;
|
||||
onEndpointChange: (value: string) => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onConnect: () => void;
|
||||
reportUrl?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<div className="logo">SA</div>
|
||||
<span className="header-title">Sandbox Agent</span>
|
||||
</div>
|
||||
{reportUrl && (
|
||||
<div className="header-right">
|
||||
<a className="button ghost small" href={reportUrl} target="_blank" rel="noreferrer">
|
||||
Report Bug
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="landing">
|
||||
<div className="landing-container">
|
||||
<div className="landing-hero">
|
||||
<div className="landing-logo">SA</div>
|
||||
<h1 className="landing-title">Sandbox Agent</h1>
|
||||
<p className="landing-subtitle">
|
||||
Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="connect-card">
|
||||
<div className="connect-card-title">Connect to Server</div>
|
||||
|
||||
{connectError && <div className="banner error">{connectError}</div>}
|
||||
|
||||
<label className="field">
|
||||
<span className="label">Endpoint</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="http://localhost:2468"
|
||||
value={endpoint}
|
||||
onChange={(event) => onEndpointChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">Token (optional)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Bearer token"
|
||||
value={token}
|
||||
onChange={(event) => onTokenChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="button primary" onClick={onConnect} disabled={connecting}>
|
||||
{connecting ? (
|
||||
<>
|
||||
<span className="spinner" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="button-icon" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="hint">
|
||||
Start the server with CORS enabled for browser access:
|
||||
<br />
|
||||
<code>sandbox-agent server --cors-allow-origin http://localhost:5173</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectScreen;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import type { SessionInfo } from "sandbox-agent";
|
||||
|
||||
const SessionSidebar = ({
|
||||
sessions,
|
||||
selectedSessionId,
|
||||
onSelectSession,
|
||||
onRefresh,
|
||||
onCreateSession
|
||||
}: {
|
||||
sessions: SessionInfo[];
|
||||
selectedSessionId: string;
|
||||
onSelectSession: (session: SessionInfo) => void;
|
||||
onRefresh: () => void;
|
||||
onCreateSession: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="session-sidebar">
|
||||
<div className="sidebar-header">
|
||||
<span className="sidebar-title">Sessions</span>
|
||||
<div className="sidebar-header-actions">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="session-list">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="sidebar-empty">No sessions yet.</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""}`}
|
||||
onClick={() => onSelectSession(session)}
|
||||
>
|
||||
<div className="session-item-id">{session.sessionId}</div>
|
||||
<div className="session-item-meta">
|
||||
<span className="session-item-agent">{session.agent}</span>
|
||||
<span className="session-item-events">{session.eventCount} events</span>
|
||||
{session.ended && <span className="session-item-ended">ended</span>}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionSidebar;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { ComponentType } from "react";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Brain,
|
||||
Download,
|
||||
FileDiff,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
Image,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
PlayCircle,
|
||||
Plug,
|
||||
Shield,
|
||||
Terminal,
|
||||
Wrench
|
||||
} from "lucide-react";
|
||||
import type { AgentCapabilitiesView } from "../../types/agents";
|
||||
|
||||
const badges = [
|
||||
{ key: "planMode", label: "Plan", icon: GitBranch },
|
||||
{ key: "permissions", label: "Perms", icon: Shield },
|
||||
{ key: "questions", label: "Q&A", icon: HelpCircle },
|
||||
{ key: "toolCalls", label: "Tool Calls", icon: Wrench },
|
||||
{ key: "toolResults", label: "Tool Results", icon: Download },
|
||||
{ key: "textMessages", label: "Text", icon: MessageSquare },
|
||||
{ key: "images", label: "Images", icon: Image },
|
||||
{ key: "fileAttachments", label: "Files", icon: Paperclip },
|
||||
{ key: "sessionLifecycle", label: "Lifecycle", icon: PlayCircle },
|
||||
{ key: "errorEvents", label: "Errors", icon: AlertTriangle },
|
||||
{ key: "reasoning", label: "Reasoning", icon: Brain },
|
||||
{ key: "commandExecution", label: "Commands", icon: Terminal },
|
||||
{ key: "fileChanges", label: "File Changes", icon: FileDiff },
|
||||
{ key: "mcpTools", label: "MCP", icon: Plug },
|
||||
{ key: "streamingDeltas", label: "Deltas", icon: Activity }
|
||||
] as const;
|
||||
|
||||
type BadgeItem = (typeof badges)[number];
|
||||
|
||||
const getEnabled = (capabilities: AgentCapabilitiesView, key: BadgeItem["key"]) =>
|
||||
Boolean((capabilities as Record<string, boolean | undefined>)[key]);
|
||||
|
||||
const CapabilityBadges = ({ capabilities }: { capabilities: AgentCapabilitiesView }) => {
|
||||
return (
|
||||
<div className="capability-badges">
|
||||
{badges.map(({ key, label, icon: Icon }) => (
|
||||
<span key={key} className={`capability-badge ${getEnabled(capabilities, key) ? "enabled" : "disabled"}`}>
|
||||
<Icon size={12} />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CapabilityBadges;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Send } from "lucide-react";
|
||||
|
||||
const ChatInput = ({
|
||||
message,
|
||||
onMessageChange,
|
||||
onSendMessage,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
disabled
|
||||
}: {
|
||||
message: string;
|
||||
onMessageChange: (value: string) => void;
|
||||
onSendMessage: () => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder: string;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className="input-container">
|
||||
<div className="input-wrapper">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => onMessageChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button className="send-button" onClick={onSendMessage} disabled={disabled || !message.trim()}>
|
||||
<Send />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInput;
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { getAvatarLabel, getMessageClass } from "./messageUtils";
|
||||
import renderContentPart from "./renderContentPart";
|
||||
import type { TimelineEntry } from "./types";
|
||||
|
||||
const ChatMessages = ({
|
||||
entries,
|
||||
sessionError,
|
||||
messagesEndRef
|
||||
}: {
|
||||
entries: TimelineEntry[];
|
||||
sessionError: string | null;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
}) => {
|
||||
return (
|
||||
<div className="messages">
|
||||
{entries.map((entry) => {
|
||||
if (entry.kind === "meta") {
|
||||
const messageClass = entry.meta?.severity === "error" ? "error" : "system";
|
||||
return (
|
||||
<div key={entry.id} className={`message ${messageClass}`}>
|
||||
<div className="avatar">{getAvatarLabel(messageClass)}</div>
|
||||
<div className="message-content">
|
||||
<div className="message-meta">
|
||||
<span>{entry.meta?.title ?? "Status"}</span>
|
||||
</div>
|
||||
{entry.meta?.detail && <div className="part-body">{entry.meta.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const item = entry.item;
|
||||
if (!item) return null;
|
||||
const hasParts = (item.content ?? []).length > 0;
|
||||
const isInProgress = item.status === "in_progress";
|
||||
const isFailed = item.status === "failed";
|
||||
const messageClass = getMessageClass(item);
|
||||
const statusLabel = item.status !== "completed" ? item.status.replace("_", " ") : "";
|
||||
const kindLabel = item.kind.replace("_", " ");
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={`message ${messageClass} ${isFailed ? "error" : ""}`}>
|
||||
<div className="avatar">{getAvatarLabel(isFailed ? "error" : messageClass)}</div>
|
||||
<div className="message-content">
|
||||
{(item.kind !== "message" || item.status !== "completed") && (
|
||||
<div className="message-meta">
|
||||
<span>{kindLabel}</span>
|
||||
{statusLabel && (
|
||||
<span className={`pill ${item.status === "failed" ? "danger" : "accent"}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasParts ? (
|
||||
(item.content ?? []).map(renderContentPart)
|
||||
) : entry.deltaText ? (
|
||||
<span>
|
||||
{entry.deltaText}
|
||||
{isInProgress && <span className="cursor" />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">No content yet.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sessionError && <div className="message-error">{sessionError}</div>}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessages;
|
||||
164
frontend/packages/inspector/src/components/chat/ChatPanel.tsx
Normal file
164
frontend/packages/inspector/src/components/chat/ChatPanel.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { MessageSquare, Plus, Terminal } from "lucide-react";
|
||||
import type { AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent";
|
||||
import ApprovalsTab from "../debug/ApprovalsTab";
|
||||
import ChatInput from "./ChatInput";
|
||||
import ChatMessages from "./ChatMessages";
|
||||
import ChatSetup from "./ChatSetup";
|
||||
import type { TimelineEntry } from "./types";
|
||||
|
||||
const ChatPanel = ({
|
||||
sessionId,
|
||||
polling,
|
||||
transcriptEntries,
|
||||
sessionError,
|
||||
message,
|
||||
onMessageChange,
|
||||
onSendMessage,
|
||||
onKeyDown,
|
||||
onCreateSession,
|
||||
messagesEndRef,
|
||||
agentId,
|
||||
agentMode,
|
||||
permissionMode,
|
||||
model,
|
||||
variant,
|
||||
streamMode,
|
||||
availableAgents,
|
||||
activeModes,
|
||||
currentAgentVersion,
|
||||
onAgentChange,
|
||||
onAgentModeChange,
|
||||
onPermissionModeChange,
|
||||
onModelChange,
|
||||
onVariantChange,
|
||||
onStreamModeChange,
|
||||
onToggleStream,
|
||||
questionRequests,
|
||||
permissionRequests,
|
||||
questionSelections,
|
||||
onSelectQuestionOption,
|
||||
onAnswerQuestion,
|
||||
onRejectQuestion,
|
||||
onReplyPermission
|
||||
}: {
|
||||
sessionId: string;
|
||||
polling: boolean;
|
||||
transcriptEntries: TimelineEntry[];
|
||||
sessionError: string | null;
|
||||
message: string;
|
||||
onMessageChange: (value: string) => void;
|
||||
onSendMessage: () => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onCreateSession: () => void;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
agentId: string;
|
||||
agentMode: string;
|
||||
permissionMode: string;
|
||||
model: string;
|
||||
variant: string;
|
||||
streamMode: "poll" | "sse";
|
||||
availableAgents: string[];
|
||||
activeModes: AgentModeInfo[];
|
||||
currentAgentVersion?: string | null;
|
||||
onAgentChange: (value: string) => void;
|
||||
onAgentModeChange: (value: string) => void;
|
||||
onPermissionModeChange: (value: string) => void;
|
||||
onModelChange: (value: string) => void;
|
||||
onVariantChange: (value: string) => void;
|
||||
onStreamModeChange: (value: "poll" | "sse") => void;
|
||||
onToggleStream: () => void;
|
||||
questionRequests: QuestionEventData[];
|
||||
permissionRequests: PermissionEventData[];
|
||||
questionSelections: Record<string, string[][]>;
|
||||
onSelectQuestionOption: (requestId: string, optionLabel: string) => void;
|
||||
onAnswerQuestion: (request: QuestionEventData) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: "once" | "always" | "reject") => void;
|
||||
}) => {
|
||||
const hasApprovals = questionRequests.length > 0 || permissionRequests.length > 0;
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="panel-header">
|
||||
<div className="panel-header-left">
|
||||
<MessageSquare className="button-icon" />
|
||||
<span className="panel-title">Session</span>
|
||||
{sessionId && <span className="session-id-display">{sessionId}</span>}
|
||||
</div>
|
||||
{polling && <span className="pill accent">Live</span>}
|
||||
</div>
|
||||
|
||||
<div className="messages-container">
|
||||
{!sessionId ? (
|
||||
<div className="empty-state">
|
||||
<MessageSquare className="empty-state-icon" />
|
||||
<div className="empty-state-title">No Session Selected</div>
|
||||
<p className="empty-state-text">Create a new session to start chatting with an agent.</p>
|
||||
<button className="button primary" onClick={onCreateSession}>
|
||||
<Plus className="button-icon" />
|
||||
Create Session
|
||||
</button>
|
||||
</div>
|
||||
) : transcriptEntries.length === 0 && !sessionError ? (
|
||||
<div className="empty-state">
|
||||
<Terminal className="empty-state-icon" />
|
||||
<div className="empty-state-title">Ready to Chat</div>
|
||||
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
entries={transcriptEntries}
|
||||
sessionError={sessionError}
|
||||
messagesEndRef={messagesEndRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasApprovals && (
|
||||
<div className="approvals-inline">
|
||||
<div className="approvals-inline-header">Approvals</div>
|
||||
<ApprovalsTab
|
||||
questionRequests={questionRequests}
|
||||
permissionRequests={permissionRequests}
|
||||
questionSelections={questionSelections}
|
||||
onSelectQuestionOption={onSelectQuestionOption}
|
||||
onAnswerQuestion={onAnswerQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
onReplyPermission={onReplyPermission}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatInput
|
||||
message={message}
|
||||
onMessageChange={onMessageChange}
|
||||
onSendMessage={onSendMessage}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={sessionId ? "Send a message..." : "Select or create a session first"}
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
|
||||
<ChatSetup
|
||||
agentId={agentId}
|
||||
agentMode={agentMode}
|
||||
permissionMode={permissionMode}
|
||||
model={model}
|
||||
variant={variant}
|
||||
streamMode={streamMode}
|
||||
polling={polling}
|
||||
availableAgents={availableAgents}
|
||||
activeModes={activeModes}
|
||||
currentAgentVersion={currentAgentVersion}
|
||||
onAgentChange={onAgentChange}
|
||||
onAgentModeChange={onAgentModeChange}
|
||||
onPermissionModeChange={onPermissionModeChange}
|
||||
onModelChange={onModelChange}
|
||||
onVariantChange={onVariantChange}
|
||||
onStreamModeChange={onStreamModeChange}
|
||||
onToggleStream={onToggleStream}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPanel;
|
||||
133
frontend/packages/inspector/src/components/chat/ChatSetup.tsx
Normal file
133
frontend/packages/inspector/src/components/chat/ChatSetup.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { PauseCircle, PlayCircle } from "lucide-react";
|
||||
import type { AgentModeInfo } from "sandbox-agent";
|
||||
|
||||
const ChatSetup = ({
|
||||
agentId,
|
||||
agentMode,
|
||||
permissionMode,
|
||||
model,
|
||||
variant,
|
||||
streamMode,
|
||||
polling,
|
||||
availableAgents,
|
||||
activeModes,
|
||||
currentAgentVersion,
|
||||
onAgentChange,
|
||||
onAgentModeChange,
|
||||
onPermissionModeChange,
|
||||
onModelChange,
|
||||
onVariantChange,
|
||||
onStreamModeChange,
|
||||
onToggleStream
|
||||
}: {
|
||||
agentId: 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;
|
||||
onAgentModeChange: (value: string) => void;
|
||||
onPermissionModeChange: (value: string) => void;
|
||||
onModelChange: (value: string) => void;
|
||||
onVariantChange: (value: string) => void;
|
||||
onStreamModeChange: (value: "poll" | "sse") => void;
|
||||
onToggleStream: () => void;
|
||||
}) => {
|
||||
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"
|
||||
>
|
||||
{activeModes.length > 0 ? (
|
||||
activeModes.map((mode) => (
|
||||
<option key={mode.id} value={mode.id}>
|
||||
{mode.name || mode.id}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">Mode</option>
|
||||
)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="setup-select"
|
||||
value={permissionMode}
|
||||
onChange={(e) => onPermissionModeChange(e.target.value)}
|
||||
title="Permission Mode"
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="plan">Plan</option>
|
||||
<option value="bypass">Bypass</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
className="setup-input"
|
||||
value={model}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
placeholder="Model"
|
||||
title="Model"
|
||||
/>
|
||||
|
||||
<input
|
||||
className="setup-input"
|
||||
value={variant}
|
||||
onChange={(e) => onVariantChange(e.target.value)}
|
||||
placeholder="Variant"
|
||||
title="Variant"
|
||||
/>
|
||||
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatSetup;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import type { UniversalItem } from "sandbox-agent";
|
||||
|
||||
export const getMessageClass = (item: UniversalItem) => {
|
||||
if (item.kind === "tool_call" || item.kind === "tool_result") return "tool";
|
||||
if (item.kind === "system" || item.kind === "status") return "system";
|
||||
if (item.role === "user") return "user";
|
||||
if (item.role === "tool") return "tool";
|
||||
if (item.role === "system") return "system";
|
||||
return "assistant";
|
||||
};
|
||||
|
||||
export const getAvatarLabel = (messageClass: string) => {
|
||||
if (messageClass === "user") return "U";
|
||||
if (messageClass === "tool") return "T";
|
||||
if (messageClass === "system") return "S";
|
||||
if (messageClass === "error") return "!";
|
||||
return "AI";
|
||||
};
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import type { ContentPart } from "sandbox-agent";
|
||||
import { formatJson } from "../../utils/format";
|
||||
|
||||
const renderContentPart = (part: ContentPart, index: number) => {
|
||||
const partType = (part as { type?: string }).type ?? "unknown";
|
||||
const key = `${partType}-${index}`;
|
||||
switch (partType) {
|
||||
case "text":
|
||||
return (
|
||||
<div key={key} className="part">
|
||||
<div className="part-body">{(part as { text: string }).text}</div>
|
||||
</div>
|
||||
);
|
||||
case "json":
|
||||
return (
|
||||
<div key={key} className="part">
|
||||
<div className="part-title">json</div>
|
||||
<pre className="code-block">{formatJson((part as { json: unknown }).json)}</pre>
|
||||
</div>
|
||||
);
|
||||
case "tool_call": {
|
||||
const { name, arguments: args, call_id } = part as {
|
||||
name: string;
|
||||
arguments: string;
|
||||
call_id: string;
|
||||
};
|
||||
return (
|
||||
<div key={key} className="part">
|
||||
<div className="part-title">
|
||||
tool call - {name}
|
||||
{call_id ? ` - ${call_id}` : ""}
|
||||
</div>
|
||||
{args ? <pre className="code-block">{args}</pre> : <div className="muted">No arguments</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "tool_result": {
|
||||
const { call_id, output } = part as { call_id: string; output: string };
|
||||
return (
|
||||
<div key={key} className="part">
|
||||
<div className="part-title">tool result - {call_id}</div>
|
||||
{output ? <pre className="code-block">{output}</pre> : <div className="muted">No output</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "file_ref": {
|
||||
const { path, action, diff } = part as { path: string; action: string; diff?: string | null };
|
||||
return (
|
||||
<div key={key} className="part">
|
||||
<div className="part-title">file - {action}</div>
|
||||
<div className="part-body mono">{path}</div>
|
||||
{diff && <pre className="code-block">{diff}</pre>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "reasoning": {
|
||||
const { text, visibility } = part as { text: string; visibility: string };
|
||||
return (
|
||||
<div key={key} className="part">
|
||||
<div className="part-title">reasoning - {visibility}</div>
|
||||
<div className="part-body muted">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "image": {
|
||||
const { path, mime } = part as { path: string; mime?: string | null };
|
||||
return (
|
||||
<div key={key} className="part">
|
||||
<div className="part-title">image {mime ? `- ${mime}` : ""}</div>
|
||||
<div className="part-body mono">{path}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "status": {
|
||||
const { label, detail } = part as { label: string; detail?: string | null };
|
||||
return (
|
||||
<div key={key} className="part">
|
||||
<div className="part-title">status - {label}</div>
|
||||
{detail && <div className="part-body">{detail}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div key={key} className="part">
|
||||
<div className="part-title">unknown</div>
|
||||
<pre className="code-block">{formatJson(part)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default renderContentPart;
|
||||
14
frontend/packages/inspector/src/components/chat/types.ts
Normal file
14
frontend/packages/inspector/src/components/chat/types.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { UniversalItem } from "sandbox-agent";
|
||||
|
||||
export type TimelineEntry = {
|
||||
id: string;
|
||||
kind: "item" | "meta";
|
||||
time: string;
|
||||
item?: UniversalItem;
|
||||
deltaText?: string;
|
||||
meta?: {
|
||||
title: string;
|
||||
detail?: string;
|
||||
severity?: "info" | "error";
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { Download, RefreshCw } from "lucide-react";
|
||||
import type { AgentInfo, AgentModeInfo } from "sandbox-agent";
|
||||
import CapabilityBadges from "../agents/CapabilityBadges";
|
||||
import { emptyCapabilities } from "../../types/agents";
|
||||
|
||||
const AgentsTab = ({
|
||||
agents,
|
||||
defaultAgents,
|
||||
modesByAgent,
|
||||
onRefresh,
|
||||
onInstall
|
||||
}: {
|
||||
agents: AgentInfo[];
|
||||
defaultAgents: string[];
|
||||
modesByAgent: Record<string, AgentModeInfo[]>;
|
||||
onRefresh: () => void;
|
||||
onInstall: (agentId: string, reinstall: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="inline-row" style={{ marginBottom: 16 }}>
|
||||
<button className="button secondary small" onClick={onRefresh}>
|
||||
<RefreshCw className="button-icon" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{agents.length === 0 && <div className="card-meta">No agents reported. Click refresh to check.</div>}
|
||||
|
||||
{(agents.length
|
||||
? agents
|
||||
: defaultAgents.map((id) => ({
|
||||
id,
|
||||
installed: false,
|
||||
version: undefined,
|
||||
path: undefined,
|
||||
capabilities: emptyCapabilities
|
||||
}))).map((agent) => (
|
||||
<div key={agent.id} className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">{agent.id}</span>
|
||||
<span className={`pill ${agent.installed ? "success" : "danger"}`}>
|
||||
{agent.installed ? "Installed" : "Missing"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
{agent.version ? `v${agent.version}` : "Version unknown"}
|
||||
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<CapabilityBadges capabilities={agent.capabilities ?? emptyCapabilities} />
|
||||
</div>
|
||||
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && (
|
||||
<div className="card-meta" style={{ marginTop: 8 }}>
|
||||
Modes: {modesByAgent[agent.id].map((mode) => mode.id).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-actions">
|
||||
<button className="button secondary small" onClick={() => onInstall(agent.id, false)}>
|
||||
<Download className="button-icon" /> Install
|
||||
</button>
|
||||
<button className="button ghost small" onClick={() => onInstall(agent.id, true)}>
|
||||
Reinstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentsTab;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { HelpCircle, Shield } from "lucide-react";
|
||||
import type { PermissionEventData, QuestionEventData } from "sandbox-agent";
|
||||
import { formatJson } from "../../utils/format";
|
||||
|
||||
const ApprovalsTab = ({
|
||||
questionRequests,
|
||||
permissionRequests,
|
||||
questionSelections,
|
||||
onSelectQuestionOption,
|
||||
onAnswerQuestion,
|
||||
onRejectQuestion,
|
||||
onReplyPermission
|
||||
}: {
|
||||
questionRequests: QuestionEventData[];
|
||||
permissionRequests: PermissionEventData[];
|
||||
questionSelections: Record<string, string[][]>;
|
||||
onSelectQuestionOption: (requestId: string, optionLabel: string) => void;
|
||||
onAnswerQuestion: (request: QuestionEventData) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: "once" | "always" | "reject") => void;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{questionRequests.length === 0 && permissionRequests.length === 0 ? (
|
||||
<div className="card-meta">No pending approvals.</div>
|
||||
) : (
|
||||
<>
|
||||
{questionRequests.map((request) => {
|
||||
const selections = questionSelections[request.question_id] ?? [];
|
||||
const selected = selections[0] ?? [];
|
||||
const answered = selected.length > 0;
|
||||
return (
|
||||
<div key={request.question_id} className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">
|
||||
<HelpCircle className="button-icon" style={{ marginRight: 6 }} />
|
||||
Question
|
||||
</span>
|
||||
<span className="pill accent">Pending</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 12, marginBottom: 8 }}>{request.prompt}</div>
|
||||
<div className="option-list">
|
||||
{request.options.map((option) => {
|
||||
const isSelected = selected.includes(option);
|
||||
return (
|
||||
<label key={option} className="option-item">
|
||||
<input
|
||||
type="radio"
|
||||
checked={isSelected}
|
||||
onChange={() => onSelectQuestionOption(request.question_id, option)}
|
||||
/>
|
||||
<span>{option}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button className="button success small" disabled={!answered} onClick={() => onAnswerQuestion(request)}>
|
||||
Reply
|
||||
</button>
|
||||
<button className="button danger small" onClick={() => onRejectQuestion(request.question_id)}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{permissionRequests.map((request) => (
|
||||
<div key={request.permission_id} className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">
|
||||
<Shield className="button-icon" style={{ marginRight: 6 }} />
|
||||
Permission
|
||||
</span>
|
||||
<span className="pill accent">Pending</span>
|
||||
</div>
|
||||
<div className="card-meta" style={{ marginTop: 8 }}>
|
||||
{request.action}
|
||||
</div>
|
||||
{request.metadata !== null && request.metadata !== undefined && (
|
||||
<pre className="code-block">{formatJson(request.metadata)}</pre>
|
||||
)}
|
||||
<div className="card-actions">
|
||||
<button className="button success small" onClick={() => onReplyPermission(request.permission_id, "once")}>
|
||||
Allow Once
|
||||
</button>
|
||||
<button className="button secondary small" onClick={() => onReplyPermission(request.permission_id, "always")}>
|
||||
Always
|
||||
</button>
|
||||
<button className="button danger small" onClick={() => onReplyPermission(request.permission_id, "reject")}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalsTab;
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { Cloud, PlayCircle, Terminal } from "lucide-react";
|
||||
import type { AgentInfo, AgentModeInfo, UniversalEvent } from "sandbox-agent";
|
||||
import AgentsTab from "./AgentsTab";
|
||||
import EventsTab from "./EventsTab";
|
||||
import RequestLogTab from "./RequestLogTab";
|
||||
import type { RequestLog } from "../../types/requestLog";
|
||||
|
||||
export type DebugTab = "log" | "events" | "agents";
|
||||
|
||||
const DebugPanel = ({
|
||||
debugTab,
|
||||
onDebugTabChange,
|
||||
events,
|
||||
offset,
|
||||
onFetchEvents,
|
||||
onResetEvents,
|
||||
requestLog,
|
||||
copiedLogId,
|
||||
onClearRequestLog,
|
||||
onCopyRequestLog,
|
||||
agents,
|
||||
defaultAgents,
|
||||
modesByAgent,
|
||||
onRefreshAgents,
|
||||
onInstallAgent
|
||||
}: {
|
||||
debugTab: DebugTab;
|
||||
onDebugTabChange: (tab: DebugTab) => void;
|
||||
events: UniversalEvent[];
|
||||
offset: number;
|
||||
onFetchEvents: () => void;
|
||||
onResetEvents: () => void;
|
||||
requestLog: RequestLog[];
|
||||
copiedLogId: number | null;
|
||||
onClearRequestLog: () => void;
|
||||
onCopyRequestLog: (entry: RequestLog) => void;
|
||||
agents: AgentInfo[];
|
||||
defaultAgents: string[];
|
||||
modesByAgent: Record<string, AgentModeInfo[]>;
|
||||
onRefreshAgents: () => void;
|
||||
onInstallAgent: (agentId: string, reinstall: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="debug-panel">
|
||||
<div className="debug-tabs">
|
||||
<button className={`debug-tab ${debugTab === "events" ? "active" : ""}`} onClick={() => onDebugTabChange("events")}>
|
||||
<PlayCircle className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
||||
Events
|
||||
{events.length > 0 && <span className="debug-tab-badge">{events.length}</span>}
|
||||
</button>
|
||||
<button className={`debug-tab ${debugTab === "log" ? "active" : ""}`} onClick={() => onDebugTabChange("log")}>
|
||||
<Terminal className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
||||
Request Log
|
||||
</button>
|
||||
<button className={`debug-tab ${debugTab === "agents" ? "active" : ""}`} onClick={() => onDebugTabChange("agents")}>
|
||||
<Cloud className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
||||
Agents
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="debug-content">
|
||||
{debugTab === "log" && (
|
||||
<RequestLogTab
|
||||
requestLog={requestLog}
|
||||
copiedLogId={copiedLogId}
|
||||
onClear={onClearRequestLog}
|
||||
onCopy={onCopyRequestLog}
|
||||
/>
|
||||
)}
|
||||
|
||||
{debugTab === "events" && (
|
||||
<EventsTab events={events} offset={offset} onFetch={onFetchEvents} onClear={onResetEvents} />
|
||||
)}
|
||||
|
||||
{debugTab === "agents" && (
|
||||
<AgentsTab
|
||||
agents={agents}
|
||||
defaultAgents={defaultAgents}
|
||||
modesByAgent={modesByAgent}
|
||||
onRefresh={onRefreshAgents}
|
||||
onInstall={onInstallAgent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugPanel;
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { UniversalEvent } from "sandbox-agent";
|
||||
import { formatJson, formatTime } from "../../utils/format";
|
||||
import { getEventCategory, getEventClass, getEventIcon, getEventKey, getEventType } from "./eventUtils";
|
||||
|
||||
const EventsTab = ({
|
||||
events,
|
||||
offset,
|
||||
onFetch,
|
||||
onClear
|
||||
}: {
|
||||
events: UniversalEvent[];
|
||||
offset: number;
|
||||
onFetch: () => void;
|
||||
onClear: () => void;
|
||||
}) => {
|
||||
const [collapsedEvents, setCollapsedEvents] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (events.length === 0) {
|
||||
setCollapsedEvents({});
|
||||
}
|
||||
}, [events.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<button className="button ghost small" onClick={onClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<div className="card-meta">No events yet. Start streaming to receive events.</div>
|
||||
) : (
|
||||
<div className="event-list">
|
||||
{[...events].reverse().map((event) => {
|
||||
const type = getEventType(event);
|
||||
const category = getEventCategory(type);
|
||||
const eventClass = `${category} ${getEventClass(type)}`;
|
||||
const eventKey = getEventKey(event);
|
||||
const isCollapsed = collapsedEvents[eventKey] ?? true;
|
||||
const toggleCollapsed = () =>
|
||||
setCollapsedEvents((prev) => ({
|
||||
...prev,
|
||||
[eventKey]: !(prev[eventKey] ?? true)
|
||||
}));
|
||||
const Icon = getEventIcon(type);
|
||||
return (
|
||||
<div key={eventKey} className={`event-item ${isCollapsed ? "collapsed" : "expanded"}`}>
|
||||
<button
|
||||
className="event-summary"
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
title={isCollapsed ? "Expand payload" : "Collapse payload"}
|
||||
>
|
||||
<span className={`event-icon ${eventClass}`}>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<div className="event-summary-main">
|
||||
<div className="event-title-row">
|
||||
<span className={`event-type ${eventClass}`}>{type}</span>
|
||||
<span className="event-time">{formatTime(event.time)}</span>
|
||||
</div>
|
||||
<div className="event-id">
|
||||
Event #{event.event_id || event.sequence} - seq {event.sequence} - {event.source}
|
||||
{event.synthetic ? " (synthetic)" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="event-chevron">
|
||||
{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
</span>
|
||||
</button>
|
||||
{!isCollapsed && <pre className="code-block event-payload">{formatJson(event.data)}</pre>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventsTab;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Clipboard } from "lucide-react";
|
||||
|
||||
import type { RequestLog } from "../../types/requestLog";
|
||||
|
||||
const RequestLogTab = ({
|
||||
requestLog,
|
||||
copiedLogId,
|
||||
onClear,
|
||||
onCopy
|
||||
}: {
|
||||
requestLog: RequestLog[];
|
||||
copiedLogId: number | null;
|
||||
onClear: () => void;
|
||||
onCopy: (entry: RequestLog) => void;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
|
||||
<span className="card-meta">{requestLog.length} requests</span>
|
||||
<button className="button ghost small" onClick={onClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{requestLog.length === 0 ? (
|
||||
<div className="card-meta">No requests logged yet.</div>
|
||||
) : (
|
||||
requestLog.map((entry) => (
|
||||
<div key={entry.id} className="log-item">
|
||||
<span className="log-method">{entry.method}</span>
|
||||
<span className="log-url text-truncate">{entry.url}</span>
|
||||
<span className={`log-status ${entry.status && entry.status < 400 ? "ok" : "error"}`}>
|
||||
{entry.status || "ERR"}
|
||||
</span>
|
||||
<div className="log-meta">
|
||||
<span>
|
||||
{entry.time}
|
||||
{entry.error && ` - ${entry.error}`}
|
||||
</span>
|
||||
<button className="copy-button" onClick={() => onCopy(entry)}>
|
||||
<Clipboard />
|
||||
{copiedLogId === entry.id ? "Copied" : "curl"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestLogTab;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Brain,
|
||||
CheckCircle,
|
||||
FileDiff,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
Shield,
|
||||
Terminal,
|
||||
Wrench,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import type { UniversalEvent } from "sandbox-agent";
|
||||
|
||||
export const getEventType = (event: UniversalEvent) => event.type;
|
||||
|
||||
export const getEventKey = (event: UniversalEvent) =>
|
||||
event.event_id ? `id:${event.event_id}` : `seq:${event.sequence}`;
|
||||
|
||||
export const getEventCategory = (type: string) => type.split(".")[0] ?? type;
|
||||
|
||||
export const getEventClass = (type: string) => type.replace(/\./g, "-");
|
||||
|
||||
export const getEventIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "session.started":
|
||||
return PlayCircle;
|
||||
case "session.ended":
|
||||
return PauseCircle;
|
||||
case "item.started":
|
||||
return MessageSquare;
|
||||
case "item.delta":
|
||||
return Activity;
|
||||
case "item.completed":
|
||||
return CheckCircle;
|
||||
case "question.requested":
|
||||
return HelpCircle;
|
||||
case "question.resolved":
|
||||
return CheckCircle;
|
||||
case "permission.requested":
|
||||
return Shield;
|
||||
case "permission.resolved":
|
||||
return CheckCircle;
|
||||
case "error":
|
||||
return AlertTriangle;
|
||||
case "agent.unparsed":
|
||||
return Brain;
|
||||
default:
|
||||
if (type.startsWith("item.")) return MessageSquare;
|
||||
if (type.startsWith("session.")) return PlayCircle;
|
||||
if (type.startsWith("error")) return AlertTriangle;
|
||||
if (type.startsWith("agent.")) return Brain;
|
||||
if (type.startsWith("question.")) return HelpCircle;
|
||||
if (type.startsWith("permission.")) return Shield;
|
||||
if (type.startsWith("file.")) return FileDiff;
|
||||
if (type.startsWith("command.")) return Terminal;
|
||||
if (type.startsWith("tool.")) return Wrench;
|
||||
return Zap;
|
||||
}
|
||||
};
|
||||
33
frontend/packages/inspector/src/types/agents.ts
Normal file
33
frontend/packages/inspector/src/types/agents.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { AgentCapabilities } from "sandbox-agent";
|
||||
|
||||
export type AgentCapabilitiesView = AgentCapabilities & {
|
||||
toolResults?: boolean;
|
||||
textMessages?: boolean;
|
||||
images?: boolean;
|
||||
fileAttachments?: boolean;
|
||||
sessionLifecycle?: boolean;
|
||||
errorEvents?: boolean;
|
||||
reasoning?: boolean;
|
||||
commandExecution?: boolean;
|
||||
fileChanges?: boolean;
|
||||
mcpTools?: boolean;
|
||||
streamingDeltas?: boolean;
|
||||
};
|
||||
|
||||
export const emptyCapabilities: AgentCapabilitiesView = {
|
||||
planMode: false,
|
||||
permissions: false,
|
||||
questions: false,
|
||||
toolCalls: false,
|
||||
toolResults: false,
|
||||
textMessages: false,
|
||||
images: false,
|
||||
fileAttachments: false,
|
||||
sessionLifecycle: false,
|
||||
errorEvents: false,
|
||||
reasoning: false,
|
||||
commandExecution: false,
|
||||
fileChanges: false,
|
||||
mcpTools: false,
|
||||
streamingDeltas: false
|
||||
};
|
||||
10
frontend/packages/inspector/src/types/requestLog.ts
Normal file
10
frontend/packages/inspector/src/types/requestLog.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export type RequestLog = {
|
||||
id: number;
|
||||
method: string;
|
||||
url: string;
|
||||
body?: string;
|
||||
status?: number;
|
||||
time: string;
|
||||
curl: string;
|
||||
error?: string;
|
||||
};
|
||||
18
frontend/packages/inspector/src/utils/format.ts
Normal file
18
frontend/packages/inspector/src/utils/format.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export const formatJson = (value: unknown) => {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatTime = (value: string) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
export const escapeSingleQuotes = (value: string) => value.replace(/'/g, `'\\''`);
|
||||
15
frontend/packages/inspector/src/utils/http.ts
Normal file
15
frontend/packages/inspector/src/utils/http.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { escapeSingleQuotes } from "./format";
|
||||
|
||||
export const buildCurl = (method: string, url: string, body?: string, token?: string) => {
|
||||
const headers: string[] = [];
|
||||
if (token) {
|
||||
headers.push(`-H 'Authorization: Bearer ${escapeSingleQuotes(token)}'`);
|
||||
}
|
||||
if (body) {
|
||||
headers.push(`-H 'Content-Type: application/json'`);
|
||||
}
|
||||
const data = body ? `-d '${escapeSingleQuotes(body)}'` : "";
|
||||
return `curl -X ${method} ${headers.join(" ")} ${data} '${escapeSingleQuotes(url)}'`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue