chore: recover lisbon workspace state

This commit is contained in:
Nathan Flurry 2026-03-09 19:59:48 -07:00
parent 5d65013aa5
commit 053417e85e
272 changed files with 1884 additions and 43241 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=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
color-scheme: dark;
@ -51,8 +51,7 @@
body {
color: var(--text);
font-family: 'Manrope', system-ui, sans-serif;
font-weight: 500;
font-family: 'Open Sans', system-ui, sans-serif;
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
@ -1348,6 +1347,13 @@
padding: 16px;
}
.chat-conversation {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.messages-container:has(.empty-state) {
display: flex;
align-items: center;

View file

@ -1,3 +1,4 @@
import type { TranscriptEntry } from "@sandbox-agent/react";
import { BookOpen } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@ -23,7 +24,6 @@ type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string };
import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb";
import ChatPanel from "./components/chat/ChatPanel";
import type { TimelineEntry } from "./components/chat/types";
import ConnectScreen from "./components/ConnectScreen";
import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel";
import SessionSidebar from "./components/SessionSidebar";
@ -921,7 +921,7 @@ export default function App() {
// Build transcript entries from raw SessionEvents
const transcriptEntries = useMemo(() => {
const entries: TimelineEntry[] = [];
const entries: TranscriptEntry[] = [];
// Accumulators for streaming chunks
let assistantAccumId: string | null = null;
@ -954,7 +954,7 @@ export default function App() {
};
// Track tool calls by ID for updates
const toolEntryMap = new Map<string, TimelineEntry>();
const toolEntryMap = new Map<string, TranscriptEntry>();
for (const event of events) {
const payload = event.payload as Record<string, unknown>;
@ -1068,7 +1068,7 @@ export default function App() {
if (update.title) existing.toolName = update.title as string;
existing.time = time;
} else {
const entry: TimelineEntry = {
const entry: TranscriptEntry = {
id: `tool-${toolCallId}`,
eventId: event.id,
kind: "tool",

View file

@ -1,37 +0,0 @@
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

@ -1,292 +0,0 @@
import { useState } from "react";
import { getMessageClass } from "./messageUtils";
import type { TimelineEntry } from "./types";
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle } from "lucide-react";
import MarkdownText from "./MarkdownText";
const ToolItem = ({
entry,
isLast,
onEventClick
}: {
entry: TimelineEntry;
isLast: boolean;
onEventClick?: (eventId: string) => void;
}) => {
const [expanded, setExpanded] = useState(false);
const isTool = entry.kind === "tool";
const isReasoning = entry.kind === "reasoning";
const isMeta = entry.kind === "meta";
const isComplete = isTool && (entry.toolStatus === "completed" || entry.toolStatus === "failed");
const isFailed = isTool && entry.toolStatus === "failed";
const isInProgress = isTool && entry.toolStatus === "in_progress";
let label = "";
let icon = <Info size={12} />;
if (isTool) {
const statusLabel = entry.toolStatus && entry.toolStatus !== "completed"
? ` (${entry.toolStatus.replace("_", " ")})`
: "";
label = `${entry.toolName ?? "tool"}${statusLabel}`;
icon = <Wrench size={12} />;
} else if (isReasoning) {
label = `Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`;
icon = <Brain size={12} />;
} else if (isMeta) {
label = entry.meta?.title ?? "Status";
icon = entry.meta?.severity === "error" ? <AlertTriangle size={12} /> : <Info size={12} />;
}
const hasContent = isTool
? Boolean(entry.toolInput || entry.toolOutput)
: isReasoning
? Boolean(entry.reasoning?.text?.trim())
: Boolean(entry.meta?.detail?.trim());
const canOpenEvent = Boolean(
entry.eventId &&
onEventClick &&
!(isMeta && entry.meta?.title === "Available commands update"),
);
return (
<div className={`tool-item ${isLast ? "last" : ""} ${isFailed ? "failed" : ""}`}>
<div className="tool-item-connector">
<div className="tool-item-dot" />
{!isLast && <div className="tool-item-line" />}
</div>
<div className="tool-item-content">
<button
className={`tool-item-header ${expanded ? "expanded" : ""}`}
onClick={() => hasContent && setExpanded(!expanded)}
disabled={!hasContent}
>
<span className="tool-item-icon">{icon}</span>
<span className="tool-item-label">{label}</span>
{isInProgress && (
<span className="tool-item-spinner">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
)}
{canOpenEvent && (
<span
className="tool-item-link"
onClick={(e) => {
e.stopPropagation();
onEventClick?.(entry.eventId!);
}}
title="View in Events"
>
<ExternalLink size={10} />
</span>
)}
{hasContent && (
<span className="tool-item-chevron">
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
)}
</button>
{expanded && hasContent && (
<div className="tool-item-body">
{isTool && entry.toolInput && (
<div className="tool-section">
<div className="tool-section-title">Input</div>
<pre className="tool-code">{entry.toolInput}</pre>
</div>
)}
{isTool && isComplete && entry.toolOutput && (
<div className="tool-section">
<div className="tool-section-title">Output</div>
<pre className="tool-code">{entry.toolOutput}</pre>
</div>
)}
{isReasoning && entry.reasoning?.text && (
<div className="tool-section">
<pre className="tool-code muted">{entry.reasoning.text}</pre>
</div>
)}
{isMeta && entry.meta?.detail && (
<div className="tool-section">
<pre className="tool-code">{entry.meta.detail}</pre>
</div>
)}
</div>
)}
</div>
</div>
);
};
const ToolGroup = ({ entries, onEventClick }: { entries: TimelineEntry[]; onEventClick?: (eventId: string) => void }) => {
const [expanded, setExpanded] = useState(false);
// If only one item, render it directly without macro wrapper
if (entries.length === 1) {
return (
<div className="tool-group-single">
<ToolItem entry={entries[0]} isLast={true} onEventClick={onEventClick} />
</div>
);
}
const totalCount = entries.length;
const summary = `${totalCount} Event${totalCount > 1 ? "s" : ""}`;
// Check if any are in progress
const hasInProgress = entries.some(e => e.kind === "tool" && e.toolStatus === "in_progress");
const hasFailed = entries.some(e => e.kind === "tool" && e.toolStatus === "failed");
return (
<div className={`tool-group-container ${hasFailed ? "failed" : ""}`}>
<button
className={`tool-group-header ${expanded ? "expanded" : ""}`}
onClick={() => setExpanded(!expanded)}
>
<span className="tool-group-icon">
<PlayCircle size={14} />
</span>
<span className="tool-group-label">{summary}</span>
<span className="tool-group-chevron">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
</button>
{expanded && (
<div className="tool-group">
{entries.map((entry, idx) => (
<ToolItem
key={entry.id}
entry={entry}
isLast={idx === entries.length - 1}
onEventClick={onEventClick}
/>
))}
</div>
)}
</div>
);
};
const agentLogos: Record<string, string> = {
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
};
const ChatMessages = ({
entries,
sessionError,
eventError,
messagesEndRef,
onEventClick,
isThinking,
agentId
}: {
entries: TimelineEntry[];
sessionError: string | null;
eventError?: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
onEventClick?: (eventId: string) => void;
isThinking?: boolean;
agentId?: string;
}) => {
// Group consecutive tool/reasoning/meta entries together
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider"; entries: TimelineEntry[] }> = [];
let currentToolGroup: TimelineEntry[] = [];
const flushToolGroup = () => {
if (currentToolGroup.length > 0) {
groupedEntries.push({ type: "tool-group", entries: currentToolGroup });
currentToolGroup = [];
}
};
for (const entry of entries) {
const isStatusDivider = entry.kind === "meta" &&
["Session Started", "Turn Started", "Turn Ended"].includes(entry.meta?.title ?? "");
if (isStatusDivider) {
flushToolGroup();
groupedEntries.push({ type: "divider", entries: [entry] });
} else if (entry.kind === "tool" || entry.kind === "reasoning" || (entry.kind === "meta" && entry.meta?.detail)) {
currentToolGroup.push(entry);
} else if (entry.kind === "meta" && !entry.meta?.detail) {
// Simple meta without detail - add to tool group as single item
currentToolGroup.push(entry);
} else {
// Regular message
flushToolGroup();
groupedEntries.push({ type: "message", entries: [entry] });
}
}
flushToolGroup();
return (
<div className="messages">
{groupedEntries.map((group, idx) => {
if (group.type === "divider") {
const entry = group.entries[0];
const title = entry.meta?.title ?? "Status";
return (
<div key={entry.id} className="status-divider">
<div className="status-divider-line" />
<span className="status-divider-text">{title}</span>
<div className="status-divider-line" />
</div>
);
}
if (group.type === "tool-group") {
return <ToolGroup key={`group-${idx}`} entries={group.entries} onEventClick={onEventClick} />;
}
// Regular message
const entry = group.entries[0];
const messageClass = getMessageClass(entry);
return (
<div key={entry.id} className={`message ${messageClass} no-avatar`}>
<div className="message-content">
{entry.text ? (
<MarkdownText text={entry.text} />
) : (
<span className="thinking-indicator">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
)}
</div>
</div>
);
})}
{sessionError && <div className="message-error">{sessionError}</div>}
{eventError && <div className="message-error">{eventError}</div>}
{isThinking && (
<div className="thinking-row">
<div className="thinking-avatar">
{agentId && agentLogos[agentId] ? (
<img src={agentLogos[agentId]} alt="" className="thinking-avatar-img" />
) : (
<span className="ai-label">AI</span>
)}
</div>
<span className="thinking-indicator">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
);
};
export default ChatMessages;

View file

@ -1,3 +1,4 @@
import type { TranscriptEntry } from "@sandbox-agent/react";
import { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo } from "sandbox-agent";
@ -6,9 +7,7 @@ import { formatShortId } from "../../utils/format";
type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string };
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages";
import type { TimelineEntry } from "./types";
import InspectorConversation from "./InspectorConversation";
const HistoryLoadingSkeleton = () => (
<div className="chat-loading-skeleton" aria-hidden>
@ -59,7 +58,7 @@ const ChatPanel = ({
tokenUsage,
}: {
sessionId: string;
transcriptEntries: TimelineEntry[];
transcriptEntries: TranscriptEntry[];
isLoadingHistory?: boolean;
sessionError: string | null;
message: string;
@ -212,8 +211,8 @@ const ChatPanel = ({
</div>
)}
<div className="messages-container">
{!sessionId ? (
{!sessionId ? (
<div className="messages-container">
<div className="empty-state">
<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>
@ -239,37 +238,35 @@ const ChatPanel = ({
/>
</div>
</div>
) : transcriptEntries.length === 0 && !sessionError ? (
isLoadingHistory ? (
<HistoryLoadingSkeleton />
) : (
<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}
eventError={null}
messagesEndRef={messagesEndRef}
onEventClick={onEventClick}
isThinking={isThinking}
agentId={agentId}
/>
)}
</div>
<ChatInput
message={message}
onMessageChange={onMessageChange}
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionEnded ? "Session ended" : sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId || sessionEnded}
/>
</div>
) : (
<InspectorConversation
entries={transcriptEntries}
sessionError={sessionError}
eventError={null}
messagesEndRef={messagesEndRef}
onEventClick={onEventClick}
isThinking={isThinking}
agentId={agentId}
emptyState={
isLoadingHistory ? (
<HistoryLoadingSkeleton />
) : (
<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>
)
}
message={message}
onMessageChange={onMessageChange}
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionEnded ? "Session ended" : "Send a message..."}
disabled={sessionEnded}
/>
)}
</div>
);
};

View file

@ -0,0 +1,165 @@
import {
AgentConversation,
type AgentConversationClassNames,
type AgentTranscriptClassNames,
type ChatComposerClassNames,
type TranscriptEntry,
} from "@sandbox-agent/react";
import { AlertTriangle, Brain, ChevronDown, ChevronRight, ExternalLink, Info, PlayCircle, Send, Wrench } from "lucide-react";
import type { ReactNode } from "react";
import MarkdownText from "./MarkdownText";
const agentLogos: Record<string, string> = {
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
};
const transcriptClassNames: Partial<AgentTranscriptClassNames> = {
root: "messages",
divider: "status-divider",
dividerLine: "status-divider-line",
dividerText: "status-divider-text",
message: "message",
messageContent: "message-content",
error: "message-error",
toolGroupSingle: "tool-group-single",
toolGroupContainer: "tool-group-container",
toolGroupHeader: "tool-group-header",
toolGroupIcon: "tool-group-icon",
toolGroupLabel: "tool-group-label",
toolGroupChevron: "tool-group-chevron",
toolGroupBody: "tool-group",
toolItem: "tool-item",
toolItemConnector: "tool-item-connector",
toolItemDot: "tool-item-dot",
toolItemLine: "tool-item-line",
toolItemContent: "tool-item-content",
toolItemHeader: "tool-item-header",
toolItemIcon: "tool-item-icon",
toolItemLabel: "tool-item-label",
toolItemSpinner: "tool-item-spinner",
toolItemLink: "tool-item-link",
toolItemChevron: "tool-item-chevron",
toolItemBody: "tool-item-body",
toolSection: "tool-section",
toolSectionTitle: "tool-section-title",
toolCode: "tool-code",
toolCodeMuted: "muted",
thinkingRow: "thinking-row",
thinkingIndicator: "thinking-indicator",
};
const conversationClassNames: Partial<AgentConversationClassNames> = {
root: "chat-conversation",
transcript: "messages-container",
};
const composerClassNames: Partial<ChatComposerClassNames> = {
root: "input-container",
form: "input-wrapper",
submit: "send-button",
};
const ThinkingDots = () => (
<>
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</>
);
export interface InspectorConversationProps {
entries: TranscriptEntry[];
sessionError: string | null;
eventError?: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
onEventClick?: (eventId: string) => void;
isThinking?: boolean;
agentId?: string;
emptyState?: ReactNode;
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder: string;
disabled: boolean;
}
const InspectorConversation = ({
entries,
sessionError,
eventError,
messagesEndRef,
onEventClick,
isThinking,
agentId,
emptyState,
message,
onMessageChange,
onSendMessage,
onKeyDown,
placeholder,
disabled,
}: InspectorConversationProps) => {
return (
<AgentConversation
entries={entries}
classNames={conversationClassNames}
emptyState={emptyState}
transcriptClassNames={transcriptClassNames}
transcriptProps={{
endRef: messagesEndRef,
sessionError,
eventError,
onEventClick,
isThinking,
agentId,
canOpenEvent: (entry) => !(entry.kind === "meta" && entry.meta?.title === "Available commands update"),
renderMessageText: (entry) => <MarkdownText text={entry.text ?? ""} />,
renderInlinePendingIndicator: () => <ThinkingDots />,
renderToolItemIcon: (entry) => {
if (entry.kind === "tool") {
return <Wrench size={12} />;
}
if (entry.kind === "reasoning") {
return <Brain size={12} />;
}
return entry.meta?.severity === "error" ? <AlertTriangle size={12} /> : <Info size={12} />;
},
renderToolGroupIcon: () => <PlayCircle size={14} />,
renderChevron: (expanded) => (expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />),
renderEventLinkContent: () => <ExternalLink size={10} />,
renderThinkingState: ({ agentId: activeAgentId }) => (
<div className="thinking-row">
<div className="thinking-avatar">
{activeAgentId && agentLogos[activeAgentId] ? (
<img src={agentLogos[activeAgentId]} alt="" className="thinking-avatar-img" />
) : (
<span className="ai-label">AI</span>
)}
</div>
<span className="thinking-indicator">
<ThinkingDots />
</span>
</div>
),
}}
composerClassNames={composerClassNames}
composerProps={{
message,
onMessageChange,
onSubmit: onSendMessage,
onKeyDown,
placeholder,
disabled,
submitLabel: "Send",
renderSubmitContent: () => <Send />,
}}
/>
);
};
export default InspectorConversation;

View file

@ -1,19 +0,0 @@
import type { TimelineEntry } from "./types";
import { Settings, AlertTriangle } from "lucide-react";
import type { ReactNode } from "react";
export const getMessageClass = (entry: TimelineEntry) => {
if (entry.kind === "tool") return "tool";
if (entry.kind === "meta") return entry.meta?.severity === "error" ? "error" : "system";
if (entry.kind === "reasoning") return "assistant";
if (entry.role === "user") return "user";
return "assistant";
};
export const getAvatarLabel = (messageClass: string): ReactNode => {
if (messageClass === "user") return null;
if (messageClass === "tool") return "T";
if (messageClass === "system") return <Settings size={14} />;
if (messageClass === "error") return <AlertTriangle size={14} />;
return "AI";
};

View file

@ -1,18 +0,0 @@
export type TimelineEntry = {
id: string;
eventId?: string; // Links back to the original event for navigation
kind: "message" | "tool" | "meta" | "reasoning";
time: string;
// For messages:
role?: "user" | "assistant";
text?: string;
// For tool calls:
toolName?: string;
toolInput?: string;
toolOutput?: string;
toolStatus?: string;
// For reasoning:
reasoning?: { text: string; visibility?: string };
// For meta:
meta?: { title: string; detail?: string; severity?: "info" | "error" };
};

View file

@ -46,9 +46,13 @@ 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 />
<!-- 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" />
<!-- 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" />
<title>{title}</title>

View file

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

View file

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