Skip to main content

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.

Try it

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).

Loading File Explorer...
This demo runs on a fake tree

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:

PropPurpose
fileExplorer'builtin' (default) or 'off'
autoRevealOnOpenFileCardWhether an arriving open-file card auto-reveals the aside (default true); suppressed while a file has unsaved edits
fileExplorerBasePathOverride 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>
Always wrap in AsgardThemeScope

Outside <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>
Compose Workspace, not its parts

FileExplorer.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;
}
The old names still work

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.

Breaking type change (0.3.84)

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:

  1. 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.
  2. 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.

A vanished source falls back to the first one

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