REFERENCE
SDK Demos
How to use @asgard-js/core and @asgard-js/react, with demos you can try on the page.
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).
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
Chatbotprops:Prop Purpose fileExplorer'builtin'(default) or'off'autoRevealOnOpenFileCardWhether an arriving open-filecard auto-reveals the aside (defaulttrue); suppressed while a file has unsaved editsfileExplorerBasePathOverride the tree root (absolute path) instead of the sandbox's workingDirectoryPlacing 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>Always wrap inAsgardThemeScopeOutside
<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>ComposeWorkspace, not its partsFileExplorer.Workspacepacks 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; assemblingToolbar/Tree/View/ContextMenuyourself does work, but the two sides will drift apart. The header is the only piece that should genuinely fork.Rootis 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 outsideProviderthrows 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)turnsLaunchedSandbox[]intoFsSource[](with multiple boxes it foldssandboxNameinto 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-folderhandoff 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;
}The old names still workactiveSandboxName/selectSandboxandRequestedFile.sandboxNameare kept as aliases pointing at the same state — existing code needs no changes at all.requestFileis exactly what asandbox://…/open-filecard calls under the hood;requestFolderis itsopen-foldercounterpart.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
}kindis 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 answers500forfs/fileon a directory while dropping thefs/watchconnection, which rules out "try it as a file first". That is precisely whyopen-fileandopen-folderare two separate uri actions.Breaking type change (0.3.84)kindwent from optional to required. In practice the only producer is the controller itself and consumers only read it, so code callingrequestFile()/requestFolder()is unaffected; only code that assembles aRequestedFileobject 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 formupdateSourceView(id, prev => next)— that is what keeps two writes in the same handler from clobbering each other.A vanished source falls back to the first oneWhen 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.
Escdefers: 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 ownEschandler 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
listDiris 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".watchFilebeing 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 invokesonSandboxUnreachableso 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
NUDGEturn — 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
nudgeDisabledso 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
- sandbox:// Handoff Cards — where