diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index efa583b3..2f52967b 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.4" + "@opencode-ai/plugin": "1.2.6" } -} +} \ No newline at end of file diff --git a/packages/ui/src/components/info-view.tsx b/packages/ui/src/components/info-view.tsx index 4229b0ec..b2387f5b 100644 --- a/packages/ui/src/components/info-view.tsx +++ b/packages/ui/src/components/info-view.tsx @@ -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 = (props) => { return (
-
- {(inst) => } +
+ {(inst) => }
diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx index 9231547a..da54521b 100644 --- a/packages/ui/src/components/instance-info.tsx +++ b/packages/ui/src/components/instance-info.tsx @@ -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 = (props) => { const { t } = useI18n() const metadataContext = useOptionalInstanceMetadataContext() @@ -16,6 +23,8 @@ const InstanceInfo: Component = (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 = (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 (
@@ -156,6 +205,19 @@ const InstanceInfo: Component = (props) => {
+ + +
+ +
+
) diff --git a/packages/ui/src/lib/i18n/messages/en/logs.ts b/packages/ui/src/lib/i18n/messages/en/logs.ts index a38f681e..22f74872 100644 --- a/packages/ui/src/lib/i18n/messages/en/logs.ts +++ b/packages/ui/src/lib/i18n/messages/en/logs.ts @@ -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 diff --git a/packages/ui/src/lib/i18n/messages/es/logs.ts b/packages/ui/src/lib/i18n/messages/es/logs.ts index ab1cdc68..b9057cfb 100644 --- a/packages/ui/src/lib/i18n/messages/es/logs.ts +++ b/packages/ui/src/lib/i18n/messages/es/logs.ts @@ -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 diff --git a/packages/ui/src/lib/i18n/messages/fr/logs.ts b/packages/ui/src/lib/i18n/messages/fr/logs.ts index eda164fa..689fb58c 100644 --- a/packages/ui/src/lib/i18n/messages/fr/logs.ts +++ b/packages/ui/src/lib/i18n/messages/fr/logs.ts @@ -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 diff --git a/packages/ui/src/lib/i18n/messages/ja/logs.ts b/packages/ui/src/lib/i18n/messages/ja/logs.ts index 4498f06f..ed602609 100644 --- a/packages/ui/src/lib/i18n/messages/ja/logs.ts +++ b/packages/ui/src/lib/i18n/messages/ja/logs.ts @@ -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 diff --git a/packages/ui/src/lib/i18n/messages/ru/logs.ts b/packages/ui/src/lib/i18n/messages/ru/logs.ts index e9a364b8..dd5cd39d 100644 --- a/packages/ui/src/lib/i18n/messages/ru/logs.ts +++ b/packages/ui/src/lib/i18n/messages/ru/logs.ts @@ -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 diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts index d0b4e9b8..55a3f8f7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts @@ -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 diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index 7df7659f..5f525eda 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -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}/` diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 77a4fdb4..e6354e2c 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -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 } 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 getStatus(instanceId: string): ConnectionStatus | null { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index f2a39bda..66ca1f38 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -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>() +const pendingRehydrations = new Map>() + function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance { const existing = instances().get(descriptor.id) return { @@ -228,10 +238,15 @@ async function syncPendingQuestions(instanceId: string): Promise { } } -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 { + 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 { + 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 { + 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 { const pending = disconnectedInstance() if (!pending) { @@ -995,4 +1119,5 @@ export { disconnectedInstance, acknowledgeDisconnectedInstance, fetchLspStatus, + disposeInstance, } diff --git a/packages/ui/src/styles/components/buttons.css b/packages/ui/src/styles/components/buttons.css index 71c6c4c5..105d42a4 100644 --- a/packages/ui/src/styles/components/buttons.css +++ b/packages/ui/src/styles/components/buttons.css @@ -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; +} diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 44aaa1fd..1e387127 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -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;