Skip to content

Commit 50d375a

Browse files
quickbeardclaude
andauthored
feat(app): set the sandbox up for the user when Work needs one (#260)
* feat(app): set the sandbox up for the user when Work needs one Reaching Work without a sandbox opened the full Add sandbox form: a name, a folder, an image, and on a Mac a CPU count and a memory size, before anything could happen. The folder question is the worst of them — at that moment the user has not decided what they want to work on, and answering it wrong is permanent, since a sandbox's project folder is fixed for its life. Work now opens a dialog that asks for none of it. The main process picks the name (codev-sandbox), makes the project folder, and leaves the limits unset so the backend default applies; the only thing left to choose is a custom guest image, which stays because supplying one is a real answer on a machine that cannot reach the storage host. Settings → Servers keeps the full form, for a second sandbox or one pointed at a particular folder from the start. The folder it makes is a placeholder the user never sees. It lives beside the app's other sandbox data, under %LOCALAPPDATA% on Windows rather than the Electron userData path: that resolves under %APPDATA%, which a roaming profile can point at a UNC share, and the WSL backend reaches a project folder through wslpath, which cannot translate one. SandboxConfig carries placeholderProject so the app knows to keep that folder out of the sidebar, out of the folder Work lands on when switching to it, and to delete it with the sandbox — destroy() clears the provider's directories but never a project folder, since the usual one belongs to the user. With no folder to show, the sidebar asks for one: upstream's empty-projects fallback offers a picker that browses the server it is pointed at, which for a sandbox is the guest's own home. The Folders dialog also opens by itself once the sandbox is created, so the common path is create then mount, and the prompt is what remains for a user who cancels it or comes back later. The image field moves to src/sandbox/image-field.tsx so both dialogs share it. It is the only field they have in common and the only one with behaviour worth getting wrong twice: a verdict has to belong to the path it was made about, and a picker failure has to be visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(app): mount a project when the user asks, not the moment a sandbox exists Three corrections to the one-click sandbox. The Mount project dialog listed the sandbox's project folder as a fixed row. For the sandbox Work creates that folder is app data — a path in Application Support the user did not choose and cannot remove — so the one dialog that still showed it was the one place they would ever meet it. It is hidden there now, like everywhere else; a sandbox pointed at a folder the user picked still shows it, since that is the folder it works on. Creating a sandbox no longer opens the Mount project dialog by itself. Two dialogs in a row is the app deciding what the user does next, and the sidebar prompt is already there when they want it. That leaves the prompt as the only route, so its lazy import moves back inline and open-mounts.tsx goes. "Mount a folder" is now "Mount a project", matching the dialog's own title and the sidebar button, which already said project. The strings inside the dialog follow: Add project, Remove project, and an empty state that talks about projects rather than extra folders. What is on disk is still described as a folder, because that is what the user picks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(app): close a folder's sessions when it is unmounted from a sandbox Start a session in a mounted folder, unmount that folder, mount another, and the first session was still there — the conversation on screen, its tab in the strip, and nothing in Recents. Its folder had left the sandbox, so no folder listed it, and workspace/side.ts deliberately shows a tab whose directory the sandbox no longer lists in *every* folder: the fallback that keeps a tab which has not learned its directory from becoming invisible also kept an orphaned one permanently visible. It followed the user into whatever they mounted next. Unmounting now takes the folder's tabs with it. `tabs.removeDirectory` removes every session and draft tab of one (server, folder), modelled on removeServer rather than closeTab — the folder left the sandbox, so this is not a close the user can undo, and a reopened tab would point at a folder that is gone. The sessions stay in the guest's database, so mounting the folder again brings them back. Both routes go through it. The project row's Unmount already removed the row; the Mount project dialog removed neither, so a folder unmounted there left both its row and its tabs behind. They now share forgetSandboxFolder, and the dialog reads each folder's guest path before saving — once the mount is gone, so is the mapping from host path to guest path. The matching is its own module (workspace/tab-directory.ts) because both context/tabs.tsx and workspace/side.ts reach the router at import time and cannot be imported by a test. A tab that has not learned its directory is not matched: it belongs to whichever folder it resolves to later, and guessing would close a tab the user is still working in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(app): show the pointer cursor on Work's mount buttons Both ways into the Mount project dialog from the sidebar looked unclickable: the icon button beside Projects and the prompt shown while a sandbox has no folders both kept the arrow cursor on hover. The icon button needs the important modifier. icon-button-v2.css sets `cursor: default` on every icon button, and that file is unlayered while Tailwind's utilities live in @layer utilities — unlayered CSS wins over any layered rule regardless of specificity, so a plain `cursor-pointer` would have been silently dropped. `enabled:` keeps the disabled state on the not-allowed cursor the variant already gives it, which is what the button shows while its sandbox is unhealthy. The prompt is a plain button with no component CSS competing, so it needs no modifier. Work only: this is the button that mounts a host folder into the sandbox. The same element on Code is upstream's Add project, which is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(app): show an unusable sandbox image in the danger colour "That image is for macOS sandboxes. This computer needs the Windows/Linux image." was rendered in the muted colour, the same as the line above it that says the image is being checked and the one below that says it is fine. The one message that means the create cannot go ahead read as an aside. It now uses the v2 danger token with role="alert", matching how the other v2 dialogs report an inline validation failure. The whole message() family goes with it — wrong architecture, not an archive, not a sandbox image, unreadable — since each of them blocks the create for the same reason. One change covers both dialogs: the field is shared by DialogAddSandbox in Settings and DialogAddDefaultSandbox in Work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(desktop): name the sandbox Work creates default-sandbox Renamed from codev-sandbox. It says what the sandbox is — the one the app made without asking — rather than repeating the product name, which every sandbox on the machine shares. The name is not only a label: it becomes the sandbox id (sandbox:default-sandbox), its data directory, its placeholder project folder, and on Windows the WSL distribution name. Nothing migrates, because nothing has shipped: a sandbox created from a build before this keeps the old name and goes on working, and only deleting and re-adding it picks the new one up. Left alone: the bubblewrap guest hostname and the codev-sandbox package, docker image tags and os-release ID, none of which name this sandbox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3b62f08 commit 50d375a

30 files changed

Lines changed: 870 additions & 137 deletions

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,27 @@ file is the human-readable summary of what changed and why.
1616

1717
### Sandbox
1818

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

‎DIVERGENCES.md‎

Lines changed: 2 additions & 1 deletion
Large diffs are not rendered by default.

‎packages/app/src/components/app-sidebar.tsx‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import {
3636
HomeServerRow,
3737
HomeUtilityNav,
3838
} from "@/pages/home"
39+
import { SandboxMountPrompt } from "@/sandbox/mount-prompt"
40+
import { isPlaceholderSandbox } from "@/sandbox/projects"
3941
import { SidebarSection } from "@/components/sidebar-section"
4042
import { createInlineEditorController } from "@/pages/layout/inline-editor"
4143
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
@@ -115,7 +117,16 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
115117
data-action={side() === "work" ? "work-mount-project" : "home-add-project"}
116118
variant="ghost-muted"
117119
size="large"
118-
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
120+
class={`titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted${
121+
// Fork: icon-button-v2.css sets cursor: default on every icon
122+
// button, and Tailwind utilities sit in @layer utilities, so
123+
// the unlayered component rule wins without the important
124+
// modifier. `enabled:` leaves the disabled state on the
125+
// not-allowed cursor the variant already gives it. Work only:
126+
// this is the button that mounts a folder into the sandbox,
127+
// and Code's Add project is upstream's, untouched.
128+
side() === "work" ? " enabled:cursor-pointer!" : ""
129+
}`}
119130
icon={<IconV2 name="folder-add-left" />}
120131
disabled={global.servers.health[ServerConnection.key(sideServers()[0]!)]?.healthy === false}
121132
onClick={() => {
@@ -144,14 +155,24 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
144155
<Show
145156
when={model.projects().length > 0}
146157
fallback={
147-
<HomeProjectEmpty
148-
server={sideServers()[0]!}
149-
recentlyClosed={model.recentlyClosed()}
150-
homedir={model.homedir()}
151-
chooseProject={model.chooseProject}
152-
openRecentProject={(conn, directory) => model.addProjects(conn, [directory])}
153-
language={language}
154-
/>
158+
// Fork: a sandbox whose only folder is a placeholder has
159+
// nothing to list, and nothing upstream's picker could add —
160+
// that picker browses the guest. Ask for a mount instead.
161+
<Show
162+
when={isPlaceholderSandbox(sideServers()[0]!)}
163+
fallback={
164+
<HomeProjectEmpty
165+
server={sideServers()[0]!}
166+
recentlyClosed={model.recentlyClosed()}
167+
homedir={model.homedir()}
168+
chooseProject={model.chooseProject}
169+
openRecentProject={(conn, directory) => model.addProjects(conn, [directory])}
170+
language={language}
171+
/>
172+
}
173+
>
174+
<SandboxMountPrompt server={sideServers()[0]!} />
175+
</Show>
155176
}
156177
>
157178
<HomeProjectList

‎packages/app/src/context/server.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ export namespace ServerConnection {
224224
// Extra host folders shared into the running guest, with the guest
225225
// paths they resolved to.
226226
mounts?: { path: string; guestPath: string }[]
227+
// projectDir is a folder the app made up, not one the user chose, so
228+
// it is never shown as a project — this sandbox's folders are its
229+
// mounts and nothing else.
230+
placeholderProject?: boolean
227231
}
228232
) &
229233
Base

‎packages/app/src/context/tabs.tsx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { usePlatform } from "./platform"
99
import { uuid } from "@/utils/uuid"
1010
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
1111
import { sessionHref } from "@/utils/session-route"
12+
import { tabsInDirectory } from "@/workspace/tab-directory"
1213
import { createTabMemory } from "./tab-memory"
1314
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
1415
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
@@ -378,6 +379,35 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
378379
for (const draftID of drafts) removeDraftPersisted(draftID)
379380
if (server.key === key) navigate("/")
380381
},
382+
// Fork: every tab belonging to one mounted folder of one sandbox, for
383+
// when that folder is unmounted. Its sessions stay in the guest, but the
384+
// folder they ran in is no longer part of the sandbox, so a tab left
385+
// behind is a conversation the user cannot continue and the sidebar does
386+
// not list — inScope() shows a tab whose folder the sandbox no longer
387+
// lists in *every* folder, which is right for a tab that has not learned
388+
// its directory yet and wrong for one whose directory is gone for good.
389+
// Modelled on removeServer: the container went away, so this is not a
390+
// close the user can undo.
391+
removeDirectory(input: { server: ServerConnection.Key; directory: string }) {
392+
const going = tabsInDirectory(store, input.server, input.directory)
393+
if (!going.length) return
394+
const removed = going.map(tabKey)
395+
const drafts = going.flatMap((tab) => (tab.type === "draft" ? [tab.draftID] : []))
396+
const active = removed.includes(recentKey() ?? "") && location.pathname !== "/"
397+
updateClosed((stack) => stack.filter((entry) => !removed.includes(tabKey(entry.tab))))
398+
void startTransition(() => {
399+
setStore((tabs) => tabs.filter((tab) => !removed.includes(tabKey(tab))))
400+
// Home rather than a neighbouring tab: the folder that was on screen
401+
// is gone, and home is where the sandbox's remaining folders are.
402+
if (active) {
403+
setRecentKey(undefined)
404+
navigate("/")
405+
}
406+
})
407+
for (const key of removed) memory.remove(key)
408+
for (const key of removed) removeInfo(key)
409+
for (const draftID of drafts) removeDraftPersisted(draftID)
410+
},
381411
removeSessions: (input: SessionTabsRemovedDetail) => {
382412
const targetServer = input.server ?? server.key
383413
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))

