Handle disconnected instances with blocking modal
This commit is contained in:
@@ -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) => {
|
||||
|
||||
31
src/App.tsx
31
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 (
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
47
src/components/instance-disconnected-modal.tsx
Normal file
47
src/components/instance-disconnected-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user