diff --git a/Taskfile.yml b/Taskfile.yml index b191bf19a..e9e9c4c70 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -148,9 +148,22 @@ tasks: desc: run e2e tests using act with focus cmd: act workflow_dispatch -W .github/workflows/act.yml -j e2e-test --input test_focus={{ .CLI_ARGS }} + desktop:deps: + desc: install Linux desktop runtime dependencies for Electron and Playwright + cmds: + - cmd: sudo apt-get update + - cmd: >- + sudo apt-get install -y + libglib2.0-0 libnspr4 libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 + libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 + libcairo2 libxshmfence1 libgtk-3-0 libxss1 libatspi2.0-0 libnotify4 libxtst6 xdg-utils + libuuid1 xvfb + desktop:setup: desc: setup Electron desktop application dir: desktop + deps: + - desktop:deps cmds: - task: cli:build:dev - cmd: mkdir -p resources/bin @@ -183,8 +196,22 @@ tasks: desktop:test:e2e: desc: run desktop e2e tests + deps: + - desktop:deps dir: desktop - cmd: npm run test:e2e + cmd: | + export DISPLAY=:99 + if [ ! -f /tmp/.X99-lock ]; then + Xvfb :99 -screen 0 1280x720x24 >/tmp/devsy-xvfb.log 2>&1 & + # Wait for Xvfb to be ready (max 10 seconds) + for i in $(seq 1 50); do + if [ -f /tmp/.X99-lock ]; then + break + fi + sleep 0.2 + done + fi + npm run test:e2e desktop:act:build:ui: desc: build desktop ui using act diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs old mode 100755 new mode 100644 index cd7200652..75666367d --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -204,19 +204,35 @@ function handleSsh() { process.exit(0) } -// Adds a completed workspace to state. -function materializeWorkspace(wsId, source, providerFlag, ideFlag) { - state.workspaces.push({ - id: wsId, - uid: `ws-${Date.now()}`, - source: { gitRepository: source }, - provider: { name: providerFlag || "docker" }, - ide: { name: ideFlag || "none" }, - status: "Running", - lastUsed: new Date().toISOString(), - created: new Date().toISOString(), - context: "default", - }) +// Adds or updates a workspace in state, preserving the existing workspace +// shape when the same id already exists so lifecycle transitions are stable. +function materializeWorkspace(wsId, source, providerFlag, ideFlag, status = "Running") { + const existingIndex = state.workspaces.findIndex((w) => w.id === wsId) + const now = new Date().toISOString() + + if (existingIndex >= 0) { + // For existing workspaces, merge only lifecycle fields; preserve metadata + const existing = state.workspaces[existingIndex] + state.workspaces[existingIndex] = { + ...existing, + status, + lastUsed: now, + } + } else { + // For new workspaces, build all fields from arguments + const entry = { + id: wsId, + uid: `ws-${Date.now()}`, + source: { gitRepository: source }, + provider: { name: providerFlag || "docker" }, + ide: { name: ideFlag || "none" }, + status, + lastUsed: now, + created: now, + context: "default", + } + state.workspaces.push(entry) + } saveState(state) } @@ -256,7 +272,7 @@ function handleUp(args) { out("Pulling image") out("Starting workspace") out("Workspace ready") - materializeWorkspace(wsId, source, providerFlag, ideFlag) + materializeWorkspace(wsId, source, providerFlag, ideFlag, "Running") process.exit(0) } @@ -404,6 +420,18 @@ function handleStop(args) { process.exit(0) } +function handleStart(args) { + const { positional, idFlag, providerFlag, ideFlag } = parseArgs(args) + const source = positional[0] + const wsId = idFlag || source || "workspace" + out("Resolving source") + out("Pulling image") + out("Starting workspace") + out("Workspace ready") + materializeWorkspace(wsId, source, providerFlag, ideFlag, "Running") + process.exit(0) +} + function handleDelete(args) { const { positional } = parseArgs(args) const wsId = positional[0] @@ -448,6 +476,7 @@ const workspaceHandlers = { ssh: handleSsh, up: handleUp, task: handleTask, + start: handleStart, stop: handleStop, delete: handleDelete, rename: handleRename, diff --git a/desktop/e2e/workspaces.e2e.ts b/desktop/e2e/workspaces.e2e.ts index c5ebc555a..9b405d930 100644 --- a/desktop/e2e/workspaces.e2e.ts +++ b/desktop/e2e/workspaces.e2e.ts @@ -89,11 +89,124 @@ test.describe("Workspace lifecycle badges", () => { }) }) +test.describe("Workspace detail flow", () => { + test.beforeEach(async () => { + await page.click('[data-sidebar="sidebar"] a[href="#/workspaces"]') + await page.locator("table").waitFor({ timeout: 10000 }) + }) + + const api = async (channel: string, args: Record) => + page.evaluate( + ([c, a]) => + ( + window as unknown as { + electronAPI: { + invoke: ( + c: string, + a?: Record, + ) => Promise + } + } + ).electronAPI.invoke(c as string, a as Record), + [channel, args] as const, + ) + + test("supports a start/stop playthrough from the workspaces list", async () => { + const workspaceName = "flow-playthrough" + await api("workspace_up", { + source: "https://example.com/flow-playthrough.git", + workspaceId: workspaceName, + }) + + await expect(page.locator("table")).toContainText(workspaceName, { + timeout: 10000, + }) + + await page + .locator("tbody tr") + .filter({ hasText: workspaceName }) + .first() + .click() + + await expect( + page.getByRole("heading", { name: workspaceName }), + ).toBeVisible({ timeout: 10000 }) + + const main = page.locator('[data-slot="sidebar-inset"] main') + await expect(main).toContainText("Running") + + await page.getByRole("button", { name: /^stop$/i }).click() + await expect(main).toContainText("Stopping", { timeout: 3000 }) + await expect(main).toContainText("Stopped", { timeout: 10000 }) + + await page.getByRole("button", { name: /^start$/i }).click() + await expect(main).toContainText("Starting", { timeout: 3000 }) + await expect(main).toContainText("Running", { timeout: 10000 }) + }) + + test("shows rebuild and delete confirmation flows from the detail page", async () => { + const workspaceName = "flow-delete-rebuild" + await api("workspace_up", { + source: "https://example.com/flow-delete-rebuild.git", + workspaceId: workspaceName, + }) + + await page.locator("table").waitFor({ timeout: 10000 }) + await expect( + page.locator("tbody tr").filter({ hasText: workspaceName }).first(), + ).toBeVisible({ timeout: 10000 }) + + await page + .locator("tbody tr") + .filter({ hasText: workspaceName }) + .first() + .click() + + await expect( + page.getByRole("heading", { name: workspaceName }), + ).toBeVisible({ timeout: 10000 }) + + await page.getByRole("button", { name: /more actions/i }).click() + await page.getByRole("menuitem", { name: /rebuild/i }).click() + + const rebuildDialog = page.locator('[role="dialog"]').filter({ hasText: /rebuild workspace/i }).first() + await expect(rebuildDialog).toBeVisible({ timeout: 5000 }) + await expect(rebuildDialog).toContainText("Rebuild workspace") + await rebuildDialog.getByRole("button", { name: /^cancel$/i }).click() + + await page.getByRole("button", { name: /more actions/i }).click() + await page.getByRole("menuitem", { name: /delete/i }).click() + + const deleteDialog = page.locator('[role="dialog"]').filter({ hasText: /delete workspace/i }).first() + await expect(deleteDialog).toBeVisible({ timeout: 5000 }) + await expect(deleteDialog).toContainText("Delete workspace") + await deleteDialog.getByRole("button", { name: /^cancel$/i }).click() + }) +}) + test.describe.serial("Create Workspace Wizard", () => { - test("should open the wizard and show step 1 (provider)", async () => { - await page.getByRole("button", { name: /create workspace/i }).click() + async function openCreateWorkspaceWizard(page: Page) { + await page.click('[data-sidebar="sidebar"] a[href="#/workspaces"]') + await page.locator('[data-slot="sidebar-inset"] main').first().waitFor({ + timeout: 30_000, + }) + + // Support both old/new CTA labels. + const createWorkspaceButton = page + .getByRole("button", { name: /create workspace|new workspace/i }) + .first() + + await expect(createWorkspaceButton).toBeVisible({ timeout: 30_000 }) + await expect(createWorkspaceButton).toBeEnabled({ timeout: 30_000 }) + await createWorkspaceButton.click() + const dialog = page.locator('[role="dialog"]').first() - await expect(dialog).toBeVisible({ timeout: 5000 }) + await expect(dialog).toBeVisible({ timeout: 10_000 }) + return dialog + } + + test("should open the wizard and show step 1 (provider)", async () => { + const dialog = await openCreateWorkspaceWizard(page) // Step indicator labels — all 5 steps present for (const label of ["Provider", "Source", "IDE", "Review", "Launch"]) { diff --git a/desktop/src/main/__tests__/state.test.ts b/desktop/src/main/__tests__/state.test.ts index 343e849fa..b241dd019 100644 --- a/desktop/src/main/__tests__/state.test.ts +++ b/desktop/src/main/__tests__/state.test.ts @@ -36,6 +36,17 @@ describe("DaemonState", () => { expect(state.workspaceList()).toHaveLength(1) }) + it("preserves a cached workspace status when later updates omit it", () => { + const state = new DaemonState() + expect( + state.updateWorkspaces([{ id: "ws1", lastUsed: "2024-01-01", status: "Running" }]), + ).toBe(true) + expect( + state.updateWorkspaces([{ id: "ws1", lastUsed: "2024-01-02" }]), + ).toBe(true) + expect(state.workspaceList()[0].status).toBe("Running") + }) + it("detects provider changes", () => { const state = new DaemonState() const providers = [makeProvider("docker")] diff --git a/desktop/src/main/cli.ts b/desktop/src/main/cli.ts index 7d98ae5e6..0e1d219f6 100644 --- a/desktop/src/main/cli.ts +++ b/desktop/src/main/cli.ts @@ -272,17 +272,24 @@ export class CliRunner { } let lastCliError: CLIError | undefined + let suppressCallbacks = false if (child.stdout) { const applyBackpressure = backpressureController(child.stdout) const rl = createInterface({ input: child.stdout }) - rl.on("line", (line) => applyBackpressure(onLine(line, "stdout"))) + rl.on("line", (line) => { + if (suppressCallbacks) return + applyBackpressure(onLine(line, "stdout")) + }) + // Store readline interface for cleanup + ;(child as unknown as { _rlStdout?: typeof rl })._rlStdout = rl } if (child.stderr) { const applyBackpressure = backpressureController(child.stderr) const rl = createInterface({ input: child.stderr }) rl.on("line", (line) => { + if (suppressCallbacks) return const parsed = parseStderrLine(line) if (parsed?.cliError) { lastCliError = parsed.cliError @@ -295,6 +302,13 @@ export class CliRunner { } applyBackpressure(onLine(line, "stderr", meta)) }) + // Store readline interface for cleanup + ;(child as unknown as { _rlStderr?: typeof rl })._rlStderr = rl + } + + // Expose a method to suppress callbacks (used by cancelFor timeout) + ;(child as unknown as { _suppressCallbacks?: () => void })._suppressCallbacks = () => { + suppressCallbacks = true } let settled = false @@ -313,6 +327,9 @@ export class CliRunner { onExit(code, cliError) } + // Expose finish for cancelFor timeout handling + ;(child as unknown as { _finish?: typeof finish })._finish = finish + // A spawn failure (missing binary, EACCES) emits "error" and never // "close". Without this, onExit never fires: callers that wrap this in a // promise hang forever, and the concurrency slot is never released. @@ -345,13 +362,66 @@ export class CliRunner { const waits: Promise[] = [] for (const child of bucket) { + let timedOut = false waits.push( new Promise((resolve) => { + let settled = false + let timer: ReturnType | null = setTimeout(() => { + timer = null + settled = true + timedOut = true + resolve() + }, 2000) + if (child.exitCode !== null || child.signalCode !== null) { + if (timer) clearTimeout(timer) + settled = true resolve() return } - child.once("close", () => resolve()) + child.once("close", () => { + if (timer) { + clearTimeout(timer) + timer = null + } + settled = true + resolve() + }) + }).then(() => { + // If process did not close in time, forcefully kill and suppress late callbacks + if (child.exitCode === null && child.signalCode === null) { + // Run lifecycle cleanup BEFORE suppressing callbacks/removing listeners + // so finish(...) can properly clean up sessions and call onExit + if (timedOut) { + const finishFn = (child as unknown as { _finish?: (code: number, cliError?: CLIError) => void })._finish + if (finishFn) { + finishFn(-1, { code: "timeout", message: "Process did not exit in time" }) + } + } + + // Now suppress callbacks at the source + const suppressFn = (child as unknown as { _suppressCallbacks?: () => void })._suppressCallbacks + if (suppressFn) suppressFn() + + // Close readline interfaces + const rlStdout = (child as unknown as { _rlStdout?: { close: () => void } })._rlStdout + const rlStderr = (child as unknown as { _rlStderr?: { close: () => void } })._rlStderr + if (rlStdout) rlStdout.close() + if (rlStderr) rlStderr.close() + + // Destroy streams to stop emitting data events + if (child.stdout) { + child.stdout.removeAllListeners() + child.stdout.destroy() + } + if (child.stderr) { + child.stderr.removeAllListeners() + child.stderr.destroy() + } + + child.removeAllListeners() + child.kill("SIGKILL") + } }), ) child.kill("SIGTERM") diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 1635fd462..b72f18e52 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -228,15 +228,60 @@ export function registerIpcHandlers(deps: IpcDependencies): { const tunnelProc = tunnelProcesses.get(workspaceId) if (tunnelProc) { tunnelProcesses.delete(workspaceId) + let settled = false const tunnelExit = new Promise((resolve) => { + let timer: ReturnType | null = setTimeout(() => { + timer = null + settled = true + resolve() + }, 2000) + if (tunnelProc.exitCode !== null || tunnelProc.signalCode !== null) { + if (timer) clearTimeout(timer) + settled = true resolve() return } - tunnelProc.once("close", () => resolve()) + tunnelProc.once("close", () => { + if (timer) { + clearTimeout(timer) + timer = null + } + settled = true + resolve() + }) }) tunnelProc.kill("SIGTERM") await tunnelExit + // If process did not close in time, forcefully kill and suppress any late callbacks + if (!settled || (tunnelProc.exitCode === null && tunnelProc.signalCode === null)) { + // Suppress workspace callbacks from the onLine handler + const suppressWorkspaceFn = (tunnelProc as unknown as { _suppressWorkspaceCallbacks?: () => void })._suppressWorkspaceCallbacks + if (suppressWorkspaceFn) suppressWorkspaceFn() + + // Suppress callbacks at the readline level + const suppressFn = (tunnelProc as unknown as { _suppressCallbacks?: () => void })._suppressCallbacks + if (suppressFn) suppressFn() + + // Close readline interfaces + const rlStdout = (tunnelProc as unknown as { _rlStdout?: { close: () => void } })._rlStdout + const rlStderr = (tunnelProc as unknown as { _rlStderr?: { close: () => void } })._rlStderr + if (rlStdout) rlStdout.close() + if (rlStderr) rlStderr.close() + + // Destroy streams to stop emitting data events + if (tunnelProc.stdout) { + tunnelProc.stdout.removeAllListeners() + tunnelProc.stdout.destroy() + } + if (tunnelProc.stderr) { + tunnelProc.stderr.removeAllListeners() + tunnelProc.stderr.destroy() + } + + tunnelProc.removeAllListeners() + tunnelProc.kill("SIGKILL") + } } } @@ -908,6 +953,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { prebuildRepository?: string platform?: string recovery?: boolean + commandId?: string }, ) => { trackEvent("workspace_create", { @@ -929,7 +975,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (args.recovery) cliArgs.push("--recovery") const wsId = args.workspaceId ?? args.source - const cmdId = crypto.randomUUID() + const cmdId = args.commandId ?? crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(wsId), wsId) const sink = createLogSink( deps.getMainWindow, @@ -975,12 +1021,13 @@ export function registerIpcHandlers(deps: IpcDependencies): { } let signalledDone = false + let suppressCallbacks = false let child: import("node:child_process").ChildProcess try { child = await cli.runStreaming( ["workspace", "task", "logs", taskId, "--follow"], (line, stream) => { - if (signalledDone) return + if (signalledDone || suppressCallbacks) return // Structured NDJSON envelopes only ever appear on stdout; stderr // carries freeform zap log lines. @@ -1027,7 +1074,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (tunnelProcesses.get(wsId) === child) { tunnelProcesses.delete(wsId) } - if (signalledDone) return + if (signalledDone || suppressCallbacks) return void sink.done( formatLogLine( `Exit code: ${code}`, @@ -1040,6 +1087,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, wsId, ) + // Expose a method to suppress callbacks from cancelActiveUp + ;(child as unknown as { _suppressWorkspaceCallbacks?: () => void })._suppressWorkspaceCallbacks = () => { + suppressCallbacks = true + } } catch (error) { // The task is already submitted; keep it registered so a later // cancel can still reach it, and close the sink so the UI isn't @@ -1064,12 +1115,12 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_stop", - async (_event, args: { workspaceId: string; debug?: boolean }) => { + async (_event, args: { workspaceId: string; debug?: boolean; commandId?: string }) => { trackEvent("workspace_stop", { workspace_ref: hashWorkspaceRef(args.workspaceId), }) await quiesceWorkspace(args.workspaceId) - const cmdId = crypto.randomUUID() + const cmdId = args.commandId ?? crypto.randomUUID() const logPath = logStore.createLogFile( state.workspaceContext(args.workspaceId), args.workspaceId, @@ -1104,7 +1155,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_delete", - async (_event, args: { workspaceId: string; debug?: boolean }) => { + async (_event, args: { workspaceId: string; debug?: boolean; commandId?: string }) => { trackEvent("workspace_delete", { workspace_ref: hashWorkspaceRef(args.workspaceId), }) @@ -1114,7 +1165,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { // stdout/stderr lands on a log file the CLI is about to unlink, causing // an ENOENT crash in the main process. await quiesceWorkspace(args.workspaceId) - const cmdId = crypto.randomUUID() + const cmdId = args.commandId ?? crypto.randomUUID() const logPath = logStore.createLogFile( state.workspaceContext(args.workspaceId), args.workspaceId, @@ -1160,11 +1211,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_rebuild", - async (_event, args: { workspaceId: string; debug?: boolean }) => { + async (_event, args: { workspaceId: string; debug?: boolean; commandId?: string }) => { trackEvent("workspace_rebuild", { workspace_ref: hashWorkspaceRef(args.workspaceId), }) - const cmdId = crypto.randomUUID() + const cmdId = args.commandId ?? crypto.randomUUID() const logPath = logStore.createLogFile( state.workspaceContext(args.workspaceId), args.workspaceId, @@ -1201,11 +1252,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_reset", - async (_event, args: { workspaceId: string; debug?: boolean }) => { + async (_event, args: { workspaceId: string; debug?: boolean; commandId?: string }) => { trackEvent("workspace_reset", { workspace_ref: hashWorkspaceRef(args.workspaceId), }) - const cmdId = crypto.randomUUID() + const cmdId = args.commandId ?? crypto.randomUUID() const logPath = logStore.createLogFile( state.workspaceContext(args.workspaceId), args.workspaceId, diff --git a/desktop/src/main/pty.ts b/desktop/src/main/pty.ts index 143f89261..c8cf3b5db 100644 --- a/desktop/src/main/pty.ts +++ b/desktop/src/main/pty.ts @@ -102,12 +102,51 @@ export class PtyManager { for (const id of sessionIds) { const proc = this.sessions.get(id) if (!proc) continue + let timedOut = false waits.push( new Promise((resolve) => { + let settled = false + let timer: ReturnType | null = setTimeout(() => { + timer = null + settled = true + timedOut = true + disposable.dispose() + resolve() + }, 2000) + const disposable = proc.onExit(() => { + if (timer) { + clearTimeout(timer) + timer = null + } + settled = true disposable.dispose() resolve() }) + }).then(() => { + // If PTY did not exit in time, forcefully kill and suppress late callbacks + if (!timedOut) return + try { + // Run lifecycle cleanup BEFORE removing listeners so terminal:exit is sent + const stillInSessions = this.sessions.has(id) + if (stillInSessions) { + this.sessions.delete(id) + if (workspaceId) { + const bucket = this.sessionsByWorkspace.get(workspaceId) + if (bucket) { + bucket.delete(id) + if (bucket.size === 0) this.sessionsByWorkspace.delete(workspaceId) + } + } + this.send("terminal:exit", { sessionId: id, exitCode: -1, signal: "SIGKILL" }) + } + + proc.kill("SIGKILL") + // Remove all event handlers to suppress late data/exit callbacks + ;(proc as unknown as { removeAllListeners?: () => void }).removeAllListeners?.() + } catch { + // Process may have already exited + } }), ) proc.kill() diff --git a/desktop/src/main/state.ts b/desktop/src/main/state.ts index 6cacdfa1c..3baeeb66f 100644 --- a/desktop/src/main/state.ts +++ b/desktop/src/main/state.ts @@ -27,7 +27,18 @@ export class DaemonState { private activeContext = "" updateWorkspaces(list: Workspace[]): boolean { - const newMap = new Map(list.map((w) => [w.id, w])) + const merged = list.map((w) => { + const existing = this.workspaces.get(w.id) + if ( + existing && + typeof w.status === "undefined" && + typeof existing.status !== "undefined" + ) { + return { ...w, status: existing.status } + } + return w + }) + const newMap = new Map(merged.map((w) => [w.id, w])) if (this.mapsEqual(this.workspaces, newMap)) return false this.workspaces = newMap return true diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index bcf16b6bb..757d56dd5 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -47,6 +47,7 @@ export async function workspaceUp(params: { prebuildRepository?: string platform?: string recovery?: boolean + commandId?: string }): Promise { return invoke("workspace_up", params) } @@ -54,29 +55,33 @@ export async function workspaceUp(params: { export async function workspaceStop( workspaceId: string, debug?: boolean, + commandId?: string, ): Promise { - return invoke("workspace_stop", { workspaceId, debug }) + return invoke("workspace_stop", { workspaceId, debug, commandId }) } export async function workspaceDelete( workspaceId: string, debug?: boolean, + commandId?: string, ): Promise { - return invoke("workspace_delete", { workspaceId, debug }) + return invoke("workspace_delete", { workspaceId, debug, commandId }) } export async function workspaceRebuild( workspaceId: string, debug?: boolean, + commandId?: string, ): Promise { - return invoke("workspace_rebuild", { workspaceId, debug }) + return invoke("workspace_rebuild", { workspaceId, debug, commandId }) } export async function workspaceReset( workspaceId: string, debug?: boolean, + commandId?: string, ): Promise { - return invoke("workspace_reset", { workspaceId, debug }) + return invoke("workspace_reset", { workspaceId, debug, commandId }) } export async function workspaceStatus( diff --git a/desktop/src/renderer/src/lib/stores/workspaces.test.ts b/desktop/src/renderer/src/lib/stores/workspaces.test.ts index 2586cd2d5..2506ffd13 100644 --- a/desktop/src/renderer/src/lib/stores/workspaces.test.ts +++ b/desktop/src/renderer/src/lib/stores/workspaces.test.ts @@ -114,6 +114,30 @@ describe("workspaces store", () => { expect(current[0].status).toBeUndefined() }) + it("preserves an existing workspace status when updates omit it", async () => { + mockListen.mockImplementation((_channel, cb) => { + return Promise.resolve(() => {}) + }) + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "workspace_list") return Promise.resolve([{ id: "ws-1" }]) + if (cmd === "workspace_status") return Promise.resolve('{"state":"Running"}') + return Promise.resolve(undefined) + }) + + workspaces.set([{ id: "ws-1", status: "Running" }]) + await initWorkspaces() + + const eventHandler = mockListen.mock.calls[0][1] as (event: { + payload: { workspaces: Array<{ id: string }>; jobs: Record } + }) => void + eventHandler({ + payload: { workspaces: [{ id: "ws-1" }], jobs: {} }, + }) + + const current = get(workspaces) + expect(current[0].status).toBe("Running") + }) + it("destroyWorkspaces cleans up listener", async () => { const mockUnlisten = vi.fn() mockListen.mockResolvedValue(mockUnlisten) diff --git a/desktop/src/renderer/src/lib/stores/workspaces.ts b/desktop/src/renderer/src/lib/stores/workspaces.ts index 7662b5ed7..010a7f996 100644 --- a/desktop/src/renderer/src/lib/stores/workspaces.ts +++ b/desktop/src/renderer/src/lib/stores/workspaces.ts @@ -16,11 +16,19 @@ let pollInterval: ReturnType | null = null const STATUS_POLL_MS = 10_000 +function mergeWorkspaceStatuses(current: Workspace[], updated: Workspace[]) { + const statusMap = new Map(current.map((ws) => [ws.id, ws.status])) + return updated.map((ws) => ({ + ...ws, + status: ws.status ?? statusMap.get(ws.id), + })) +} + export async function initWorkspaces() { workspacesLoading.set(true) try { const list = await workspaceList() - workspaces.set(list) + workspaces.set(mergeWorkspaceStatuses(get(workspaces), list)) fetchStatuses(list) } catch { // IPC not available (e.g. during browser preview) @@ -30,7 +38,7 @@ export async function initWorkspaces() { try { unlisten = await onWorkspacesChanged((updated, jobs) => { - workspaces.set(updated) + workspaces.update((current) => mergeWorkspaceStatuses(current, updated)) workspaceJobs.set(jobs) fetchStatuses(updated) }) diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index 801998bf1..8bc44f6f2 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -118,7 +118,15 @@ let isStopped = $derived( workspace.status.toLowerCase() === "stopped" || workspace.status.toLowerCase() === "notfound", ) -let isBusy = $derived(workspace?.status?.toLowerCase() === "busy") +let isBusy = $derived.by(() => { + const status = workspace?.status?.toLowerCase() + return ( + status === "busy" || + status === "starting" || + status === "stopping" || + status === "deleting" + ) +}) function statusBadgeVariant(): "default" | "secondary" | "outline" { if (isRunning) return "default" @@ -126,6 +134,13 @@ function statusBadgeVariant(): "default" | "secondary" | "outline" { return "outline" } +function setWorkspaceStatus(status?: string) { + if (!id) return + workspaces.update((current) => + current.map((ws) => (ws.id === id ? { ...ws, status } : ws)), + ) +} + const BUILD_OPS = new Set(["Start", "Open IDE", "Recovery", "Rebuild", "Reset"]) let activeTab = $state("overview") @@ -410,7 +425,9 @@ function isDebug(): boolean { return loadLocalOptions().debugFlag } -function startStreamingOp(label: string) { +function startStreamingOp(label: string, pendingStatus?: string): string { + const newCmdId = crypto.randomUUID() + commandId = newCmdId operationLabel = label operationRunning = true buildFailed = false @@ -421,22 +438,29 @@ function startStreamingOp(label: string) { cancelAnimationFrame(flushHandle) flushHandle = null } + if (pendingStatus) { + setWorkspaceStatus(pendingStatus) + } activeTab = "logs" + return newCmdId } async function handleStart() { const ide = currentIde const folder = customFolder || undefined - startStreamingOp("Start") + const previousStatus = workspace?.status + const cmdId = startStreamingOp("Start", "starting") try { - commandId = await workspaceUp({ + await workspaceUp({ source: id, ide, debug: isDebug(), workspaceFolder: folder, + commandId: cmdId, }) } catch (err) { operationRunning = false + setWorkspaceStatus(previousStatus) toasts.error(`Failed to start: ${extractErrorMessage(err)}`) } } @@ -463,17 +487,20 @@ function handleBuildFailure(progress: CommandProgress) { async function handleRecovery() { const ide = currentIde const folder = customFolder || undefined - startStreamingOp("Recovery") + const previousStatus = workspace?.status + const cmdId = startStreamingOp("Recovery", "busy") try { - commandId = await workspaceUp({ + await workspaceUp({ source: id, ide, recovery: true, debug: isDebug(), workspaceFolder: folder, + commandId: cmdId, }) } catch (err) { operationRunning = false + setWorkspaceStatus(previousStatus) toasts.error( `Failed to start recovery container: ${extractErrorMessage(err)}`, ) @@ -483,63 +510,74 @@ async function handleRecovery() { async function handleOpenIde() { const ide = currentIde const folder = customFolder || undefined + const previousStatus = workspace?.status trackEngagement("ide_open", { ide }) - startStreamingOp("Open IDE") + const cmdId = startStreamingOp("Open IDE", "busy") try { - commandId = await workspaceUp({ + await workspaceUp({ source: id, ide, ideLaunch: "auto", debug: isDebug(), workspaceFolder: folder, + commandId: cmdId, }) } catch (err) { operationRunning = false + setWorkspaceStatus(previousStatus) toasts.error(`Failed to open IDE: ${extractErrorMessage(err)}`) } } async function handleStop() { - startStreamingOp("Stop") + const previousStatus = workspace?.status + const cmdId = startStreamingOp("Stop", "stopping") try { - commandId = await workspaceStop(id, isDebug()) + await workspaceStop(id, isDebug(), cmdId) } catch (err) { operationRunning = false + setWorkspaceStatus(previousStatus) toasts.error(`Failed to stop: ${extractErrorMessage(err)}`) } } async function handleRebuild() { confirmRebuildOpen = false - startStreamingOp("Rebuild") + const previousStatus = workspace?.status + const cmdId = startStreamingOp("Rebuild", "busy") try { - commandId = await workspaceRebuild(id, isDebug()) + await workspaceRebuild(id, isDebug(), cmdId) } catch (err) { operationRunning = false + setWorkspaceStatus(previousStatus) toasts.error(`Failed to rebuild: ${extractErrorMessage(err)}`) } } async function handleReset() { confirmResetOpen = false - startStreamingOp("Reset") + const previousStatus = workspace?.status + const cmdId = startStreamingOp("Reset", "busy") try { - commandId = await workspaceReset(id, isDebug()) + await workspaceReset(id, isDebug(), cmdId) } catch (err) { operationRunning = false + setWorkspaceStatus(previousStatus) toasts.error(`Failed to reset: ${extractErrorMessage(err)}`) } } async function handleDelete() { confirmDeleteOpen = false - startStreamingOp("Delete") + const previousStatus = workspace?.status + const cmdId = startStreamingOp("Delete", "deleting") deleting = true try { - commandId = await workspaceDelete(id, isDebug()) + await workspaceDelete(id, isDebug(), cmdId) } catch (err) { operationRunning = false deleting = false + setWorkspaceStatus(previousStatus) toasts.error(`Failed to delete: ${extractErrorMessage(err)}`) } } @@ -619,8 +657,11 @@ async function handleRenameConfirmed() { Rename {/if} - {#if workspace.status} - {workspace.status} + + {workspace.status ?? "Checking..."} + + {#if operationRunning || isBusy} + {/if} {#if inRecovery} @@ -653,17 +694,25 @@ async function handleRenameConfirmed() { - {#if isRunning || isBusy} - - {:else} - - {/if} + + +