Skip to content

feat(app): unmount a Work folder from its own menu, and colour it as destructive - #256

Merged
quickbeard merged 7 commits into
mainfrom
feat/unmount-project-menu
Aug 17, 2026
Merged

quickbeard merged 7 commits into
mainfrom
feat/unmount-project-menu

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

Follows the investigation of the Close item on a Work project. A project row there is a folder mounted into a sandbox, but Close only took the row out of this window's sidebar: the folder stayed mounted, stayed listed under Folders…, and the row could come back on the next launch. The gesture that looked most like removal did the least, while the real one was buried in a dialog.

What the menu does now

HomeProjectRow's trailing separator and item come from the fork-only ProjectRemovalMenuItems, so pages/home.tsx keeps one mount point instead of a branch:

row item
any project on a plain server Close — upstream's, unchanged
an extra mounted folder on a sandbox Unmount, in red
the folder the sandbox was created with nothing
a row whose folder is already unmounted Close

The sandbox's own project folder is fixed for its life, so a disabled item that can never be enabled says less than no item at all — deleting the sandbox is still the way to be rid of that one. The last row is the one the Folders… dialog deliberately leaves behind (sessions may reference it); without Close it could never be cleared away.

Unmount removes the folder from SandboxConfig.mounts via api.sandboxes.updateMounts, then drops the row with projects.remove(), not close() — the folder is leaving the sandbox, which is the "internal, non-user removal" that store documents. close() would also record it as user-closed, and src/sandbox/projects.ts honours that, so remounting the folder would leave the user with no project for it.

