- Couldn't verify compatibility for your machine ({hostPlatform ||
+ Could not verify compatibility for your machine ({hostPlatform ||
"unknown"}); the image will be pulled as-is.
{/if}
@@ -1241,28 +1328,26 @@ function selectTemplate(t: { name: string; source: string }) {
{:else if currentStep === "launch"}
-
-
- {#if launchRunning}
- Creating Workspace
- {:else if launchSuccess}
- Workspace Ready
- {:else if launchError}
- Workspace Creation Failed
- {:else}
- Launching...
- {/if}
-
-
- {#if launchRunning}
- Preparing your workspace...
- {:else if launchSuccess}
- {launchedWorkspaceId ?? resolvedId} is ready to use.
- {:else if launchError}
- Something went wrong while creating the workspace.
- {/if}
-
-
+ {#if !launchRunning}
+
+
+ {#if launchSuccess}
+ Workspace Ready
+ {:else if launchError}
+ Workspace Creation Failed
+ {:else}
+ Launching...
+ {/if}
+
+
+ {#if launchSuccess}
+ {launchedWorkspaceId ?? resolvedId} is ready to use.
+ {:else if launchError}
+ Something went wrong while creating the workspace.
+ {/if}
+
+
+ {/if}
{#if launchError}
@@ -1271,43 +1356,53 @@ function selectTemplate(t: { name: string; source: string }) {
{/if}
- {#if commandId && $workspaceJobs[lastAttemptedId ?? ""]?.commandId === commandId}
-
- {:else if launchRunning && operationStatus}
-
-
- {operationStatus.phase.replaceAll("_", " ")}
-
- {#if operationStatus.step}
- — {operationStatus.step}
- {/if}
-
+ {#if commandId && launchJob?.commandId === commandId && (launchJob.state === "running" || launchJob.state === "reconciling")}
+
+ {:else if launchRunning}
+
{/if}
{#if outputLines.length > 0}
-
-
Output
- {
- try {
- await navigator.clipboard.writeText(outputLines.map(stripAnsi).join("\n"))
- toasts.success("Copied to clipboard")
- } catch {
- toasts.error("Failed to copy")
- }
- }}
- >
- Copy
-
-
-
-
- {:else if launchRunning}
-
-
+
(showLogs = !showLogs)}
+ >
+
+ View details
+
+ {#if showLogs}
+
+
+ {
+ try {
+ await navigator.clipboard.writeText(outputLines.map(stripAnsi).join("\n"))
+ toasts.success("Copied to clipboard")
+ } catch {
+ toasts.error("Failed to copy")
+ }
+ }}
+ >
+ Copy
+
+
+
+
+ {/if}
{/if}
diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts
index 082ffd8b2..cec4270b5 100644
--- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts
+++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts
@@ -1,7 +1,11 @@
import { fireEvent, render } from "@testing-library/svelte"
import { tick } from "svelte"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import type { CommandProgress, Provider, WorkspaceStatus } from "$lib/types/index.js"
+import type {
+ CommandProgress,
+ Provider,
+ WorkspaceStatus,
+} from "$lib/types/index.js"
const workspaceUp = vi.fn()
const onCommandProgress = vi.fn()
@@ -43,7 +47,10 @@ vi.mock("$lib/stores/providers.js", async () => {
vi.mock("$lib/stores/workspaces.js", async () => {
const { writable } = await import("svelte/store")
- return { workspaces: writable<{ id: string }[]>([]), workspaceJobs: writable({}) }
+ return {
+ workspaces: writable<{ id: string }[]>([]),
+ workspaceJobs: writable({}),
+ }
})
vi.mock("$lib/stores/toasts.js", () => ({
@@ -59,7 +66,7 @@ vi.mock("$lib/router.js", () => ({
}))
import { providers } from "$lib/stores/providers.js"
-import { workspaces } from "$lib/stores/workspaces.js"
+import { workspaceJobs, workspaces } from "$lib/stores/workspaces.js"
import WorkspaceWizard from "./WorkspaceWizard.svelte"
function makeProvider(name: string, initialized = true): Provider {
@@ -117,6 +124,7 @@ describe("WorkspaceWizard", () => {
onWorkspaceStatus.mockReset()
providers.set([])
workspaces.set([])
+ ;(workspaceJobs as { set: (v: unknown) => void }).set({})
progressCallback = null
statusCallback = null
@@ -167,9 +175,9 @@ describe("WorkspaceWizard", () => {
expect(
getByText(/At least one initialized provider is required/i),
).toBeTruthy()
- const continueBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Continue") as HTMLButtonElement
+ const continueBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Continue",
+ ) as HTMLButtonElement
expect(continueBtn).toBeTruthy()
expect(continueBtn.disabled).toBe(true)
unmount()
@@ -310,9 +318,9 @@ describe("WorkspaceWizard", () => {
expect(nameInput.value).not.toBe("")
expect(nameInput.value).not.toBe("python")
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
expect(launchBtn.disabled).toBe(false)
unmount()
})
@@ -333,9 +341,9 @@ describe("WorkspaceWizard", () => {
await flushAsync()
expect(getByText(/already exists/i)).toBeTruthy()
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
expect(launchBtn.disabled).toBe(true)
unmount()
})
@@ -357,9 +365,9 @@ describe("WorkspaceWizard", () => {
await flushAsync()
await advanceToReview(getByText)
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
@@ -376,9 +384,9 @@ describe("WorkspaceWizard", () => {
await flushAsync()
await advanceToReview(getByText)
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
@@ -389,13 +397,15 @@ describe("WorkspaceWizard", () => {
} as CommandProgress)
await flushAsync()
+ await fireEvent.click(getByText("View details"))
+ await flushAsync()
expect(queryByText(/Building workspace/)).not.toBeNull()
unmount()
})
it("shows the current structured operation status while launching", async () => {
providers.set([makeProvider("docker")])
- const { getByText, queryByTestId, unmount } = render(WorkspaceWizard, {
+ const { getByText, unmount } = render(WorkspaceWizard, {
props: { open: true },
})
await flushAsync()
@@ -407,17 +417,84 @@ describe("WorkspaceWizard", () => {
await fireEvent.click(launchBtn)
await flushAsync()
+ expect(document.querySelector('[role="status"]')?.textContent).toContain(
+ "Preparing workspace",
+ )
+
statusCallback?.({
commandId: "cmd-1",
workspaceId: "python",
phase: "building_image",
+ step: "Waiting for lock",
state: "started",
})
await flushAsync()
- expect(queryByTestId("operation-status")?.textContent).toContain(
- "building image",
- )
+ const region = document.querySelector('[role="status"]')
+ expect(region?.textContent).toContain("Creating")
+ expect(region?.textContent).toContain("Waiting for lock")
+ unmount()
+ })
+
+ it("shows exactly one failure surface when the job journal reports failure", async () => {
+ providers.set([makeProvider("docker")])
+ const { getByText, queryByText, unmount } = render(WorkspaceWizard, {
+ props: { open: true },
+ })
+ await flushAsync()
+ await advanceToReview(getByText)
+
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
+ await fireEvent.click(launchBtn)
+ await flushAsync()
+
+ statusCallback?.({
+ commandId: "cmd-1",
+ workspaceId: "python",
+ phase: "building_image",
+ state: "started",
+ })
+ await flushAsync()
+ ;(workspaceJobs as { set: (v: unknown) => void }).set({
+ python: {
+ commandId: "cmd-1",
+ activity: "creating",
+ state: "failed",
+ phase: "building_image",
+ error: "provider exploded",
+ },
+ })
+ await flushAsync()
+
+ expect(queryByText(/provider exploded/)).not.toBeNull()
+ expect(document.querySelector('[role="status"]')).toBeNull()
+ unmount()
+ })
+
+ it("shows exactly one success headline when the job journal reports success", async () => {
+ providers.set([makeProvider("docker")])
+ const { getByText, queryByText, unmount } = render(WorkspaceWizard, {
+ props: { open: true },
+ })
+ await flushAsync()
+ await advanceToReview(getByText)
+
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
+ await fireEvent.click(launchBtn)
+ await flushAsync()
+
+ ;(workspaceJobs as { set: (v: unknown) => void }).set({
+ python: { commandId: "cmd-1", activity: "creating", state: "succeeded" },
+ })
+ await flushAsync()
+
+ expect(queryByText(/is ready to use/)).not.toBeNull()
+ expect(queryByText("Checking")).toBeNull()
+ expect(document.querySelector('[role="status"]')).toBeNull()
unmount()
})
@@ -429,9 +506,9 @@ describe("WorkspaceWizard", () => {
await flushAsync()
await advanceToReview(getByText)
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
@@ -455,9 +532,9 @@ describe("WorkspaceWizard", () => {
await flushAsync()
await advanceToReview(getByText)
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
@@ -480,9 +557,9 @@ describe("WorkspaceWizard", () => {
await flushAsync()
await advanceToReview(getByText)
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
@@ -524,9 +601,9 @@ describe("WorkspaceWizard", () => {
})
await flushAsync()
- const advancedToggle = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => /advanced options/i.test(b.textContent ?? "")) as HTMLElement
+ const advancedToggle = Array.from(document.querySelectorAll("button")).find(
+ (b) => /advanced options/i.test(b.textContent ?? ""),
+ ) as HTMLElement
await fireEvent.click(advancedToggle)
await flushAsync()
@@ -537,7 +614,9 @@ describe("WorkspaceWizard", () => {
const wsFolderInput = document.querySelector(
'input[placeholder="/workspaces/app"]',
) as HTMLInputElement
- await fireEvent.input(wsFolderInput, { target: { value: "/workspaces/app" } })
+ await fireEvent.input(wsFolderInput, {
+ target: { value: "/workspaces/app" },
+ })
const prebuildInput = document.querySelector(
'input[placeholder*="ghcr.io/org/prebuilds"]',
) as HTMLInputElement
@@ -551,9 +630,9 @@ describe("WorkspaceWizard", () => {
await fireEvent.click(getActiveContinue(getByText))
await flushAsync()
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
@@ -581,15 +660,17 @@ describe("WorkspaceWizard", () => {
// Set a workspace folder via the git tab's advanced section; this state
// persists when we switch source types.
- const advancedToggle = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => /advanced options/i.test(b.textContent ?? "")) as HTMLElement
+ const advancedToggle = Array.from(document.querySelectorAll("button")).find(
+ (b) => /advanced options/i.test(b.textContent ?? ""),
+ ) as HTMLElement
await fireEvent.click(advancedToggle)
await flushAsync()
const wsFolderInput = document.querySelector(
'input[placeholder="/workspaces/app"]',
) as HTMLInputElement
- await fireEvent.input(wsFolderInput, { target: { value: "/workspaces/app" } })
+ await fireEvent.input(wsFolderInput, {
+ target: { value: "/workspaces/app" },
+ })
await flushAsync()
// Switch to the Image tab and enter a custom image ref.
@@ -598,7 +679,9 @@ describe("WorkspaceWizard", () => {
const customImageInput = document.querySelector(
'input[placeholder*="registry/image:tag"]',
) as HTMLInputElement
- await fireEvent.input(customImageInput, { target: { value: "ubuntu:22.04" } })
+ await fireEvent.input(customImageInput, {
+ target: { value: "ubuntu:22.04" },
+ })
await flushAsync()
await fireEvent.click(getActiveContinue(getByText))
@@ -606,9 +689,9 @@ describe("WorkspaceWizard", () => {
await fireEvent.click(getActiveContinue(getByText))
await flushAsync()
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
@@ -639,7 +722,9 @@ describe("WorkspaceWizard", () => {
const customImageInput = document.querySelector(
'input[placeholder*="registry/image:tag"]',
) as HTMLInputElement
- await fireEvent.input(customImageInput, { target: { value: "ubuntu:22.04" } })
+ await fireEvent.input(customImageInput, {
+ target: { value: "ubuntu:22.04" },
+ })
await flushAsync()
await fireEvent.click(getActiveContinue(getByText))
@@ -654,9 +739,9 @@ describe("WorkspaceWizard", () => {
) as HTMLInputElement
expect(nameInput.value).toBe("ubuntu-22.04")
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
expect(launchBtn.disabled).toBe(false)
unmount()
})
@@ -678,9 +763,9 @@ describe("WorkspaceWizard", () => {
await flushAsync()
await advanceToReview(getByText)
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
@@ -704,6 +789,7 @@ describe("launch watchdog", () => {
onCommandProgress.mockReset()
providers.set([])
workspaces.set([])
+ ;(workspaceJobs as { set: (v: unknown) => void }).set({})
progressCallback = null
onCommandProgress.mockImplementation(
@@ -734,9 +820,9 @@ describe("launch watchdog", () => {
await flushAsync()
await advanceToReview(getByText)
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
@@ -758,9 +844,9 @@ describe("launch watchdog", () => {
await flushAsync()
await advanceToReview(getByText)
- const launchBtn = Array.from(
- document.querySelectorAll("button"),
- ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement
+ const launchBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Launch",
+ ) as HTMLButtonElement
await fireEvent.click(launchBtn)
await flushAsync()
diff --git a/desktop/src/renderer/src/lib/stores/toasts.ts b/desktop/src/renderer/src/lib/stores/toasts.ts
index 5b010781f..19037303e 100644
--- a/desktop/src/renderer/src/lib/stores/toasts.ts
+++ b/desktop/src/renderer/src/lib/stores/toasts.ts
@@ -21,19 +21,32 @@ const historyStore = writable
([])
let nextId = 0
-function add(message: string, variant: Toast["variant"] = "default") {
+export interface ToastOptions {
+ sticky?: boolean
+ action?: { label: string; onClick: () => void }
+}
+
+function add(
+ message: string,
+ variant: Toast["variant"] = "default",
+ options?: ToastOptions,
+) {
const id = String(++nextId)
- const duration = DURATION_MS[variant]
+ const duration = options?.sticky ? Infinity : DURATION_MS[variant]
const toast: Toast = { id, message, variant, timestamp: Date.now(), duration }
historyStore.update((list) => [toast, ...list].slice(0, MAX_HISTORY))
+ const sonnerOptions = {
+ duration,
+ ...(options?.action ? { action: options.action } : {}),
+ }
if (variant === "success") {
- sonnerToast.success(message, { duration })
+ sonnerToast.success(message, sonnerOptions)
} else if (variant === "error") {
- sonnerToast.error(message, { duration })
+ sonnerToast.error(message, sonnerOptions)
} else {
- sonnerToast.info(message, { duration })
+ sonnerToast.info(message, sonnerOptions)
}
return id
@@ -53,9 +66,12 @@ const unreadCount = derived(historyStore, ($history) => {
})
export const toasts = {
- success: (message: string) => add(message, "success"),
- error: (message: string) => add(message, "error"),
- info: (message: string) => add(message, "default"),
+ success: (message: string, options?: ToastOptions) =>
+ add(message, "success", options),
+ error: (message: string, options?: ToastOptions) =>
+ add(message, "error", options),
+ info: (message: string, options?: ToastOptions) =>
+ add(message, "default", options),
dismiss: (id?: string | number) => sonnerToast.dismiss(id),
}
diff --git a/desktop/src/renderer/src/lib/stores/workspaces.test.ts b/desktop/src/renderer/src/lib/stores/workspaces.test.ts
index 41ff4d299..94f8e2ffe 100644
--- a/desktop/src/renderer/src/lib/stores/workspaces.test.ts
+++ b/desktop/src/renderer/src/lib/stores/workspaces.test.ts
@@ -5,14 +5,25 @@ import {
mockListen,
resetTauriMocks,
} from "$lib/__mocks__/tauri.js"
+import { toasts } from "./toasts.js"
import {
destroyWorkspaces,
initWorkspaces,
- workspaces,
workspaceJobs,
+ workspaces,
workspacesLoading,
} from "./workspaces.js"
+vi.mock("./toasts.js", () => ({
+ toasts: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
+ notificationHistory: {
+ subscribe: () => () => {},
+ remove: vi.fn(),
+ clear: vi.fn(),
+ unreadCount: { subscribe: () => () => {} },
+ },
+}))
+
const job = {
activity: "deleting",
commandId: "delete",
@@ -21,6 +32,7 @@ const job = {
}
beforeEach(() => {
resetTauriMocks()
+ vi.clearAllMocks()
workspaces.set([])
workspaceJobs.set({})
})
@@ -103,6 +115,94 @@ describe("workspace snapshot store", () => {
await first
expect(get(workspaces)[0].id).toBe("new")
})
+ it("stays silent while a job runs or reconciles, then confirms once", async () => {
+ mockInvoke.mockResolvedValue({
+ revision: 1,
+ workspaces: [{ id: "ws", status: "Running" }],
+ jobs: { ws: job },
+ })
+ await initWorkspaces()
+ event({
+ revision: 2,
+ workspaces: [{ id: "ws", status: "Running" }],
+ jobs: { ws: { ...job, state: "reconciling", phase: "Refreshing list" } },
+ })
+ expect(toasts.success).not.toHaveBeenCalled()
+ expect(toasts.error).not.toHaveBeenCalled()
+ event({
+ revision: 3,
+ workspaces: [],
+ jobs: { ws: { ...job, state: "succeeded" } },
+ })
+ expect(toasts.success).toHaveBeenCalledTimes(1)
+ expect(toasts.success).toHaveBeenCalledWith("ws: Workspace deleted")
+ })
+ it("reports an operation failure once, sticky, with the operation named", async () => {
+ mockInvoke.mockResolvedValue({
+ revision: 1,
+ workspaces: [{ id: "ws", status: "Running" }],
+ jobs: { ws: { ...job, activity: "stopping", commandId: "stop" } },
+ })
+ await initWorkspaces()
+ event({
+ revision: 2,
+ workspaces: [{ id: "ws", status: "Running" }],
+ jobs: {
+ ws: {
+ ...job,
+ activity: "stopping",
+ commandId: "stop",
+ state: "failed",
+ error: "provider unavailable",
+ },
+ },
+ })
+ expect(toasts.error).toHaveBeenCalledTimes(1)
+ expect(toasts.error).toHaveBeenCalledWith(
+ "ws: Stop failed - provider unavailable",
+ {
+ sticky: true,
+ action: { label: "View logs", onClick: expect.any(Function) },
+ },
+ )
+ })
+ it("toasts when a terminal job is the first observation of its command", async () => {
+ mockInvoke.mockResolvedValue({
+ revision: 1,
+ workspaces: [{ id: "ws", status: "Running" }],
+ jobs: {},
+ })
+ await initWorkspaces()
+ event({
+ revision: 2,
+ workspaces: [{ id: "ws", status: "Stopped" }],
+ jobs: {
+ ws: {
+ ...job,
+ activity: "stopping",
+ commandId: "stop",
+ state: "succeeded",
+ },
+ },
+ })
+ expect(toasts.success).toHaveBeenCalledTimes(1)
+ expect(toasts.success).toHaveBeenCalledWith("ws: Workspace stopped")
+ })
+ it("does not toast while a refresh is stalled", async () => {
+ mockInvoke.mockResolvedValue({
+ revision: 1,
+ workspaces: [{ id: "ws", status: "Running" }],
+ jobs: { ws: job },
+ })
+ await initWorkspaces()
+ event({
+ revision: 2,
+ workspaces: [{ id: "ws", status: "Running" }],
+ jobs: { ws: { ...job, state: "reconciling", refreshError: "offline" } },
+ })
+ expect(toasts.success).not.toHaveBeenCalled()
+ expect(toasts.error).not.toHaveBeenCalled()
+ })
it("leaves existing observations intact when snapshot loading fails", async () => {
workspaces.set([{ id: "ws", status: "Running" }])
mockInvoke.mockRejectedValue(new Error("offline"))
diff --git a/desktop/src/renderer/src/lib/stores/workspaces.ts b/desktop/src/renderer/src/lib/stores/workspaces.ts
index 269f78d22..c6b9ac87f 100644
--- a/desktop/src/renderer/src/lib/stores/workspaces.ts
+++ b/desktop/src/renderer/src/lib/stores/workspaces.ts
@@ -1,12 +1,17 @@
-import { derived, get, writable } from "svelte/store"
+import { derived, writable } from "svelte/store"
import { workspaceSnapshot } from "$lib/ipc/commands.js"
import { onWorkspacesChanged } from "$lib/ipc/events.js"
import type { UnlistenFn } from "$lib/ipc/types.js"
+import { goto } from "$lib/router.js"
import type {
Workspace,
WorkspaceJob,
WorkspaceStatus,
} from "$lib/types/index.js"
+import {
+ workspaceConfirmedToast,
+ workspaceFailedHeadline,
+} from "$shared/workspace-operation.js"
import { toasts } from "./toasts.js"
export const workspaces = writable([])
@@ -38,7 +43,6 @@ function apply(
) {
if (nextRevision <= revision) return
revision = nextRevision
- const previous = get(workspaceJobs)
const pending = Object.entries(jobs).filter(
([id, job]) =>
!updated.some((workspace) => workspace.id === id) &&
@@ -48,17 +52,24 @@ function apply(
workspaces.set([...updated, ...pending.map(([id]) => ({ id }))])
workspaceJobs.set(jobs)
for (const [id, job] of Object.entries(jobs)) {
- if (job.state === "running") continue
- if (!notify || !previous[id] || notified.has(job.commandId)) {
+ if (job.state === "running" || job.state === "reconciling") continue
+ if (!notify || notified.has(job.commandId)) {
notified.add(job.commandId)
continue
}
notified.add(job.commandId)
- if (job.error) toasts.error(`${id}: ${job.error}`)
- else
- toasts.success(
- `${id}: ${job.activity === "deleting" ? "Deleted" : "Operation completed"}`,
+ if (job.state === "failed")
+ toasts.error(
+ `${id}: ${workspaceFailedHeadline(job.activity)} - ${job.error ?? "Workspace operation failed"}`,
+ {
+ sticky: true,
+ action: {
+ label: "View logs",
+ onClick: () => goto(`/workspaces/${id}?tab=logs`),
+ },
+ },
)
+ else toasts.success(`${id}: ${workspaceConfirmedToast(job.activity)}`)
}
}
@@ -88,6 +99,7 @@ export async function initWorkspaces() {
export function destroyWorkspaces() {
lifecycle++
+ notified.clear()
unlisten?.()
unlisten = null
}
diff --git a/desktop/src/renderer/src/lib/utils/workspace-operation.test.ts b/desktop/src/renderer/src/lib/utils/workspace-operation.test.ts
new file mode 100644
index 000000000..54d4e4342
--- /dev/null
+++ b/desktop/src/renderer/src/lib/utils/workspace-operation.test.ts
@@ -0,0 +1,181 @@
+import { describe, expect, it } from "vitest"
+import {
+ humanPhase,
+ presentWorkspaceStatus,
+ type WorkspaceActivity,
+ type WorkspaceJob,
+ workspaceConfirmedToast,
+ workspaceFailedHeadline,
+} from "$shared/workspace-operation.js"
+
+function job(partial: Partial): WorkspaceJob {
+ return {
+ commandId: "cmd",
+ activity: "creating",
+ state: "running",
+ phase: "cloning_repository",
+ ...partial,
+ }
+}
+
+const ACTIVITIES: WorkspaceActivity[] = [
+ "creating",
+ "starting",
+ "stopping",
+ "deleting",
+ "rebuilding",
+ "resetting",
+]
+
+describe("presentWorkspaceStatus", () => {
+ it("falls back to the observed lifecycle when no job is active", () => {
+ const view = presentWorkspaceStatus({ lifecycle: "Running" })
+ expect(view.headline).toBe("Running")
+ expect(view.tone).toBe("default")
+ expect(view.busy).toBe(false)
+ expect(view.phase).toBeUndefined()
+ })
+ it("shows Checking when neither job nor status exists", () => {
+ expect(presentWorkspaceStatus({}).headline).toBe("Checking")
+ })
+ it("clears the operation layer once the job succeeded", () => {
+ const view = presentWorkspaceStatus({
+ lifecycle: "Stopped",
+ job: job({ state: "succeeded", activity: "stopping" }),
+ })
+ expect(view.headline).toBe("Stopped")
+ expect(view.busy).toBe(false)
+ })
+ it.each(ACTIVITIES)("shows the bare verb while %s is running", (activity) => {
+ const view = presentWorkspaceStatus({
+ lifecycle: "Running",
+ job: job({ activity }),
+ })
+ expect(view.headline).toBe(activity[0].toUpperCase() + activity.slice(1))
+ expect(view.busy).toBe(true)
+ expect(view.tone).toBe("secondary")
+ expect(view.phase).toBe("Cloning repository")
+ })
+ it("keeps the verb headline and says Confirming status while reconciling", () => {
+ const view = presentWorkspaceStatus({
+ lifecycle: "Running",
+ job: job({
+ activity: "stopping",
+ state: "reconciling",
+ phase: "Refreshing status",
+ }),
+ })
+ expect(view.headline).toBe("Stopping")
+ expect(view.phase).toBe("Confirming status")
+ expect(view.busy).toBe(true)
+ })
+ it("says Confirming removal for a delete awaiting confirmation", () => {
+ const view = presentWorkspaceStatus({
+ lifecycle: "Running",
+ job: job({
+ activity: "deleting",
+ state: "reconciling",
+ phase: "Refreshing list",
+ }),
+ })
+ expect(view.headline).toBe("Deleting")
+ expect(view.phase).toBe("Confirming removal")
+ expect(view.recovery).toBeUndefined()
+ expect(view.busy).toBe(true)
+ })
+ it("never claims Deleted before confirmation", () => {
+ for (const state of ["running", "reconciling"] as const) {
+ const view = presentWorkspaceStatus({
+ lifecycle: "Running",
+ job: job({ activity: "deleting", state }),
+ })
+ expect(view.headline).not.toBe("Deleted")
+ expect(view.headline).toBe("Deleting")
+ }
+ })
+ it("surfaces a stalled list refresh as recovery, not failure", () => {
+ const view = presentWorkspaceStatus({
+ lifecycle: "Running",
+ job: job({
+ activity: "deleting",
+ state: "reconciling",
+ refreshError: "offline",
+ }),
+ })
+ expect(view.headline).toBe("Deleting")
+ expect(view.recovery).toEqual({
+ message: "List may be out of date",
+ canRetry: true,
+ })
+ expect(view.error).toBeUndefined()
+ expect(view.tone).toBe("warning")
+ expect(view.busy).toBe(false)
+ })
+ it("uses Status may be out of date for non-delete stalls", () => {
+ const view = presentWorkspaceStatus({
+ job: job({
+ activity: "stopping",
+ state: "reconciling",
+ refreshError: "x",
+ }),
+ })
+ expect(view.recovery?.message).toBe("Status may be out of date")
+ })
+ it.each(ACTIVITIES)(
+ "maps a failed %s to its failure headline",
+ (activity) => {
+ const view = presentWorkspaceStatus({
+ job: job({ activity, state: "failed", error: "boom" }),
+ })
+ expect(view.headline).toBe(workspaceFailedHeadline(activity))
+ expect(view.error).toBe("boom")
+ expect(view.tone).toBe("destructive")
+ expect(view.busy).toBe(false)
+ },
+ )
+ it("falls back when a failed job has no error message", () => {
+ const view = presentWorkspaceStatus({
+ job: job({ state: "failed" }),
+ })
+ expect(view.error).toBe("Workspace operation failed")
+ expect(view.tone).toBe("destructive")
+ })
+ it("marks logs available while a job exists and not otherwise", () => {
+ expect(presentWorkspaceStatus({ job: job({}) }).detailsAvailable).toBe(true)
+ expect(
+ presentWorkspaceStatus({ lifecycle: "Running" }).detailsAvailable,
+ ).toBe(false)
+ })
+})
+
+describe("humanPhase", () => {
+ it("maps known CLI phases to customer language", () => {
+ expect(humanPhase("cloning_repository")).toBe("Cloning repository")
+ expect(humanPhase("building_image")).toBe("Building image")
+ expect(humanPhase("starting_container")).toBe("Starting container")
+ expect(humanPhase("injecting_agent")).toBe("Connecting agent")
+ expect(humanPhase("stopping_workspace")).toBe("Stopping resources")
+ })
+ it("sentence-cases unknown phases without exposing underscores", () => {
+ expect(humanPhase("waiting_for_lock")).toBe("Waiting for lock")
+ })
+ it("returns undefined for empty input", () => {
+ expect(humanPhase(undefined)).toBeUndefined()
+ expect(humanPhase("")).toBeUndefined()
+ })
+})
+
+describe("toast labels", () => {
+ it("confirms each operation with locked wording", () => {
+ expect(workspaceConfirmedToast("creating")).toBe("Workspace ready")
+ expect(workspaceConfirmedToast("starting")).toBe("Workspace running")
+ expect(workspaceConfirmedToast("stopping")).toBe("Workspace stopped")
+ expect(workspaceConfirmedToast("deleting")).toBe("Workspace deleted")
+ expect(workspaceConfirmedToast("rebuilding")).toBe("Workspace rebuilt")
+ expect(workspaceConfirmedToast("resetting")).toBe("Workspace reset")
+ })
+ it("names failed operations", () => {
+ expect(workspaceFailedHeadline("deleting")).toBe("Delete failed")
+ expect(workspaceFailedHeadline("stopping")).toBe("Stop failed")
+ })
+})
diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte
index d4c585ec5..a2571b4a5 100644
--- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte
+++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte
@@ -32,7 +32,10 @@ import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte"
import LogTable from "$lib/components/log/LogTable.svelte"
import TerminalComponent from "$lib/components/terminal/Terminal.svelte"
import WorkspaceOperation from "$lib/components/workspace/WorkspaceOperation.svelte"
-import { workspaceJobBusy, workspaceJobInterruptible } from "$shared/workspace-operation.js"
+import {
+ workspaceJobBusy,
+ workspaceJobInterruptible,
+} from "$shared/workspace-operation.js"
import { workspaces, workspaceJobs } from "$lib/stores/workspaces.js"
import { addTerminal, removeTerminal } from "$lib/stores/terminals.js"
import { destroyTerminalInstance } from "$lib/stores/terminal-instances.js"
@@ -133,18 +136,32 @@ let isBusy = $derived.by(() => {
const BUILD_OPS = new Set(["Start", "Open IDE", "Recovery", "Rebuild", "Reset"])
let activeTab = $state("overview")
+$effect(() => {
+ const tab = new URLSearchParams($querystring ?? "").get("tab")
+ if (tab === "overview" || tab === "logs" || tab === "terminal")
+ activeTab = tab
+})
let outputLines = $state([])
let commandId = $state(null)
let operationLabel = $state("")
let awaitingAcceptance = $state(false)
-let operationRunning = $derived(awaitingAcceptance || workspaceJobBusy($workspaceJobs[id]))
+let operationRunning = $derived(
+ awaitingAcceptance || workspaceJobBusy($workspaceJobs[id]),
+)
$effect(() => {
const job = $workspaceJobs[id]
if (job && (!awaitingAcceptance || job.commandId === commandId)) {
awaitingAcceptance = false
if (commandId !== job.commandId) {
commandId = job.commandId
- operationLabel = ({ creating: "Create", starting: "Start", stopping: "Stop", deleting: "Delete", rebuilding: "Rebuild", resetting: "Reset" })[job.activity]
+ operationLabel = {
+ creating: "Create",
+ starting: "Start",
+ stopping: "Stop",
+ deleting: "Delete",
+ rebuilding: "Rebuild",
+ resetting: "Reset",
+ }[job.activity]
outputLines = []
}
}
@@ -637,10 +654,7 @@ async function handleRenameConfirmed() {
Rename
{/if}
-
- {#if operationRunning || isBusy}
-
- {/if}
+ (activeTab = "logs")} />
{#if inRecovery}
diff --git a/desktop/src/shared/workspace-operation.ts b/desktop/src/shared/workspace-operation.ts
index 4d7dceb56..878340eb3 100644
--- a/desktop/src/shared/workspace-operation.ts
+++ b/desktop/src/shared/workspace-operation.ts
@@ -30,21 +30,162 @@ export function workspaceJobInterruptible(job?: WorkspaceJob): boolean {
)
}
-export function workspaceJobLabel(job?: WorkspaceJob): string | undefined {
- if (!job || job.state === "succeeded") return undefined
- const action = job.activity[0].toUpperCase() + job.activity.slice(1)
- if (job.error)
- return `${{ creating: "Create", starting: "Start", stopping: "Stop", deleting: "Delete", rebuilding: "Rebuild", resetting: "Reset" }[job.activity]} failed`
- if (job.state === "reconciling" && job.activity === "deleting" && !job.error)
- return "Deleted"
- return action
+export type WorkspaceStatusTone =
+ | "default"
+ | "secondary"
+ | "outline"
+ | "destructive"
+ | "warning"
+
+export interface WorkspaceStatusView {
+ headline: string
+ phase?: string
+ tone: WorkspaceStatusTone
+ busy: boolean
+ announce: string
+ recovery?: { message: string; canRetry: boolean }
+ error?: string
+ detailsAvailable: boolean
}
-export function workspaceJobPhase(job?: WorkspaceJob): string | undefined {
+const HEADLINE: Record = {
+ creating: "Creating",
+ starting: "Starting",
+ stopping: "Stopping",
+ deleting: "Deleting",
+ rebuilding: "Rebuilding",
+ resetting: "Resetting",
+}
+
+const FAILED_HEADLINE: Record = {
+ creating: "Create failed",
+ starting: "Start failed",
+ stopping: "Stop failed",
+ deleting: "Delete failed",
+ rebuilding: "Rebuild failed",
+ resetting: "Reset failed",
+}
+
+const CONFIRMED_TOAST: Record = {
+ creating: "Workspace ready",
+ starting: "Workspace running",
+ stopping: "Workspace stopped",
+ deleting: "Workspace deleted",
+ rebuilding: "Workspace rebuilt",
+ resetting: "Workspace reset",
+}
+
+export function workspaceConfirmedToast(activity: WorkspaceActivity): string {
+ return CONFIRMED_TOAST[activity]
+}
+
+export function workspaceFailedHeadline(activity: WorkspaceActivity): string {
+ return FAILED_HEADLINE[activity]
+}
+
+const PHASE_LABELS: Record = {
+ cloning_repository: "Cloning repository",
+ resolving_config: "Resolving configuration",
+ initialize_command: "Running initialize command",
+ building_image: "Building image",
+ starting_container: "Starting container",
+ injecting_agent: "Connecting agent",
+ running_lifecycle_hook: "Running lifecycle hooks",
+ waiting_for: "Waiting",
+ running_command: "Running command",
+ configuring_workspace: "Configuring workspace",
+ configuring_ssh: "Configuring SSH",
+ starting_ssh_tunnel: "Starting SSH tunnel",
+ launching_ide: "Launching IDE",
+ stopping_workspace: "Stopping resources",
+ deleting_workspace: "Removing workspace",
+ rebuilding_workspace: "Rebuilding workspace",
+ resetting_workspace: "Resetting workspace",
+ ready: "Ready",
+ failed: "Failed",
+}
+
+export function humanPhase(raw?: string): string | undefined {
+ if (!raw) return undefined
+ const mapped = PHASE_LABELS[raw]
+ if (mapped) return mapped
+ const spaced = raw.replaceAll("_", " ").replaceAll("-", " ").trim()
+ if (!spaced) return undefined
+ return spaced[0].toUpperCase() + spaced.slice(1)
+}
+
+function confirmingPhase(activity: WorkspaceActivity): string {
+ return activity === "deleting" ? "Confirming removal" : "Confirming status"
+}
+
+function stalledMessage(activity: WorkspaceActivity): string {
+ return activity === "deleting"
+ ? "List may be out of date"
+ : "Status may be out of date"
+}
+
+export function presentWorkspaceStatus(input: {
+ lifecycle?: string
+ job?: WorkspaceJob
+}): WorkspaceStatusView {
+ const { lifecycle, job } = input
+ if (!job || job.state === "succeeded") {
+ const headline = lifecycle ?? "Checking"
+ return {
+ headline,
+ tone: lifecycle?.toLowerCase() === "running" ? "default" : "outline",
+ busy: false,
+ announce: headline,
+ detailsAvailable: false,
+ }
+ }
+ if (job.state === "failed") {
+ const headline = FAILED_HEADLINE[job.activity]
+ return {
+ headline,
+ tone: "destructive",
+ busy: false,
+ announce: headline,
+ error: job.error ?? "Workspace operation failed",
+ detailsAvailable: true,
+ }
+ }
+ const headline = HEADLINE[job.activity]
+ if (job.state === "reconciling") {
+ if (job.refreshError) {
+ const message = stalledMessage(job.activity)
+ return {
+ headline,
+ tone: "warning",
+ busy: false,
+ announce: `${headline}. ${message}`,
+ recovery: { message, canRetry: true },
+ detailsAvailable: true,
+ }
+ }
+ const phase = confirmingPhase(job.activity)
+ return {
+ headline,
+ phase,
+ tone: "secondary",
+ busy: true,
+ announce: `${headline}. ${phase}`,
+ detailsAvailable: true,
+ }
+ }
+ const phase = humanPhase(job.phase)
+ return {
+ headline,
+ phase,
+ tone: "secondary",
+ busy: true,
+ announce: phase ? `${headline}. ${phase}` : headline,
+ detailsAvailable: true,
+ }
+}
+
+export function workspaceJobLabel(job?: WorkspaceJob): string | undefined {
if (!job || job.state === "succeeded") return undefined
- if (job.refreshError)
- return job.activity === "deleting"
- ? "Unable to refresh list"
- : "Unable to refresh status"
- return job.phase
+ if (job.state === "failed") return workspaceFailedHeadline(job.activity)
+ return job.activity[0].toUpperCase() + job.activity.slice(1)
}