# Keeping the real workspace beside the conversation

Author: Chris Chabot
Canonical URL: https://chabot.dev/blog/keeping-the-real-workspace-beside-the-conversation/
Published: 2026-11-03

Giving a browser workspace stable panel identity, separate lifetimes for layout and shell, and the agent’s real files and terminal instead of a plausible substitute.

A coding agent’s transcript is an account of the work, and the work itself is somewhere else: files on a disk in a sandbox, a shell with its own working directory and history, a diff against a branch, a dev server that only exists inside that machine. You can get a long way by reading the account. Sooner or later, though, you want to open the file the agent just described, or run one command in its shell to see whether its confidence was justified, and you want to do that without losing your place in a conversation that is still moving.

Giving people that in our browser workspace meant solving three problems that are easy to mistake for one. Each piece of content needed a stable identity, so that the same file opened twice would not become two tabs. Whatever sat inside a panel needed a lifetime of its own, so that dragging or hiding the panel would not destroy it. And each panel had to show the real thing, the sandbox’s file and the sandbox’s shell, and say so plainly when it could not reach them rather than offering a convincing substitute. A docking library answers part of the second problem and none of the others, and ours was replaced about a month after it went in.

## Four ways to place a panel

By July 2026 the workspace had four separate mechanisms for deciding where a panel went, among them a flat split-and-slide-over layout engine and a bottom drawer for the terminal. I consolidated them on [dockview](https://dockview.dev/), working with coding agents on the implementation. Moving a panel reparents its DOM node, which reloads an iframe but leaves a terminal’s canvas alone. That was a tradeoff I was willing to accept: different kinds of content have different requirements for surviving a move.

The new store held only desired state, which surfaces were open and which one was active. A reconciler applied that to dockview, and the existing code that opened files and terminals kept its function signatures, so callers around the client did not have to learn anything about docking.

## One piece of content, one panel

Agents refer to the same files over and over, and a transcript can easily hold a dozen links to one path. If every click opened a tab, the workspace would fill with duplicates, none of which would be the copy you had already scrolled to the right place. So a surface’s identity was derived from its content: `panel:file:<path>` for a file, `panel:diff:<path>` for a diff, `panel:resource:<url>` for a resource, the thread and turn for a research report. Opening a surface refreshed its descriptor, added it only if it was absent and made it active. A position hint applied only the first time a surface appeared, so a panel you had dragged somewhere stayed there when something opened it again.

The vocabulary of surfaces was small and typed: files, a terminal, a browser view, a side chat, an inbox, and previews of files, diffs, resources and review material. The task plan was left out on purpose. It floated over the workspace as an overlay and was never a dock surface, to the point that a saved layout containing a docked plan tab counted as inconsistent and was thrown away.

## A layout is not a process

Layouts were saved per thread with a project-level fallback, on autosave and on `pagehide`. Just before a snapshot the dock fired an event so that mounted views could commit their scroll positions, which is what let a rearrangement preserve your place inside a panel as well as the panel itself.

A snapshot is old data that may have been written by an earlier build, so restoring one treated it as untrusted input. Invalid JSON, the wrong version, a shape that was not a grid plus panels, a surface that failed validation or a dangling reference to a built-in panel all made the parser return nothing, and the validators were specific about what each kind of surface had to carry: a file preview needed a path and a session ID, a diff needed a path and the diff itself. In any of those cases you got the default layout rather than a half-restored one.

What a snapshot restores is an arrangement of panels, and the word “restore” is easily taken to mean more than that. Nothing in that mechanism brings back the shell that was running in a terminal panel. How long the shell lives is a separate decision, and it is the one that changed most in the move.

In June the terminal had lived in a drawer that had to be unmounted while hidden, because at zero height its auto-resize fell into a loop that reflowed the page. Keeping a session across that meant a replay buffer to repaint the screen whenever the drawer came back. In the dock, the terminal panel used dockview’s `renderer: "always"`, which keeps an inactive panel in the DOM and reparents its host element during a drag without unmounting the React tree. The terminal mounted once, the tunnel and the emulator lived as long as the panel, and the replay buffer could be deleted. Closing the panel or changing the thread ended the session, and that was the whole rule.

Keeping something alive while it is hidden brings failure modes of its own. Dockview hides an inactive tab by collapsing its box, so a terminal with auto-resize would report a 0×0 grid and drive a bogus one-row resize of the PTY, and the resize handler had to learn to ignore degenerate dimensions. The hidden host was also `aria-hidden` while the terminal’s textarea could still hold focus, which hides a focused element from assistive technology, so the host became `inert`.

## The agent’s shell, not a shell

A terminal beside an agent is only worth having if it is the agent’s environment. A local shell, or a fresh container from the same image, would be a different machine with a different disk and none of the state you wanted to inspect.

The emulator was [wterm](https://www.npmjs.com/package/@wterm/react), the sandbox came from [E2B](https://github.com/e2b-dev/infra), and the PTY ran through envd, E2B’s daemon inside the sandbox, so the work was all in the seams. What made it the agent’s shell was the sequence at the start of the connection. The browser opened a WebSocket to our gateway and sent a hello frame. The gateway authenticated that frame, checked that the caller could access that particular thread, since a valid login says nothing about whose sandbox you are opening, and then found the sandbox whose metadata carried the thread’s ID as its agent ID. Nobody picked a machine at any point, so the shell you got was the one in the agent’s own sandbox. The rest was plumbing: raw terminal bytes in binary frames, control frames for resizing, status and closure, and a client for envd that mirrored the one our agent runtime already used for the same daemon. That client spoke the [Connect protocol](https://connectrpc.com/docs/protocol/), whose framing looks enough like gRPC to mislead you and is not interchangeable with it: our unary calls used plain JSON, and streaming calls wrapped each message in an envelope of one flags byte and a four-byte big-endian length.

A stored sandbox ID only proves that a sandbox once existed, so opening a terminal could mean waking or recovering one. The tunnel first made sure the runtime instance was there, probed for the sandbox, asked the agent’s toolkit to ensure one, and finally tried reviving the instance if it was still unreachable. Each rung reported a status frame, and each frame re-armed a three-minute watchdog on the client; a bring-up attempt that stopped receiving status frames and never became ready timed out when that window elapsed. Initial connection and automatic reconnection had their own limits before showing an error you could retry from. A reconnect is a new path to the sandbox, and it promises nothing about a command that was in flight when the connection dropped. One smaller courtesy: the terminal took focus when it came up only if nothing else had claimed it, because a slow bring-up should not steal keystrokes from someone typing a message.

## The file, or an explanation

Viewing files needed the same discipline. Our gateway kept a local index of files, but agents clone repositories and write files with their own tools, and none of that passes through a gateway write API. In August I changed the read path so that an index miss resolved only through threads the caller could access, and then read the actual file from a bounded set of candidate runtime bindings. A workspace you could not access looked exactly like one that did not exist, and binary content returned metadata instead of invalid text. Paths were treated as hostile on the way in, with backslashes and control characters rejected and up to eight rounds of decoding so that repeatedly encoded traversal could not slip past the check for `.` and `..` segments. The decoded form served only for that validation. The read itself used the name exactly as listed, because a literal percent sign is a legitimate character in a filename.

The more interesting decision was what to do when a read failed. The route gave the runtime read 2.5 seconds, used a listing to tell a missing file from an unavailable workspace, retried with backoff under a rate-limited allowance for waking the sandbox, and then answered 404 or 503. What it would not do was fall back to text reconstructed from the transcript. That fallback is tempting, because the transcript usually does contain what the agent wrote, and a plausible file looks friendlier than an error. But the file may have changed since then, through a formatter, a shell command or a later edit, and the moment you open a file to check the agent’s work is the worst possible moment to be shown the agent’s own account of it.

## Attachments go the other way

The same principle applies to what people send in. On 7 August I fixed the path that carries a person’s files to the agent, which had been built for text. A data URL ran into an 8 KB limit on URLs and came back as an input-too-large error, while a large paste could be refused with a 413 after the thread had already been created, and the client swallowed the failure. Even text that looked small enough was not, because the agent runtime’s coordinator accepted at most 2 MB per request and the input envelope repeated the text about five times.

By the end of August the transport kept at most 128 KB inline by default. Anything larger, and all media whatever its size, went through the sandbox’s files channel, and the agent got a note with the path and a hint to use PDF text extraction or image analysis. On the client, a file over 8 MB was refused at the moment of attaching, which held its base64 form to about 10.7 MB against a WebSocket frame limit of roughly 16 MB. A rejected send raised a visible error rather than appearing as a sent message. The document became a file in the workspace, and the conversation carried a pointer to it, which is the same relationship the rest of the workspace depends on.

## The part that got replaced

On 25 August, about a month after the dock landed, the classic web client was removed from the main branch. Its replacement, whose initial version a coding agent wrote with me as co-author, put a new presentation layer built from shadcn/ui and AI SDK Elements on top of the same shared protocol, state and logic. Its workspace did not use dockview at all. It arranged resizable left, right and bottom regions around the chat, and the dock components still sitting in the shared source were no longer what people saw in the browser.

The decisions underneath came through. The new workspace still rendered surfaces by ID, and it rendered each tab with `forceMount`, hiding inactive ones and marking them `inert`, so the component inside a hidden tab stayed mounted. That is the same lifetime decision expressed in different primitives. The gateway’s terminal tunnel and file route did not have to be rebuilt for it.

Choosing the docking library was worth doing, since it retired four mechanisms and a good deal of code. But it turned out to be the most replaceable thing in the workspace. What lasted were the answers to questions it never asked: what makes two panels the same panel, how long the thing inside a panel lives and what ends it, and what the panel should say when it cannot reach the real thing.