‎packages/app/src/i18n/en.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,12 @@ export const dict = {
386386
"workspace.empty.starting": "Your sandbox is not running yet. It appears here once it is ready.",
387387
"workspace.empty.stopped": "Your sandbox is stopped. Start it again from Settings → Servers.",
388388
"sandbox.add.workIntro": "Work requires a sandbox environment to run sessions.",
389+
"sandbox.addDefault.title": "Set up your sandbox",
390+
"sandbox.addDefault.intro":
391+
"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.",
392+
"sandbox.addDefault.create": "Set up",
393+
"sandbox.mountPrompt.body": "Your sandbox is ready. Mount a project to start working in it.",
394+
"sandbox.mountPrompt.action": "Mount a project",
389395
"sandbox.server.add": "Add sandbox",
390396
"sandbox.server.label": "Sandbox",
391397
"sandbox.server.menu.label": "Sandbox",
@@ -438,11 +444,11 @@ export const dict = {
438444
"sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB",
439445
"sandbox.add.creating": "Creating the sandbox\u2026 this takes a few minutes.",
440446
"sandbox.mounts.title": "Mount project",
441-
"sandbox.mounts.hint": "Folders mounted here are the only ones on this computer the sandbox can reach.",
447+
"sandbox.mounts.hint": "Projects mounted here are the only folders on this computer the sandbox can reach.",
442448
"sandbox.mounts.primary": "Project folder",
443-
"sandbox.mounts.empty": "No extra folders are mounted.",
444-
"sandbox.mounts.add": "Add folder...",
445-
"sandbox.mounts.remove": "Remove folder",
449+
"sandbox.mounts.empty": "No projects are mounted yet.",
450+
"sandbox.mounts.add": "Add project...",
451+
"sandbox.mounts.remove": "Remove project",
446452
"sandbox.mounts.unmount": "Unmount",
447453
"sandbox.mounts.save": "Save",
448454
"sandbox.mounts.restartWarning":

‎packages/app/src/i18n/es.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,12 @@ export const dict = {
378378
"workspace.empty.starting": "Tu sandbox todavía no está en marcha. Aparecerá aquí cuando esté listo.",
379379
"workspace.empty.stopped": "Tu sandbox está detenido. Vuelve a iniciarlo desde Ajustes → Servidores.",
380380
"sandbox.add.workIntro": "Trabajo requiere un entorno de sandbox para ejecutar sesiones.",
381+
"sandbox.addDefault.title": "Configura tu sandbox",
382+
"sandbox.addDefault.intro":
383+
"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.",
384+
"sandbox.addDefault.create": "Configurar",
385+
"sandbox.mountPrompt.body": "Tu sandbox está listo. Monta un proyecto para empezar a trabajar en él.",
386+
"sandbox.mountPrompt.action": "Montar un proyecto",
381387
"sandbox.server.add": "Añadir sandbox",
382388
"sandbox.server.label": "Sandbox",
383389
"sandbox.server.menu.label": "Sandbox",
@@ -431,11 +437,12 @@ export const dict = {
431437
"sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB",
432438
"sandbox.add.creating": "Creando el sandbox\u2026 esto tarda unos minutos.",
433439
"sandbox.mounts.title": "Montar proyecto",
434-
"sandbox.mounts.hint": "Las carpetas montadas aquí son las únicas de este equipo a las que el sandbox puede acceder.",
440+
"sandbox.mounts.hint":
441+
"Los proyectos montados aquí son las únicas carpetas de este equipo a las que el sandbox puede acceder.",
435442
"sandbox.mounts.primary": "Carpeta del proyecto",
436-
"sandbox.mounts.empty": "No hay carpetas adicionales montadas.",
437-
"sandbox.mounts.add": "Añadir carpeta...",
438-
"sandbox.mounts.remove": "Quitar carpeta",
443+
"sandbox.mounts.empty": "Aún no hay proyectos montados.",
444+
"sandbox.mounts.add": "Añadir proyecto...",
445+
"sandbox.mounts.remove": "Quitar proyecto",
439446
"sandbox.mounts.unmount": "Desmontar",
440447
"sandbox.mounts.save": "Guardar",
441448
"sandbox.mounts.restartWarning":
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { useDialog } from "@opencode-ai/ui/context/dialog"
2+
import { showToast } from "@opencode-ai/ui/toast"
3+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
4+
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
5+
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
6+
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
7+
import { Show } from "solid-js"
8+
import { createStore } from "solid-js/store"
9+
import { useLanguage } from "@/context/language"
10+
import { usePlatform } from "@/context/platform"
11+
import type { SandboxConfig } from "./types"
12+
import { useSandboxes } from "./context"
13+
import { sandboxCreatePhase } from "./create-phase"
14+
import { createSandboxImageField } from "./image-field"
15+
16+
// The sandbox Work offers when there is none: everything DialogAddSandbox asks
17+
// for has an answer the app can give, so it asks for none of it. The name, the
18+
// folder and the limits are the main process's business (main/sandbox/ipc.ts);
19+
// the image stays because supplying one is a real choice on a machine that
20+
// cannot reach the storage host.
21+
//
22+
// DialogAddSandbox is untouched and still reachable from Settings → Servers —
23+
// that is where a second sandbox, or one pointed at a particular folder, gets
24+
// made.
25+
export function DialogAddDefaultSandbox(props: { onCreated?: (config: SandboxConfig) => void }) {
26+
const language = useLanguage()
27+
const platform = usePlatform()
28+
const dialog = useDialog()
29+
const sandboxes = useSandboxes()
30+
const api = platform.sandboxes!
31+
const [store, setStore] = createStore({ adding: false })
32+
const image = createSandboxImageField()
33+
34+
const availability = () => sandboxes.availability.data
35+
const unsupportedMessage = () => {
36+
const value = availability()
37+
return value && !value.supported ? value.message : null
38+
}
39+
const warning = () => {
40+
const value = availability()
41+
return value?.supported ? value.warning : null
42+
}
43+
44+
const createJob = () => sandboxes.state.data?.jobs.find((entry) => entry.kind === "create")
45+
const createPhase = () => sandboxCreatePhase({ adding: store.adding, job: createJob() })
46+
const downloadProgress = () => (createPhase() === "downloading" ? createJob()?.progress : undefined)
47+
const statusMessage = () => {
48+
const phase = createPhase()
49+
if (phase === "queued") return language.t("sandbox.job.queued")
50+
if (phase === "creating") return language.t("sandbox.add.creating")
51+
return null
52+
}
53+
54+
const create = async () => {
55+
if (store.adding || availability()?.supported === false || !image.usable()) return
56+
setStore("adding", true)
57+
try {
58+
// Resolves undefined when the download was cancelled: keep the dialog
59+
// open so the user can try again rather than dropping them back on Code
60+
// with nothing to show for it.
61+
const created = await api.addDefault(image.path())
62+
if (created) {
63+
props.onCreated?.(created)
64+
dialog.close()
65+
}
66+
} catch (error) {
67+
showToast({
68+
variant: "error",
69+
title: language.t("common.requestFailed"),
70+
description: error instanceof Error ? error.message : String(error),
71+
})
72+
} finally {
73+
setStore("adding", false)
74+
}
75+
}
76+
77+
// Its own channel rather than api.cancel(name): the name belongs to the main
78+
// process, and a sandbox being created is not in the sandbox list yet — the
79+
// controller only publishes it once the create has finished — so there is
80+
// nothing here to read it off.
81+
const cancel = () => {
82+
if (!store.adding) {
83+
dialog.close()
84+
return
85+
}
86+
void api.cancelDefault()
87+
}
88+
89+
return (
90+
<Dialog fit>
91+
<DialogHeader hideClose={true}>
92+
<DialogTitleGroup
93+
title={language.t("sandbox.addDefault.title")}
94+
description={language.t("sandbox.addDefault.intro")}
95+
/>
96+
</DialogHeader>
97+
<DividerV2 />
98+
<DialogBody class="flex flex-col gap-3 px-4 pt-3 pb-1">
99+
<Show
100+
when={availability()?.supported !== false}
101+
fallback={<p class="text-14 text-v2-text-text-muted">{unsupportedMessage()}</p>}
102+
>
103+
{/* warn-never-block: the warning is shown, creation stays enabled */}
104+
<Show when={warning()}>{(text) => <p class="text-12 text-v2-text-text-muted">{text()}</p>}</Show>
105+
<image.Field disabled={store.adding} />
106+
<Show when={downloadProgress()}>
107+
{(current) => (
108+
<div class="flex flex-col gap-1.5">
109+
<div class="h-1 w-full overflow-hidden rounded-full bg-v2-background-bg-layer-04">
110+
<div
111+
class="h-full rounded-full bg-v2-icon-icon-base transition-[width] duration-200"
112+
style={{ width: `${Math.min(100, (current().bytes / current().totalBytes) * 100)}%` }}
113+
/>
114+
</div>
115+
<p class="text-12 text-v2-text-text-muted">
116+
{language.t("sandbox.add.downloadProgress", {
117+
done: Math.round(current().bytes / 1024 / 1024),
118+
total: Math.round(current().totalBytes / 1024 / 1024),
119+
})}
120+
</p>
121+
</div>
122+
)}
123+
</Show>
124+
<Show when={statusMessage()}>{(text) => <p class="text-12 text-v2-text-text-muted">{text()}</p>}</Show>
125+
</Show>
126+
</DialogBody>
127+
<DialogFooter>
128+
<ButtonV2 variant="neutral" onClick={cancel}>
129+
{language.t("common.cancel")}
130+
</ButtonV2>
131+
<Show when={availability()?.supported !== false}>
132+
<ButtonV2
133+
variant={store.adding ? "loading" : "contrast"}
134+
disabled={!store.adding && !image.usable()}
135+
onClick={() => void create()}
136+
>
137+
<Show when={store.adding} fallback={language.t("sandbox.addDefault.create")}>
138+
<LoaderV2 />
139+
</Show>
140+
</ButtonV2>
141+
</Show>
142+
</DialogFooter>
143+
</Dialog>
144+
)
145+
}

0 commit comments

Comments
 (0)