Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ file is the human-readable summary of what changed and why.

### Sandbox

- **CoDev tells you when a new sandbox version is out, and keeps your work when
you take it** — a sandbox ran the version it was created with, for good: there
was no sign a newer one existed, and the only way to get it was **Reset**,
which throws away everything inside the sandbox on the way. Work now shows a
line when a new version is available, Settings → Servers marks the sandbox and
offers **Update sandbox**, and updating carries your sessions and the files in
the sandbox's home folder across to the new version. Software you installed
inside the sandbox yourself is replaced by what the new version ships, and the
folders you mounted from this computer are never touched. **Reset** is
unchanged and still means a clean sandbox. CoDev checks shortly after it
starts and every few hours after that; it never downloads a version on its own,
because that is over a gigabyte and the sandbox restarts to take it. On a Mac,
keeping your work needs a sandbox version new enough to know how — the update
says so plainly when it is not, rather than finding out afterwards. Updating
also clears the version it replaced off the disk, and deleting a sandbox now
clears the version it was running — a sandbox version is over a gigabyte, and
until now nothing ever removed one, so every version a computer had ever
downloaded stayed on it for good. A version another sandbox is still on is
kept.

- **CoDev installs WSL for you when a sandbox needs it** — a sandbox on Windows
runs inside WSL 2, and a computer without it got a dead end: the create-sandbox
dialog said WSL was not installed, offered nothing but **Cancel**, and told you
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ from the squash message first, or use a plain merge.
| `turbo.json` | Added task `"@codev/sandbox#test"` (no deps, no outputs) | CI's `bun turbo test` only runs test tasks registered here; the fork-only sandbox package's tests must gate PRs. | Keep our task entry; take upstream's other tasks. |
| Sandbox project label fallback: `packages/app/src/context/global.tsx` | `enrich()` (the per-server project-list decorator) gains one fork line: when the merged project record has no `name` yet, it fills it from the fork-only `sandboxProjectHostName(conn, worktree, primary)` (`packages/app/src/sandbox/projects.ts`), which maps a sandbox connection's guest paths back to host folder basenames — `conn.projectDir` for the primary (matched by the synced primary directory, or the `/workspace` literal before sync loads) and `conn.mounts[].path` for extras. Non-sandbox connections and named projects are untouched (the helper returns `undefined` / the branch is skipped). | A fresh sandbox project renders `displayName()` = name-or-worktree-basename, and inside the guest the primary worktree is `/workspace`, so every new sandbox flashed the label "workspace" until `adoptFolderName`'s server-side rename round-tripped (guest reports the project → `project.update` → `project.updated` event). The fallback is display-only; the durable rename through the server is unchanged and takes precedence the moment it lands. | Keep the one `if (!base.name)` line after upstream's `base` construction and take upstream's surrounding `enrich` changes. If upstream renames or restructures `enrich`, re-apply the fallback wherever project records are decorated for the sidebar list. The helper stays in the fork-only sandbox module — do not inline it. |
| Sandbox/WSL delete cleanup + confirmation: `packages/app/src/context/server.tsx`, `packages/app/src/components/dialog-select-server.tsx`, `packages/app/src/i18n/{en,es}.ts` | `server.tsx` gains `forget(key)` beside `remove(key)`: it deletes the persisted per-server `projects`/`lastProject`/`recentlyClosed` map entries for one key. `dialog-select-server.tsx` `handleRemove` (already diverged, see the renderer-slice row) additionally: for `sandbox:` and `wsl:` keys, awaits a confirmation through the fork-only `packages/app/src/sandbox/dialog-delete.tsx` / `packages/app/src/wsl/dialog-delete.tsx` (lazy-imported, pushed with `dialog.push` so it stacks on whatever dialog opened the menu) and returns without touching anything when declined; and calls `server.forget(key)` right after `server.remove(key)` for both key kinds. The controller gains a `useDialog()` binding for this. i18n gains the `sandbox.delete.*` and `wsl.delete.*` families. The two dialogs state different consequences on purpose: deleting a sandbox erases its disk (sessions, terminals, guest files — permanently; mounted host folders survive), while removing a WSL server destroys nothing — the app never unregisters distributions — it only forgets the app-side project state. | `remove()` only edits the stored HTTP server list; the project maps are keyed by server key and outlive the server, which is right for an HTTP server that still exists remotely but wrong for a deleted sandbox or removed distro — a recreated sandbox with the same name inherited the old projects (`server.projects["sandbox:<name>"]`) and, via the leftover `recentlyClosed`, silently refused to seed folders closed in the previous sandbox's life; a re-added distro likewise inherited its previous project list. And deleting a sandbox erases guest data permanently, so it must not be a one-click menu action; the confirmations live in `handleRemove` because the settings rows and the generic server list all funnel through it. | Keep the confirm-and-forget block at the top of `handleRemove` and the `forget` calls between `server.remove(key)` and the awaited platform removals; take upstream's surrounding changes. `forget` is fork-only API — if upstream reworks the persisted `server` record shape, re-point the three deletes at the new maps. Do not gate `forget` behind the platform removal succeeding: the tab/workspace cleanup ahead of it already treats the server as gone. HTTP removals deliberately keep their maps — the server still exists remotely and re-adding it should restore its projects. If the app ever gains real distro unregistration, the WSL dialog's "nothing is deleted" wording must change with it. |
| Sandbox image updates: `packages/app/src/components/app-sidebar.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/index.ts` | `app-sidebar.tsx` renders the fork-only `<SandboxUpdateNotice side={side()} />` directly under `<WorkspaceTabs/>`: one import, one element, and the component itself returns nothing unless the showing side is Work and some sandbox has a newer published image. `preload/index.ts` exposes `sandbox-update` (rebuild a sandbox on the current image, keeping the guest's home) and `sandbox-check-updates` (re-ask now, beside the main process's own slow sweep). i18n gains the `sandbox.update.*` family plus `sandbox.server.update` and `sandbox.job.update`. Everything else is fork-only: the verdict comes from `SandboxProvider.imageStatus` and the controller's `checkImageUpdates`, and the offer is rendered by `src/sandbox/{update-notice,dialog-update}.tsx` and the sandbox settings rows. | Reset already re-provisioned from the current manifest, so the upgrade existed but nothing ever said it was available — guests stayed on the image they were created with until someone happened to reset, which also wiped everything inside them. Telling the user where they actually use sandboxes is the point of the sidebar notice; Settings → Servers is where a sandbox is managed, not where anyone looks. | Keep the one element and its import, and take upstream's other sidebar changes. It must stay above the projects header — it is a notice about the side, not about a project. Do not merge it into the empty-state branches: it has to show on a Work side that is working perfectly well. If upstream restructures the preload surface, keep both channels; the renderer must never mint a sandbox id, so `update` takes the id it was given in the state and nothing else. |