Also colours the sandbox Delete (Settings → Servers) red, which the generic and WSL server rows already were (#251).

Testing

packages/app: 691 unit + 97 browser tests pass, typecheck clean. projectRemoval is a pure function with cover for each row above, including the two "primary not known yet" cases.

Driven against the real desktop app over CDP, since this is a menu:

  • Work, codev-landing-page and codev-hub: New session / Edit project / Clear notifications / Unmount at rgb(241, 116, 113) with --menu-v2-fg: var(--v2-state-fg-danger). No Close, and no "Reveal in Finder" (a sandbox is not ServerConnection.local, so that was already correct).
  • Work, codev-backend (the sandbox's own folder): no removal item.
  • Code: New session / Edit project / Reveal in Finder / Clear notifications / Close, all in the normal colour.
  • Settings → Servers → the sandbox row: Delete at rgb(241, 116, 113).
  • Clicking Unmount: config.mounts 2 → 1, sandbox restarts, row gone, remaining rows correct. Re-mounting the folder brings both the mount and its project row back.

That run is also what caught a bug I would have shipped: ipcRenderer.invoke rejected the mount list with "An object could not be cloned" because config.mounts are Solid store proxies. The list is now rebuilt as plain objects before it crosses (the dialog is accidentally safe — it builds its list from strings). The sandbox's mounts were restored to their original pair afterwards.

Notes

  • One upstream file (pages/home.tsx): one import, one element. DIVERGENCES.md row added, including the warning not to pass the row's close handler as the unmount path.
  • New i18n key sandbox.mounts.unmount in en + es.
  • Unmounting restarts the sandbox on macOS and Linux, as it does from the Folders… dialog. There is no confirmation step — the red is the warning. Say the word if you want one; there's no confirm-dialog primitive in the app yet, so it would mean building one.

🤖 Generated with Claude Code

@quickbeard
quickbeard force-pushed the feat/unmount-project-menu branch from 07fd2e9 to df89d02 Compare August 17, 2026 10:48
quickbeard and others added 6 commits August 17, 2026 18:06
…destructive

A project row on Work is a folder mounted into a sandbox, but its menu
offered Close, which only took the row out of this window's sidebar. The
folder stayed mounted, stayed listed under Folders..., and the row could
come back on the next launch -- the removal gesture that looked most like
removal did the least, while the real one was buried in a dialog.

The row's trailing separator and item now come from the fork-only
ProjectRemovalMenuItems, so pages/home.tsx keeps one mount point instead
of a branch:

- a plain server renders upstream's Close, unchanged;
- an extra mounted folder gets Unmount, in the same danger colour the
  server and session menus use, which removes it from SandboxConfig.mounts
  and then drops the row;
- the folder the sandbox was created with offers neither. It is fixed for
  the sandbox's life, and a disabled item that can never be enabled says
  less than no item at all;
- a row whose folder is already gone -- unmounted from the dialog instead,
  which deliberately leaves the row behind -- keeps Close, or it could
  never be cleared away.

The row is dropped with projects.remove(), not the menu's close(): the
folder is leaving the sandbox, which is the internal, non-user removal
that store documents. close() would also record it as user-closed and the
seeding effect honours that, so remounting the folder would leave the user
with no project for it.

Also colours the sandbox Delete item (Settings -> Servers) red, which the
generic and WSL server rows already were.

Driven against the real app over CDP: Work rows offer Unmount in
rgb(241, 116, 113) and the sandbox's own folder offers nothing, Code still
offers Close, unmounting takes the folder out of the config and the row out
of the sidebar, and remounting brings both back. That run also caught the
IPC rejecting Solid store proxies ("An object could not be cloned"), so the
mount list is rebuilt as plain objects before it crosses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o the local server

A restart -- mounting or unmounting a folder -- takes a sandbox out of the
server list for a second or two. Session tabs already waited for it
(TargetServerRoute renders ServerAway), but the draft route did not: its
providers took `conn` as undefined and resolved to whichever server is
selected instead, so a New session tab belonging to a sandbox rendered the
local machine's composer. The sidebar said Work and the screen was Code --
and sending the prompt would have run it on the user's own machine.

ResolvedDraftRoute gets the same guard, so both server-scoped routes wait
in the same way and come back on their own.

ServerAway gains a LoaderV2 over its copy: this state always ends by
itself, so it should read as waiting rather than as something to act on.
Its description now says "this tab" rather than "this session" — the draft
route shares the screen and has no session yet.

Verified in the app with a sandbox draft open: while the sandbox is away
the pane is the waiting screen, spinner and all, with no composer; the
composer returns on its own, still on the sandbox, once it is back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…estarts

The waiting screen fixed the main pane, but the rest of the window still
treated a restarting sandbox as one that had gone. Two more places:

home-model's "keep the selection on a server that still exists" effect
moved it to the local server, and every focused* memo falls back to the
current server anyway — so the Work sidebar showed the local machine's
projects, pinned and recents for the second or two the sandbox was away,
then switched back. A retained server now holds the selection, and the
sections render empty instead of someone else's sessions: the Projects
area already says the sandbox is not running yet, and an empty list beside
that reads as waiting. `projects`, `recentlyClosed` and `indexedSessions`
answer before their fallbacks rather than through them, because each falls
back to the current server by a different route.

useWorkspaceScope lost its sandbox with the connection, so the tab strip
widened from one folder to every folder of the sandbox and then narrowed
again. The scope memo keeps its previous value while the server it names
is retained.

Verified in the app across a stop/start with Work selected: the strip
holds at three tabs (it went to six), pinned and recents stay empty (they
listed the local machine's sessions), and Code is unchanged -- its own
pinned and recents still populate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The block inherited `pb-42` from the session-not-found screen it was
modelled on. That padding is there to clear the composer underneath, but
this screen replaces the whole route and has nothing below it, so the
offset only pushed the block above the middle -- about 80px on a full
window.

Measured after: content centre and pane centre are the same pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The faint third line was the routing key (`sandbox:<name>`), not a name
anyone chose: the connection that would carry a display name is exactly
what is missing while the server is away, so there was nothing to resolve
it against. An internal id shown to someone waiting a second or two is
noise, and the tab they are looking at already says which server it is.

The prop goes with it — both call sites passed the key only for this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The block was centred vertically but sat hard against the sidebar. The
shell's <main> is `flex flex-col items-start`, so a child that does not
claim the width shrinks to its content -- 496px here, the description's
max-w-md plus padding -- and centres inside that instead of the pane. The
session page claims the width the same way; this one did not.

Measured after: content centre and pane centre are the same pixel on both
axes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard force-pushed the feat/unmount-project-menu branch from df89d02 to 6f4217c Compare August 17, 2026 11:06
…s ready

The retained-server guard covered a sandbox that is restarting, but not a
sandbox that does not exist yet: addSandbox puts it in the controller's
state only after create resolves, so while one is being created the Work
side has no server at all and the selection is still whatever Code left
behind. Every focused* fallback then resolved to the current server and
the sidebar filled with the local machine's projects, pinned and recents.

The guard is now "nothing the selected server owns can be on screen",
which covers both: the selection belongs to the side that is not showing,
or it is on this side and away.

The main area had the same gap from the other end. enter() returns early
when the side has no connected server, which left the previous side's
session on screen -- composer and all -- under a sidebar that had already
switched. It navigates home instead, the destination the no-project branch
below it already used.

Verified in the app: on Work with its sandbox down and the selection on a
Code project, the sidebar shows the "not running" message with Pinned and
Recents empty, and the pane shows the home screen's "Nothing here yet"
rather than the Code session. Code is unchanged, and Work's own content
comes back when the sandbox does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 36411be into main Aug 17, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the feat/unmount-project-menu branch August 17, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant