Skip to content
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,38 @@ file is the human-readable summary of what changed and why.

### Desktop / Web UI

- **A folder on Work is unmounted from its own menu** — the menu on a **Work**
project offered **Close**, which only took the row out of the sidebar while
the sandbox went on holding the folder: the folder was still mounted, still
listed under **Folders…**, and the row could come back on the next launch.
That entry is now **Unmount**, in red, and it does what it says — the folder
leaves the sandbox, and the row goes with it. Mounting the folder again
brings its project back. The folder the sandbox was created with is fixed for
its life, so its menu offers neither; deleting the sandbox is still the way
to be rid of that one. On macOS and Linux unmounting restarts the sandbox, as
it does from the Folders… dialog. **Code** keeps Close, unchanged.
- **Work stops borrowing Code's sessions before its sandbox is ready** — while
a sandbox is being created or started, the **Work** side has no server of its
own, and the sidebar filled **Pinned** and **Recents** with the local
machine's sessions while the main area kept whatever Code session was open,
composer and all. Both belonged to the side you had just left. The sections
now stay empty next to the message explaining the sandbox is not ready, and
switching to a side with nothing connected lands on its home screen instead
of the other side's session.
- **A restarting sandbox no longer shows you the wrong machine** — mounting or
unmounting a folder restarts the sandbox, and while it was away a **New
session** tab belonging to it quietly rendered the local machine's composer:
the sidebar said Work, the screen was Code, and sending the prompt would have
run it on your own machine. Those tabs now wait, with a spinner, and come back
on their own once the sandbox does — the same screen open sessions already
showed. The sidebar went the same way: **Pinned** and **Recents** switched to
the local machine's sessions for those seconds and then switched back, and the
tab strip briefly widened to every folder of the sandbox. Both now hold still
— the sections stay empty beside "Your sandbox is not running yet", and the
strip keeps the folder you were in.
- **Deleting a sandbox now looks destructive** — **Delete** in a sandbox's menu
(Settings → Servers) sat in the same colour as Reset and Mount project. It is
now red, matching ordinary and WSL servers.
- **A new sandbox's project no longer flashes "workspace"** — a sandbox's
project folder is mounted inside the guest at a fixed path, and until the
app finished renaming the project after your folder — a round trip through
Expand Down
3 changes: 2 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

