Handle disconnected instances with blocking modal

This commit is contained in:
Shantur Rathore
2025-11-13 10:46:03 +00:00
parent 28131aec47
commit 041dfc6824
5 changed files with 122 additions and 29 deletions

View File

@@ -182,7 +182,8 @@ class ProcessManager {
async kill(pid: number): Promise<void> {
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) => {

View File

@@ -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 (
<div class="h-screen w-screen flex flex-col">
<Show
when={!hasInstances()}
fallback={
<>
<InstanceTabs
<>
<InstanceDisconnectedModal
open={Boolean(disconnectedInstance())}
folder={disconnectedInstance()?.folder}
reason={disconnectedInstance()?.reason}
onClose={handleDisconnectedInstanceClose}
/>
<div class="h-screen w-screen flex flex-col">
<Show
when={!hasInstances()}
fallback={
<>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
@@ -1176,6 +1194,7 @@ const App: Component = () => {
}}
/>
</div>
</>
)
}

View File

@@ -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 (
<Dialog open={props.open} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
{folderLabel} can no longer be reached. Close the tab to continue working.
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary">
<p class="font-medium text-primary">Details</p>
<p class="mt-2 text-secondary">{reasonLabel}</p>
{props.folder && (
<p class="mt-2 text-secondary">
Folder: <span class="font-mono text-primary break-all">{props.folder}</span>
</p>
)}
</div>
<div class="flex justify-end">
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
Close Instance
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -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<string, SSEConnection>()
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<void>
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
return connectionStatus().get(instanceId) ?? null

View File

@@ -11,6 +11,7 @@ import {
clearInstanceDraftPrompts,
} from "./sessions"
import { preferences, updateLastUsedBinary } from "./preferences"
import { setHasInstances } from "./ui"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
@@ -20,6 +21,12 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
// Permission queue management per instance
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
interface DisconnectedInstanceInfo {
id: string
folder: string
reason: string
}
const [disconnectedInstance, setDisconnectedInstance] = createSignal<DisconnectedInstanceInfo | null>(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<void> {
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,
}