Merge origin/main into share-chat-ui-components

This commit is contained in:
Nathan Flurry 2026-03-10 22:07:05 -07:00
commit b609f1ab2b
306 changed files with 44551 additions and 1001 deletions

View file

@ -8,7 +8,7 @@
<!-- Preconnect to font providers -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
color-scheme: dark;
@ -51,7 +51,8 @@
body {
color: var(--text);
font-family: 'Open Sans', system-ui, sans-serif;
font-family: 'Manrope', system-ui, sans-serif;
font-weight: 500;
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
@ -1601,6 +1602,118 @@
flex-shrink: 0;
}
/* Permission prompt */
.permission-prompt {
margin: 8px 0;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.permission-prompt.resolved {
opacity: 0.7;
}
.permission-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.permission-icon {
color: var(--warning, #f59e0b);
flex-shrink: 0;
}
.permission-prompt.resolved .permission-icon {
color: var(--muted);
}
.permission-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.permission-description {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
padding-left: 22px;
}
.permission-actions {
display: flex;
gap: 6px;
padding-left: 22px;
}
.permission-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.permission-btn:hover:not(:disabled) {
background: var(--surface);
color: var(--text);
border-color: rgba(255, 255, 255, 0.2);
}
.permission-btn.allow:hover:not(:disabled) {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
color: #22c55e;
}
.permission-btn.reject:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.permission-btn:disabled {
cursor: default;
opacity: 0.6;
}
.permission-btn.selected {
opacity: 1;
}
.permission-btn.selected.allow {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: #22c55e;
}
.permission-btn.selected.reject {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
.permission-btn.dimmed {
opacity: 0.35;
}
.permission-auto-resolved {
font-size: 11px;
color: var(--muted);
font-style: italic;
}
.status-divider {
display: flex;
align-items: center;

View file

@ -7,6 +7,8 @@ import {
type AgentInfo,
type SessionEvent,
type Session,
type SessionPermissionRequest,
type PermissionReply,
InMemorySessionPersistDriver,
type SessionPersistDriver,
} from "sandbox-agent";
@ -295,6 +297,11 @@ export default function App() {
const clientRef = useRef<SandboxAgent | null>(null);
const activeSessionRef = useRef<Session | null>(null);
const eventUnsubRef = useRef<(() => void) | null>(null);
const permissionUnsubRef = useRef<(() => void) | null>(null);
const pendingPermissionsRef = useRef<Map<string, SessionPermissionRequest>>(new Map());
const permissionToolCallToIdRef = useRef<Map<string, string>>(new Map());
const [pendingPermissionIds, setPendingPermissionIds] = useState<Set<string>>(new Set());
const [resolvedPermissions, setResolvedPermissions] = useState<Map<string, string>>(new Map());
const sessionEventsCacheRef = useRef<Map<string, SessionEvent[]>>(new Map());
const selectedSessionIdRef = useRef(sessionId);
const resumeInFlightSessionIdRef = useRef<string | null>(null);
@ -538,8 +545,45 @@ export default function App() {
});
});
eventUnsubRef.current = unsub;
// Subscribe to permission requests
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
const permUnsub = session.onPermissionRequest((request: SessionPermissionRequest) => {
if (!isCurrentSubscription()) return;
pendingPermissionsRef.current.set(request.id, request);
if (request.toolCall?.toolCallId) {
permissionToolCallToIdRef.current.set(request.toolCall.toolCallId, request.id);
}
setPendingPermissionIds((prev) => new Set([...prev, request.id]));
});
permissionUnsubRef.current = permUnsub;
}, [getClient]);
const handlePermissionReply = useCallback(async (permissionId: string, reply: PermissionReply) => {
const session = activeSessionRef.current;
if (!session) return;
try {
await session.respondPermission(permissionId, reply);
const request = pendingPermissionsRef.current.get(permissionId);
const selectedOption = request?.options.find((o) =>
reply === "always" ? o.kind === "allow_always" :
reply === "once" ? o.kind === "allow_once" :
o.kind === "reject_once" || o.kind === "reject_always"
);
setResolvedPermissions((prev) => new Map([...prev, [permissionId, selectedOption?.optionId ?? reply]]));
setPendingPermissionIds((prev) => {
const next = new Set(prev);
next.delete(permissionId);
return next;
});
} catch (error) {
pushErrorToast(error, "Failed to respond to permission request");
}
}, [pushErrorToast]);
const connectToDaemon = async (reportError: boolean, overrideEndpoint?: string) => {
setConnecting(true);
if (reportError) {
@ -551,6 +595,10 @@ export default function App() {
eventUnsubRef.current();
eventUnsubRef.current = null;
}
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
subscriptionGenerationRef.current += 1;
activeSessionRef.current = null;
if (clientRef.current) {
@ -603,6 +651,10 @@ export default function App() {
eventUnsubRef.current();
eventUnsubRef.current = null;
}
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
subscriptionGenerationRef.current += 1;
activeSessionRef.current = null;
if (clientRef.current) {
@ -818,7 +870,7 @@ export default function App() {
// Apply mode if selected
if (!skipPostCreateConfig && config.agentMode) {
try {
await session.send("session/set_mode", { modeId: config.agentMode });
await session.rawSend("session/set_mode", { modeId: config.agentMode });
} catch {
// Mode application is best-effort
}
@ -834,7 +886,7 @@ export default function App() {
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string"
);
if (modelOption && config.model !== modelOption.currentValue) {
await session.send("session/set_config_option", {
await session.rawSend("session/set_config_option", {
optionId: modelOption.id,
value: config.model,
});
@ -880,6 +932,10 @@ export default function App() {
eventUnsubRef.current();
eventUnsubRef.current = null;
}
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
activeSessionRef.current = null;
await fetchSessions();
} catch (error) {
@ -1165,6 +1221,43 @@ export default function App() {
continue;
}
if (event.sender === "agent" && method === "session/request_permission") {
const params = payload.params as {
options?: Array<{ optionId: string; name: string; kind: string }>;
toolCall?: { title?: string; toolCallId?: string; description?: string };
} | undefined;
const toolCallId = params?.toolCall?.toolCallId;
const sdkPermissionId = toolCallId
? permissionToolCallToIdRef.current.get(toolCallId)
: undefined;
const permissionId = sdkPermissionId
?? (typeof payload.id === "number" || typeof payload.id === "string"
? String(payload.id)
: event.id);
const options = (params?.options ?? []).map((o) => ({
optionId: o.optionId,
name: o.name,
kind: o.kind,
}));
const title = params?.toolCall?.title ?? params?.toolCall?.toolCallId ?? "Permission request";
const resolved = resolvedPermissions.get(permissionId);
entries.push({
id: event.id,
eventId: event.id,
kind: "permission",
time,
permission: {
permissionId,
title,
description: params?.toolCall?.description,
options,
resolved: resolved != null || sdkPermissionId == null,
selectedOptionId: resolved,
},
});
continue;
}
if (event.sender === "agent" && method === "_sandboxagent/agent/unparsed") {
const params = payload.params as { error?: string; location?: string } | undefined;
entries.push({
@ -1194,7 +1287,7 @@ export default function App() {
}
return entries;
}, [events]);
}, [events, resolvedPermissions]);
useEffect(() => {
return () => {
@ -1202,6 +1295,10 @@ export default function App() {
eventUnsubRef.current();
eventUnsubRef.current = null;
}
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
};
}, []);
@ -1684,6 +1781,7 @@ export default function App() {
isThinking={isThinking}
agentId={agentId}
tokenUsage={tokenUsage}
onPermissionReply={handlePermissionReply}
/>
<DebugPanel

View file

@ -56,6 +56,7 @@ const ChatPanel = ({
isThinking,
agentId,
tokenUsage,
onPermissionReply,
}: {
sessionId: string;
transcriptEntries: TranscriptEntry[];
@ -86,6 +87,7 @@ const ChatPanel = ({
isThinking?: boolean;
agentId?: string;
tokenUsage?: { used: number; size: number; cost?: number } | null;
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
}) => {
const [showAgentMenu, setShowAgentMenu] = useState(false);
const [copiedSessionId, setCopiedSessionId] = useState(false);
@ -265,6 +267,7 @@ const ChatPanel = ({
onKeyDown={onKeyDown}
placeholder={sessionEnded ? "Session ended" : "Send a message..."}
disabled={sessionEnded}
onPermissionReply={onPermissionReply}
/>
)}
</div>

View file

@ -3,9 +3,10 @@ import {
type AgentConversationClassNames,
type AgentTranscriptClassNames,
type ChatComposerClassNames,
type PermissionReply,
type TranscriptEntry,
} from "@sandbox-agent/react";
import { AlertTriangle, Brain, ChevronDown, ChevronRight, ExternalLink, Info, PlayCircle, Send, Wrench } from "lucide-react";
import { AlertTriangle, Brain, Check, ChevronDown, ChevronRight, ExternalLink, Info, PlayCircle, Send, Shield, Wrench, X } from "lucide-react";
import type { ReactNode } from "react";
import MarkdownText from "./MarkdownText";
@ -48,6 +49,14 @@ const transcriptClassNames: Partial<AgentTranscriptClassNames> = {
toolSectionTitle: "tool-section-title",
toolCode: "tool-code",
toolCodeMuted: "muted",
permissionPrompt: "permission-prompt",
permissionHeader: "permission-header",
permissionIcon: "permission-icon",
permissionTitle: "permission-title",
permissionDescription: "permission-description",
permissionActions: "permission-actions",
permissionButton: "permission-btn",
permissionAutoResolved: "permission-auto-resolved",
thinkingRow: "thinking-row",
thinkingIndicator: "thinking-indicator",
};
@ -86,6 +95,7 @@ export interface InspectorConversationProps {
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder: string;
disabled: boolean;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
}
const InspectorConversation = ({
@ -103,6 +113,7 @@ const InspectorConversation = ({
onKeyDown,
placeholder,
disabled,
onPermissionReply,
}: InspectorConversationProps) => {
return (
<AgentConversation
@ -132,6 +143,14 @@ const InspectorConversation = ({
renderToolGroupIcon: () => <PlayCircle size={14} />,
renderChevron: (expanded) => (expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />),
renderEventLinkContent: () => <ExternalLink size={10} />,
onPermissionReply,
renderPermissionIcon: () => <Shield size={14} />,
renderPermissionOptionContent: ({ option, label, selected }) => (
<>
{selected ? (option.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />) : null}
{label}
</>
),
renderThinkingState: ({ agentId: activeAgentId }) => (
<div className="thinking-row">
<div className="thinking-avatar">

View file

@ -46,13 +46,9 @@ const structuredData = {
<!-- Preconnect to font providers -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
<!-- Satoshi for headings (from Fontshare) -->
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@700,900&display=swap" rel="stylesheet" />
<!-- Open Sans + JetBrains Mono (from Google Fonts) -->
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Open+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Manrope + JetBrains Mono (from Google Fonts) -->
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<title>{title}</title>

View file

@ -35,7 +35,8 @@
body {
@apply bg-black text-white antialiased;
font-family: 'Open Sans', system-ui, sans-serif;
font-family: 'Manrope', system-ui, sans-serif;
font-weight: 500;
}
/* Text selection - matches rivet.dev */

View file

@ -18,8 +18,8 @@ export default {
'code-comment': '#737373',
},
fontFamily: {
sans: ['Open Sans', 'system-ui', 'sans-serif'],
heading: ['Satoshi', 'Open Sans', 'system-ui', 'sans-serif'],
sans: ['Manrope', 'system-ui', 'sans-serif'],
heading: ['Manrope', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
animation: {