39 changes: 25 additions & 14 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ function TargetServerRoute(props: ParentProps) {
// reachable, and mounting the providers with no connection would send
// this session's requests to whatever server is selected instead. Wait
// for it: the route swaps back the moment it is connected again.
<Show when={conn() || !server.retained.includes(current)} fallback={<ServerAway name={current} />}>
<Show when={conn() || !server.retained.includes(current)} fallback={<ServerAway />}>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
Expand Down Expand Up @@ -234,25 +234,36 @@ function DraftRoute() {

function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const server = useServer()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory
const serverKey = () => props.draft.server

return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ModelsProvider directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
{/* Fork: the same wait TargetServerRoute does, for the draft half of the
route table. Without it a draft on a restarting sandbox mounts its
providers with no connection, which resolves to whichever server is
selected instead: the Work side quietly renders the local machine's
new-session screen, and sending the prompt would have run it there. */}
<Show
when={conn() || !server.retained.includes(props.draft.server)}
fallback={<ServerAway />}
>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ModelsProvider directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
</Show>
)
}
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ export const dict = {
"sandbox.mounts.empty": "No extra folders are mounted.",
"sandbox.mounts.add": "Add folder...",
"sandbox.mounts.remove": "Remove folder",
"sandbox.mounts.unmount": "Unmount",
"sandbox.mounts.save": "Save",
"sandbox.mounts.restartWarning":
"Saving restarts the sandbox. Files on disk are kept; running terminals and in-flight work in the sandbox stop.",
Expand Down Expand Up @@ -723,7 +724,7 @@ export const dict = {
// that gap, so its sessions need something truer to say than "not found".
"session.error.disconnected": "Waiting for this server",
"session.error.disconnected.description":
"This session is on a server that is not running right now. It comes back on its own once the server does.",
"This tab is on a server that is not running right now. It comes back on its own once the server does.",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change",
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ export const dict = {
"sandbox.mounts.empty": "No hay carpetas adicionales montadas.",
"sandbox.mounts.add": "Añadir carpeta...",
"sandbox.mounts.remove": "Quitar carpeta",
"sandbox.mounts.unmount": "Desmontar",
"sandbox.mounts.save": "Guardar",
"sandbox.mounts.restartWarning":
"Al guardar se reinicia el sandbox. Los archivos en disco se conservan; las terminales abiertas y el trabajo en curso dentro del sandbox se detienen.",
Expand Down Expand Up @@ -703,7 +704,7 @@ export const dict = {
"session.error.notFound.closeTab": "Cerrar pestaña",
"session.error.disconnected": "Esperando a este servidor",
"session.error.disconnected.description":
"Esta sesión está en un servidor que no está en ejecución ahora mismo. Vuelve por sí sola cuando el servidor lo haga.",
"Esta pestaña está en un servidor que no está en ejecución ahora mismo. Vuelve por sí sola cuando el servidor lo haga.",
"session.error.serverConnection": "No se puede conectar a este servidor",
"session.review.filesChanged": "{{count}} Archivos Cambiados",
"session.review.change.one": "Cambio",
Expand Down
62 changes: 50 additions & 12 deletions packages/app/src/pages/home-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { sideForServerKey } from "@/workspace/side"
import { useWorkspaceFolder } from "@/workspace/tabs"
import { useWorkspaceFolder, useWorkspaceSide } from "@/workspace/tabs"
import { archiveHomeSession } from "./home-session-archive"
import { findOpenDraft } from "./home-draft-reuse"
import { shouldOpenSessionInBackground } from "./home-session-open"
Expand Down Expand Up @@ -110,22 +110,48 @@ export function createHomeModel() {
const pickDirectory = useDirectoryPicker()
const pinned = usePinnedSessions()
const folder = useWorkspaceFolder()
const side = useWorkspaceSide()

const selection = layout.home.selection

const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
)
// Fork: nothing the selected server owns can be on screen. Two ways to get
// there, and both used to fall through to the *current* server, which put the
// local machine's projects, pinned and recents under a Work sidebar:
//
// - the selection belongs to the side that is not showing. Work has no
// server of its own until its first sandbox is ready, so the selection is
// still whatever Code left behind while one is being created or started;
// - it is on this side and exists, but is not connected — a sandbox
// restarting to apply a mounted folder is gone for a second or two.
//
// Nothing is a better answer than someone else's sessions: the Projects area
// already says the sandbox is not ready, and the sections read empty beside
// it. Both collapse to false on the web build, where every server is Code.
const focusedUnavailable = createMemo(() => {
if (sideForServerKey(selection().server) !== side()) return true
return (
!global.servers.list().some((conn) => ServerConnection.key(conn) === selection().server) &&
server.retained.includes(selection().server)
)
})
const focusedServer = createMemo(() => {
if (focusedUnavailable()) return undefined
return global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current
})
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const homeSessions = () => focusedSync().homeSessions
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
// `layout.projects` is the *current* server's, so the unavailable case has to
// answer before the fallback rather than through it.
const projects = createMemo(() =>
focusedUnavailable() ? [] : (focusedServerCtx()?.projects.list() ?? layout.projects.list()),
)
const recentlyClosed = createMemo(() =>
focusedUnavailable() ? [] : (focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed()),
)
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
// Per session, not a memo over the server config: `share` only appears on the
Expand Down Expand Up @@ -183,12 +209,17 @@ export function createHomeModel() {
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
// Unavailable, `homeSessions()` is the current server's cache and its query
// is disabled, so this would hand the sidebar that server's sessions — the
// same leak `projects` guards against, one layer down.
const indexedSessions = createMemo(() =>
retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Date.now(),
),
focusedUnavailable()
? []
: retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
Expand Down Expand Up @@ -217,9 +248,16 @@ export function createHomeModel() {

// Keeps the selection pointing at a server that still exists. Idempotent, so
// it is safe with both the sidebar and the home screen holding a model.
//
// Fork: existing, not connected. A sandbox restarting to apply a mount is
// absent from the list for a second or two, and moving the selection off it
// sent the sidebar to the local server — then back again, which is a jump the
// user did not ask for either way. Retained servers hold their selection and
// the sections render empty until they answer again.
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
if (server.retained.includes(selection().server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
Expand Down
15 changes: 11 additions & 4 deletions packages/app/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { type ServerHealth } from "@/utils/server-health"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { createHomeModel, isBackgroundOpen, type HomeSessionRecord, type OpenSessionOptions } from "./home-model"
import { ProjectRemovalMenuItems } from "@/sandbox/project-menu"
import { showToast } from "@/utils/toast"
import { fileManagerApp } from "@/utils/file-manager"

Expand Down Expand Up @@ -852,10 +853,16 @@ function HomeProjectRow(props: {
>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.server, props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
{/* Fork: on Work a project row is a folder mounted into a
sandbox, so its removal is Unmount rather than the
client-side Close. The separator belongs to the item, since
the sandbox's own project folder offers neither. Code is
unchanged. */}
<ProjectRemovalMenuItems
server={props.server}
worktree={props.project.worktree}
close={() => props.closeProject(props.server, props.project.worktree)}
/>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
Expand Down
59 changes: 58 additions & 1 deletion packages/app/src/sandbox/mounts-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"
import { validateSandboxMounts } from "./mounts-model"
import type { ServerConnection } from "@/context/server"
import { projectRemoval, validateSandboxMounts } from "./mounts-model"

test("accepts distinct sibling folders", () => {
expect(validateSandboxMounts("/home/user/project", ["/home/user/docs", "/srv/data"])).toBeNull()
Expand Down Expand Up @@ -35,3 +36,59 @@ test("rejects nesting in either direction, against the project dir and other mou
test("rejects UNC paths", () => {
expect(validateSandboxMounts("C:\\p", ["\\\\server\\share"])).toEqual({ code: "unc", path: "\\\\server\\share" })
})

const sandbox = (mounts: { path: string; guestPath: string }[]): ServerConnection.Any => ({
displayName: "test1",
type: "sidecar",
variant: "sandbox",
name: "test1",
projectDir: "/Users/me/backend",
mounts,
http: { url: "http://127.0.0.1:1" },
})

const local: ServerConnection.Any = {
displayName: "Local",
type: "sidecar",
variant: "base",
http: { url: "http://127.0.0.1:2" },
}

test("a plain server keeps Close", () => {
expect(projectRemoval({ server: local, worktree: "/Users/me/backend", primary: undefined })).toEqual({
kind: "close",
})
})

test("a mounted folder offers Unmount, carrying the host path to remove", () => {
const server = sandbox([{ path: "/Users/me/hub", guestPath: "/mnt/hub" }])
expect(projectRemoval({ server, worktree: "/mnt/hub", primary: "/workspace" })).toEqual({
kind: "unmount",
path: "/Users/me/hub",
})
})

// The folder the sandbox was created with is fixed for its life, so the menu
// offers nothing rather than an item that can never do anything.
test("the sandbox's own project folder offers neither", () => {
const server = sandbox([{ path: "/Users/me/hub", guestPath: "/mnt/hub" }])
expect(projectRemoval({ server, worktree: "/workspace", primary: "/workspace" })).toBeNull()
})

// Unmounting from the Folders dialog leaves the row behind on purpose —
// sessions may still reference it — so Close has to stay reachable for it.
test("a row whose folder is no longer mounted falls back to Close", () => {
const server = sandbox([{ path: "/Users/me/hub", guestPath: "/mnt/hub" }])
expect(projectRemoval({ server, worktree: "/mnt/gone", primary: "/workspace" })).toEqual({ kind: "close" })
})

// The primary is only known once the guest has reported its own directory; a
// row must not silently become unmountable in that gap.
test("with no primary known yet, a mounted folder still resolves by its mount", () => {
const server = sandbox([{ path: "/Users/me/hub", guestPath: "/mnt/hub" }])
expect(projectRemoval({ server, worktree: "/mnt/hub", primary: undefined })).toEqual({
kind: "unmount",
path: "/Users/me/hub",
})
expect(projectRemoval({ server, worktree: "/workspace", primary: undefined })).toEqual({ kind: "close" })
})
30 changes: 30 additions & 0 deletions packages/app/src/sandbox/mounts-model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { ServerConnection } from "@/context/server"
import { pathKey } from "@/utils/path-key"

// Pure validation for the manage-mounts dialog, mirroring validateMountPaths
// in @codev/sandbox (the app must never import that node-flavored package —
// see types.ts). The main process re-validates on save; this copy exists for
Expand All @@ -24,6 +27,33 @@ export function validateSandboxMounts(projectDir: string, paths: string[]): Sand
return null
}

// What a project row's menu offers as its way of getting rid of the project.
// On Code that is upstream's Close, which only takes the row out of this
// window's list. On Work a project row *is* a mounted folder, so hiding the
// row while the sandbox went on holding the folder was the confusing half of
// the old behaviour — there, removal means unmounting.
//
// `primary` is the folder the sandbox was created with, as the guest reports
// its own working directory. It is fixed for the sandbox's life, so it offers
// nothing: deleting the sandbox is the only way to be rid of it, and a
// disabled item that can never be enabled says less than no item at all.
//
// A folder that is neither the primary nor currently mounted still gets Close.
// That is the row left behind when a folder is unmounted from the Folders
// dialog instead — sessions may still reference it, so it is not removed for
// the user, and without this it could never be removed at all.
export function projectRemoval(input: {
server: ServerConnection.Any
worktree: string
primary: string | undefined
}): { kind: "close" } | { kind: "unmount"; path: string } | null {
if (input.server.type !== "sidecar" || input.server.variant !== "sandbox") return { kind: "close" }
if (input.primary && pathKey(input.worktree) === pathKey(input.primary)) return null
const mount = input.server.mounts?.find((entry) => pathKey(entry.guestPath) === pathKey(input.worktree))
if (!mount) return { kind: "close" }
return { kind: "unmount", path: mount.path }
}

// Windows paths compare case-insensitively; detection is by shape (drive
// letter or backslash) so the model needs no platform flag.
function comparableMountPath(path: string) {
Expand Down
Loading
Loading