import { reviewItemId } from "@/components/task/review-selection"; import { reviewPanelId } from "@/lib/state/dockview-panel-actions"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { PluginConversationApi, PluginHostApi, PluginModalHandle, PluginStorageApi, PluginTaskReviewOptions, PluginToastApi, } from "./types"; function pluginLoadAbortError(): DOMException { return new DOMException("Plugin load is no longer active", "AbortError"); } /** Every side effect owned by one successful or in-flight load generation. */ export class PluginLoadResources { private readonly controller = new AbortController(); private readonly cleanups = new Set<() => void>(); private active = true; constructor(private readonly pluginId: string) {} /** Registers an idempotent cleanup and returns it as the public disposer. */ track(cleanup: () => void): () => void { let pending = true; const dispose = () => { if (!pending) return; pending = false; this.cleanups.delete(dispose); try { cleanup(); } catch (error) { console.error(`[plugins] error cleaning up resource for "${this.pluginId}"`, error); } }; if (this.active) this.cleanups.add(dispose); else dispose(); return dispose; } /** Runs one request under both the caller's signal and this load generation. */ runRequest( callerSignal: AbortSignal | null | undefined, operation: (signal: AbortSignal) => Promise, ): Promise { if (!this.active || this.controller.signal.aborted || callerSignal?.aborted) { return Promise.reject(pluginLoadAbortError()); } const requestController = new AbortController(); const abort = () => requestController.abort(pluginLoadAbortError()); this.controller.signal.addEventListener("abort", abort, { once: true }); callerSignal?.addEventListener("abort", abort, { once: true }); let request: Promise; try { request = operation(requestController.signal); } catch (error) { request = Promise.reject(error as Error); } return request.finally(() => { this.controller.signal.removeEventListener("abort", abort); callerSignal?.removeEventListener("abort", abort); }); } /** Revokes the generation in abort-first order, then disposes owned UI/listeners. */ revoke(): void { if (!this.active) return; this.active = false; this.controller.abort(pluginLoadAbortError()); for (const cleanup of [...this.cleanups]) cleanup(); } } /** Fences and owns host-side effects for one load generation. */ export function generationFencedHost( host: PluginHostApi, isCurrent: () => boolean, resources: PluginLoadResources, ): PluginHostApi { const inertHandle = (): ReturnType => ({ close: () => {} }); const setState = ((...args: unknown[]) => { if (isCurrent()) { (host.store.setState as unknown as (...forwarded: unknown[]) => void)(...args); } }) as PluginHostApi["store"]["setState"]; const subscribe: PluginHostApi["store"]["subscribe"] = (listener) => { if (!isCurrent()) return () => {}; return resources.track( host.store.subscribe((state, previousState) => { if (isCurrent()) listener(state, previousState); }), ); }; const fetch: PluginHostApi["api"]["fetch"] = (path, init) => { if (!isCurrent()) return Promise.reject(pluginLoadAbortError()); return resources.runRequest(init?.signal, (signal) => host.api.fetch(path, { ...init, signal }), ); }; const invokeAction: PluginHostApi["api"]["invokeAction"] = (key, input, options) => { if (!isCurrent()) return Promise.reject(pluginLoadAbortError()); return resources.runRequest(options?.signal, (signal) => host.api.invokeAction(key, input, { ...options, signal }), ); }; return { pluginId: host.pluginId, React: host.React, jsx: host.jsx, conversation: generationFencedConversation(host.conversation, isCurrent), store: { ...host.store, setState, subscribe }, context: generationFencedContext(host, isCurrent, resources), api: { fetch, invokeAction, get baseUrl() { return host.api.baseUrl; }, }, ui: host.ui, i18n: host.i18n, useResponsiveBreakpoint: host.useResponsiveBreakpoint, get theme() { return host.theme; }, onThemeChange: (listener) => { if (!isCurrent()) return () => {}; return resources.track( host.onThemeChange((theme) => { if (isCurrent()) listener(theme); }), ); }, navigate: (...args) => { if (isCurrent()) host.navigate(...args); }, openModal: (options) => isCurrent() ? trackModalHandle(resources, host.openModal(options)) : inertHandle(), openTaskLinkDialog: (options) => isCurrent() ? trackModalHandle(resources, host.openTaskLinkDialog(options)) : inertHandle(), openTaskReview: (options) => { if (isCurrent()) openTaskReviewAndTrack(host, resources, options); }, toast: generationFencedToast(host.toast, isCurrent, resources), utils: host.utils, storage: generationFencedStorage(host.storage, isCurrent, resources), useSettingsSaveContributor: host.useSettingsSaveContributor, setIntegrationEnabled: (integrationId, workspaceId, enabled) => { if (isCurrent()) host.setIntegrationEnabled(integrationId, workspaceId, enabled); }, }; } function generationFencedConversation( conversation: PluginConversationApi, isCurrent: () => boolean, ): PluginConversationApi { return { useSessionMessages(query) { const state = conversation.useSessionMessages(query); if (isCurrent()) return state; return { messages: [], loading: false, hydrated: false, loadingMore: false, error: null, hasMore: false, removed: false, loadMore: async () => 0, retry: () => {}, }; }, useSessionTurns(sessionId, taskId) { const state = conversation.useSessionTurns(sessionId, taskId); if (isCurrent()) return state; return { turns: [], loading: false, hydrated: false, error: null, removed: false, retry: () => {}, }; }, useMessageFavorite(sessionId, messageId) { const favorite = conversation.useMessageFavorite(sessionId, messageId); return isCurrent() ? favorite : false; }, }; } function generationFencedContext( host: PluginHostApi, isCurrent: () => boolean, resources: PluginLoadResources, ): PluginHostApi["context"] { return { getActiveWorkspaceId: () => (isCurrent() ? host.context.getActiveWorkspaceId() : undefined), subscribeActiveWorkspace: (listener) => generationFencedSubscription( host.context.subscribeActiveWorkspace, host.context, isCurrent, resources, listener, ), getWorkspaceIds: () => (isCurrent() ? host.context.getWorkspaceIds() : []), subscribeWorkspaces: (listener) => generationFencedSubscription( host.context.subscribeWorkspaces, host.context, isCurrent, resources, listener, ), getTaskCreationContext: (workspaceId) => isCurrent() ? host.context.getTaskCreationContext(workspaceId) : null, subscribeTaskCreationContext: (workspaceId, listener) => { if (!isCurrent()) return () => {}; return resources.track( host.context.subscribeTaskCreationContext(workspaceId, (context) => { if (isCurrent()) listener(context); }), ); }, resolveRepositoryId: (identity) => isCurrent() ? host.context.resolveRepositoryId(identity) : undefined, }; } function generationFencedSubscription( subscribe: (listener: (value: Value) => void) => () => void, context: unknown, isCurrent: () => boolean, resources: PluginLoadResources, listener: (value: Value) => void, ): () => void { if (!isCurrent()) return () => {}; return resources.track( subscribe.call(context, (value) => { if (isCurrent()) listener(value); }), ); } function trackModalHandle( resources: PluginLoadResources, handle: PluginModalHandle, ): PluginModalHandle { return { close: resources.track(() => handle.close()) }; } function generationFencedStorage( storage: PluginStorageApi, isCurrent: () => boolean, resources: PluginLoadResources, ): PluginStorageApi { const unavailable = (): Promise => Promise.reject(pluginLoadAbortError()); return { get: (scope, scopeId, key, options) => isCurrent() ? resources.runRequest(options?.signal, (signal) => storage.get(scope, scopeId, key, { ...options, signal }), ) : unavailable(), set: (scope, scopeId, key, value, options) => isCurrent() ? resources.runRequest(options?.signal, (signal) => storage.set(scope, scopeId, key, value, { ...options, signal }), ) : unavailable(), delete: (scope, scopeId, key, options) => isCurrent() ? resources.runRequest(options?.signal, (signal) => storage.delete(scope, scopeId, key, { ...options, signal }), ) : unavailable(), list: (scope, scopeId, options) => isCurrent() ? resources.runRequest(options?.signal, (signal) => storage.list(scope, scopeId, { ...options, signal }), ) : unavailable(), subscribe: (filter, handler) => { if (!isCurrent()) return () => {}; return resources.track( storage.subscribe(filter, (change) => { if (isCurrent()) handler(change); }), ); }, }; } function generationFencedToast( toast: PluginToastApi, isCurrent: () => boolean, resources: PluginLoadResources, ): PluginToastApi { const activeToasts = new Map void>(); const show = ( method: "default" | "success" | "error" | "warning" | "info", message: string, options?: Record, ): string | number => { if (!isCurrent()) return 0; const id = method === "default" ? toast(message, options) : toast[method](message, options); const dispose = resources.track(() => { activeToasts.delete(id); toast.dismiss(id); }); activeToasts.set(id, dispose); return id; }; const fenced = ((message: string, options?: Record) => show("default", message, options)) as PluginToastApi; fenced.success = (message, options) => show("success", message, options); fenced.error = (message, options) => show("error", message, options); fenced.warning = (message, options) => show("warning", message, options); fenced.info = (message, options) => show("info", message, options); fenced.dismiss = (id) => { if (id === undefined) { for (const dispose of [...activeToasts.values()]) dispose(); return; } activeToasts.get(id)?.(); }; return fenced; } function openTaskReviewAndTrack( host: PluginHostApi, resources: PluginLoadResources, options: PluginTaskReviewOptions, ): void { if (options.presentation === "desktop") { const id = reviewPanelId(options); const existed = Boolean(useDockviewStore.getState().api?.getPanel(id)); host.openTaskReview(options); if (!existed && useDockviewStore.getState().api?.getPanel(id)) { resources.track(() => { const api = useDockviewStore.getState().api; const panel = api?.getPanel(id); if (api && panel) api.removePanel(panel); }); } return; } const before = host.store.getState(); const previousReview = before.mobileSession?.reviewItemIdBySessionId?.[options.sessionId]; const previousPanel = before.mobileSession?.activePanelBySessionId?.[options.sessionId]; const openedReview = reviewItemId(options); host.openTaskReview(options); if (typeof before.setMobileSessionReview !== "function") return; resources.track(() => { const current = host.store.getState(); if (current.mobileSession?.reviewItemIdBySessionId?.[options.sessionId] !== openedReview) return; current.setMobileSessionReview(options.sessionId, previousReview ?? null); if (previousPanel && typeof current.setMobileSessionPanel === "function") { current.setMobileSessionPanel(options.sessionId, previousPanel); } }); }