diff --git a/electron/main/process-manager.ts b/electron/main/process-manager.ts index d1360aa3..51bc2abd 100644 --- a/electron/main/process-manager.ts +++ b/electron/main/process-manager.ts @@ -182,7 +182,8 @@ class ProcessManager { async kill(pid: number): Promise { const meta = this.processes.get(pid) if (!meta) { - throw new Error(`Process ${pid} not found`) + // Treat unknown processes as already stopped so tabs close cleanly + return } return new Promise((resolve, reject) => { diff --git a/src/App.tsx b/src/App.tsx index 04376a78..add8766c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import InfoView from "./components/info-view" import AgentSelector from "./components/agent-selector" import ModelSelector from "./components/model-selector" import KeyboardHint from "./components/keyboard-hint" +import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import { initMarkdown } from "./lib/markdown" import { useTheme } from "./lib/theme" import { createCommandRegistry } from "./lib/commands" @@ -39,6 +40,8 @@ import { addLog, getActivePermission, sendPermissionResponse, + disconnectedInstance, + acknowledgeDisconnectedInstance, } from "./stores/instances" import { getSessions, @@ -433,6 +436,14 @@ const App: Component = () => { } } + async function handleDisconnectedInstanceClose() { + try { + await acknowledgeDisconnectedInstance() + } catch (error) { + console.error("Failed to finalize disconnected instance:", error) + } + } + async function handleCloseInstance(instanceId: string) { if (confirm("Stop OpenCode instance? This will stop the server.")) { await stopInstance(instanceId) @@ -1019,12 +1030,19 @@ const App: Component = () => { }) return ( -
- - + +
+ + { }} />
+ ) } diff --git a/src/components/instance-disconnected-modal.tsx b/src/components/instance-disconnected-modal.tsx new file mode 100644 index 00000000..6c454ead --- /dev/null +++ b/src/components/instance-disconnected-modal.tsx @@ -0,0 +1,47 @@ +import { Dialog } from "@kobalte/core/dialog" + +interface InstanceDisconnectedModalProps { + open: boolean + folder?: string + reason?: string + onClose: () => void +} + +export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) { + const folderLabel = props.folder || "this workspace" + const reasonLabel = props.reason || "The server stopped responding" + + return ( + + + +
+ +
+ Instance Disconnected + + {folderLabel} can no longer be reached. Close the tab to continue working. + +
+ +
+

Details

+

{reasonLabel}

+ {props.folder && ( +

+ Folder: {props.folder} +

+ )} +
+ +
+ +
+
+
+
+
+ ) +} diff --git a/src/lib/sse-manager.ts b/src/lib/sse-manager.ts index ed43fb64..8de1ed1d 100644 --- a/src/lib/sse-manager.ts +++ b/src/lib/sse-manager.ts @@ -17,7 +17,6 @@ import type { interface SSEConnection { instanceId: string eventSource: EventSource - reconnectAttempts: number status: "connecting" | "connected" | "disconnected" | "error" } @@ -51,8 +50,6 @@ const [connectionStatus, setConnectionStatus] = createSignal< class SSEManager { private connections = new Map() - private maxReconnectAttempts = 5 - private baseReconnectDelay = 1000 connect(instanceId: string, port: number): void { if (this.connections.has(instanceId)) { @@ -65,7 +62,6 @@ class SSEManager { const connection: SSEConnection = { instanceId, eventSource, - reconnectAttempts: 0, status: "connecting", } @@ -74,7 +70,6 @@ class SSEManager { eventSource.onopen = () => { connection.status = "connected" - connection.reconnectAttempts = 0 this.updateConnectionStatus(instanceId, "connected") console.log(`[SSE] Connected to instance ${instanceId}`) } @@ -92,7 +87,7 @@ class SSEManager { connection.status = "error" this.updateConnectionStatus(instanceId, "error") console.error(`[SSE] Connection error for instance ${instanceId}`) - this.handleReconnect(instanceId, port) + this.handleConnectionLost(instanceId, "Connection to instance lost") } } @@ -148,25 +143,15 @@ class SSEManager { } } - private handleReconnect(instanceId: string, port: number): void { + private handleConnectionLost(instanceId: string, reason: string): void { const connection = this.connections.get(instanceId) if (!connection) return - if (connection.reconnectAttempts >= this.maxReconnectAttempts) { - console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`) - connection.status = "disconnected" - this.updateConnectionStatus(instanceId, "disconnected") - return - } - - const delay = this.baseReconnectDelay * Math.pow(2, connection.reconnectAttempts) - connection.reconnectAttempts++ - - console.log(`[SSE] Reconnecting to ${instanceId} in ${delay}ms (attempt ${connection.reconnectAttempts})`) - - setTimeout(() => { - this.connect(instanceId, port) - }, delay) + connection.eventSource.close() + this.connections.delete(instanceId) + connection.status = "disconnected" + this.updateConnectionStatus(instanceId, "disconnected") + this.onConnectionLost?.(instanceId, reason) } private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void { @@ -188,6 +173,7 @@ class SSEManager { onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void + onConnectionLost?: (instanceId: string, reason: string) => void | Promise getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null { return connectionStatus().get(instanceId) ?? null diff --git a/src/stores/instances.ts b/src/stores/instances.ts index 76e652e7..a39e23c6 100644 --- a/src/stores/instances.ts +++ b/src/stores/instances.ts @@ -11,6 +11,7 @@ import { clearInstanceDraftPrompts, } from "./sessions" import { preferences, updateLastUsedBinary } from "./preferences" +import { setHasInstances } from "./ui" const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) @@ -20,6 +21,12 @@ const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) +interface DisconnectedInstanceInfo { + id: string + folder: string + reason: string +} +const [disconnectedInstance, setDisconnectedInstance] = createSignal(null) const MAX_LOG_ENTRIES = 1000 @@ -356,6 +363,37 @@ async function sendPermissionResponse( } } +sseManager.onConnectionLost = (instanceId, reason) => { + const instance = instances().get(instanceId) + if (!instance) { + return + } + + setDisconnectedInstance({ + id: instanceId, + folder: instance.folder, + reason, + }) +} + +async function acknowledgeDisconnectedInstance(): Promise { + const pending = disconnectedInstance() + if (!pending) { + return + } + + try { + await stopInstance(pending.id) + } catch (error) { + console.error("Failed to stop disconnected instance:", error) + } finally { + setDisconnectedInstance(null) + if (instances().size === 0) { + setHasInstances(false) + } + } +} + export { instances, activeInstanceId, @@ -382,4 +420,6 @@ export { removePermissionFromQueue, clearPermissionQueue, sendPermissionResponse, + disconnectedInstance, + acknowledgeDisconnectedInstance, }