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

### Sandbox

- **Work says when the sandbox failed to start** — the Work sidebar showed
"Your sandbox is not running yet. It appears here once it is ready." for a
sandbox that had already failed to boot, so the only way to learn something
went wrong was to go looking in Settings → Servers. The empty state now says
the sandbox failed, shows the failure message, and offers a retry in place.
- **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
Expand Down
3 changes: 1 addition & 2 deletions DIVERGENCES.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ export const dict = {
"workspace.empty.none": "No sandbox yet. Create one to work in an isolated environment.",
"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.",
"workspace.empty.failed": "Your sandbox failed to start.",
"workspace.empty.failed.retry": "Try again",
"sandbox.add.workIntro": "Work requires a sandbox environment to run sessions.",
"sandbox.addDefault.title": "Set up your sandbox",
"sandbox.addDefault.intro":
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ export const dict = {
"workspace.empty.none": "Aún no hay ningún sandbox. Crea uno para trabajar en un entorno aislado.",
"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.",
"workspace.empty.failed": "Tu sandbox no se pudo iniciar.",
"workspace.empty.failed.retry": "Reintentar",
"sandbox.add.workIntro": "Trabajo requiere un entorno de sandbox para ejecutar sesiones.",
"sandbox.addDefault.title": "Configura tu sandbox",
"sandbox.addDefault.intro":
Expand Down
26 changes: 26 additions & 0 deletions packages/app/src/workspace/side.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ServerConnection } from "@/context/server"
import { tabKey, type Tab } from "@/context/tabs"
import type { SandboxesState } from "@/sandbox/types"
import { pathKey } from "@/utils/path-key"

// The desktop app is split into two workspaces: Code, everything that runs on
Expand Down Expand Up @@ -163,6 +164,31 @@ export function leavesWorkEmpty(sandboxes: { config: { id: string } }[], removed
return sideForServerKey(removed) === "work" && !sandboxes.some((item) => item.config.id !== removed)
}

// What the Work empty state says while the side has no ready sandbox. A
// sandbox still coming up wins — a starting runtime, or any create/reset job,
// whose runtime reads "stopped" while it runs — because one sandbox on its way
// is enough for the wait message to be true. Only then is a failed sandbox
// called out, with its runtime message so the user is not left waiting on a
// boot that already gave up; a side whose sandboxes are all deliberately
// stopped is the fallback.
export function workspaceEmptyState(
side: WorkspaceSide,
state: SandboxesState | undefined,
): {
message: "workspace.empty.none" | "workspace.empty.starting" | "workspace.empty.failed" | "workspace.empty.stopped"
failure?: { id: string; message: string }
} {
const sandboxes = state?.sandboxes ?? []
if (side !== "work" || !sandboxes.length) return { message: "workspace.empty.none" }
if (sandboxes.some((item) => item.runtime.kind === "starting") || state?.jobs.length)
return { message: "workspace.empty.starting" }
const failure = sandboxes.flatMap((item) =>
item.runtime.kind === "failed" ? [{ id: item.config.id, message: item.runtime.message }] : [],
)[0]
if (failure) return { message: "workspace.empty.failed", failure }
return { message: "workspace.empty.stopped" }
}

// The tab strip only renders one side, so it can only ever reorder that side's
// keys. Splice the new order back into the slots those tabs already occupy and
// leave the other side untouched — tabs.reorder() rejects a list that is not
Expand Down
37 changes: 29 additions & 8 deletions packages/app/src/workspace/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTabs } from "@/context/tabs"
import { findOpenDraft } from "@/pages/home-draft-reuse"
import { isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { sessionHref } from "@/utils/session-route"
import { showToast } from "@/utils/toast"
import { useSandboxes } from "@/sandbox/context"
import { isPlaceholderSandbox } from "@/sandbox/projects"
import { usePlatform } from "@/context/platform"
Expand All @@ -20,6 +21,7 @@ import {
selectionForSide,
sideForServer,
WORKSPACE_SIDES,
workspaceEmptyState,
type TabScope,
type WorkspaceSide,
} from "./side"
Expand Down Expand Up @@ -299,19 +301,38 @@ export function useWorkspaceSwitch() {
export function WorkspaceEmpty(props: { side: WorkspaceSide }) {
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const sandboxes = useSandboxes()
const configured = () => sandboxes.state.data?.sandboxes ?? []
// Nothing here is coming up on its own once every sandbox is stopped, so
// saying "it appears here once it is ready" would be a promise we do not
// keep. One sandbox still starting is enough for the wait to be true.
const message = () => {
if (props.side !== "work" || !configured().length) return "workspace.empty.none"
return configured().every((item) => item.config.stopped) ? "workspace.empty.stopped" : "workspace.empty.starting"
}
const empty = () => workspaceEmptyState(props.side, sandboxes.state.data)

return (
<div class="flex flex-col items-start gap-2 px-1.5 py-3">
<p class="text-12 text-v2-text-text-muted">{language.t(message())}</p>
<p class="text-12 text-v2-text-text-muted">{language.t(empty().message)}</p>
<Show when={empty().failure}>
{(failed) => (
<>
<p role="alert" class="text-12 text-v2-state-fg-danger">
{failed().message}
</p>
<button
type="button"
class="text-12-medium text-v2-text-text-base underline"
onClick={() =>
platform.sandboxes?.start(failed().id).catch((error: unknown) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
)
}
>
{language.t("workspace.empty.failed.retry")}
</button>
</>
)}
</Show>
<Show when={props.side === "work" && !configured().length}>
<button
type="button"
Expand Down
70 changes: 70 additions & 0 deletions packages/app/test-browser/workspace-side.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { tabKey, type Tab } from "@/context/tabs"
import type { SandboxesState } from "@/sandbox/types"
import {
effectiveSide,
folderEntry,
Expand All @@ -13,6 +14,7 @@ import {
sideForServer,
tabsForScope,
tabsForSide,
workspaceEmptyState,
type TabScope,
} from "@/workspace/side"

Expand Down Expand Up @@ -337,3 +339,71 @@ test("a recorded session and an open draft both outrank an open tab", async () =
test("a folder with no tabs at all falls back to its most recent session", async () => {
expect(await entry({ recent: "ses_recent" })).toEqual({ type: "session", sessionId: "ses_recent" })
})

// The Work empty state has to say when a sandbox failed: the wait message
// otherwise leaves the user watching for a sandbox that already gave up.
const emptyItem = (id: string, runtime: SandboxesState["sandboxes"][number]["runtime"]) => ({
config: { id, name: id, backend: "bwrap" as const, projectDir: "/home/user/project" },
runtime,
})

const emptyState = (sandboxes: SandboxesState["sandboxes"], jobs: SandboxesState["jobs"] = []): SandboxesState => ({
sandboxes,
jobs,
})

test("the empty state offers to create the first sandbox", () => {
expect(workspaceEmptyState("work", undefined)).toEqual({ message: "workspace.empty.none" })
expect(workspaceEmptyState("work", emptyState([]))).toEqual({ message: "workspace.empty.none" })
expect(
workspaceEmptyState("code", emptyState([emptyItem("sandbox:a", { kind: "failed", message: "boom" })])),
).toEqual({ message: "workspace.empty.none" })
})

test("one sandbox still coming up keeps the wait message", () => {
expect(workspaceEmptyState("work", emptyState([emptyItem("sandbox:a", { kind: "starting" })]))).toEqual({
message: "workspace.empty.starting",
})
// A failure beside it does not interrupt a wait that is still true.
expect(
workspaceEmptyState(
"work",
emptyState([
emptyItem("sandbox:a", { kind: "failed", message: "boom" }),
emptyItem("sandbox:b", { kind: "starting" }),
]),
),
).toEqual({ message: "workspace.empty.starting" })
// A create/reset job counts too: its sandbox's runtime reads "stopped"
// while the job runs.
expect(
workspaceEmptyState(
"work",
emptyState(
[emptyItem("sandbox:a", { kind: "stopped" })],
[{ kind: "reset", id: "sandbox:a", startedAt: 0, status: "running" }],
),
),
).toEqual({ message: "workspace.empty.starting" })
})

test("a failed sandbox is called out with its message", () => {
expect(
workspaceEmptyState(
"work",
emptyState([
emptyItem("sandbox:a", { kind: "stopped" }),
emptyItem("sandbox:b", { kind: "failed", message: "sandbox server did not become healthy within 30000ms" }),
]),
),
).toEqual({
message: "workspace.empty.failed",
failure: { id: "sandbox:b", message: "sandbox server did not become healthy within 30000ms" },
})
})

test("all sandboxes stopped is its own message", () => {
expect(workspaceEmptyState("work", emptyState([emptyItem("sandbox:a", { kind: "stopped" })]))).toEqual({
message: "workspace.empty.stopped",
})
})
Loading