Handle disconnected instances with blocking modal
This commit is contained in:
@@ -182,7 +182,8 @@ class ProcessManager {
|
|||||||
async kill(pid: number): Promise<void> {
|
async kill(pid: number): Promise<void> {
|
||||||
const meta = this.processes.get(pid)
|
const meta = this.processes.get(pid)
|
||||||
if (!meta) {
|
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) => {
|
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 AgentSelector from "./components/agent-selector"
|
||||||
import ModelSelector from "./components/model-selector"
|
import ModelSelector from "./components/model-selector"
|
||||||
import KeyboardHint from "./components/keyboard-hint"
|
import KeyboardHint from "./components/keyboard-hint"
|
||||||
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
import { initMarkdown } from "./lib/markdown"
|
||||||
import { useTheme } from "./lib/theme"
|
import { useTheme } from "./lib/theme"
|
||||||
import { createCommandRegistry } from "./lib/commands"
|
import { createCommandRegistry } from "./lib/commands"
|
||||||
@@ -39,6 +40,8 @@ import {
|
|||||||
addLog,
|
addLog,
|
||||||
getActivePermission,
|
getActivePermission,
|
||||||
sendPermissionResponse,
|
sendPermissionResponse,
|
||||||
|
disconnectedInstance,
|
||||||
|
acknowledgeDisconnectedInstance,
|
||||||
} from "./stores/instances"
|
} from "./stores/instances"
|
||||||
import {
|
import {
|
||||||
getSessions,
|
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) {
|
async function handleCloseInstance(instanceId: string) {
|
||||||
if (confirm("Stop OpenCode instance? This will stop the server.")) {
|
if (confirm("Stop OpenCode instance? This will stop the server.")) {
|
||||||
await stopInstance(instanceId)
|
await stopInstance(instanceId)
|
||||||
@@ -1019,12 +1030,19 @@ const App: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-screen w-screen flex flex-col">
|
<>
|
||||||
<Show
|
<InstanceDisconnectedModal
|
||||||
when={!hasInstances()}
|
open={Boolean(disconnectedInstance())}
|
||||||
fallback={
|
folder={disconnectedInstance()?.folder}
|
||||||
<>
|
reason={disconnectedInstance()?.reason}
|
||||||
<InstanceTabs
|
onClose={handleDisconnectedInstanceClose}
|
||||||
|
/>
|
||||||
|
<div class="h-screen w-screen flex flex-col">
|
||||||
|
<Show
|
||||||
|
when={!hasInstances()}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<InstanceTabs
|
||||||
instances={instances()}
|
instances={instances()}
|
||||||
activeInstanceId={activeInstanceId()}
|
activeInstanceId={activeInstanceId()}
|
||||||
onSelect={setActiveInstanceId}
|
onSelect={setActiveInstanceId}
|
||||||
@@ -1176,6 +1194,7 @@ const App: Component = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 {
|
interface SSEConnection {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
eventSource: EventSource
|
eventSource: EventSource
|
||||||
reconnectAttempts: number
|
|
||||||
status: "connecting" | "connected" | "disconnected" | "error"
|
status: "connecting" | "connected" | "disconnected" | "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +50,6 @@ const [connectionStatus, setConnectionStatus] = createSignal<
|
|||||||
|
|
||||||
class SSEManager {
|
class SSEManager {
|
||||||
private connections = new Map<string, SSEConnection>()
|
private connections = new Map<string, SSEConnection>()
|
||||||
private maxReconnectAttempts = 5
|
|
||||||
private baseReconnectDelay = 1000
|
|
||||||
|
|
||||||
connect(instanceId: string, port: number): void {
|
connect(instanceId: string, port: number): void {
|
||||||
if (this.connections.has(instanceId)) {
|
if (this.connections.has(instanceId)) {
|
||||||
@@ -65,7 +62,6 @@ class SSEManager {
|
|||||||
const connection: SSEConnection = {
|
const connection: SSEConnection = {
|
||||||
instanceId,
|
instanceId,
|
||||||
eventSource,
|
eventSource,
|
||||||
reconnectAttempts: 0,
|
|
||||||
status: "connecting",
|
status: "connecting",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +70,6 @@ class SSEManager {
|
|||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
connection.status = "connected"
|
connection.status = "connected"
|
||||||
connection.reconnectAttempts = 0
|
|
||||||
this.updateConnectionStatus(instanceId, "connected")
|
this.updateConnectionStatus(instanceId, "connected")
|
||||||
console.log(`[SSE] Connected to instance ${instanceId}`)
|
console.log(`[SSE] Connected to instance ${instanceId}`)
|
||||||
}
|
}
|
||||||
@@ -92,7 +87,7 @@ class SSEManager {
|
|||||||
connection.status = "error"
|
connection.status = "error"
|
||||||
this.updateConnectionStatus(instanceId, "error")
|
this.updateConnectionStatus(instanceId, "error")
|
||||||
console.error(`[SSE] Connection error for instance ${instanceId}`)
|
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)
|
const connection = this.connections.get(instanceId)
|
||||||
if (!connection) return
|
if (!connection) return
|
||||||
|
|
||||||
if (connection.reconnectAttempts >= this.maxReconnectAttempts) {
|
connection.eventSource.close()
|
||||||
console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`)
|
this.connections.delete(instanceId)
|
||||||
connection.status = "disconnected"
|
connection.status = "disconnected"
|
||||||
this.updateConnectionStatus(instanceId, "disconnected")
|
this.updateConnectionStatus(instanceId, "disconnected")
|
||||||
return
|
this.onConnectionLost?.(instanceId, reason)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
|
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
|
||||||
@@ -188,6 +173,7 @@ class SSEManager {
|
|||||||
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
|
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
|
||||||
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
|
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
|
||||||
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
|
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
|
||||||
|
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
|
||||||
|
|
||||||
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
|
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
|
||||||
return connectionStatus().get(instanceId) ?? null
|
return connectionStatus().get(instanceId) ?? null
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
clearInstanceDraftPrompts,
|
clearInstanceDraftPrompts,
|
||||||
} from "./sessions"
|
} from "./sessions"
|
||||||
import { preferences, updateLastUsedBinary } from "./preferences"
|
import { preferences, updateLastUsedBinary } from "./preferences"
|
||||||
|
import { setHasInstances } from "./ui"
|
||||||
|
|
||||||
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
||||||
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
|
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
|
||||||
@@ -20,6 +21,12 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
|||||||
// Permission queue management per instance
|
// Permission queue management per instance
|
||||||
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())
|
||||||
|
interface DisconnectedInstanceInfo {
|
||||||
|
id: string
|
||||||
|
folder: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
const [disconnectedInstance, setDisconnectedInstance] = createSignal<DisconnectedInstanceInfo | null>(null)
|
||||||
|
|
||||||
const MAX_LOG_ENTRIES = 1000
|
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 {
|
export {
|
||||||
instances,
|
instances,
|
||||||
activeInstanceId,
|
activeInstanceId,
|
||||||
@@ -382,4 +420,6 @@ export {
|
|||||||
removePermissionFromQueue,
|
removePermissionFromQueue,
|
||||||
clearPermissionQueue,
|
clearPermissionQueue,
|
||||||
sendPermissionResponse,
|
sendPermissionResponse,
|
||||||
|
disconnectedInstance,
|
||||||
|
acknowledgeDisconnectedInstance,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user