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

@ -30,7 +30,7 @@ const ConnectScreen = ({
<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" }} />
</div>
{(docsUrl || discordUrl || reportUrl) && (
<div className="header-right">
@ -42,13 +42,17 @@ const ConnectScreen = ({
)}
{discordUrl && (
<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>
)}
{reportUrl && (
<a className="header-link" href={reportUrl} 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>
)}
@ -59,7 +63,7 @@ const ConnectScreen = ({
<main className="landing">
<div className="landing-container">
<div className="landing-hero">
<img src={logoUrl} alt="Sandbox Agent" style={{ height: '32px', width: 'auto', marginBottom: '20px' }} />
<img src={logoUrl} alt="Sandbox Agent" style={{ height: "32px", width: "auto", marginBottom: "20px" }} />
</div>
<div className="connect-card">
@ -67,17 +71,15 @@ const ConnectScreen = ({
{connectError && <div className="banner error">{connectError}</div>}
{isHttpsToHttpConnection(window.location.href, endpoint) &&
isLocalNetworkTarget(endpoint) && (
<div className="banner warning">
<AlertTriangle size={16} />
<span>
Connecting from HTTPS to a local HTTP server requires{" "}
<strong>local network access</strong> permission. Your browser may prompt you to
allow this connection.
</span>
</div>
)}
{isHttpsToHttpConnection(window.location.href, endpoint) && isLocalNetworkTarget(endpoint) && (
<div className="banner warning">
<AlertTriangle size={16} />
<span>
Connecting from HTTPS to a local HTTP server requires <strong>local network access</strong> permission. Your browser may prompt you to allow
this connection.
</span>
</div>
)}
<label className="field">
<span className="label">Endpoint</span>
@ -92,13 +94,7 @@ const ConnectScreen = ({
<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)}
/>
<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}>
@ -116,7 +112,11 @@ const ConnectScreen = ({
</button>
<p className="hint">
Having trouble connecting? See the <a href="https://sandboxagent.dev/docs/cors" target="_blank" rel="noreferrer">CORS documentation</a>.
Having trouble connecting? See the{" "}
<a href="https://sandboxagent.dev/docs/cors" target="_blank" rel="noreferrer">
CORS documentation
</a>
.
</p>
</div>
</div>

View file

@ -18,7 +18,7 @@ const agentLabels: Record<string, string> = {
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
cursor: "Cursor"
cursor: "Cursor",
};
const agentLogos: Record<string, string> = {
@ -39,7 +39,7 @@ const SessionCreateMenu = ({
onCreateSession,
onSelectAgent,
open,
onClose
onClose,
}: {
agents: AgentInfo[];
agentsLoading: boolean;
@ -157,54 +157,45 @@ const SessionCreateMenu = ({
<div className="session-create-menu">
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>}
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="sidebar-add-status">No agents available.</div>
)}
{!agentsLoading && !agentsError && (() => {
const codingAgents = agents.filter((a) => a.id !== "mock");
const mockAgent = agents.find((a) => a.id === "mock");
return (
<>
{codingAgents.map((agent) => (
<button
key={agent.id}
className="sidebar-add-option"
onClick={() => handleAgentClick(agent.id)}
>
<div className="agent-option-left">
{agentLogos[agent.id] && (
<img src={agentLogos[agent.id]} alt="" className="agent-option-logo" />
)}
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-option-version">{agent.version}</span>}
</div>
<div className="agent-option-badges">
{agent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
))}
{mockAgent && (
<>
<div className="agent-divider" />
<button
className="sidebar-add-option"
onClick={() => handleAgentClick(mockAgent.id)}
>
{!agentsLoading && !agentsError && agents.length === 0 && <div className="sidebar-add-status">No agents available.</div>}
{!agentsLoading &&
!agentsError &&
(() => {
const codingAgents = agents.filter((a) => a.id !== "mock");
const mockAgent = agents.find((a) => a.id === "mock");
return (
<>
{codingAgents.map((agent) => (
<button key={agent.id} className="sidebar-add-option" onClick={() => handleAgentClick(agent.id)}>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[mockAgent.id] ?? mockAgent.id}</span>
{mockAgent.version && <span className="agent-option-version">{mockAgent.version}</span>}
{agentLogos[agent.id] && <img src={agentLogos[agent.id]} alt="" className="agent-option-logo" />}
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-option-version">{agent.version}</span>}
</div>
<div className="agent-option-badges">
{mockAgent.installed && <span className="agent-badge installed">Installed</span>}
{agent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
</>
)}
</>
);
})()}
))}
{mockAgent && (
<>
<div className="agent-divider" />
<button className="sidebar-add-option" onClick={() => handleAgentClick(mockAgent.id)}>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[mockAgent.id] ?? mockAgent.id}</span>
{mockAgent.version && <span className="agent-option-version">{mockAgent.version}</span>}
</div>
<div className="agent-option-badges">
{mockAgent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
</>
)}
</>
);
})()}
</div>
);
}
@ -237,12 +228,7 @@ const SessionCreateMenu = ({
autoFocus
/>
) : (
<select
className="setup-select"
value={selectedModel}
onChange={(e) => handleModelSelectChange(e.target.value)}
title="Model"
>
<select className="setup-select" value={selectedModel} onChange={(e) => handleModelSelectChange(e.target.value)} title="Model">
{activeModels.map((m) => (
<option key={m.id} value={m.id}>
{m.name || m.id}
@ -258,9 +244,7 @@ const SessionCreateMenu = ({
setIsCustomModel(false);
setCustomModel("");
const defaultModel = defaultModelByAgent[selectedAgent];
setSelectedModel(
defaultModel || (activeModels.length > 0 ? activeModels[0].id : "")
);
setSelectedModel(defaultModel || (activeModels.length > 0 ? activeModels[0].id : ""));
}}
title="Back to model list"
type="button"
@ -272,12 +256,7 @@ const SessionCreateMenu = ({
{activeModes.length > 0 && (
<div className="setup-field">
<span className="setup-label">Mode</span>
<select
className="setup-select"
value={agentMode}
onChange={(e) => setAgentMode(e.target.value)}
title="Mode"
>
<select className="setup-select" value={agentMode} onChange={(e) => setAgentMode(e.target.value)} title="Mode">
{activeModes.map((m) => (
<option key={m.id} value={m.id}>
{m.name || m.id}

View file

@ -20,7 +20,7 @@ const agentLabels: Record<string, string> = {
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
cursor: "Cursor"
cursor: "Cursor",
};
const persistenceDocsUrl = "https://sandboxagent.dev/docs/session-persistence";
const MIN_REFRESH_SPIN_MS = 350;
@ -64,9 +64,7 @@ const SessionSidebar = ({
const activeSessions = sessions.filter((session) => !session.archived);
const archivedSessions = sessions.filter((session) => session.archived);
const visibleSessions = showArchived ? archivedSessions : activeSessions;
const orderedVisibleSessions = showArchived
? [...visibleSessions].sort((a, b) => Number(a.ended) - Number(b.ended))
: visibleSessions;
const orderedVisibleSessions = showArchived ? [...visibleSessions].sort((a, b) => Number(a.ended) - Number(b.ended)) : visibleSessions;
useEffect(() => {
if (!showMenu) return;
@ -114,27 +112,14 @@ const SessionSidebar = ({
onClick={() => setShowArchived((value) => !value)}
title={showArchived ? "Hide archived sessions" : `Show archived sessions (${archivedCount})`}
>
{showArchived ? (
<ArrowLeft size={12} className="button-icon" />
) : (
<Archive size={12} className="button-icon" />
)}
{showArchived ? <ArrowLeft size={12} className="button-icon" /> : <Archive size={12} className="button-icon" />}
</button>
)}
<button
className="button secondary small"
onClick={() => void handleRefresh()}
title="Refresh sessions"
disabled={sessionsLoading || refreshing}
>
<button className="button secondary small" onClick={() => void handleRefresh()} title="Refresh sessions" disabled={sessionsLoading || refreshing}>
<RefreshCw size={12} className={`button-icon ${sessionsLoading || refreshing ? "spinner-icon" : ""}`} />
</button>
<div className="sidebar-add-menu-wrapper" ref={menuRef}>
<button
className="sidebar-add-btn"
onClick={() => setShowMenu((value) => !value)}
title="New session"
>
<button className="sidebar-add-btn" onClick={() => setShowMenu((value) => !value)} title="New session">
<Plus size={14} />
</button>
<SessionCreateMenu
@ -164,31 +149,27 @@ const SessionSidebar = ({
<>
{showArchived && <div className="sidebar-empty">Archived Sessions</div>}
{orderedVisibleSessions.map((session) => (
<div
key={session.sessionId}
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""} ${session.ended ? "ended" : ""} ${session.archived ? "ended" : ""}`}
>
<button
className="session-item-content"
onClick={() => onSelectSession(session)}
>
<div className="session-item-id" title={session.sessionId}>
{formatShortId(session.sessionId)}
</div>
<div className="session-item-meta">
<span className="session-item-agent">
{agentLabels[session.agent] ?? session.agent}
</span>
{(session.archived || session.ended) && <span className="session-item-ended">ended</span>}
</div>
</button>
</div>
))}
<div
key={session.sessionId}
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""} ${session.ended ? "ended" : ""} ${session.archived ? "ended" : ""}`}
>
<button className="session-item-content" onClick={() => onSelectSession(session)}>
<div className="session-item-id" title={session.sessionId}>
{formatShortId(session.sessionId)}
</div>
<div className="session-item-meta">
<span className="session-item-agent">{agentLabels[session.agent] ?? session.agent}</span>
{(session.archived || session.ended) && <span className="session-item-ended">ended</span>}
</div>
</button>
</div>
))}
</>
)}
</div>
<div className="session-persistence-note">
Sessions are persisted in your browser using IndexedDB. These sessions are only from your browser; your SDK sessions are separate. Adding inspector support for SDK soon.{" "}
Sessions are persisted in your browser using IndexedDB. These sessions are only from your browser; your SDK sessions are separate. Adding inspector
support for SDK soon.{" "}
<a href={persistenceDocsUrl} target="_blank" rel="noreferrer" style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
Configure persistence
<ArrowUpRight size={10} />

View file

@ -17,7 +17,7 @@ import {
Plug,
Shield,
Terminal,
Wrench
Wrench,
} from "lucide-react";
import type { FeatureCoverageView } from "../../types/agents";
@ -39,7 +39,7 @@ const badges = [
{ key: "mcpTools", label: "MCP", icon: Plug },
{ key: "streamingDeltas", label: "Deltas", icon: Activity },
{ key: "itemStarted", label: "Item Start", icon: CircleDot },
{ key: "variants", label: "Variants", icon: Layers }
{ key: "variants", label: "Variants", icon: Layers },
] as const;
type BadgeItem = (typeof badges)[number];

View file

@ -156,9 +156,7 @@ const ChatPanel = ({
{modelLabel}
</span>
)}
{sessionId && currentAgentVersion && (
<span className="header-meta-pill">v{currentAgentVersion}</span>
)}
{sessionId && currentAgentVersion && <span className="header-meta-pill">v{currentAgentVersion}</span>}
{sessionId && (
<button
type="button"
@ -171,11 +169,9 @@ const ChatPanel = ({
)}
</div>
<div className="panel-header-right">
{sessionId && tokenUsage && (
<span className="token-pill">{tokenUsage.used.toLocaleString()} tokens</span>
)}
{sessionId && (
sessionEnded ? (
{sessionId && tokenUsage && <span className="token-pill">{tokenUsage.used.toLocaleString()} tokens</span>}
{sessionId &&
(sessionEnded ? (
<>
<span className="button ghost small session-ended-status" title="Session ended">
<CheckSquare size={12} />
@ -192,17 +188,11 @@ const ChatPanel = ({
</button>
</>
) : (
<button
type="button"
className="button ghost small"
onClick={onEndSession}
title="End session"
>
<button type="button" className="button ghost small" onClick={onEndSession} title="End session">
<Square size={12} />
End
</button>
)
)}
))}
</div>
</div>
@ -219,10 +209,7 @@ const ChatPanel = ({
<div className="empty-state-title">No Session Selected</div>
<p className="empty-state-text no-session-subtext">Create a new session to start chatting with an agent.</p>
<div className="empty-state-menu-wrapper" ref={menuRef}>
<button
className="button primary"
onClick={() => setShowAgentMenu((value) => !value)}
>
<button className="button primary" onClick={() => setShowAgentMenu((value) => !value)}>
<Plus className="button-icon" />
Create Session
</button>

View file

@ -147,7 +147,7 @@ const InspectorConversation = ({
renderPermissionIcon: () => <Shield size={14} />,
renderPermissionOptionContent: ({ option, label, selected }) => (
<>
{selected ? (option.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />) : null}
{selected ? option.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} /> : null}
{label}
</>
),

View file

@ -14,7 +14,7 @@ const AgentsTab = ({
onRefresh,
onInstall,
loading,
error
error,
}: {
agents: AgentInfo[];
defaultAgents: string[];
@ -60,9 +60,7 @@ const AgentsTab = ({
</div>
{error && <div className="banner error">{error}</div>}
{!loading && agents.length === 0 && (
<div className="card-meta">No agents reported. Click refresh to check.</div>
)}
{!loading && agents.length === 0 && <div className="card-meta">No agents reported. Click refresh to check.</div>}
{(agents.length
? agents
@ -73,16 +71,15 @@ const AgentsTab = ({
version: undefined as string | undefined,
path: undefined as string | undefined,
capabilities: emptyFeatureCoverage as AgentInfo["capabilities"],
}))).map((agent) => {
}))
).map((agent) => {
const isInstalling = installingAgent === agent.id;
return (
<div key={agent.id} className="card">
<div className="card-header">
<span className="card-title">{agent.id}</span>
<div className="card-header-pills">
<span className={`pill ${agent.installed ? "success" : "danger"}`}>
{agent.installed ? "Installed" : "Missing"}
</span>
<span className={`pill ${agent.installed ? "success" : "danger"}`}>{agent.installed ? "Installed" : "Missing"}</span>
<span className={`pill ${agent.credentialsAvailable ? "success" : "warning"}`}>
{agent.credentialsAvailable ? "Authenticated" : "No Credentials"}
</span>
@ -90,7 +87,11 @@ const AgentsTab = ({
</div>
<div className="card-meta">
{agent.version ?? "Version unknown"}
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
{agent.path && (
<span className="mono muted" style={{ marginLeft: 8 }}>
{agent.path}
</span>
)}
</div>
<div className="card-meta" style={{ marginTop: 8 }}>
Feature coverage
@ -104,16 +105,8 @@ const AgentsTab = ({
</div>
)}
<div className="card-actions">
<button
className="button secondary small"
onClick={() => handleInstall(agent.id, agent.installed)}
disabled={isInstalling}
>
{isInstalling ? (
<Loader2 className="button-icon spinner-icon" />
) : (
<Download className="button-icon" />
)}
<button className="button secondary small" onClick={() => handleInstall(agent.id, agent.installed)} disabled={isInstalling}>
{isInstalling ? <Loader2 className="button-icon spinner-icon" /> : <Download className="button-icon" />}
{isInstalling ? "Installing..." : agent.installed ? "Reinstall" : "Install"}
</button>
</div>

View file

@ -59,11 +59,7 @@ const DebugPanel = ({
return (
<div className={`debug-panel ${collapsed ? "collapsed" : ""}`}>
<div className="debug-tabs">
<button
className="debug-collapse-btn"
onClick={onToggleCollapse}
title={collapsed ? "Expand panel" : "Collapse panel"}
>
<button className="debug-collapse-btn" onClick={onToggleCollapse} title={collapsed ? "Expand panel" : "Collapse panel"}>
{collapsed ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
</button>
<button className={`debug-tab ${debugTab === "events" ? "active" : ""}`} onClick={() => onDebugTabChange("events")}>
@ -98,22 +94,10 @@ const DebugPanel = ({
</div>
<div className="debug-content">
{debugTab === "log" && (
<RequestLogTab
requestLog={requestLog}
copiedLogId={copiedLogId}
onClear={onClearRequestLog}
onCopy={onCopyRequestLog}
/>
)}
{debugTab === "log" && <RequestLogTab requestLog={requestLog} copiedLogId={copiedLogId} onClear={onClearRequestLog} onCopy={onCopyRequestLog} />}
{debugTab === "events" && (
<EventsTab
events={events}
onClear={onResetEvents}
highlightedEventId={highlightedEventId}
onClearHighlight={onClearHighlight}
/>
<EventsTab events={events} onClear={onResetEvents} highlightedEventId={highlightedEventId} onClearHighlight={onClearHighlight} />
)}
{debugTab === "agents" && (
@ -128,21 +112,13 @@ const DebugPanel = ({
/>
)}
{debugTab === "mcp" && (
<McpTab getClient={getClient} />
)}
{debugTab === "mcp" && <McpTab getClient={getClient} />}
{debugTab === "processes" && (
<ProcessesTab getClient={getClient} />
)}
{debugTab === "processes" && <ProcessesTab getClient={getClient} />}
{debugTab === "run-process" && (
<ProcessRunTab getClient={getClient} />
)}
{debugTab === "run-process" && <ProcessRunTab getClient={getClient} />}
{debugTab === "skills" && (
<SkillsTab getClient={getClient} />
)}
{debugTab === "skills" && <SkillsTab getClient={getClient} />}
</div>
</div>
);

View file

@ -125,12 +125,15 @@ const EventsTab = ({
const handleCopy = () => {
const text = JSON.stringify(events, null, 2);
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(() => {
fallbackCopy(text);
});
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
@ -188,13 +191,7 @@ const EventsTab = ({
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">{events.length} events</span>
<div className="inline-row">
<button
type="button"
className="button ghost small"
onClick={handleCopy}
disabled={events.length === 0}
title="Copy all events as JSON"
>
<button type="button" className="button ghost small" onClick={handleCopy} disabled={events.length === 0} title="Copy all events as JSON">
{copied ? "Copied" : "Copy JSON"}
</button>
<button className="button ghost small" onClick={onClear}>
@ -204,9 +201,7 @@ const EventsTab = ({
</div>
{events.length === 0 ? (
<div className="card-meta">
No events yet. Create a session and send a message.
</div>
<div className="card-meta">No events yet. Create a session and send a message.</div>
) : (
<div className="event-list">
{[...events].reverse().map((event) => {
@ -215,7 +210,7 @@ const EventsTab = ({
const toggleCollapsed = () =>
setCollapsedEvents((prev) => ({
...prev,
[eventKey]: !(prev[eventKey] ?? true)
[eventKey]: !(prev[eventKey] ?? true),
}));
const method = getMethod(event);
const payload = event.payload as Record<string, unknown>;
@ -231,30 +226,21 @@ const EventsTab = ({
id={`event-${event.id}`}
className={`event-item ${isCollapsed ? "collapsed" : "expanded"} ${isHighlighted ? "highlighted" : ""}`}
>
<button
className="event-summary"
type="button"
onClick={toggleCollapsed}
title={isCollapsed ? "Expand payload" : "Collapse payload"}
>
<button className="event-summary" type="button" onClick={toggleCollapsed} title={isCollapsed ? "Expand payload" : "Collapse payload"}>
<span className={`event-icon ${category}`}>
<Icon size={14} />
</span>
<div className="event-summary-main">
<div className="event-title-row">
<span className={`event-type ${category}`}>{method}</span>
<span className={`pill ${senderClass === "client" ? "accent" : "success"}`}>
{event.sender}
</span>
<span className={`pill ${senderClass === "client" ? "accent" : "success"}`}>{event.sender}</span>
<span className="event-time">{time}</span>
</div>
<div className="event-id" title={event.id}>
{formatShortId(event.id)}
</div>
</div>
<span className="event-chevron">
{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
</span>
<span className="event-chevron">{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}</span>
</button>
{!isCollapsed && <pre className="code-block event-payload">{formatJson(event.payload)}</pre>}
</div>

View file

@ -10,11 +10,7 @@ type McpEntry = {
const MCP_DIRECTORY_STORAGE_KEY = "sandbox-agent-inspector-mcp-directory";
const McpTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const McpTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const [directory, setDirectory] = useState(() => {
if (typeof window === "undefined") return "/";
try {
@ -35,28 +31,29 @@ const McpTab = ({
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const loadAll = useCallback(async (dir: string) => {
setLoading(true);
setError(null);
try {
const configPath = `${dir === "/" ? "" : dir}/.sandbox-agent/config/mcp.json`;
const bytes = await getClient().readFsFile({ path: configPath });
const text = new TextDecoder().decode(bytes);
if (!text.trim()) {
const loadAll = useCallback(
async (dir: string) => {
setLoading(true);
setError(null);
try {
const configPath = `${dir === "/" ? "" : dir}/.sandbox-agent/config/mcp.json`;
const bytes = await getClient().readFsFile({ path: configPath });
const text = new TextDecoder().decode(bytes);
if (!text.trim()) {
setEntries([]);
return;
}
const map = JSON.parse(text) as Record<string, Record<string, unknown>>;
setEntries(Object.entries(map).map(([name, config]) => ({ name, config })));
} catch {
// File doesn't exist yet or is empty — that's fine
setEntries([]);
return;
} finally {
setLoading(false);
}
const map = JSON.parse(text) as Record<string, Record<string, unknown>>;
setEntries(
Object.entries(map).map(([name, config]) => ({ name, config })),
);
} catch {
// File doesn't exist yet or is empty — that's fine
setEntries([]);
} finally {
setLoading(false);
}
}, [getClient]);
},
[getClient],
);
useEffect(() => {
loadAll(directory);
@ -106,10 +103,7 @@ const McpTab = ({
setSaving(true);
setEditError(null);
try {
await getClient().setMcpConfig(
{ directory, mcpName: name },
parsed as Parameters<SandboxAgent["setMcpConfig"]>[1],
);
await getClient().setMcpConfig({ directory, mcpName: name }, parsed as Parameters<SandboxAgent["setMcpConfig"]>[1]);
cancelEdit();
await loadAll(directory);
} catch (err) {
@ -159,26 +153,34 @@ const McpTab = ({
{editing && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="card-header">
<span className="card-title">
{editName ? `Edit: ${editName}` : "Add MCP Server"}
</span>
<span className="card-title">{editName ? `Edit: ${editName}` : "Add MCP Server"}</span>
</div>
<div style={{ marginTop: 8 }}>
<input
className="setup-input"
value={editName}
onChange={(e) => { setEditName(e.target.value); setEditError(null); }}
onChange={(e) => {
setEditName(e.target.value);
setEditError(null);
}}
placeholder="server-name"
style={{ marginBottom: 8, width: "100%", boxSizing: "border-box" }}
/>
<textarea
className="setup-input mono"
value={editJson}
onChange={(e) => { setEditJson(e.target.value); setEditError(null); }}
onChange={(e) => {
setEditJson(e.target.value);
setEditError(null);
}}
rows={6}
style={{ width: "100%", boxSizing: "border-box", fontFamily: "monospace", fontSize: 11, resize: "vertical" }}
/>
{editError && <div className="banner error" style={{ marginTop: 4 }}>{editError}</div>}
{editError && (
<div className="banner error" style={{ marginTop: 4 }}>
{editError}
</div>
)}
</div>
<div className="card-actions">
<button className="button primary small" onClick={save} disabled={saving}>
@ -192,11 +194,7 @@ const McpTab = ({
</div>
)}
{entries.length === 0 && !editing && !loading && (
<div className="card-meta">
No MCP servers configured in this directory.
</div>
)}
{entries.length === 0 && !editing && !loading && <div className="card-meta">No MCP servers configured in this directory.</div>}
{entries.map((entry) => {
const isCollapsed = collapsedServers[entry.name] ?? true;
@ -215,15 +213,8 @@ const McpTab = ({
<span className="card-title">{entry.name}</span>
</div>
<div className="card-header-pills">
<span className="pill accent">
{(entry.config as { type?: string }).type ?? "unknown"}
</span>
<button
className="button ghost small"
onClick={() => remove(entry.name)}
title="Remove"
style={{ padding: "2px 4px" }}
>
<span className="pill accent">{(entry.config as { type?: string }).type ?? "unknown"}</span>
<button className="button ghost small" onClick={() => remove(entry.name)} title="Remove" style={{ padding: "2px 4px" }}>
<Trash2 size={12} />
</button>
</div>

View file

@ -3,13 +3,13 @@ import { useState } from "react";
import { SandboxAgentError } from "sandbox-agent";
import type { ProcessRunResponse, SandboxAgent } from "sandbox-agent";
const parseArgs = (value: string): string[] => value.split("\n").map((part) => part.trim()).filter(Boolean);
const parseArgs = (value: string): string[] =>
value
.split("\n")
.map((part) => part.trim())
.filter(Boolean);
const ProcessRunTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const ProcessRunTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const [command, setCommand] = useState("");
const [argsText, setArgsText] = useState("");
const [cwd, setCwd] = useState("");
@ -91,11 +91,7 @@ const ProcessRunTab = ({
/>
</div>
<button
className="process-advanced-toggle"
onClick={() => setShowAdvanced((prev) => !prev)}
type="button"
>
<button className="process-advanced-toggle" onClick={() => setShowAdvanced((prev) => !prev)} type="button">
{showAdvanced ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Advanced
</button>

View file

@ -26,7 +26,11 @@ const formatDateTime = (value: number | null | undefined): string => {
return new Date(value).toLocaleString();
};
const parseArgs = (value: string): string[] => value.split("\n").map((part) => part.trim()).filter(Boolean);
const parseArgs = (value: string): string[] =>
value
.split("\n")
.map((part) => part.trim())
.filter(Boolean);
const formatCommandSummary = (process: Pick<ProcessInfo, "command" | "args">): string => {
return [process.command, ...process.args].join(" ").trim();
@ -36,11 +40,7 @@ const canOpenTerminal = (process: ProcessInfo | null | undefined): boolean => {
return Boolean(process && process.status === "running" && process.interactive && process.tty);
};
const ProcessesTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const ProcessesTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
@ -62,63 +62,64 @@ const ProcessesTab = ({
const [terminalOpen, setTerminalOpen] = useState(false);
const [actingProcessId, setActingProcessId] = useState<string | null>(null);
const loadProcesses = useCallback(async (mode: "initial" | "refresh" = "initial") => {
if (mode === "initial") {
setLoading(true);
} else {
setRefreshing(true);
}
setError(null);
try {
const response = await getClient().listProcesses();
setProcesses(response.processes);
setSelectedProcessId((current) => {
if (!current) {
return response.processes[0]?.id ?? null;
}
return response.processes.some((listedProcess) => listedProcess.id === current)
? current
: response.processes[0]?.id ?? null;
});
} catch (loadError) {
setError(extractErrorMessage(loadError, "Unable to load processes."));
} finally {
setLoading(false);
setRefreshing(false);
}
}, [getClient]);
const loadProcesses = useCallback(
async (mode: "initial" | "refresh" = "initial") => {
if (mode === "initial") {
setLoading(true);
} else {
setRefreshing(true);
}
setError(null);
try {
const response = await getClient().listProcesses();
setProcesses(response.processes);
setSelectedProcessId((current) => {
if (!current) {
return response.processes[0]?.id ?? null;
}
return response.processes.some((listedProcess) => listedProcess.id === current) ? current : (response.processes[0]?.id ?? null);
});
} catch (loadError) {
setError(extractErrorMessage(loadError, "Unable to load processes."));
} finally {
setLoading(false);
setRefreshing(false);
}
},
[getClient],
);
const loadSelectedLogs = useCallback(async (process: ProcessInfo | null) => {
if (!process) {
setLogsText("");
const loadSelectedLogs = useCallback(
async (process: ProcessInfo | null) => {
if (!process) {
setLogsText("");
setLogsError(null);
return;
}
setLogsLoading(true);
setLogsError(null);
return;
}
setLogsLoading(true);
setLogsError(null);
try {
const response = await getClient().getProcessLogs(process.id, {
stream: process.tty ? "pty" : "combined",
tail: 200,
});
const text = response.entries.map((logEntry) => decodeBase64Utf8(logEntry.data)).join("");
setLogsText(text);
} catch (loadError) {
setLogsError(extractErrorMessage(loadError, "Unable to load process logs."));
setLogsText("");
} finally {
setLogsLoading(false);
}
}, [getClient]);
try {
const response = await getClient().getProcessLogs(process.id, {
stream: process.tty ? "pty" : "combined",
tail: 200,
});
const text = response.entries.map((logEntry) => decodeBase64Utf8(logEntry.data)).join("");
setLogsText(text);
} catch (loadError) {
setLogsError(extractErrorMessage(loadError, "Unable to load process logs."));
setLogsText("");
} finally {
setLogsLoading(false);
}
},
[getClient],
);
useEffect(() => {
void loadProcesses();
}, [loadProcesses]);
const selectedProcess = useMemo(
() => processes.find((process) => process.id === selectedProcessId) ?? null,
[processes, selectedProcessId]
);
const selectedProcess = useMemo(() => processes.find((process) => process.id === selectedProcessId) ?? null, [processes, selectedProcessId]);
useEffect(() => {
void loadSelectedLogs(selectedProcess);
@ -187,11 +188,7 @@ const ProcessesTab = ({
<div className="processes-container">
{/* Create form */}
<div className="processes-section">
<button
className="processes-section-toggle"
onClick={() => setShowCreateForm((prev) => !prev)}
type="button"
>
<button className="processes-section-toggle" onClick={() => setShowCreateForm((prev) => !prev)} type="button">
{showCreateForm ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>Create Process</span>
</button>
@ -311,7 +308,9 @@ const ProcessesTab = ({
<span className={`process-status-dot ${process.status}`} />
<span className="process-list-item-cmd mono">{formatCommandSummary(process)}</span>
{process.interactive && process.tty && (
<span className="pill neutral" style={{ fontSize: 9 }}>tty</span>
<span className="pill neutral" style={{ fontSize: 9 }}>
tty
</span>
)}
</div>
<div className="process-list-item-meta">
@ -336,7 +335,10 @@ const ProcessesTab = ({
<>
<button
className="button secondary small"
onClick={(e) => { e.stopPropagation(); void handleAction(process.id, "stop"); }}
onClick={(e) => {
e.stopPropagation();
void handleAction(process.id, "stop");
}}
disabled={Boolean(actingProcessId)}
>
{isStopping ? <Loader2 className="button-icon spinner-icon" size={12} /> : null}
@ -344,7 +346,10 @@ const ProcessesTab = ({
</button>
<button
className="button secondary small"
onClick={(e) => { e.stopPropagation(); void handleAction(process.id, "kill"); }}
onClick={(e) => {
e.stopPropagation();
void handleAction(process.id, "kill");
}}
disabled={Boolean(actingProcessId)}
>
{isKilling ? <Loader2 className="button-icon spinner-icon" size={12} /> : <Skull className="button-icon" size={12} />}
@ -355,7 +360,10 @@ const ProcessesTab = ({
{process.status === "exited" ? (
<button
className="button secondary small"
onClick={(e) => { e.stopPropagation(); void handleAction(process.id, "delete"); }}
onClick={(e) => {
e.stopPropagation();
void handleAction(process.id, "delete");
}}
disabled={Boolean(actingProcessId)}
>
{isDeleting ? <Loader2 className="button-icon spinner-icon" size={12} /> : <Trash2 className="button-icon" size={12} />}
@ -385,30 +393,21 @@ const ProcessesTab = ({
<span>Created: {formatDateTime(selectedProcess.createdAtMs)}</span>
{selectedProcess.exitedAtMs ? <span>Exited: {formatDateTime(selectedProcess.exitedAtMs)}</span> : null}
{selectedProcess.exitCode != null ? <span>Exit code: {selectedProcess.exitCode}</span> : null}
<span className="mono" style={{ opacity: 0.6 }}>{selectedProcess.id}</span>
<span className="mono" style={{ opacity: 0.6 }}>
{selectedProcess.id}
</span>
</div>
{/* Terminal */}
{terminalOpen && canOpenTerminal(selectedProcess) ? (
<ProcessTerminal
client={getClient()}
processId={selectedProcess.id}
style={{ marginTop: 4 }}
onExit={handleTerminalExit}
/>
<ProcessTerminal client={getClient()} processId={selectedProcess.id} style={{ marginTop: 4 }} onExit={handleTerminalExit} />
) : canOpenTerminal(selectedProcess) ? (
<button
className="button secondary small"
onClick={() => setTerminalOpen(true)}
style={{ marginTop: 8 }}
>
<button className="button secondary small" onClick={() => setTerminalOpen(true)} style={{ marginTop: 8 }}>
<SquareTerminal className="button-icon" size={12} />
Open Terminal
</button>
) : selectedProcess.interactive && selectedProcess.tty ? (
<div className="process-terminal-empty">
Terminal available while process is running.
</div>
<div className="process-terminal-empty">Terminal available while process is running.</div>
) : null}
{/* Logs */}

View file

@ -8,7 +8,7 @@ const RequestLogTab = ({
requestLog,
copiedLogId,
onClear,
onCopy
onCopy,
}: {
requestLog: RequestLog[];
copiedLogId: number | null;
@ -49,16 +49,16 @@ const RequestLogTab = ({
<div className="event-summary-main">
<div className="event-title-row">
<span className="log-method">{entry.method}</span>
<span className="log-url text-truncate" style={{ flex: 1 }}>{entry.url}</span>
<span className="log-url text-truncate" style={{ flex: 1 }}>
{entry.url}
</span>
</div>
<div className="event-id">
{entry.time}
{entry.error && ` - ${entry.error}`}
</div>
</div>
<span className={`log-status ${entry.status && entry.status < 400 ? "ok" : "error"}`}>
{entry.status || "ERR"}
</span>
<span className={`log-status ${entry.status && entry.status < 400 ? "ok" : "error"}`}>{entry.status || "ERR"}</span>
<span
className="copy-button"
onClick={(e) => {
@ -77,18 +77,18 @@ const RequestLogTab = ({
<Clipboard size={14} />
{copiedLogId === entry.id ? "Copied" : "curl"}
</span>
{hasDetails && (
<span className="event-chevron">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
)}
{hasDetails && <span className="event-chevron">{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</span>}
</button>
{isExpanded && (
<div className="event-payload" style={{ padding: "8px 12px" }}>
{entry.headers && Object.keys(entry.headers).length > 0 && (
<div style={{ marginBottom: 8 }}>
<div className="part-title">Request Headers</div>
<pre className="code-block">{Object.entries(entry.headers).map(([k, v]) => `${k}: ${v}`).join("\n")}</pre>
<pre className="code-block">
{Object.entries(entry.headers)
.map(([k, v]) => `${k}: ${v}`)
.join("\n")}
</pre>
</div>
)}
{entry.body && (

View file

@ -10,11 +10,7 @@ type SkillEntry = {
const SKILLS_DIRECTORY_STORAGE_KEY = "sandbox-agent-inspector-skills-directory";
const SkillsTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const SkillsTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const officialSkills = [
{
name: "Sandbox Agent SDK",
@ -27,13 +23,7 @@ const SkillsTab = ({
skillId: "rivet",
source: "rivet-dev/skills",
summary: "Open-source platform for building, deploying, and scaling AI agents.",
features: [
"Session Persistence",
"Resumable Sessions",
"Multi-Agent Support",
"Realtime Events",
"Tool Call Visibility",
],
features: ["Session Persistence", "Resumable Sessions", "Multi-Agent Support", "Realtime Events", "Tool Call Visibility"],
},
];
@ -63,28 +53,29 @@ const SkillsTab = ({
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const loadAll = useCallback(async (dir: string) => {
setLoading(true);
setError(null);
try {
const configPath = `${dir === "/" ? "" : dir}/.sandbox-agent/config/skills.json`;
const bytes = await getClient().readFsFile({ path: configPath });
const text = new TextDecoder().decode(bytes);
if (!text.trim()) {
const loadAll = useCallback(
async (dir: string) => {
setLoading(true);
setError(null);
try {
const configPath = `${dir === "/" ? "" : dir}/.sandbox-agent/config/skills.json`;
const bytes = await getClient().readFsFile({ path: configPath });
const text = new TextDecoder().decode(bytes);
if (!text.trim()) {
setEntries([]);
return;
}
const map = JSON.parse(text) as Record<string, SkillEntry["config"]>;
setEntries(Object.entries(map).map(([name, config]) => ({ name, config })));
} catch {
// File doesn't exist yet or is empty — that's fine
setEntries([]);
return;
} finally {
setLoading(false);
}
const map = JSON.parse(text) as Record<string, SkillEntry["config"]>;
setEntries(
Object.entries(map).map(([name, config]) => ({ name, config })),
);
} catch {
// File doesn't exist yet or is empty — that's fine
setEntries([]);
} finally {
setLoading(false);
}
}, [getClient]);
},
[getClient],
);
useEffect(() => {
loadAll(directory);
@ -139,7 +130,10 @@ const SkillsTab = ({
if (editRef.trim()) skillEntry.ref = editRef.trim();
if (editSubpath.trim()) skillEntry.subpath = editSubpath.trim();
const skillsList = editSkills.trim()
? editSkills.split(",").map((s) => s.trim()).filter(Boolean)
? editSkills
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
if (skillsList && skillsList.length > 0) skillEntry.skills = skillsList;
@ -148,10 +142,7 @@ const SkillsTab = ({
setSaving(true);
setEditError(null);
try {
await getClient().setSkillsConfig(
{ directory, skillName: name },
config,
);
await getClient().setSkillsConfig({ directory, skillName: name }, config);
cancelEdit();
await loadAll(directory);
} catch (err) {
@ -197,7 +188,7 @@ const SkillsTab = ({
}
};
const applySkillPreset = (skill: typeof officialSkills[0]) => {
const applySkillPreset = (skill: (typeof officialSkills)[0]) => {
setEditing(true);
setEditName(skill.skillId);
setEditSource(skill.source);
@ -222,12 +213,12 @@ const SkillsTab = ({
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">Skills Configuration</span>
<div className="inline-row" style={{ gap: 6 }}>
<button
className="button secondary small"
onClick={() => setShowSdkSkills((prev) => !prev)}
title="Toggle official skills list"
>
{showSdkSkills ? <ChevronDown className="button-icon" style={{ width: 12, height: 12 }} /> : <ChevronRight className="button-icon" style={{ width: 12, height: 12 }} />}
<button className="button secondary small" onClick={() => setShowSdkSkills((prev) => !prev)} title="Toggle official skills list">
{showSdkSkills ? (
<ChevronDown className="button-icon" style={{ width: 12, height: 12 }} />
) : (
<ChevronRight className="button-icon" style={{ width: 12, height: 12 }} />
)}
Official Skills
</button>
{!editing && (
@ -261,7 +252,9 @@ const SkillsTab = ({
{copiedId === `skill-input-${skill.skillId}` ? "Filled" : "Use"}
</button>
</div>
<div className="card-meta" style={{ fontSize: 10, marginBottom: skill.features ? 6 : 0 }}>{skill.summary}</div>
<div className="card-meta" style={{ fontSize: 10, marginBottom: skill.features ? 6 : 0 }}>
{skill.summary}
</div>
{skill.features && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{skill.features.map((feature) => (
@ -299,17 +292,15 @@ const SkillsTab = ({
<input
className="setup-input"
value={editName}
onChange={(e) => { setEditName(e.target.value); setEditError(null); }}
onChange={(e) => {
setEditName(e.target.value);
setEditError(null);
}}
placeholder="skill-name"
style={{ marginBottom: 6, width: "100%", boxSizing: "border-box" }}
/>
<div className="inline-row" style={{ marginBottom: 6, gap: 4 }}>
<select
className="setup-select"
value={editType}
onChange={(e) => setEditType(e.target.value)}
style={{ width: 90 }}
>
<select className="setup-select" value={editType} onChange={(e) => setEditType(e.target.value)} style={{ width: 90 }}>
<option value="github">github</option>
<option value="local">local</option>
<option value="git">git</option>
@ -317,7 +308,10 @@ const SkillsTab = ({
<input
className="setup-input mono"
value={editSource}
onChange={(e) => { setEditSource(e.target.value); setEditError(null); }}
onChange={(e) => {
setEditSource(e.target.value);
setEditError(null);
}}
placeholder={editType === "github" ? "owner/repo" : editType === "local" ? "/path/to/skill" : "https://..."}
style={{ flex: 1 }}
/>
@ -347,7 +341,11 @@ const SkillsTab = ({
/>
</div>
)}
{editError && <div className="banner error" style={{ marginTop: 4 }}>{editError}</div>}
{editError && (
<div className="banner error" style={{ marginTop: 4 }}>
{editError}
</div>
)}
</div>
<div className="card-actions">
<button className="button primary small" onClick={save} disabled={saving}>
@ -361,11 +359,7 @@ const SkillsTab = ({
</div>
)}
{entries.length === 0 && !editing && !loading && (
<div className="card-meta">
No skills configured in this directory.
</div>
)}
{entries.length === 0 && !editing && !loading && <div className="card-meta">No skills configured in this directory.</div>}
{entries.map((entry) => {
const isCollapsed = collapsedSkills[entry.name] ?? true;
@ -387,12 +381,7 @@ const SkillsTab = ({
<span className="pill accent">
{entry.config.sources.length} source{entry.config.sources.length !== 1 ? "s" : ""}
</span>
<button
className="button ghost small"
onClick={() => remove(entry.name)}
title="Remove"
style={{ padding: "2px 4px" }}
>
<button className="button ghost small" onClick={() => remove(entry.name)} title="Remove" style={{ padding: "2px 4px" }}>
<Trash2 size={12} />
</button>
</div>