feat(ui): add dispose instance and rehydrate

Adds a dispose instance action to the instance info view, POSTing to /instance/dispose and rehydrating per-instance stores; also handles server.instance.disposed events and adds danger button styling.
This commit is contained in:
Shantur Rathore
2026-02-18 01:07:52 +00:00
parent 4eaa711f01
commit 859312ba3b
14 changed files with 296 additions and 12 deletions

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.4"
"@opencode-ai/plugin": "1.2.6"
}
}
}

View File

@@ -1,5 +1,5 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid"
import InstanceInfo from "./instance-info"
import { useI18n } from "../lib/i18n"
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
return (
<div class="log-container">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
</div>
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">

View File

@@ -1,14 +1,21 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n"
import { showConfirmDialog } from "../stores/alerts"
import { disposeInstance } from "../stores/instances"
import { showToastNotification } from "../lib/notifications"
import { getLogger } from "../lib/logger"
interface InstanceInfoProps {
instance: Instance
compact?: boolean
showDisposeButton?: boolean
}
const log = getLogger("actions")
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext()
@@ -16,6 +23,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const [isDisposing, setIsDisposing] = createSignal(false)
const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
@@ -25,6 +34,46 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
return env ? Object.entries(env) : []
})
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
const handleDisposeInstance = async () => {
if (!disposeEnabled()) return
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
title: t("infoView.dispose.confirm.title"),
variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
})
if (!confirmed) return
setIsDisposing(true)
try {
const ok = await disposeInstance(currentInstance().id)
if (ok) {
showToastNotification({
message: t("infoView.dispose.toast.success"),
variant: "success",
duration: 8000,
})
} else {
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
}
} catch (error) {
log.error("Failed to dispose instance", error)
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
} finally {
setIsDisposing(false)
}
}
return (
<div class="panel">
<div class="panel-header">
@@ -156,6 +205,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</div>
</div>
<Show when={props.showDisposeButton}>
<div class="pt-3 border-t border-base">
<button
type="button"
class="button-danger button-small w-full"
onClick={handleDisposeInstance}
disabled={!disposeEnabled()}
>
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
</button>
</div>
</Show>
</div>
</div>
)

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
"infoView.logs.empty.waiting": "Waiting for server output...",
"infoView.logs.scrollToBottom": "Scroll to bottom",
"infoView.dispose.actions.dispose": "Dispose instance",
"infoView.dispose.actions.disposing": "Disposing...",
"infoView.dispose.confirm.title": "Dispose instance?",
"infoView.dispose.confirm.message": "This clears cached per-project state for this directory and reloads the instance.",
"infoView.dispose.confirm.confirmLabel": "Dispose",
"infoView.dispose.confirm.cancelLabel": "Cancel",
"infoView.dispose.toast.success": "Instance disposed. Reloading...",
"infoView.dispose.toast.error": "Failed to dispose instance.",
} as const

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.",
"infoView.logs.empty.waiting": "Esperando la salida del servidor...",
"infoView.logs.scrollToBottom": "Desplazarse al final",
"infoView.dispose.actions.dispose": "Desechar instancia",
"infoView.dispose.actions.disposing": "Desechando...",
"infoView.dispose.confirm.title": "¿Desechar instancia?",
"infoView.dispose.confirm.message": "Esto borra el estado en caché por proyecto para este directorio y recarga la instancia.",
"infoView.dispose.confirm.confirmLabel": "Desechar",
"infoView.dispose.confirm.cancelLabel": "Cancelar",
"infoView.dispose.toast.success": "Instancia desechada. Recargando...",
"infoView.dispose.toast.error": "No se pudo desechar la instancia.",
} as const

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.",
"infoView.logs.empty.waiting": "En attente de la sortie du serveur...",
"infoView.logs.scrollToBottom": "Aller en bas",
"infoView.dispose.actions.dispose": "Réinitialiser l'instance",
"infoView.dispose.actions.disposing": "Réinitialisation...",
"infoView.dispose.confirm.title": "Réinitialiser l'instance ?",
"infoView.dispose.confirm.message": "Cela efface l'état en cache pour ce répertoire et recharge l'instance.",
"infoView.dispose.confirm.confirmLabel": "Réinitialiser",
"infoView.dispose.confirm.cancelLabel": "Annuler",
"infoView.dispose.toast.success": "Instance réinitialisée. Rechargement...",
"infoView.dispose.toast.error": "Impossible de réinitialiser l'instance.",
} as const

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。",
"infoView.logs.empty.waiting": "サーバー出力を待機中...",
"infoView.logs.scrollToBottom": "最下部へスクロール",
"infoView.dispose.actions.dispose": "インスタンスを破棄",
"infoView.dispose.actions.disposing": "破棄しています...",
"infoView.dispose.confirm.title": "インスタンスを破棄しますか?",
"infoView.dispose.confirm.message": "このディレクトリのプロジェクト状態キャッシュをクリアし、インスタンスを再読み込みします。",
"infoView.dispose.confirm.confirmLabel": "破棄",
"infoView.dispose.confirm.cancelLabel": "キャンセル",
"infoView.dispose.toast.success": "インスタンスを破棄しました。再読み込み中...",
"infoView.dispose.toast.error": "インスタンスの破棄に失敗しました。",
} as const

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.",
"infoView.logs.empty.waiting": "Ожидание вывода сервера…",
"infoView.logs.scrollToBottom": "Прокрутить вниз",
"infoView.dispose.actions.dispose": "Сбросить инстанс",
"infoView.dispose.actions.disposing": "Сброс...",
"infoView.dispose.confirm.title": "Сбросить инстанс?",
"infoView.dispose.confirm.message": "Это очистит кэш состояния проекта для этого каталога и перезагрузит инстанс.",
"infoView.dispose.confirm.confirmLabel": "Сбросить",
"infoView.dispose.confirm.cancelLabel": "Отмена",
"infoView.dispose.toast.success": "Инстанс сброшен. Перезагрузка...",
"infoView.dispose.toast.error": "Не удалось сбросить инстанс.",
} as const

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。",
"infoView.logs.empty.waiting": "正在等待服务器输出...",
"infoView.logs.scrollToBottom": "滚动到底部",
"infoView.dispose.actions.dispose": "释放实例",
"infoView.dispose.actions.disposing": "正在释放...",
"infoView.dispose.confirm.title": "要释放实例吗?",
"infoView.dispose.confirm.message": "这将清除此目录的项目缓存状态,并重新加载实例。",
"infoView.dispose.confirm.confirmLabel": "释放",
"infoView.dispose.confirm.cancelLabel": "取消",
"infoView.dispose.toast.success": "实例已释放。正在重新加载...",
"infoView.dispose.toast.error": "释放实例失败。",
} as const

