Skip to content
Merged
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@ file is the human-readable summary of what changed and why.

### Sandbox

- **Unmounting a folder takes its sessions with it** — a session you had
started in a mounted folder stayed open after you unmounted that folder, and
followed you into whatever folder you mounted next: the conversation was on
screen while Recents showed nothing, because the folder it belonged to was no
longer part of the sandbox. Unmounting a folder now closes its sessions and
drafts along with its project row, whether you unmount from the folder's own
menu or by removing it in the Mount project dialog. The sessions themselves
are still inside the sandbox; mounting the folder again brings them back.
- **Work sets its sandbox up for you** — going to Work without a sandbox used
to open the full Add sandbox form: a name, a folder, an image, and on a Mac a
CPU count and a memory size, all before anything could happen. The folder
question was the worst of them, because at that point you have not decided
what you want to work on. Work now asks for none of it. It creates a sandbox
called `default-sandbox` with the standard image and the standard limits, and
the only thing you can still choose is a guest image file of your own, for a
machine that cannot reach the download. Once it is ready, Work asks you to
mount a project — the folder on this computer you want to work on, which the
sandbox can then reach. That is the only folder it can reach: the sandbox's
own scratch folder is the app's business and is not shown to you anywhere,
including in the Mount project dialog. Settings → Servers keeps the full form
for a second sandbox, or one pointed at a particular folder from the start.
- **Sessions in a sandbox survive stopping it** — anything the sandbox had
written in the seconds before it stopped was thrown away: stop a sandbox, or
add a folder to one (which restarts it), or quit the app, and the sessions
Expand Down
3 changes: 2 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

