chore: sync workspace changes

This commit is contained in:
Nathan Flurry 2026-01-27 05:06:33 -08:00
parent d24f983e2c
commit bf58891edf
139 changed files with 5454 additions and 8986 deletions

View file

@ -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;

View file

@ -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;

View 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;

View 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;

View file

@ -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";
};

View file

@ -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;

View 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";
};
};