File Explorer
When the agent touches files inside a sandbox, the user needs to see that tree. The
SDK ships a File Explorer aside: a source dropdown, a lazily-expanded tree rooted
at workingDirectory, a single-file CodeMirror 6 view with watch-and-reload, plus a
right-click context menu and clipboard-style move/copy.
It is tied to neither <Chatbot> nor a sandbox: a screen with no conversation, no
channel and a single fixed file source works just as well (see
Composing the Parts Yourself and
A Source Need Not Be a Sandbox).
Sandbox File Explorer
When the agent touches files inside a sandbox, the user needs to see that tree. The SDK ships a File Explorer aside: a sandbox dropdown, a lazily-expanded tree rooted at workingDirectory, a single-file CodeMirror 6 view with watch-and-reload, plus a right-click context menu and clipboard-style move/copy.
Expand src/ or docs/ → double-click a file to open → edit and save → right-click for new folder, delete, copy/paste.
Built-in vs. placed yourself
The default fileExplorer="builtin" puts a folder toggle on the header that opens a right-side aside, all wired up. Set it to "off" and neither appears — you place the exported <FileExplorerPanel> wherever you like, as this demo does.
import {
FileExplorerPanel, useFileExplorerController, AsgardThemeScope,
} from '@asgard-js/react';
import { createSandboxFsProviders } from '@asgard-js/react';
const controller = useFileExplorerController({ open: true });
const fs = createSandboxFsProviders(client);
<Chatbot fileExplorer="off" {...rest} />
<AsgardThemeScope theme={theme}>
<FileExplorerPanel
sandboxes={launchedSandboxes} // from channel.launchedSandboxes$
controller={controller}
{...fs} // listDir / readFile / saveFile / watchFile…
onNudge={() => channel.nudge()} // wake an idle sandbox
nudgeDisabled={isConnecting}
/>
</AsgardThemeScope>This demo swaps listDir / readFile / saveFile / watchFile for an in-memory tree — the panel does not care where the data comes from. In production you wire it to AsgardServiceClient's sandbox fs methods (see createSandboxFsProviders below).
The panel takes file access as a set of async callbacks, so the demo above swaps
listDir / readFile / saveFile / watchFile for an in-memory tree — lazy
expansion, editing, saving and the context menu are all genuinely running, there is
just no sandbox behind them. In production you wire createSandboxFsProviders.
Built-in vs. Placed Yourself
// Default: a folder toggle on the header opens a right-side aside, all wired up
<Chatbot fileExplorer="builtin" {...rest} />
// Opt out: neither appears — you place <FileExplorerPanel> wherever you like
<Chatbot fileExplorer="off" {...rest} />
Related Chatbot props:
| Prop | Purpose |
|---|---|
fileExplorer | 'builtin' (default) or 'off' |
autoRevealOnOpenFileCard | Whether an arriving open-file card auto-reveals the aside (default true); suppressed while a file has unsaved edits |
fileExplorerBasePath | Override the tree root (absolute path) instead of the sandbox's workingDirectory |
Placing It Yourself
import {
FileExplorerPanel,
useFileExplorerController,
createSandboxFsProviders,
AsgardThemeScope,
} from "@asgard-js/react";
const controller = useFileExplorerController({ open: true });
const fs = createSandboxFsProviders(client);
<Chatbot fileExplorer="off" {...rest} />
<AsgardThemeScope theme={theme}>
<FileExplorerPanel
sandboxes={launchedSandboxes} // from channel.launchedSandboxes$
controller={controller}
{...fs} // listDir / readFile / saveFile / watchFile / mkdir…
onNudge={() => channel.nudge()} // wake an idle sandbox
nudgeDisabled={isConnecting}
chrome="card" // 'card' standalone / 'flush' built-in aside
/>
</AsgardThemeScope>
AsgardThemeScopeOutside <Chatbot> the panel misses the design tokens the chat shell emits and falls
back to the light-themed defaults. See
Re-establish the Theme.
Composing the Parts Yourself
<FileExplorerPanel> is a ready-made composition and fits the vast majority of
cases. When its header is not what you need — say you are browsing something that is
not a sandbox at all, but one fixed file source with nothing to pick between — compose
the same parts yourself:
import { FileExplorer } from "@asgard-js/react";
<FileExplorer.Provider sources={[mySource]} controller={controller} providers={fs}>
<FileExplorer.Root chrome="card">
<FileExplorer.Header>
<FileExplorer.HeaderRow>
<FileExplorer.SourceSelect /> {/* skip this one when there is a single source */}
<FileExplorer.CloseButton />
</FileExplorer.HeaderRow>
<FileExplorer.Cwd />
</FileExplorer.Header>
<FileExplorer.Workspace /> {/* toolbar + tree/file view + context menu */}
</FileExplorer.Root>
</FileExplorer.Provider>
Workspace, not its partsFileExplorer.Workspace packs every behavior below the header into one part: the
toolbar, the lazily-expanded tree, the single-file view, the context menu, the
clipboard, refresh. Both compositions share it, which is what keeps their behavior
consistent over time; assembling Toolbar / Tree / View / ContextMenu
yourself does work, but the two sides will drift apart. The header is the only piece
that should genuinely fork.
Root is not optional — the panel frame, the positioning anchor for the context menu,
and the always-mounted confirm/prompt dialogs and hidden upload input all live on
it. A part rendered outside Provider throws outright rather than quietly falling back
to defaults.
The available parts: Provider, Root, Header, HeaderRow, SourceSelect,
CloseButton, Cwd, Toolbar, Body, Tree, View, ContextMenu, EmptyState,
Workspace.
A Source Need Not Be a Sandbox
What the panel understands is a source; a sandbox is merely one kind of it:
interface FsSource {
id: string; // identity key — every provider call is addressed by it
label: string; // the name shown in the dropdown
rootPath: string; // the tree root; for a sandbox, its workingDirectory
}
sandboxesAsSources(launchedSandboxes) turns LaunchedSandbox[] into FsSource[]
(with multiple boxes it folds sandboxName into the label to tell them apart).
Paths flowing inside the panel are always absolute paths rooted at rootPath; if
your backend only accepts relative paths, convert inside your own providers.
The Shared Controller
The header toggle, the open-file / open-folder handoff cards, and the panel itself all
bind one controller:
interface FileExplorerController {
open: boolean;
activeSourceId: string | null; // null = unset; the panel falls back to the first source
requestedFile: RequestedFile | null; // kind decides where the reveal ends — see below
isEditingDirty: boolean; // a file has unsaved changes
sourceViews: Record<string, SourceViewState>; // per-source browsing state
openExplorer(): void;
closeExplorer(): void;
toggle(): void;
selectSource(sourceId: string): void;
requestFile(
sourceId: string,
absolutePath: string,
options?: { reveal?: boolean }, // reveal: false = fire the intent without yanking the panel
): void;
requestFolder( // the same request, ending on the tree
sourceId: string,
absolutePath: string,
options?: { reveal?: boolean },
): void;
setEditingDirty(dirty: boolean): void;
sourceView(sourceId: string | null): SourceViewState;
updateSourceView(sourceId: string, update: (prev: SourceViewState) => SourceViewState): void;
}
activeSandboxName / selectSandbox and RequestedFile.sandboxName are kept as
aliases pointing at the same state — existing code needs no changes at all.
requestFile is exactly what a
sandbox://…/open-file card calls under the hood; requestFolder
is its open-folder counterpart.
Both fire the same request — they differ only in where it ends:
interface RequestedFile {
kind: "file" | "folder"; // 'file' opens the viewer (read + watch); 'folder' only unfolds and selects
sourceId: string;
absolutePath: string;
nonce: number; // lets a repeat request for the same path re-trigger the reveal
}
kind is required, and it always comes from the card rather than a guess — the panel has not
listed that level when the reveal arrives, so it cannot tell a file from a directory, and the backend
answers 500 for fs/file on a directory while dropping the fs/watch connection, which rules out
"try it as a file first". That is precisely why open-file and open-folder are two separate uri
actions.
kind went from optional to required. In practice the only producer is the controller itself and
consumers only read it, so code calling requestFile() / requestFolder() is unaffected; only code
that assembles a RequestedFile object by hand needs to add the field.
Browsing State Lives on the Controller, One Record per Source
What the user was looking at in a source — which directories are unfolded, what is selected, which file is open in the viewer — is kept on the controller:
interface SourceViewState {
expanded: Set<string>; // absolute paths of the unfolded directories
selectedPath: string | null;
selectedEntry: FsEntry | null;
openFile: FsEntry | null; // the file open in the viewer, if any
}
It lives there rather than inside <FileExplorer.Provider> for two reasons:
- One record per source is what makes leaving a source and coming back restore the view instead of resetting it. The provider used to hold a single copy and wipe it on every switch, so A → B → A landed on an empty tree.
- The controller is created by the consumer (
useFileExplorerController()), so a host that remounts its panel — one that rebuilds the whole conversation subtree when you switch conversations, say — can hold the controller above that boundary and keep the view across the remount. State kept inside the provider cannot survive that by construction.
A source never visited simply has no record; always read through sourceView(id), which falls
back to the empty state. Write with the updater form updateSourceView(id, prev => next) — that
is what keeps two writes in the same handler from clobbering each other.
When the selected source disappears from the dropdown (a sandbox shut down, say), the panel falls back to the first available source rather than sitting on an empty state.
Clearing the Selection
The selection can be cleared: click the tree's empty space (below the last row), or press Esc.
This has consequences beyond the highlight going away — every selection-derived action falls back to the tree root: upload, new file, new folder, paste. Without this exit, one click on a subfolder pinned all of them to it until the page was reloaded. The toolbar actions that require a selection (download / copy / cut / rename / delete) return to disabled at the same time.
Esc defers: while a dialog or the context menu is open that keypress belongs to them and the
selection is left alone — press it again to clear. It also does nothing while a file is open in the
viewer, where neither the tree nor the toolbar is mounted; clearing an invisible selection there would
only show up on the way back, as a selection the user never dropped having gone missing.
The panel does not swallow Esc: the ones it ignores keep propagating, so a host's own Esc handler
further out still fires.
Name Deduplication on Paste
Pasting a clipboard-style move/copy into a location that already holds a file of that name gives the pasted item a free name instead of colliding with the existing file or failing outright.
File-access Providers
The panel does not care where the data comes from — it only needs these functions:
type FsListDir = (sourceId: string, path: string) => Promise<FsListResult>;
type FsReadFile = (sourceId: string, path: string) => Promise<string>;
type FsSaveFile = (sourceId: string, path: string, content: string) => Promise<void> | void;
// Subscribe to one path's changes, return an unsubscribe. The payload is deliberately
// not surfaced — the view reloads from disk either way.
type FsWatchFile = (sourceId: string, path: string, onChange: () => void) => () => void;
Only listDir is required. Everything else is optional; omit one and the matching
action simply is not offered: readFile, saveFile, watchFile, mkdir, remove,
copy, move, upload, download — which is also how you express a "read-only
source".
watchFile being optional is deliberate: the sandbox edge server has an SSE watch
endpoint, but not every kind of source does; without it the single-file view degrades
to reading once on open.
createSandboxFsProviders(client, options) wires core's sandbox fs methods into that
whole set — image files resolve to a data URL for <img src>, text files to a decoded
string. It also tracks failures: once a sandbox fails enough consecutive calls it
invokes onSandboxUnreachable so you can drop it from the dropdown.
Nudge: Waking an Idle Sandbox
Once a sandbox has gone idle and shut down, the panel's empty state offers a Nudge
button. It sends an invisible NUDGE turn — nothing appears in the thread:
await channel.nudge();
A nudge is a turn, so it is refused outright while a run holds the channel. Pass the
host's "a run is in flight" state to nudgeDisabled so the button is not a dead click.
See Also
- sandbox:// Handoff Cards — where
open-filecomes from - Cold-start HUD — where
launchedSandboxescomes from - Re-establish the Theme — required when placing it yourself