Repository navigation
fix(app): don't kill the renderer when a server leaves the list - #219
Merged
Merged
Conversation
Removing a sandbox that had a session tab open took the whole window down with "Notification server not found: sandbox:<name>". Sidecar connections are published only while healthy — readySandboxConnections and readyWslConnections list a server only in its ready state — so removing, restarting, or crashing one drops its key while its tabs are still mounted. useSessionTabAvatarState.unread then called notification.ensureServerState on the missing key, which throws, and the throw escaped a render-time memo. Guard unread on the connection, the way hasPermissions/hasQuestions/loading already guard on sync. A tab whose server is gone shows no unread marker. handleRemove also awaited the platform removal — deleting a sandbox disk, so seconds — before closing the tabs, guaranteeing a window where live tabs point at a server the list no longer has. Close the tabs first; that also keeps cleanup complete, since the tab-pruning effect that would otherwise collect them leaves the drafts' persisted state behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
quickbeard
pushed a commit
that referenced
this pull request
Aug 13, 2026
Deleting a sandbox from Settings while the Work side was open killed the renderer with "Permission server not found: sandbox:<name>". createMemo computes eagerly, so permissionState ran the moment useSessionTabAvatarState did. For a tab drawn after its server had already disappeared that is during construction — before the sync()/connection() guards downstream get a chance — and ensureServerState throws on a key it cannot find. #219 guarded the notification lookup on the same hook but left this one, which is reached earlier. Gate it on connection() too, and let effectiveSide fall back to Code once the last sandbox is deleted so the sidebar is not left sitting on a side with nothing in it. An unloaded sandbox list stays "unknown" rather than "none", or every launch would bounce off Work before the list arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
quickbeard
added a commit
that referenced
this pull request
Aug 13, 2026
* feat(app): split the sidebar into Code and Work Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. The sidebar now opens on two tabs: Code, every non-sandbox server (local sidecar, WSL, remote, SSH), and Work, the sandboxes. Each side keeps its own projects, pinned and recent sessions, and its own session tabs — the titlebar strip, its reorder and tab.prev/tab.next all follow the visible side. Clicking a tab returns you to that side's last session rather than opening a draft, falling back to a draft and then to the home screen when a side has no history and nothing to open. The Home button goes with it: it opened a draft, not a home screen, which is exactly the behaviour the tabs replace. Its mod+b command goes too. Switching to Work with no sandbox reuses DialogAddSandbox rather than growing a second one, and only switches if a sandbox actually gets created — cancel leaves you on Code. A side can now be empty (a sandbox joins the server list only once it is ready), so the sidebar gains an empty state; every branch under it indexes servers[0]. Logic lives in the fork-only workspace module and is unit tested; the upstream files carry wiring only, and every section below the tabs is the same component reading a filtered list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(app): survive a tab whose server is gone at construction Deleting a sandbox from Settings while the Work side was open killed the renderer with "Permission server not found: sandbox:<name>". createMemo computes eagerly, so permissionState ran the moment useSessionTabAvatarState did. For a tab drawn after its server had already disappeared that is during construction — before the sync()/connection() guards downstream get a chance — and ensureServerState throws on a key it cannot find. #219 guarded the notification lookup on the same hook but left this one, which is reached earlier. Gate it on connection() too, and let effectiveSide fall back to Code once the last sandbox is deleted so the sidebar is not left sitting on a side with nothing in it. An unloaded sandbox list stays "unknown" rather than "none", or every launch would bounce off Work before the list arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(app): switch to Work as soon as the sandbox is created Creating the first sandbox from the Work gate left the app on Code until the tabs were toggled by hand. Three things had to line up and none of them did. The switch checked the sandbox list after the dialog closed, but that list arrives on its own IPC event and is still empty for a beat after add() resolves. DialogAddSandbox now reports the sandbox it created and the gate switches on that. effectiveSide collapsed Work to Code whenever the sandbox list was empty, which is the same stale read from the other direction — and also fires on every launch before the list loads. An empty Work side is legible on its own; WorkspaceEmpty says so and offers to create one. Selection was corrected only at the moment of a switch, but a sandbox joins the server list only once it has started, so a sandbox created seconds earlier was never selected: the projects list showed the sandbox while Pinned and Recents still read the local server. Correcting it from an effect covers the sandbox arriving late, which is what toggling the tabs by hand was really doing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(app): return to Code when the last sandbox is deleted Deleting the only sandbox left the user on a Work side with nothing in it, still looking at a session belonging to the server that had just gone away. Driven from the removal itself rather than from watching the sandbox list. That list updates over IPC a beat after the delete resolves, so a reactive "no sandboxes means go to Code" rule reads as true both before the list has loaded and in the moment after the first sandbox is created — which is what made the earlier attempt at this bounce the user off Work twice over. Asking "does removing this key empty the side?" against the list as it stands before the removal has no such window. It runs beside the tab cleanup, ahead of the awaited platform removal, so the switch lands immediately rather than after a delete that takes as long as erasing a disk. Reuses the same enter() as selecting the tab, so Code lands on its last session rather than wherever the deleted sandbox left the route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Quickbeard <quickbeard@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Removing a sandbox that had a session tab open takes the whole window down:
Sidecar connections are published only while healthy —
readySandboxConnectionsandreadyWslConnectionsput a server in the list only in itsreadystate. So removing, restarting, or crashing one drops its key while its session tabs are still mounted.useSessionTabAvatarState.unreadthen callednotification.ensureServerState(server()), which throws by design on a key it cannot find. Because that happens inside a render-time memo, the throw escapes as a fatal renderer error rather than a contained failure.Two things made it reachable:
unreadhad no guard. The same hook already tolerates a missing connection everywhere else —hasPermissions,hasQuestions, andloadingall bail whensync()is undefined.unreadwas the one path assuming the server still existed.handleRemoveawaitedplatform.sandboxes.remove(key)— which deletes the rootfs, seconds for a multi-GB sandbox — and only then calledtabs.removeServer(key). The connection leaves the list the instant that resolves, so there was a guaranteed window with live tabs pointing at a dead key.Fix
project-avatar-state.ts—unreadgates onconnection()like the sync paths do. A tab whose server is gone shows no unread marker instead of killing the window.dialog-select-server.tsx— close tabs and switch the active server before awaiting either platform removal. This also makes cleanup complete: the tab-pruning effect intabs.tsxthat would otherwise collect these tabs does not discard their persisted draft state, whileremoveServerdoes.Scope
This is an upstream bug, not sandbox-specific — WSL servers go through the identical
readyWslConnections→ensureServerStatepath, so removing or restarting one with a tab open crashes the same way. The guard covers both.DIVERGENCES.mdsays to drop our row in favour of an upstream fix if one lands.Verification
bun typecheckandbun run test:unit(657 pass, 0 fail) clean inpackages/app.No unit test added: the hook depends on the global, notification, and permission contexts, so covering it would mean standing up mocks for all three, which the repo's testing guidance steers away from. The change is a guard on an existing memo that the neighbouring memos already apply.
Not addressed here
Uncaught (in promise) Error: Path already exists: ".cache/codev/"appears in the same log but is unrelated — it comes from the third-party@pierre/treespath store behind the file tree, and it does not take the window down.🤖 Generated with Claude Code