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. /files needs pdfjs-dist only for PdfViewer, which FilePreview uses for PDFs; without it the viewer says so and falls back to the browser's own PDF viewer. The ui README covers its worker and asset setup through configurePdfViewer.
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";An app that runs its own Tailwind v4 compile imports the source entry instead, and lists no node_modules paths:
@import "tailwindcss";
@import "@tangle-network/sandbox-ui/tailwind.css";The entry carries Brand's tokens and utility registrations, this package's runtime CSS, and @source lines for its own dist and the @tangle-network/ui peer, so every component utility compiles once, in the app's own layer order.
Agent apps import @tangle-network/agent-app/tailwind.css, which includes this entry.
For apps whose pages already provide their own gutters, pass contentClassName="px-0 pb-0 lg:px-0" to DashboardLayout. New Sandbox is at the top of the sidebar; notifications sit above the account menu. Set notificationsEnabled={false} when the app has no notification feed. The desktop has no top bar; the mobile menu opens a focus-managed drawer.
Use sidebarLeading={({ collapsed }) => <WorkspaceSwitcher compact={collapsed} />} for workspace controls; the mobile drawer supplies collapsed: false. A static node opens in a Workspace menu on the collapsed rail. topBarLeading remains a relocated legacy alias, and topNavLinks render as sidebar links. collapseEmptyTopBar is retained as a no-op. DashboardPageHeader keeps title, description and actions aligned without adding a card.
Use IntegrationsPanel from /integrations for the provider catalog, logos, search, connection status, and account actions.
Do not render a second connection catalog above it.
Supply getConnectionContext for a short access label on each connected card.
Supply getConnectionActions for authorized workspace actions in that card's menu.
Each action has an id, label, optional disabled flag, and onSelect callback.
The panel waits for asynchronous actions and surfaces failures.
Keep workspace authorization and binding persistence in the consuming application's server.
When a provider has multiple accounts, include distinct account labels and identities in its workspace actions.
Use EmbeddedAppView when a workspace presents a running web app beside an agent conversation.
Render it in a container with a defined height.
import { EmbeddedAppView } from "@tangle-network/sandbox-ui/workbench/embedded-app"
<main className="h-full min-h-0">
<EmbeddedAppView
app={{
id: selectedApp.id,
name: selectedApp.name,
status: selectedApp.previewStatus,
previewUrl: selectedApp.previewUrl,
statusMessage: selectedApp.previewError,
}}
allowedOrigins={workspace.previewOrigins}
readiness={selectedApp.httpReadiness}
access={workspace.enforcedPreviewAccess}
toolbarActions={<AppActions app={selectedApp} />}
onRetry={() => recheckPreview(selectedApp.id)}
/>
</main>The host supplies the app list, stable identity, persistence, authorization, and readiness result.
Set status: "ready" only after an app-level HTTP probe succeeds.
A preview route becoming available does not prove app health.
Supply a resolved, credential-free HTTPS URL, or HTTP loopback for local development.
Pass allowedOrigins from product policy or the enforcing service, independent of the URL.
The host must authorize access before it returns the URL.
The component shows starting and unavailable states without mounting a frame.
Its ready view uses the same URL validation and iframe isolation as PreviewView.
The compact header uses the shared 56px workspace pane row and provides Reload and Open in new tab.
Place product actions in toolbarActions to share that header without a second toolbar.
Pass service-observed readiness and enforced access to show their status inside the pane.
Show process and port state beside the pane if your runtime exposes those separately.
Do not infer either state from the preview URL or iframe load.
Use AgentMessagingConnection in any app that enrolls a member with a private agent target and a shared messaging route.
The host owns authentication, the member grant, canonical target resolution, and route readiness.
import {
AgentMessagingConnection,
type AgentEnrollmentViewState,
type SharedMessagingRouteViewState,
} from "@tangle-network/sandbox-ui/connections";
type MessagingConnectionProps = {
enrollment: AgentEnrollmentViewState;
route: SharedMessagingRouteViewState;
enroll: () => Promise<void>;
openMessages: () => void;
refresh: () => Promise<void>;
};
export function MessagingConnection({
enrollment,
route,
enroll,
openMessages,
refresh,
}: MessagingConnectionProps) {
return (
<AgentMessagingConnection
enrollment={enrollment}
route={route}
onEnroll={enroll}
onOpenMessages={openMessages}
onRetry={refresh}
/>
);
}Report eligible only after the host checks the member's current grant.
Report enrolled only after it resolves the authorized, canonical private agent target.
Give the component display labels, not raw enrollment, instance, sandbox, or session IDs.
Report route ready only when the shared route is valid for this application and member.
An attached provider line on its own does not prove that state.
The component never attaches, buys, or reassigns a provider identity.
It shows a messaging action only when both the target and route are confirmed.
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,
}}
/>
);
}New agent products compose their application shell with AgentWorkspaceCompanion from @tangle-network/agent-app/workspace-react.
Use WorkspaceFilesPane for its Files content and WorkspaceArtifactView for technical artifact rendering.
Both are exported from @tangle-network/sandbox-ui/workspace.
<WorkspaceFilesPane
paths={workspacePaths}
selectedPath={selectedPath}
onSelect={openFile}
onBack={closePreview}
preview={selectedArtifact ? {
path: selectedArtifact.path,
content: <WorkspaceArtifactView artifact={selectedArtifact} hideTitleBlock />,
actions: <DownloadFile artifact={selectedArtifact} />,
} : undefined}
/>Pass either a recursive root: FileNode or canonical paths, including trailing slashes for empty folders.
A root input returns its original node as the second onSelect argument; flat paths have no node metadata.
The pane uses the shared searchable file tree and retains it while a preview is open.
Returning to files preserves expansion, selection, and scroll.
Native search ends when a result is selected.
Adapters own loading, permissions, errors, file reads, and actions; emptyState customizes an empty workspace.
The preview header exposes Back to files, the selected path, and supplied actions without a second title frame.
Set hideTitleBlock on the nested artifact renderer when the containing pane already identifies the file.
Download, format, toolbar, and content controls remain available; standalone rendering keeps its existing title defaults.
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 and your composer as composer.
The default places the session title and status in the transcript pane header without a second shell row.
Use centerHeader={null} when the transcript should have no pane frame either.
WorkspaceLayout leaves its center row out until a caller supplies centerHeader.
Closed panes get compact edge controls with reserved space beside the transcript.
surface="inset" raises the center and right panes as one surface inside an even gutter on the recessed backdrop.
Its center always has a 48px header: closed-pane controls sit flat in its top-right corner, and an open right pane shares that header height and a divider.
rightOpenLabel, rightCloseLabel, and rightControlHint name the right-pane controls and their tooltip.
It owns 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
Markdown prose uses --foreground and --muted-foreground as HSL channel values.
Set them on the nearest product wrapper when its colors differ from the brand theme.
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.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.
DiffView from /workbench defaults to a dark code theme. If the host app offers a light/dark switch, pass its selected theme as themeType. The diff renderer updates when that value changes.
| 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 |
WorkspaceFilesPane, WorkspaceArtifactView, SandboxWorkbench, WorkspaceLayout, DirectoryPane, RuntimePane, StatusBar |
/workbench |
DiffView, ChangesPane, CodeView, artifact and preview components |
/openui |
OpenUIArtifactRenderer and schema types for structured artifact rendering |
/files |
FileTree, FilePreview, FileTabs, FileArtifactPane, PdfViewer, FileCard, FileList |
/workbench/embedded-app |
EmbeddedAppView and preview status types without terminal or editor modules |
/dashboard |
Sidebar, DashboardLayout, BillingDashboard, UsageChart |
/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 |
/tailwind.css |
Tailwind v4 source entry: tokens, runtime CSS, and the @source lines for this package and @tangle-network/ui |
- 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