View File

@@ -46,7 +46,7 @@ class SDKManager {
export type { OpencodeClient }
function buildInstanceBaseUrl(proxyPath: string): string {
export function buildInstanceBaseUrl(proxyPath: string): string {
const normalized = normalizeProxyPath(proxyPath)
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
return `${base}${normalized}/`

View File

@@ -54,6 +54,13 @@ interface BackgroundProcessRemovedEvent {
}
}
interface ServerInstanceDisposedEvent {
type: "server.instance.disposed"
properties: {
directory: string
}
}
type SSEEvent =
| MessageUpdateEvent
| MessageRemovedEvent
@@ -74,6 +81,7 @@ type SSEEvent =
| TuiToastEvent
| BackgroundProcessUpdatedEvent
| BackgroundProcessRemovedEvent
| ServerInstanceDisposedEvent
| { type: string; properties?: Record<string, unknown> }
type ConnectionStatus = InstanceStreamStatus
@@ -173,6 +181,9 @@ class SSEManager {
case "background.process.removed":
this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent)
break
case "server.instance.disposed":
this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent)
break
default:
log.warn("Unknown SSE event type", { type: event.type })
}
@@ -205,6 +216,7 @@ class SSEManager {
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
getStatus(instanceId: string): ConnectionStatus | null {

View File

@@ -6,7 +6,7 @@ import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permiss
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { getQuestionSessionId } from "../types/question"
import { requestData } from "../lib/opencode-api"
import { sdkManager } from "../lib/sdk-manager"
import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
import { serverApi } from "../lib/api-client"
import { serverEvents } from "../lib/server-events"
@@ -18,7 +18,14 @@ import {
fetchProviders,
clearInstanceDraftPrompts,
} from "./sessions"
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
import {
ensureWorktreesLoaded,
ensureWorktreeMapLoaded,
getOrCreateWorktreeClient,
getWorktreeSlugForSession,
reloadWorktreeMap,
reloadWorktrees,
} from "./worktrees"
import { fetchCommands, clearCommands } from "./commands"
import { serverSettings } from "./preferences"
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
@@ -76,6 +83,9 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal<Disconnecte
const MAX_LOG_ENTRIES = 1000
const pendingDisposeRequests = new Map<string, Promise<boolean>>()
const pendingRehydrations = new Map<string, Promise<void>>()
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance {
const existing = instances().get(descriptor.id)
return {
@@ -228,10 +238,15 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
}
}
async function hydrateInstanceData(instanceId: string) {
async function hydrateInstanceData(instanceId: string, options?: { force?: boolean }) {
try {
await ensureWorktreesLoaded(instanceId)
await ensureWorktreeMapLoaded(instanceId)
if (options?.force) {
await reloadWorktrees(instanceId)
await reloadWorktreeMap(instanceId)
} else {
await ensureWorktreesLoaded(instanceId)
await ensureWorktreeMapLoaded(instanceId)
}
await fetchSessions(instanceId)
await fetchAgents(instanceId)
await fetchProviders(instanceId)
@@ -246,6 +261,91 @@ async function hydrateInstanceData(instanceId: string) {
}
}
async function postInstanceDispose(instanceId: string): Promise<boolean> {
const instance = instances().get(instanceId)
if (!instance?.proxyPath) {
throw new Error("Instance not ready")
}
const baseUrl = buildInstanceBaseUrl(instance.proxyPath)
const url = new URL("instance/dispose", baseUrl)
const response = await fetch(url.toString(), {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Dispose request failed with ${response.status}`)
}
const contentType = response.headers.get("content-type") ?? ""
if (contentType.includes("application/json")) {
const data = await response.json().catch(() => undefined)
if (typeof data === "boolean") return data
if (data && typeof data === "object" && "data" in (data as any)) {
return Boolean((data as any).data)
}
return Boolean(data)
}
const text = await response.text().catch(() => "")
if (text.trim() === "true") return true
if (text.trim() === "false") return false
return Boolean(text)
}
async function rehydrateInstance(instanceId: string, options?: { reason?: string }): Promise<void> {
if (pendingRehydrations.has(instanceId)) {
return pendingRehydrations.get(instanceId)
}
const promise = (async () => {
const instance = instances().get(instanceId)
if (!instance?.client) {
return
}
log.info("Rehydrating instance", { instanceId, reason: options?.reason })
clearCacheForInstance(instanceId)
clearCommands(instanceId)
clearInstanceMetadata(instanceId)
clearInstanceDraftPrompts(instanceId)
clearPermissionQueue(instanceId)
clearQuestionQueue(instanceId)
await hydrateInstanceData(instanceId, { force: true })
})().finally(() => {
pendingRehydrations.delete(instanceId)
})
pendingRehydrations.set(instanceId, promise)
return promise
}
async function disposeInstance(instanceId: string): Promise<boolean> {
if (pendingDisposeRequests.has(instanceId)) {
return pendingDisposeRequests.get(instanceId)!
}
const promise = (async () => {
const ok = await postInstanceDispose(instanceId)
if (ok) {
await rehydrateInstance(instanceId, { reason: "disposed" })
}
return ok
})().finally(() => {
pendingDisposeRequests.delete(instanceId)
})
pendingDisposeRequests.set(instanceId, promise)
return promise
}
void (async function initializeWorkspaces() {
try {
const workspaces = await serverApi.fetchWorkspaces()
@@ -939,6 +1039,30 @@ sseManager.onLspUpdated = async (instanceId) => {
}
}
sseManager.onInstanceDisposed = (sourceInstanceId, event) => {
const directory = event?.properties?.directory
if (!directory) {
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
return
}
const matchingInstanceIds: string[] = []
for (const instance of instances().values()) {
if (instance.folder === directory) {
matchingInstanceIds.push(instance.id)
}
}
if (matchingInstanceIds.length === 0) {
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
return
}
for (const instanceId of matchingInstanceIds) {
void rehydrateInstance(instanceId, { reason: "disposed" })
}
}
async function acknowledgeDisconnectedInstance(): Promise<void> {
const pending = disconnectedInstance()
if (!pending) {
@@ -995,4 +1119,5 @@ export {
disconnectedInstance,
acknowledgeDisconnectedInstance,
fetchLspStatus,
disposeInstance,
}

View File

@@ -54,3 +54,28 @@ button.button-tertiary:hover:not(:disabled) {
button.button-tertiary:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}
.button-danger,
button.button-danger {
@apply px-6 py-3 text-base rounded-lg;
background-color: var(--button-danger-bg);
color: var(--button-danger-text);
border-color: var(--button-danger-bg);
}
.button-danger:hover:not(:disabled),
button.button-danger:hover:not(:disabled) {
background-color: var(--button-danger-hover-bg);
border-color: var(--button-danger-hover-bg);
}
.button-danger:focus-visible,
button.button-danger:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}
/* Smaller sizing variant for destructive actions in tight spaces. */
.button-danger.button-small,
button.button-danger.button-small {
@apply px-4 py-2 text-sm;
}

View File

@@ -64,6 +64,8 @@
button.button-primary,
.button-secondary,
button.button-secondary,
.button-danger,
button.button-danger,
.button-tertiary,
button.button-tertiary) {
@apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md;
@@ -74,6 +76,8 @@
button.button-primary,
.button-secondary,
button.button-secondary,
.button-danger,
button.button-danger,
.button-tertiary,
button.button-tertiary):focus-visible {
outline: none;
@@ -84,6 +88,8 @@
button.button-primary,
.button-secondary,
button.button-secondary,
.button-danger,
button.button-danger,
.button-tertiary,
button.button-tertiary):disabled {
@apply cursor-not-allowed opacity-50;