React component library for Tangle Sandbox — a shadcn-style primitive layer plus higher-order sandbox surfaces for agent chat, files, runtime state, artifacts, and dashboard views.
Browse the live Storybook to inspect component states in dark and light themes at desktop and mobile widths.
npm install @tangle-network/sandbox-uiRequired peers: react and react-dom 18 or 19, @tangle-network/agent-interface ^1.0.0 || ^2.0.0, @tangle-network/brand ^1.6.0, and @tangle-network/ui ^11.10.0.
Optional peers are required only by the subpaths that use them; see package.json. /editor needs its tiptap, yjs and @hocuspocus/provider peers only when it renders an editor, and the @tangle-network/ui README holds the table of which surface needs which.
import {
SandboxWorkbench,
type FileNode,
type SessionMessage,
type SessionPart,
} from "@tangle-network/sandbox-ui";Import styles in your app root:
import "@tangle-network/sandbox-ui/styles";sandbox-ui references the following font families in its design tokens but does not bundle them — consumer apps must load the fonts themselves. This is deliberate: a URL @import inside a library CSS bundle breaks when downstream apps chain-import the stylesheet (see CHANGELOG 0.10.9 for the full reasoning).
| Family | Role | Used as CSS variable |
|---|---|---|
| Geist | UI body text | --font-sans |
| Geist Mono | Code, terminal | --font-mono |
| Outfit | Display / headings (default theme) | --font-display |
| Manrope | Display / headings (vault theme) | --font-display |
| Inter | UI body (vault theme) | --font-sans |
Pick one loading strategy that fits your app:
1. Self-hosted via @fontsource/* (recommended — no external network request):
npm install @fontsource/geist-sans @fontsource/geist-mono @fontsource/outfit @fontsource/manrope @fontsource/inter// app entry
import "@fontsource/geist-sans/400.css";
import "@fontsource/geist-sans/500.css";
import "@fontsource/geist-sans/600.css";
import "@fontsource/geist-sans/700.css";
import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/500.css";
import "@fontsource/outfit/500.css";
import "@fontsource/outfit/700.css";
import "@fontsource/manrope/500.css";
import "@fontsource/manrope/700.css";
import "@fontsource/inter/400.css";
import "@fontsource/inter/600.css";2. Google Fonts via HTML <link>:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" />Any family you omit falls back per the --font-* token chain (e.g. --font-sans falls back to "DM Sans", ui-sans-serif, system-ui, sans-serif).
If you are building on the sandbox SDK directly, use useSdkSession to turn raw SDK/session-gateway events into the messages + partMap model that ChatContainer and SandboxWorkbench expect:
import {
SandboxWorkbench,
} from "@tangle-network/sandbox-ui";
import { useSdkSession } from "@tangle-network/sandbox-ui/sdk-hooks";
function App() {
const {
messages,
partMap,
isStreaming,
appendUserMessage,
beginAssistantMessage,
applySdkEvent,
completeAssistantMessage,
failAssistantMessage,
} = useSdkSession();
async function runTurn(text: string) {
appendUserMessage({ content: text });
const assistantMessageId = beginAssistantMessage();
try {
for await (const event of sdk.streamPrompt(text)) {
applySdkEvent(event, { messageId: assistantMessageId });
}
completeAssistantMessage({ messageId: assistantMessageId });
} catch (error) {
failAssistantMessage(
error instanceof Error ? error.message : "Agent run failed",
{ messageId: assistantMessageId },
);
}
}
return (
<SandboxWorkbench
session={{
messages,
partMap,
isStreaming,
onSend: runTurn,
}}
/>
);
}Compose sandbox applications around SandboxWorkbench when you want the library’s default operating model:
const root: FileNode = {
name: "agent",
path: "/home/agent",
type: "directory",
children: [],
};
const messages: SessionMessage[] = [];
const partMap: Record<string, SessionPart[]> = {};
<SandboxWorkbench
title="Tax filing workspace"
directory={{
root,
visibility: {
hiddenPathPrefixes: ["/home/agent/tax_toolkit"],
},
}}
session={{
messages,
partMap,
isStreaming: false,
presentation: "timeline",
onSend: console.log,
}}
runtime={{
title: "Runtime",
}}
/>;FileTreeVisibilityOptions is a UI-layer policy only. Sensitive paths still need to be hidden and denied by the app/backend layer.
For a chats rail · transcript + composer · artifacts layout, hand SandboxWorkbench a SessionSidebar as its rail, your composer as composer, and centerHeader={null} for the quiet center. WorkspaceLayout owns the pane sizes and, when you pass layout.leftOpen / layout.onLeftOpenChange, the open state too.
const [chatsOpen, setChatsOpen] = useState(true);
<SandboxWorkbench
centerHeader={null}
rail={
<SessionSidebar
variant="quiet"
groupBy="status"
showUpdatedAt
fill
className="border-r-0"
title="Chats"
createLabel="New chat"
items={threads}
currentItemId={threadId}
onSelectItem={(item) => navigate(item.href)}
onCreate={createThread}
onCollapse={() => setChatsOpen(false)}
/>
}
session={{ messages, partMap, isStreaming }}
composer={<ChatComposer onSend={runTurn} />}
artifacts={[
{ id: "changes", kind: "custom", title: "Changes", icon: GitCompare, pinned: true, content: <ChangesPanel /> },
...openFiles,
]}
layout={{
leftOpen: chatsOpen,
onLeftOpenChange: setChatsOpen,
keyboardShortcuts: true,
leftCollapsedControl: (
<button type="button" aria-label="Show chats" onClick={() => setChatsOpen(true)}>
<PanelLeftOpen className="h-4 w-4" />
</button>
),
}}
/>;Each SessionSidebarItem can carry an icon (rendered in a 16×20 slot, with the status dot as a corner badge) and a meta line; formatRelativeAge from /workspace formats the age for that line.
There is a built-in Tangle default theme, but consumers can restyle the library in three layers:
- Pick a built-in surface theme
- Override semantic tokens
- Wrap higher-level components when you want a different product composition
WorkspaceLayout and SandboxWorkbench support:
theme="vault"— light theme with solid surfaces- No theme prop — default dark theme
They also support density="comfortable" and density="compact".
<SandboxWorkbench
layout={{
theme: "vault",
density: "comfortable",
}}
session={{ ... }}
/>If you are not using SandboxWorkbench, you can set the same attributes yourself:
<div data-sandbox-ui data-sandbox-theme="vault" data-density="compact">
<YourSandboxApp />
</div>The shared visual contract lives in src/styles/tokens.css. The important tokens are:
- surfaces:
--bg-root,--bg-card,--bg-elevated,--bg-section,--bg-input - text:
--text-primary,--text-secondary,--text-muted - brand:
--brand-cool,--brand-glow,--brand-purple - accent surfaces:
--accent-gradient-strong,--accent-surface-soft,--accent-surface-strong,--accent-text - borders:
--border-subtle,--border-default,--border-accent - radii/shadows:
--radius-*,--shadow-card,--shadow-dropdown,--shadow-accent
App-level overrides can be scoped to a wrapper:
.tax-theme {
--brand-cool: hsl(187 75% 54%);
--brand-glow: hsl(164 74% 56%);
--bg-root: hsl(222 18% 9%);
--bg-card: hsl(223 20% 12%);
--border-accent: hsl(187 75% 48% / 0.35);
--font-sans: "Satoshi", ui-sans-serif, system-ui, sans-serif;
}<div className="tax-theme">
<SandboxWorkbench ... />
</div>Token overrides are the right tool when you want:
- a different brand color system
- different typography
- tighter or roomier density
- a more consumer-facing or operator-facing tone
Wrap or compose on lower-level exports when you want:
- a different page shell
- different header chrome
- a different artifact tab model
- app-specific empty states and actions
The higher-order dashboard/billing surfaces are now accent-token driven rather than hardcoded to the default Tangle look. The main seams are:
DashboardLayout.className,sidebarClassName,contentClassNameBillingDashboard.className,cardClassNamePricingCards.className,cardClassNameUsageChart.classNameStandalonePricingPage.className
For that, compose directly from:
/workspace/chat/run/files
Retheming is absolutely supported, but the documentation was thinner than it should be. The token layer is strong; the higher-level surfaces are themeable, but more opinionated. For a radically different product look, prefer keeping the token contract and wrapping the higher-level workbench/chat surfaces rather than fighting every internal class.
| Guide | Description |
|---|---|
| Sidebar | Composable Rail + Panel sidebar system (architecture, components, full API) |
| Subpath | Description |
|---|---|
/primitives |
Button, Card, Dialog, Badge, Input, Select, Table, Tabs, Toast, etc. |
/chat |
ChatContainer, ChatInput, ChatMessage, AgentTimeline, ThinkingIndicator |
/run |
ToolCallFeed, RunGroup, InlineToolItem, ExpandedToolDetail |
/workspace |
SandboxWorkbench, WorkspaceLayout, DirectoryPane, RuntimePane, StatusBar |
/openui |
OpenUIArtifactRenderer and schema types for structured artifact rendering |
/files |
FileTree, FilePreview, FileTabs, FileArtifactPane |
/dashboard |
Sidebar, DashboardLayout, BillingDashboard, UsageChart, ProfileSelector |
/editor |
TipTap collaborative editor (requires optional peers) |
/terminal |
xterm.js terminal view (requires optional peers) |
/markdown |
Markdown renderer with GFM, code blocks, copy button |
/auth |
AuthHeader, GitHubLoginButton, UserMenu |
/pages |
Pre-built billing, pricing, profiles pages |
/hooks |
useSSEStream, useAuth, usePtySession, useRunGroups, etc. |
/sdk-hooks |
Lightweight session/stream hooks without the React Query CRUD hook bundle |
/stores |
Session and chat nanostores |
/types |
TypeScript types for messages, parts, runs, sessions |
/utils |
cn, formatDuration, timeAgo, tool display helpers |
/styles |
Compiled CSS bundle |
- Radix UI primitives
- Tailwind CSS v4
- Lucide icons
- CVA for variant management
- Shared semantic tokens for default dark and
vaultlight sandbox themes - ESM-only, tree-shakeable, fully typed
Apache-2.0
