From 97b3175409e789e5ab0b6b4bac4997f3b43f1efa Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 17:12:55 -0600 Subject: [PATCH 1/2] feat(desktop): unify workspace operation status messaging Centralize workspace operation language across detail and launch views, with actionable terminal notifications and accessible recovery states. Signed-off-by: Samuel K --- .../workspace/WorkspaceOperation.svelte | 85 ++++-- .../workspace/WorkspaceOperation.test.ts | 90 +++++- .../WorkspaceWizard.platform.test.ts | 13 +- .../workspace/WorkspaceWizard.svelte | 259 ++++++++++++------ .../workspace/WorkspaceWizard.test.ts | 204 ++++++++++---- desktop/src/renderer/src/lib/stores/toasts.ts | 32 ++- .../src/lib/stores/workspaces.test.ts | 102 ++++++- .../src/renderer/src/lib/stores/workspaces.ts | 28 +- .../src/lib/utils/workspace-operation.test.ts | 181 ++++++++++++ .../src/pages/WorkspaceDetailPage.svelte | 28 +- desktop/src/shared/workspace-operation.ts | 169 +++++++++++- 11 files changed, 978 insertions(+), 213 deletions(-) create mode 100644 desktop/src/renderer/src/lib/utils/workspace-operation.test.ts diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.svelte index ec08a83ff..3f4e97e56 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.svelte @@ -3,35 +3,86 @@ import { workspaceJobs } from "$lib/stores/workspaces.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" import { workspaceRefresh } from "$lib/ipc/commands.js" -import { Spinner } from "$lib/components/ui/spinner/index.js" -import { workspaceJobBusy, workspaceJobLabel, workspaceJobPhase } from "$shared/workspace-operation.js" +import { Loader2 } from "@lucide/svelte" +import { + presentWorkspaceStatus, + type WorkspaceJob, +} from "$shared/workspace-operation.js" +import { goto } from "$lib/router.js" import { badgeVariants } from "$lib/components/ui/badge/index.js" -import { Button } from "$lib/components/ui/button/index.js" -let { id, status }: { id: string; status?: string } = $props() -let job = $derived($workspaceJobs[id]) -let label = $derived(workspaceJobLabel(job)) -let phase = $derived(workspaceJobPhase(job)) + +let { + id, + status, + job: jobOverride, + density = "compact", + onViewLogs, +}: { + id: string + status?: string + job?: WorkspaceJob + density?: "compact" | "expanded" + onViewLogs?: () => void +} = $props() +let job = $derived(jobOverride ?? $workspaceJobs[id]) +let view = $derived(presentWorkspaceStatus({ lifecycle: status, job })) let refreshing = $state(false) +function viewLogs(event: MouseEvent) { + event.stopPropagation() + if (onViewLogs) onViewLogs() + else goto(`/workspaces/${id}?tab=logs`) +} async function retryRefresh(event: MouseEvent) { event.stopPropagation() refreshing = true try { await workspaceRefresh(id) } catch (error) { - toasts.error(`Could not refresh workspace status: ${extractErrorMessage(error)}`) + toasts.error( + `Could not refresh workspace status: ${extractErrorMessage(error)}`, + ) } finally { refreshing = false } } +const badgeVariant = $derived( + view.tone === "warning" + ? "secondary" + : (view.tone as "default" | "secondary" | "outline" | "destructive"), +) -
- - {#if workspaceJobBusy(job)}{/if} - {label ?? status ?? "Checking"} + +
+ + {#if view.busy} + + {#if view.error} + {view.error}{#if density === "expanded"}{" · "}{/if} + {:else if view.recovery} + ⚠ {view.recovery.message}{#if view.recovery.canRetry}{" · "}{/if} + {:else if view.phase} + {view.phase} + {:else} + + {/if} - {#if phase}{phase}{/if} - {#if job?.error}{job.error}{/if} - {#if job?.refreshError} - - {/if}
diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.test.ts index 21b96034b..d445ecb74 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.test.ts +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.test.ts @@ -1,11 +1,20 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { mockInvoke, resetTauriMocks } from "$lib/__mocks__/tauri.js" -import { workspaceJobs } from "$lib/stores/workspaces.js" import { toasts } from "$lib/stores/toasts.js" +import { workspaceJobs } from "$lib/stores/workspaces.js" import WorkspaceOperation from "./WorkspaceOperation.svelte" + vi.mock("$lib/stores/toasts.js", () => ({ - toasts: { success: vi.fn(), error: vi.fn() }, + toasts: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})) +vi.mock("$lib/router.js", () => ({ + goto: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + router: {}, + location: { subscribe: () => () => {} }, + querystring: { subscribe: () => () => {} }, })) afterEach(cleanup) beforeEach(() => { @@ -14,20 +23,20 @@ beforeEach(() => { vi.clearAllMocks() }) describe("WorkspaceOperation", () => { - it("keeps the status pill at intrinsic width inside the live region", () => { + it("shows the observed lifecycle with the phase line reserved", () => { const ui = render(WorkspaceOperation, { id: "ws", status: "Running" }) - const badge = ui.getByText("Running") - const liveRegion = badge.parentElement - expect(liveRegion?.classList.contains("flex-col")).toBe(true) - expect(liveRegion?.classList.contains("items-start")).toBe(true) + expect(ui.getByText("Running")).toBeTruthy() + const region = ui.getByRole("status") + expect(region.getAttribute("aria-busy")).toBe("false") + expect(region.querySelector('[aria-hidden="true"]')).toBeTruthy() }) - it("keeps the action visible over a stale runtime observation", () => { + it("keeps the active command ahead of a stale runtime observation", () => { workspaceJobs.set({ ws: { commandId: "delete", activity: "deleting", state: "running", - phase: "Closing connections", + phase: "closing_connections", }, }) const ui = render(WorkspaceOperation, { id: "ws", status: "Running" }) @@ -35,7 +44,22 @@ describe("WorkspaceOperation", () => { expect(ui.getByText("Closing connections")).toBeTruthy() expect(ui.queryByText("Running")).toBeNull() }) - it("retries observation only and reports an IPC rejection", async () => { + it("says Confirming removal while a delete awaits confirmation, never Deleted", () => { + workspaceJobs.set({ + ws: { + commandId: "delete", + activity: "deleting", + state: "reconciling", + phase: "Refreshing list", + }, + }) + const ui = render(WorkspaceOperation, { id: "ws", status: "Running" }) + expect(ui.getByText("Deleting")).toBeTruthy() + expect(ui.getByText("Confirming removal")).toBeTruthy() + expect(ui.queryByText("Deleted")).toBeNull() + expect(ui.getByRole("status").getAttribute("aria-busy")).toBe("true") + }) + it("shows recovery wording with an inline Retry that only re-refreshes", async () => { workspaceJobs.set({ ws: { commandId: "delete", @@ -47,8 +71,12 @@ describe("WorkspaceOperation", () => { }) mockInvoke.mockRejectedValue(new Error("IPC unavailable")) const ui = render(WorkspaceOperation, { id: "ws", status: "Running" }) - expect(ui.getByText("Deleted")).toBeTruthy() - await fireEvent.click(ui.getByRole("button", { name: "Retry refresh" })) + expect(ui.getByText(/List may be out of date/)).toBeTruthy() + expect(ui.queryByText("Deleted")).toBeNull() + expect(ui.getByRole("status").getAttribute("aria-busy")).toBe("false") + await fireEvent.click( + ui.getByRole("button", { name: "Retry status for ws" }), + ) await waitFor(() => expect(toasts.error).toHaveBeenCalledWith( expect.stringContaining("IPC unavailable"), @@ -60,6 +88,42 @@ describe("WorkspaceOperation", () => { expect( mockInvoke.mock.calls.some((call) => call[0] === "workspace_delete"), ).toBe(false) - expect(ui.getByText("Deleted")).toBeTruthy() + }) + it("renders an operation failure with the error and View logs when expanded", async () => { + workspaceJobs.set({ + ws: { + commandId: "stop", + activity: "stopping", + state: "failed", + phase: "stopping_workspace", + error: "provider unavailable", + }, + }) + const onViewLogs = vi.fn() + const ui = render(WorkspaceOperation, { + id: "ws", + status: "Running", + density: "expanded", + onViewLogs, + }) + expect(ui.getByText("Stop failed")).toBeTruthy() + expect(ui.getByText(/provider unavailable/)).toBeTruthy() + expect(ui.getByRole("status").getAttribute("aria-live")).toBe("assertive") + await fireEvent.click(ui.getByRole("button", { name: "View logs for ws" })) + expect(onViewLogs).toHaveBeenCalledOnce() + }) + it("does not nest a View logs button in compact density", () => { + workspaceJobs.set({ + ws: { + commandId: "stop", + activity: "stopping", + state: "failed", + phase: "stopping_workspace", + error: "provider unavailable", + }, + }) + const ui = render(WorkspaceOperation, { id: "ws", status: "Running" }) + expect(ui.getByText("Stop failed")).toBeTruthy() + expect(ui.queryByRole("button", { name: "View logs for ws" })).toBeNull() }) }) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.platform.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.platform.test.ts index c1bb653dc..adede99f6 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.platform.test.ts +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.platform.test.ts @@ -37,7 +37,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", () => ({ toasts: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, @@ -206,7 +209,9 @@ describe("WorkspaceWizard platform compatibility", () => { await gotoReviewWithImage(getByText, "ubuntu:22.04") await waitFor(() => - expect(getByText(/Compatible with your machine \(linux\/arm64\)/i)).toBeTruthy(), + expect( + getByText(/Compatible with your machine \(linux\/arm64\)/i), + ).toBeTruthy(), ) expect(getByText(/linux\/amd64, linux\/arm64/i)).toBeTruthy() unmount() @@ -221,7 +226,7 @@ describe("WorkspaceWizard platform compatibility", () => { await gotoReviewWithImage(getByText, "ubuntu:22.04") await waitFor(() => - expect(getByText(/Couldn't verify compatibility/i)).toBeTruthy(), + expect(getByText(/Could not verify compatibility/i)).toBeTruthy(), ) expect(queryByText(/no build for your machine/i)).toBeNull() expect(document.querySelector('input[type="checkbox"]')).toBeNull() @@ -245,7 +250,7 @@ describe("WorkspaceWizard platform compatibility", () => { await gotoReviewWithImage(getByText, "ubuntu:22.04") await waitFor(() => - expect(getByText(/Couldn't verify compatibility/i)).toBeTruthy(), + expect(getByText(/Could not verify compatibility/i)).toBeTruthy(), ) expect(queryByText(/no build for your machine/i)).toBeNull() unmount() diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 76afd320f..d34fd1e5a 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -6,6 +6,7 @@ import { MediaQuery } from "svelte/reactivity" import { goto } from "$lib/router.js" import { Check, + ChevronRight, ChevronsUpDown, AlertCircle, TriangleAlert, @@ -30,7 +31,11 @@ import LanguageIcon from "$lib/components/workspace/LanguageIcon.svelte" import ImagePicker from "$lib/components/workspace/ImagePicker.svelte" import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte" import LogTable from "$lib/components/log/LogTable.svelte" -import { uniqueNamesGenerator, adjectives, animals } from "unique-names-generator" +import { + uniqueNamesGenerator, + adjectives, + animals, +} from "unique-names-generator" import { workspaceUp, openDirectoryDialog, @@ -82,7 +87,8 @@ const IDE_ICON_DARK_VARIANTS = new Set([ ]) const ideIcon = (name: string) => { - const variant = darkMode.current && IDE_ICON_DARK_VARIANTS.has(name) ? `${name}_dark` : name + const variant = + darkMode.current && IDE_ICON_DARK_VARIANTS.has(name) ? `${name}_dark` : name return `./icons/ides/${variant}.svg` } @@ -92,14 +98,26 @@ const IDE_GROUPS = [ options: [ { value: "none", label: "None", iconName: "none" }, { value: "vscode", label: "VS Code", iconName: "vscode" }, - { value: "openvscode", label: "OpenVSCode Server", iconName: "vscodebrowser" }, - { value: "vscode-web", label: "VS Code for the Web", iconName: "vscode-web" }, + { + value: "openvscode", + label: "OpenVSCode Server", + iconName: "vscodebrowser", + }, + { + value: "vscode-web", + label: "VS Code for the Web", + iconName: "vscode-web", + }, { value: "code-server", label: "code-server", iconName: "code-server" }, { value: "cursor", label: "Cursor", iconName: "cursor" }, { value: "zed", label: "Zed", iconName: "zed" }, { value: "codium", label: "VSCodium", iconName: "codium" }, { value: "windsurf", label: "Windsurf Editor", iconName: "windsurf" }, - { value: "antigravity", label: "Google Antigravity", iconName: "antigravity" }, + { + value: "antigravity", + label: "Google Antigravity", + iconName: "antigravity", + }, { value: "bob", label: "IBM Bob", iconName: "bob" }, ], }, @@ -122,9 +140,17 @@ const IDE_GROUPS = [ { label: "Other", options: [ - { value: "jupyternotebook", label: "Jupyter Notebook", iconName: "jupyter" }, + { + value: "jupyternotebook", + label: "Jupyter Notebook", + iconName: "jupyter", + }, { value: "marimo", label: "marimo", iconName: "marimo" }, - { value: "vscode-insiders", label: "VS Code Insiders", iconName: "vscode_insiders" }, + { + value: "vscode-insiders", + label: "VS Code Insiders", + iconName: "vscode_insiders", + }, { value: "positron", label: "Positron", iconName: "positron" }, { value: "rstudio", label: "RStudio Server", iconName: "rstudio" }, ], @@ -134,15 +160,33 @@ const IDE_GROUPS = [ const ALL_IDES = IDE_GROUPS.flatMap((g) => g.options) const TEMPLATES = [ - { name: "Python", source: "https://github.com/microsoft/vscode-remote-try-python" }, - { name: "Node.js", source: "https://github.com/microsoft/vscode-remote-try-node" }, + { + name: "Python", + source: "https://github.com/microsoft/vscode-remote-try-python", + }, + { + name: "Node.js", + source: "https://github.com/microsoft/vscode-remote-try-node", + }, { name: "Go", source: "https://github.com/microsoft/vscode-remote-try-go" }, - { name: "Rust", source: "https://github.com/microsoft/vscode-remote-try-rust" }, - { name: "Java", source: "https://github.com/microsoft/vscode-remote-try-java" }, + { + name: "Rust", + source: "https://github.com/microsoft/vscode-remote-try-rust", + }, + { + name: "Java", + source: "https://github.com/microsoft/vscode-remote-try-java", + }, { name: "PHP", source: "https://github.com/microsoft/vscode-remote-try-php" }, { name: "C++", source: "https://github.com/microsoft/vscode-remote-try-cpp" }, - { name: ".NET", source: "https://github.com/microsoft/vscode-remote-try-dotnet" }, - { name: "Ruby", source: "https://github.com/skevetter/devsy-quickstart-ruby" }, + { + name: ".NET", + source: "https://github.com/microsoft/vscode-remote-try-dotnet", + }, + { + name: "Ruby", + source: "https://github.com/skevetter/devsy-quickstart-ruby", + }, ] const SOURCE_TYPES: { @@ -151,9 +195,24 @@ const SOURCE_TYPES: { hint: string icon: typeof GitBranch }[] = [ - { value: "git", label: "Git Repo", hint: "Clone a repository", icon: GitBranch }, - { value: "local", label: "Local Directory", hint: "Use a folder on this machine", icon: FolderOpen }, - { value: "image", label: "Image", hint: "Start from a container image", icon: Container }, + { + value: "git", + label: "Git Repo", + hint: "Clone a repository", + icon: GitBranch, + }, + { + value: "local", + label: "Local Directory", + hint: "Use a folder on this machine", + icon: FolderOpen, + }, + { + value: "image", + label: "Image", + hint: "Start from a container image", + icon: Container, + }, ] const LAUNCH_TIMEOUT_MS = 10 * 60 * 1000 @@ -170,7 +229,7 @@ let currentStep = $state("provider") // Form state let selectedProvider = $state( - $providers.find((p) => p.isDefault && p.state?.initialized)?.name ?? "" + $providers.find((p) => p.isDefault && p.state?.initialized)?.name ?? "", ) let sourceType = $state("git") let repoUrl = $state("") @@ -229,12 +288,35 @@ let launchBuildFailed = $state(false) let launchIsRecovery = $state(false) let lastAttemptedId = $state("") let launchSuccess = $state(false) +let showLogs = $state(false) let launchedWorkspaceId = $state(null) let operationStatus = $state(null) +let launchJob = $derived($workspaceJobs[lastAttemptedId ?? ""]) +$effect(() => { + if (launchError) showLogs = true +}) $effect(() => { const job = $workspaceJobs[lastAttemptedId ?? ""] - if (launchRunning && commandId && job?.commandId === commandId && (job.state === "failed" || job.state === "succeeded")) { - finishProgress({ commandId, done: true, success: !job.error, cliError: job.error ? { code: "workspace_operation_failed", message: job.error } : undefined }, lastAttemptedId ?? undefined) + if ( + launchRunning && + commandId && + job?.commandId === commandId && + (job.state === "failed" || job.state === "succeeded") + ) { + finishProgress( + { + commandId, + done: true, + success: job.state !== "failed", + cliError: job.state === "failed" + ? { + code: "workspace_operation_failed", + message: job.error ?? "Workspace operation failed", + } + : undefined, + }, + lastAttemptedId ?? undefined, + ) } }) let confirmCancelOpen = $state(false) @@ -259,7 +341,9 @@ let initializedProviders = $derived( const selectedIdeEntry = $derived(ALL_IDES.find((i) => i.value === selectedIde)) const ideLabel = $derived(selectedIdeEntry?.label ?? "Select an IDE...") -const ideIconSrc = $derived(selectedIdeEntry ? ideIcon(selectedIdeEntry.iconName) : undefined) +const ideIconSrc = $derived( + selectedIdeEntry ? ideIcon(selectedIdeEntry.iconName) : undefined, +) let filteredIdes = $derived( ideSearch @@ -282,9 +366,7 @@ let resolvedIdInvalid = $derived( let nameConflict = $derived( resolvedId !== "" && - $workspaces.some( - (ws) => ws.id.toLowerCase() === resolvedId.toLowerCase(), - ), + $workspaces.some((ws) => ws.id.toLowerCase() === resolvedId.toLowerCase()), ) let imageIncompatible = $derived( @@ -297,7 +379,9 @@ let imageIncompatible = $derived( // Prefer linux/amd64 if the image offers it; otherwise the first listed // platform. This is what we run under emulation. let emulationTarget = $derived( - imagePlatforms.includes("linux/amd64") ? "linux/amd64" : imagePlatforms[0] ?? "", + imagePlatforms.includes("linux/amd64") + ? "linux/amd64" + : (imagePlatforms[0] ?? ""), ) let imageCompatible = $derived( @@ -348,7 +432,8 @@ function clearWatchdog() { function reset() { currentStep = "provider" - selectedProvider = $providers.find((p) => p.isDefault && p.state?.initialized)?.name ?? "" + selectedProvider = + $providers.find((p) => p.isDefault && p.state?.initialized)?.name ?? "" sourceType = "git" repoUrl = "" localPath = "" @@ -375,6 +460,7 @@ function reset() { launchRunning = false launchError = "" launchSuccess = false + showLogs = false launchedWorkspaceId = null operationStatus = null confirmCancelOpen = false @@ -465,7 +551,8 @@ function flushLines() { } function queueProgressLines(progress: CommandProgress) { - const incoming = progress.lines ?? (progress.message ? [progress.message] : []) + const incoming = + progress.lines ?? (progress.message ? [progress.message] : []) if (incoming.length === 0) return pendingLines.push(...incoming) if (flushHandle === null) { @@ -1193,7 +1280,7 @@ function selectTemplate(t: { name: string; source: string }) { {#if compatUnknown}

- 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

- -
- -
- {:else if launchRunning} -
- + + {#if showLogs} +
+
+ +
+ +
+ {/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) } From 25fd26c609635a073722b21b1b4de819e6565a19 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 08:33:56 -0600 Subject: [PATCH 2/2] fix(desktop): avoid nested workspace operation actions --- .../workspace/WorkspaceOperation.svelte | 2 +- .../workspace/WorkspaceOperation.test.ts | 39 ++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.svelte index 3f4e97e56..1844a9c21 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceOperation.svelte @@ -72,7 +72,7 @@ const badgeVariant = $derived( aria-label="View logs for {id}" onclick={viewLogs}>View logs{/if} {:else if view.recovery} - ⚠ {view.recovery.message}{#if view.recovery.canRetry}{" · "}