Show error if opencode fails to launch
This commit is contained in:
@@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/workspaces", async (request, reply) => {
|
app.post("/api/workspaces", async (request, reply) => {
|
||||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
try {
|
||||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||||
reply.code(201)
|
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||||
return workspace
|
reply.code(201)
|
||||||
|
return workspace
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, "Failed to create workspace")
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to create workspace"
|
||||||
|
reply.code(400).type("text/plain").send(message)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
|
import { connect } from "net"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { ConfigStore } from "../config/store"
|
import { ConfigStore } from "../config/store"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
@@ -7,10 +8,12 @@ import { FileSystemBrowser } from "../filesystem/browser"
|
|||||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||||
import { WorkspaceRuntime } from "./runtime"
|
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
import { getOpencodeConfigDir } from "../opencode-config"
|
import { getOpencodeConfigDir } from "../opencode-config"
|
||||||
|
|
||||||
|
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
configStore: ConfigStore
|
configStore: ConfigStore
|
||||||
@@ -108,7 +111,7 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { pid, port } = await this.runtime.launch({
|
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
folder: workspacePath,
|
folder: workspacePath,
|
||||||
binaryPath: resolvedBinaryPath,
|
binaryPath: resolvedBinaryPath,
|
||||||
@@ -116,6 +119,8 @@ export class WorkspaceManager {
|
|||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||||
|
|
||||||
descriptor.pid = pid
|
descriptor.pid = pid
|
||||||
descriptor.port = port
|
descriptor.port = port
|
||||||
descriptor.status = "ready"
|
descriptor.status = "ready"
|
||||||
@@ -241,6 +246,159 @@ export class WorkspaceManager {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForWorkspaceReadiness(params: {
|
||||||
|
workspaceId: string
|
||||||
|
port: number
|
||||||
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
|
getLastOutput: () => string
|
||||||
|
}) {
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
this.waitForPortAvailability(params.port),
|
||||||
|
params.exitPromise.then((info) => {
|
||||||
|
throw this.buildStartupError(
|
||||||
|
params.workspaceId,
|
||||||
|
"exited before becoming ready",
|
||||||
|
info,
|
||||||
|
params.getLastOutput(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
await this.waitForInstanceHealth(params)
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||||
|
params.exitPromise.then((info) => {
|
||||||
|
throw this.buildStartupError(
|
||||||
|
params.workspaceId,
|
||||||
|
"exited shortly after start",
|
||||||
|
info,
|
||||||
|
params.getLastOutput(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForInstanceHealth(params: {
|
||||||
|
workspaceId: string
|
||||||
|
port: number
|
||||||
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
|
getLastOutput: () => string
|
||||||
|
}) {
|
||||||
|
const probeResult = await Promise.race([
|
||||||
|
this.probeInstance(params.workspaceId, params.port),
|
||||||
|
params.exitPromise.then((info) => {
|
||||||
|
throw this.buildStartupError(
|
||||||
|
params.workspaceId,
|
||||||
|
"exited during health checks",
|
||||||
|
info,
|
||||||
|
params.getLastOutput(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (probeResult.ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestOutput = params.getLastOutput().trim()
|
||||||
|
const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : ""
|
||||||
|
const reason = probeResult.reason ?? "Health check failed"
|
||||||
|
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||||
|
const url = `http://127.0.0.1:${port}/project/current`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
const reason = `health probe returned HTTP ${response.status}`
|
||||||
|
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||||
|
return { ok: false, reason }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
|
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||||
|
return { ok: false, reason }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStartupError(
|
||||||
|
workspaceId: string,
|
||||||
|
phase: string,
|
||||||
|
exitInfo: ProcessExitInfo,
|
||||||
|
lastOutput: string,
|
||||||
|
): Error {
|
||||||
|
const exitDetails = this.describeExit(exitInfo)
|
||||||
|
const trimmedOutput = lastOutput.trim()
|
||||||
|
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
|
||||||
|
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
let settled = false
|
||||||
|
let retryTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
settled = true
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryConnect = () => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||||
|
cleanup()
|
||||||
|
socket.end()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
socket.once("error", () => {
|
||||||
|
socket.destroy()
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
|
||||||
|
} else {
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryTimer = null
|
||||||
|
tryConnect()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tryConnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(durationMs: number): Promise<void> {
|
||||||
|
if (durationMs <= 0) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, durationMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeExit(info: ProcessExitInfo): string {
|
||||||
|
if (info.signal) {
|
||||||
|
return `signal ${info.signal}`
|
||||||
|
}
|
||||||
|
if (info.code !== null) {
|
||||||
|
return `code ${info.code}`
|
||||||
|
}
|
||||||
|
return "unknown reason"
|
||||||
|
}
|
||||||
|
|
||||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||||
const workspace = this.workspaces.get(workspaceId)
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
if (!workspace) return
|
if (!workspace) return
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface LaunchOptions {
|
|||||||
onExit?: (info: ProcessExitInfo) => void
|
onExit?: (info: ProcessExitInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessExitInfo {
|
export interface ProcessExitInfo {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
code: number | null
|
code: number | null
|
||||||
signal: NodeJS.Signals | null
|
signal: NodeJS.Signals | null
|
||||||
@@ -30,12 +30,18 @@ export class WorkspaceRuntime {
|
|||||||
|
|
||||||
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
|
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
|
||||||
|
|
||||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
|
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||||
this.validateFolder(options.folder)
|
this.validateFolder(options.folder)
|
||||||
|
|
||||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
|
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||||
|
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
|
||||||
|
exitResolve = resolveExit
|
||||||
|
})
|
||||||
|
let lastOutput = ""
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
|
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
|
||||||
@@ -83,11 +89,21 @@ export class WorkspaceRuntime {
|
|||||||
cleanupStreams()
|
cleanupStreams()
|
||||||
child.removeListener("error", handleError)
|
child.removeListener("error", handleError)
|
||||||
child.removeListener("exit", handleExit)
|
child.removeListener("exit", handleExit)
|
||||||
|
const exitInfo: ProcessExitInfo = {
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
code,
|
||||||
|
signal,
|
||||||
|
requested: managed.requestedStop,
|
||||||
|
}
|
||||||
|
if (exitResolve) {
|
||||||
|
exitResolve(exitInfo)
|
||||||
|
exitResolve = null
|
||||||
|
}
|
||||||
if (!portFound) {
|
if (!portFound) {
|
||||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||||
reject(new Error(reason))
|
reject(new Error(reason))
|
||||||
} else {
|
} else {
|
||||||
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
options.onExit?.(exitInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +112,10 @@ export class WorkspaceRuntime {
|
|||||||
child.removeListener("exit", handleExit)
|
child.removeListener("exit", handleExit)
|
||||||
this.processes.delete(options.workspaceId)
|
this.processes.delete(options.workspaceId)
|
||||||
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
||||||
|
if (exitResolve) {
|
||||||
|
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
|
||||||
|
exitResolve = null
|
||||||
|
}
|
||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,18 +129,20 @@ export class WorkspaceRuntime {
|
|||||||
stdoutBuffer = lines.pop() ?? ""
|
stdoutBuffer = lines.pop() ?? ""
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
lastOutput = trimmed
|
||||||
this.emitLog(options.workspaceId, "info", line)
|
this.emitLog(options.workspaceId, "info", line)
|
||||||
|
|
||||||
if (!portFound) {
|
if (!portFound) {
|
||||||
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
||||||
if (portMatch) {
|
if (portMatch) {
|
||||||
portFound = true
|
portFound = true
|
||||||
cleanupStreams()
|
|
||||||
child.removeListener("error", handleError)
|
child.removeListener("error", handleError)
|
||||||
const port = parseInt(portMatch[1], 10)
|
const port = parseInt(portMatch[1], 10)
|
||||||
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
|
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
|
||||||
resolve({ pid: child.pid!, port })
|
const getLastOutput = () => lastOutput.trim()
|
||||||
|
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +155,9 @@ export class WorkspaceRuntime {
|
|||||||
stderrBuffer = lines.pop() ?? ""
|
stderrBuffer = lines.pop() ?? ""
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
lastOutput = `[stderr] ${trimmed}`
|
||||||
this.emitLog(options.workspaceId, "error", line)
|
this.emitLog(options.workspaceId, "error", line)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,11 +21,9 @@ import {
|
|||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
setIsSelectingFolder,
|
setIsSelectingFolder,
|
||||||
setHasInstances,
|
|
||||||
showFolderSelection,
|
showFolderSelection,
|
||||||
setShowFolderSelection,
|
setShowFolderSelection,
|
||||||
} from "./stores/ui"
|
} from "./stores/ui"
|
||||||
import { instances as instanceStore } from "./stores/instances"
|
|
||||||
import { useConfig } from "./stores/preferences"
|
import { useConfig } from "./stores/preferences"
|
||||||
import {
|
import {
|
||||||
createInstance,
|
createInstance,
|
||||||
@@ -65,7 +63,12 @@ const App: Component = () => {
|
|||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
interface LaunchErrorState {
|
||||||
|
message: string
|
||||||
|
binaryPath: string
|
||||||
|
missingBinary: boolean
|
||||||
|
}
|
||||||
|
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
@@ -105,14 +108,30 @@ const App: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const launchErrorPath = () => {
|
const launchErrorPath = () => {
|
||||||
const value = launchErrorBinary()
|
const value = launchError()?.binaryPath
|
||||||
if (!value) return "opencode"
|
if (!value) return "opencode"
|
||||||
return value.trim() || "opencode"
|
return value.trim() || "opencode"
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMissingBinaryError = (error: unknown): boolean => {
|
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||||
if (!error) return false
|
|
||||||
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||||
|
if (!error) {
|
||||||
|
return "Failed to launch workspace"
|
||||||
|
}
|
||||||
|
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed.error === "string") {
|
||||||
|
return parsed.error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMissingBinaryMessage = (message: string): boolean => {
|
||||||
const normalized = message.toLowerCase()
|
const normalized = message.toLowerCase()
|
||||||
return (
|
return (
|
||||||
normalized.includes("opencode binary not found") ||
|
normalized.includes("opencode binary not found") ||
|
||||||
@@ -123,7 +142,7 @@ const App: Component = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearLaunchError = () => setLaunchErrorBinary(null)
|
const clearLaunchError = () => setLaunchError(null)
|
||||||
|
|
||||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||||
if (!folderPath) {
|
if (!folderPath) {
|
||||||
@@ -135,7 +154,6 @@ const App: Component = () => {
|
|||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
setHasInstances(true)
|
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
setIsAdvancedSettingsOpen(false)
|
setIsAdvancedSettingsOpen(false)
|
||||||
|
|
||||||
@@ -144,10 +162,13 @@ const App: Component = () => {
|
|||||||
port: instances().get(instanceId)?.port,
|
port: instances().get(instanceId)?.port,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearLaunchError()
|
const message = formatLaunchErrorMessage(error)
|
||||||
if (isMissingBinaryError(error)) {
|
const missingBinary = isMissingBinaryMessage(message)
|
||||||
setLaunchErrorBinary(selectedBinary)
|
setLaunchError({
|
||||||
}
|
message,
|
||||||
|
binaryPath: selectedBinary,
|
||||||
|
missingBinary,
|
||||||
|
})
|
||||||
log.error("Failed to create instance", error)
|
log.error("Failed to create instance", error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSelectingFolder(false)
|
setIsSelectingFolder(false)
|
||||||
@@ -191,9 +212,6 @@ const App: Component = () => {
|
|||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
await stopInstance(instanceId)
|
await stopInstance(instanceId)
|
||||||
if (instances().size === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNewSession(instanceId: string) {
|
async function handleNewSession(instanceId: string) {
|
||||||
@@ -304,7 +322,7 @@ const App: Component = () => {
|
|||||||
onClose={handleDisconnectedInstanceClose}
|
onClose={handleDisconnectedInstanceClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(launchErrorBinary())} modal>
|
<Dialog open={Boolean(launchError())} modal>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
@@ -312,8 +330,8 @@ const App: Component = () => {
|
|||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
|
||||||
Advanced Settings.
|
binary from Advanced Settings.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -322,10 +340,23 @@ const App: Component = () => {
|
|||||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={launchErrorMessage()}>
|
||||||
|
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||||
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
|
||||||
|
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
|
<Show when={launchError()?.missingBinary}>
|
||||||
Open Advanced Settings
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={handleLaunchErrorAdvanced}
|
||||||
|
>
|
||||||
|
Open Advanced Settings
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
|||||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
||||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||||
|
|
||||||
|
function syncHasInstancesFlag() {
|
||||||
|
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
||||||
|
setHasInstances(readyExists)
|
||||||
|
}
|
||||||
interface DisconnectedInstanceInfo {
|
interface DisconnectedInstanceInfo {
|
||||||
id: string
|
id: string
|
||||||
folder: string
|
folder: string
|
||||||
@@ -68,7 +73,6 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
|||||||
updateInstance(descriptor.id, mapped)
|
updateInstance(descriptor.id, mapped)
|
||||||
} else {
|
} else {
|
||||||
addInstance(mapped)
|
addInstance(mapped)
|
||||||
setHasInstances(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (descriptor.status === "ready") {
|
if (descriptor.status === "ready") {
|
||||||
@@ -135,9 +139,6 @@ void (async function initializeWorkspaces() {
|
|||||||
try {
|
try {
|
||||||
const workspaces = await serverApi.fetchWorkspaces()
|
const workspaces = await serverApi.fetchWorkspaces()
|
||||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||||
if (workspaces.length === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to load workspaces", error)
|
log.error("Failed to load workspaces", error)
|
||||||
}
|
}
|
||||||
@@ -159,9 +160,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
|||||||
case "workspace.stopped":
|
case "workspace.stopped":
|
||||||
releaseInstanceResources(event.workspaceId)
|
releaseInstanceResources(event.workspaceId)
|
||||||
removeInstance(event.workspaceId)
|
removeInstance(event.workspaceId)
|
||||||
if (instances().size === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case "workspace.log":
|
case "workspace.log":
|
||||||
handleWorkspaceLog(event.entry)
|
handleWorkspaceLog(event.entry)
|
||||||
@@ -249,6 +247,7 @@ function addInstance(instance: Instance) {
|
|||||||
})
|
})
|
||||||
ensureLogContainer(instance.id)
|
ensureLogContainer(instance.id)
|
||||||
ensureLogStreamingState(instance.id)
|
ensureLogStreamingState(instance.id)
|
||||||
|
syncHasInstancesFlag()
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateInstance(id: string, updates: Partial<Instance>) {
|
function updateInstance(id: string, updates: Partial<Instance>) {
|
||||||
@@ -260,6 +259,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
|
|||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
syncHasInstancesFlag()
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeInstance(id: string) {
|
function removeInstance(id: string) {
|
||||||
@@ -301,6 +301,7 @@ function removeInstance(id: string) {
|
|||||||
clearCacheForInstance(id)
|
clearCacheForInstance(id)
|
||||||
messageStoreBus.unregisterInstance(id)
|
messageStoreBus.unregisterInstance(id)
|
||||||
clearInstanceDraftPrompts(id)
|
clearInstanceDraftPrompts(id)
|
||||||
|
syncHasInstancesFlag()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
|
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
|
||||||
@@ -328,9 +329,6 @@ async function stopInstance(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeInstance(id)
|
removeInstance(id)
|
||||||
if (instances().size === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||||
@@ -590,9 +588,6 @@ async function acknowledgeDisconnectedInstance(): Promise<void> {
|
|||||||
log.error("Failed to stop disconnected instance", error)
|
log.error("Failed to stop disconnected instance", error)
|
||||||
} finally {
|
} finally {
|
||||||
setDisconnectedInstance(null)
|
setDisconnectedInstance(null)
|
||||||
if (instances().size === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user