39 changes: 30 additions & 9 deletions packages/app/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
HomeServerRow,
HomeUtilityNav,
} from "@/pages/home"
import { SandboxMountPrompt } from "@/sandbox/mount-prompt"
import { isPlaceholderSandbox } from "@/sandbox/projects"
import { SidebarSection } from "@/components/sidebar-section"
import { createInlineEditorController } from "@/pages/layout/inline-editor"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
Expand Down Expand Up @@ -115,7 +117,16 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
data-action={side() === "work" ? "work-mount-project" : "home-add-project"}
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
class={`titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted${
// Fork: icon-button-v2.css sets cursor: default on every icon
// button, and Tailwind utilities sit in @layer utilities, so
// the unlayered component rule wins without the important
// modifier. `enabled:` leaves the disabled state on the
// not-allowed cursor the variant already gives it. Work only:
// this is the button that mounts a folder into the sandbox,
// and Code's Add project is upstream's, untouched.
side() === "work" ? " enabled:cursor-pointer!" : ""
}`}
icon={<IconV2 name="folder-add-left" />}
disabled={global.servers.health[ServerConnection.key(sideServers()[0]!)]?.healthy === false}
onClick={() => {
Expand Down Expand Up @@ -144,14 +155,24 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
<Show
when={model.projects().length > 0}
fallback={
<HomeProjectEmpty
server={sideServers()[0]!}
recentlyClosed={model.recentlyClosed()}
homedir={model.homedir()}
chooseProject={model.chooseProject}
openRecentProject={(conn, directory) => model.addProjects(conn, [directory])}
language={language}
/>
// Fork: a sandbox whose only folder is a placeholder has
// nothing to list, and nothing upstream's picker could add —
// that picker browses the guest. Ask for a mount instead.
<Show
when={isPlaceholderSandbox(sideServers()[0]!)}
fallback={
<HomeProjectEmpty
server={sideServers()[0]!}
recentlyClosed={model.recentlyClosed()}
homedir={model.homedir()}
chooseProject={model.chooseProject}
openRecentProject={(conn, directory) => model.addProjects(conn, [directory])}
language={language}
/>
}
>
<SandboxMountPrompt server={sideServers()[0]!} />
</Show>
}
>
<HomeProjectList
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ export namespace ServerConnection {
// Extra host folders shared into the running guest, with the guest
// paths they resolved to.
mounts?: { path: string; guestPath: string }[]
// projectDir is a folder the app made up, not one the user chose, so
// it is never shown as a project — this sandbox's folders are its
// mounts and nothing else.
placeholderProject?: boolean
}
) &
Base
Expand Down
30 changes: 30 additions & 0 deletions packages/app/src/context/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { tabsInDirectory } from "@/workspace/tab-directory"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
Expand Down Expand Up @@ -378,6 +379,35 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
// Fork: every tab belonging to one mounted folder of one sandbox, for
// when that folder is unmounted. Its sessions stay in the guest, but the
// folder they ran in is no longer part of the sandbox, so a tab left
// behind is a conversation the user cannot continue and the sidebar does
// not list — inScope() shows a tab whose folder the sandbox no longer
// lists in *every* folder, which is right for a tab that has not learned
// its directory yet and wrong for one whose directory is gone for good.
// Modelled on removeServer: the container went away, so this is not a
// close the user can undo.
removeDirectory(input: { server: ServerConnection.Key; directory: string }) {
const going = tabsInDirectory(store, input.server, input.directory)
if (!going.length) return
const removed = going.map(tabKey)
const drafts = going.flatMap((tab) => (tab.type === "draft" ? [tab.draftID] : []))
const active = removed.includes(recentKey() ?? "") && location.pathname !== "/"
updateClosed((stack) => stack.filter((entry) => !removed.includes(tabKey(entry.tab))))
void startTransition(() => {
setStore((tabs) => tabs.filter((tab) => !removed.includes(tabKey(tab))))
// Home rather than a neighbouring tab: the folder that was on screen
// is gone, and home is where the sandbox's remaining folders are.
if (active) {
setRecentKey(undefined)
navigate("/")
}
})
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
for (const draftID of drafts) removeDraftPersisted(draftID)
},
removeSessions: (input: SessionTabsRemovedDetail) => {
const targetServer = input.server ?? server.key
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
Expand Down
14 changes: 10 additions & 4 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,12 @@ export const dict = {
"workspace.empty.starting": "Your sandbox is not running yet. It appears here once it is ready.",
"workspace.empty.stopped": "Your sandbox is stopped. Start it again from Settings → Servers.",
"sandbox.add.workIntro": "Work requires a sandbox environment to run sessions.",
"sandbox.addDefault.title": "Set up your sandbox",
"sandbox.addDefault.intro":
"Work runs your sessions in a sandbox: an isolated environment on this computer. Setting one up downloads its image, which takes a few minutes. You choose which of your folders it can reach once it is ready.",
"sandbox.addDefault.create": "Set up",
"sandbox.mountPrompt.body": "Your sandbox is ready. Mount a project to start working in it.",
"sandbox.mountPrompt.action": "Mount a project",
"sandbox.server.add": "Add sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
Expand Down Expand Up @@ -438,11 +444,11 @@ export const dict = {
"sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB",
"sandbox.add.creating": "Creating the sandbox\u2026 this takes a few minutes.",
"sandbox.mounts.title": "Mount project",
"sandbox.mounts.hint": "Folders mounted here are the only ones on this computer the sandbox can reach.",
"sandbox.mounts.hint": "Projects mounted here are the only folders on this computer the sandbox can reach.",
"sandbox.mounts.primary": "Project folder",
"sandbox.mounts.empty": "No extra folders are mounted.",
"sandbox.mounts.add": "Add folder...",
"sandbox.mounts.remove": "Remove folder",
"sandbox.mounts.empty": "No projects are mounted yet.",
"sandbox.mounts.add": "Add project...",
"sandbox.mounts.remove": "Remove project",
"sandbox.mounts.unmount": "Unmount",
"sandbox.mounts.save": "Save",
"sandbox.mounts.restartWarning":
Expand Down
15 changes: 11 additions & 4 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,12 @@ export const dict = {
"workspace.empty.starting": "Tu sandbox todavía no está en marcha. Aparecerá aquí cuando esté listo.",
"workspace.empty.stopped": "Tu sandbox está detenido. Vuelve a iniciarlo desde Ajustes → Servidores.",
"sandbox.add.workIntro": "Trabajo requiere un entorno de sandbox para ejecutar sesiones.",
"sandbox.addDefault.title": "Configura tu sandbox",
"sandbox.addDefault.intro":
"Trabajo ejecuta tus sesiones en un sandbox: un entorno aislado en este equipo. Configurarlo descarga su imagen, lo que tarda unos minutos. Tú eliges a qué carpetas tuyas puede acceder cuando esté listo.",
"sandbox.addDefault.create": "Configurar",
"sandbox.mountPrompt.body": "Tu sandbox está listo. Monta un proyecto para empezar a trabajar en él.",
"sandbox.mountPrompt.action": "Montar un proyecto",
"sandbox.server.add": "Añadir sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
Expand Down Expand Up @@ -431,11 +437,12 @@ export const dict = {
"sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB",
"sandbox.add.creating": "Creando el sandbox\u2026 esto tarda unos minutos.",
"sandbox.mounts.title": "Montar proyecto",
"sandbox.mounts.hint": "Las carpetas montadas aquí son las únicas de este equipo a las que el sandbox puede acceder.",
"sandbox.mounts.hint":
"Los proyectos montados aquí son las únicas carpetas de este equipo a las que el sandbox puede acceder.",
"sandbox.mounts.primary": "Carpeta del proyecto",
"sandbox.mounts.empty": "No hay carpetas adicionales montadas.",
"sandbox.mounts.add": "Añadir carpeta...",
"sandbox.mounts.remove": "Quitar carpeta",
"sandbox.mounts.empty": "Aún no hay proyectos montados.",
"sandbox.mounts.add": "Añadir proyecto...",
"sandbox.mounts.remove": "Quitar proyecto",
"sandbox.mounts.unmount": "Desmontar",
"sandbox.mounts.save": "Guardar",
"sandbox.mounts.restartWarning":
Expand Down
145 changes: 145 additions & 0 deletions packages/app/src/sandbox/dialog-add-default.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@opencode-ai/ui/toast"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import type { SandboxConfig } from "./types"
import { useSandboxes } from "./context"
import { sandboxCreatePhase } from "./create-phase"
import { createSandboxImageField } from "./image-field"

// The sandbox Work offers when there is none: everything DialogAddSandbox asks
// for has an answer the app can give, so it asks for none of it. The name, the
// folder and the limits are the main process's business (main/sandbox/ipc.ts);
// the image stays because supplying one is a real choice on a machine that
// cannot reach the storage host.
//
// DialogAddSandbox is untouched and still reachable from Settings → Servers —
// that is where a second sandbox, or one pointed at a particular folder, gets
// made.
export function DialogAddDefaultSandbox(props: { onCreated?: (config: SandboxConfig) => void }) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const sandboxes = useSandboxes()
const api = platform.sandboxes!
const [store, setStore] = createStore({ adding: false })
const image = createSandboxImageField()

const availability = () => sandboxes.availability.data
const unsupportedMessage = () => {
const value = availability()
return value && !value.supported ? value.message : null
}
const warning = () => {
const value = availability()
return value?.supported ? value.warning : null
}

const createJob = () => sandboxes.state.data?.jobs.find((entry) => entry.kind === "create")
const createPhase = () => sandboxCreatePhase({ adding: store.adding, job: createJob() })
const downloadProgress = () => (createPhase() === "downloading" ? createJob()?.progress : undefined)
const statusMessage = () => {
const phase = createPhase()
if (phase === "queued") return language.t("sandbox.job.queued")
if (phase === "creating") return language.t("sandbox.add.creating")
return null
}

const create = async () => {
if (store.adding || availability()?.supported === false || !image.usable()) return
setStore("adding", true)
try {
// Resolves undefined when the download was cancelled: keep the dialog
// open so the user can try again rather than dropping them back on Code
// with nothing to show for it.
const created = await api.addDefault(image.path())
if (created) {
props.onCreated?.(created)
dialog.close()
}
} catch (error) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
} finally {
setStore("adding", false)
}
}

// Its own channel rather than api.cancel(name): the name belongs to the main
// process, and a sandbox being created is not in the sandbox list yet — the
// controller only publishes it once the create has finished — so there is
// nothing here to read it off.
const cancel = () => {
if (!store.adding) {
dialog.close()
return
}
void api.cancelDefault()
}

return (
<Dialog fit>
<DialogHeader hideClose={true}>
<DialogTitleGroup
title={language.t("sandbox.addDefault.title")}
description={language.t("sandbox.addDefault.intro")}
/>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex flex-col gap-3 px-4 pt-3 pb-1">
<Show
when={availability()?.supported !== false}
fallback={<p class="text-14 text-v2-text-text-muted">{unsupportedMessage()}</p>}
>
{/* warn-never-block: the warning is shown, creation stays enabled */}
<Show when={warning()}>{(text) => <p class="text-12 text-v2-text-text-muted">{text()}</p>}</Show>
<image.Field disabled={store.adding} />
<Show when={downloadProgress()}>
{(current) => (
<div class="flex flex-col gap-1.5">
<div class="h-1 w-full overflow-hidden rounded-full bg-v2-background-bg-layer-04">
<div
class="h-full rounded-full bg-v2-icon-icon-base transition-[width] duration-200"
style={{ width: `${Math.min(100, (current().bytes / current().totalBytes) * 100)}%` }}
/>
</div>
<p class="text-12 text-v2-text-text-muted">
{language.t("sandbox.add.downloadProgress", {
done: Math.round(current().bytes / 1024 / 1024),
total: Math.round(current().totalBytes / 1024 / 1024),
})}
</p>
</div>
)}
</Show>
<Show when={statusMessage()}>{(text) => <p class="text-12 text-v2-text-text-muted">{text()}</p>}</Show>
</Show>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={cancel}>
{language.t("common.cancel")}
</ButtonV2>
<Show when={availability()?.supported !== false}>
<ButtonV2
variant={store.adding ? "loading" : "contrast"}
disabled={!store.adding && !image.usable()}
onClick={() => void create()}
>
<Show when={store.adding} fallback={language.t("sandbox.addDefault.create")}>
<LoaderV2 />
</Show>
</ButtonV2>
</Show>
</DialogFooter>
</Dialog>
)
}
Loading
Loading