## Known rebrand gaps

Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
HomeUtilityNav,
} from "@/pages/home"
import { SandboxMountPrompt } from "@/sandbox/mount-prompt"
import { SandboxUpdateNotice } from "@/sandbox/update-notice"
import { useSandboxMountDialog } from "@/sandbox/mount-dialog"
import { isPlaceholderSandbox } from "@/sandbox/projects"
import { SidebarSection } from "@/components/sidebar-section"
Expand Down Expand Up @@ -98,6 +99,10 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
{/* Fork: the Code/Work split. Everything below is unchanged — it just
sees the servers of the selected side. */}
<WorkspaceTabs />
{/* Fork: a newer guest image, offered where sandboxes are actually
used. Renders nothing on Code, and nothing on Work until there is
one. */}
<SandboxUpdateNotice side={side()} />
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5">
<div class="text-v2-text-text-muted [font-weight:530]">{language.t("home.projects")}</div>
<Show
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,20 @@ export const dict = {
"sandbox.server.start": "Start",
"sandbox.server.stop": "Stop",
"sandbox.server.reset": "Reset",
"sandbox.server.update": "Update sandbox",
"sandbox.server.mounts": "Mount project",
"sandbox.update.available": "Update available",
"sandbox.update.notice": "A new sandbox version is available.",
"sandbox.update.title": "Update sandbox",
"sandbox.update.confirm": "Rebuild {{name}} on the latest sandbox version?",
"sandbox.update.confirmVersion": "Rebuild {{name}} on sandbox version {{version}}?",
"sandbox.update.homeKept": "Your sessions and the files in the sandbox's home folder are kept.",
"sandbox.update.homeLost":
"This version cannot carry the sandbox's contents across: the sessions and files inside it will be gone.",
"sandbox.update.software": "Anything you installed inside the sandbox yourself is replaced by the new version.",
"sandbox.update.mounts": "The folders you mounted from this computer are untouched.",
"sandbox.update.download": "It downloads over a gigabyte, and the sandbox restarts when it finishes.",
"sandbox.update.button": "Update sandbox",
"sandbox.delete.title": "Delete sandbox",
"sandbox.delete.confirm": "Delete {{name}}? This cannot be undone.",
"sandbox.delete.disk":
Expand All @@ -424,6 +437,7 @@ export const dict = {
"sandbox.job.create": "Creating...",
"sandbox.job.destroy": "Removing...",
"sandbox.job.reset": "Resetting...",
"sandbox.job.update": "Updating...",
"sandbox.job.queued": "Waiting for another sandbox...",
"sandbox.add.namePlaceholder": "Sandbox name",
"sandbox.add.nameInvalid": "Start with a letter or number, then use letters, numbers, dots, dashes, or underscores.",
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,20 @@ export const dict = {
"sandbox.server.start": "Iniciar",
"sandbox.server.stop": "Detener",
"sandbox.server.reset": "Restablecer",
"sandbox.server.update": "Actualizar sandbox",
"sandbox.server.mounts": "Montar proyecto",
"sandbox.update.available": "Actualización disponible",
"sandbox.update.notice": "Hay una nueva versión del sandbox disponible.",
"sandbox.update.title": "Actualizar sandbox",
"sandbox.update.confirm": "¿Reconstruir {{name}} con la última versión del sandbox?",
"sandbox.update.confirmVersion": "¿Reconstruir {{name}} con la versión {{version}} del sandbox?",
"sandbox.update.homeKept": "Se conservan tus sesiones y los archivos de la carpeta personal del sandbox.",
"sandbox.update.homeLost":
"Esta versión no puede conservar el contenido del sandbox: se perderán las sesiones y los archivos que haya dentro.",
"sandbox.update.software": "Todo lo que hayas instalado dentro del sandbox lo reemplaza la nueva versión.",
"sandbox.update.mounts": "Las carpetas que montaste desde este equipo no se tocan.",
"sandbox.update.download": "Descarga más de un gigabyte y el sandbox se reinicia al terminar.",
"sandbox.update.button": "Actualizar sandbox",
"sandbox.delete.title": "Eliminar sandbox",
"sandbox.delete.confirm": "¿Eliminar {{name}}? Esto no se puede deshacer.",
"sandbox.delete.disk":
Expand All @@ -414,6 +427,7 @@ export const dict = {
"sandbox.job.create": "Creando...",
"sandbox.job.destroy": "Eliminando...",
"sandbox.job.reset": "Restableciendo...",
"sandbox.job.update": "Actualizando...",
"sandbox.job.queued": "Esperando a otro sandbox...",
"sandbox.add.namePlaceholder": "Nombre del sandbox",
"sandbox.add.nameInvalid":
Expand Down
61 changes: 61 additions & 0 deletions packages/app/src/sandbox/dialog-update.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Show } from "solid-js"
import { useLanguage } from "@/context/language"

// Confirms rebuilding a sandbox on a newer guest image. Worth a dialog rather
// than a menu item that just acts: the sandbox goes down for the length of a
// download measured in gigabytes, and what happens to what is inside it
// depends on the image — so the promise this makes is the one thing the user
// has to read before agreeing.
export function DialogSandboxUpdate(props: {
name: string
version?: string
preservesHome: boolean
onConfirm: () => void
}) {
const language = useLanguage()
const dialog = useDialog()

return (
<Dialog fit>
<DialogHeader hideClose={true}>
<DialogTitle>{language.t("sandbox.update.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex max-w-[440px] flex-col gap-3 px-4 pt-3 pb-1">
<p class="text-14">
{props.version
? language.t("sandbox.update.confirmVersion", { name: props.name, version: props.version })
: language.t("sandbox.update.confirm", { name: props.name })}
</p>
<ul class="flex list-disc flex-col gap-2 pl-5 text-12 text-v2-text-text-muted">
<Show when={props.preservesHome} fallback={<li>{language.t("sandbox.update.homeLost")}</li>}>
<li>{language.t("sandbox.update.homeKept")}</li>
</Show>
<li>{language.t("sandbox.update.software")}</li>
<li>{language.t("sandbox.update.mounts")}</li>
<li>{language.t("sandbox.update.download")}</li>
</ul>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
{/* Danger only when the guest's contents cannot come along: that is the
case where agreeing throws something away. */}
<ButtonV2
variant={props.preservesHome ? "contrast" : "danger"}
onClick={() => {
props.onConfirm()
dialog.close()
}}
>
{language.t("sandbox.update.button")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
11 changes: 11 additions & 0 deletions packages/app/src/sandbox/image-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { SandboxImageStatus } from "./types"

// The only image verdict worth acting on. Everything else — up to date, a
// custom image the manifest has no say over, or a sandbox nothing has answered
// for yet — offers nothing to press.
//
// Its own module so the sidebar's update notice does not have to import the
// settings rows to ask the question.
export function sandboxImageUpdate(image: SandboxImageStatus | undefined) {
return image?.kind === "update" ? image : null
}
31 changes: 30 additions & 1 deletion packages/app/src/sandbox/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useMutation } from "@tanstack/solid-query"
import fuzzysort from "fuzzysort"
import { type Accessor, For, Show, createMemo } from "solid-js"
import { type Accessor, For, Show, createMemo, onMount } from "solid-js"
import type { useServerManagementController } from "@/components/dialog-select-server"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { showToast } from "@/utils/toast"
import { useSandboxes } from "./context"
import { sandboxImageUpdate } from "./image-update"
import { DialogSandboxMounts } from "./dialog-mounts"
import { DialogSandboxUpdate } from "./dialog-update"
import type { SandboxRuntime } from "./types"

type Controller = ReturnType<typeof useServerManagementController>
Expand Down Expand Up @@ -54,6 +56,10 @@ export function SandboxServerSettings(props: {
const sandboxes = useSandboxes()
const api = platform.sandboxes

// The main process sweeps on a slow heartbeat; this is the screen where the
// answer is on display, so it asks once on the way in.
onMount(() => void api?.checkUpdates())

const request = useMutation(() => ({
mutationFn: (action: () => Promise<unknown>) => action(),
onError: (error) =>
Expand All @@ -73,6 +79,7 @@ export function SandboxServerSettings(props: {
// sandbox's; either way its lifecycle actions must wait.
const job = () => sandboxes.state.data?.jobs.find((entry) => entry.id === item.config.id)
const busy = () => !!job()
const update = () => sandboxImageUpdate(item.image)
const meta = () => {
const current = job()
if (current)
Expand All @@ -82,6 +89,18 @@ export function SandboxServerSettings(props: {
if (item.runtime.kind === "failed") return item.runtime.message
return `${item.config.backend} · ${language.t(`sandbox.status.${item.runtime.kind}`)}`
}
const confirmUpdate = () => {
const available = update()
if (!available || !api) return
dialog.push(() => (
<DialogSandboxUpdate
name={item.config.name}
version={available.version}
preservesHome={available.preservesHome}
onConfirm={() => request.mutate(() => api.update(key))}
/>
))
}
return (
<div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead">
Expand All @@ -92,6 +111,11 @@ export function SandboxServerSettings(props: {
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
{language.t("sandbox.server.label")}
</span>
<Show when={update()}>
<span class="shrink-0 rounded-[3px] border border-v2-border-border-strong px-1 py-0.5 text-[9px] leading-none text-v2-text-text-accent">
{language.t("sandbox.update.available")}
</span>
</Show>
</span>
<span class="settings-v2-servers-meta">{meta()}</span>
</div>
Expand Down Expand Up @@ -122,6 +146,11 @@ export function SandboxServerSettings(props: {
{language.t("sandbox.server.stop")}
</MenuV2.Item>
</Show>
<Show when={update()}>
<MenuV2.Item disabled={busy()} onSelect={confirmUpdate}>
{language.t("sandbox.server.update")}
</MenuV2.Item>
</Show>
<MenuV2.Item disabled={busy()} onSelect={() => api && request.mutate(() => api.reset(key))}>
{language.t("sandbox.server.reset")}
</MenuV2.Item>
Expand Down
Loading
Loading