Browse Source

#8 VSCode: implement UI bridge state management and restore functionality

paviko 2 months ago
parent
commit
ec0f7a784b

+ 30 - 0
hosts/vscode-plugin/src/ui/ActivityBarProvider.ts

@@ -4,6 +4,7 @@ import { SettingsManager } from "../settings/SettingsManager"
 import { errorHandler } from "../utils/ErrorHandler"
 import { WebviewController } from "./WebviewController"
 import { logger } from "../globals"
+import { PathInserter } from "../utils/PathInserter"
 
 function withCacheBuster(url: string, version: string): string {
   if (url.includes("v=")) {
@@ -32,6 +33,7 @@ export class ActivityBarProvider implements vscode.WebviewViewProvider {
   private connection?: BackendConnection
   private controller?: WebviewController
   private view?: vscode.WebviewView
+  private uiState: any
 
   constructor(context: vscode.ExtensionContext, backendLauncher: BackendLauncher, settingsManager: SettingsManager) {
     this.context = context
@@ -59,12 +61,21 @@ export class ActivityBarProvider implements vscode.WebviewViewProvider {
     // Listen for the webview being disposed (e.g., when moved or closed)
     webviewView.onDidDispose(() => {
       logger.appendLine("WebviewView disposed")
+      const bridge = this.controller?.getCommunicationBridge()
       if (this.controller) {
         try {
           this.controller.dispose()
         } catch {}
         this.controller = undefined
       }
+
+      // Clear command routing pointer if it pointed to this controller
+      try {
+        const current = PathInserter.getCommunicationBridge()
+        if (current && bridge && current === bridge) {
+          PathInserter.clearCommunicationBridge()
+        }
+      } catch {}
       this.view = undefined
     })
 
@@ -80,6 +91,13 @@ export class ActivityBarProvider implements vscode.WebviewViewProvider {
       ],
     }
 
+    // Track view visibility/activeness for command routing
+    webviewView.onDidChangeVisibility(() => {
+      if (!webviewView.visible) return
+      const bridge = this.controller?.getCommunicationBridge()
+      if (bridge) PathInserter.setCommunicationBridge(bridge)
+    })
+
     await vscode.window.withProgress(
       {
         location: vscode.ProgressLocation.Notification,
@@ -106,9 +124,21 @@ export class ActivityBarProvider implements vscode.WebviewViewProvider {
             webview: webviewView.webview,
             context: this.context,
             settingsManager: this.settingsManager,
+            uiGetState: async () => this.uiState,
+            uiSetState: async (state) => {
+              this.uiState = state
+            },
           })
           await this.controller.load(this.connection)
 
+          // Prefer routing commands to this view when visible
+          if (webviewView.visible) {
+            const bridge = this.controller.getCommunicationBridge()
+            if (bridge) PathInserter.setCommunicationBridge(bridge)
+          }
+
+          // no-op
+
           progress.report({ increment: 100, message: "Ready!" })
           logger.appendLine("Webview initialization complete")
         } catch (error) {

+ 32 - 0
hosts/vscode-plugin/src/ui/IdeBridgeServer.ts

@@ -7,6 +7,8 @@ export interface SessionHandlers {
   openUrl: (url: string) => Promise<void>
   reloadPath: (path: string) => Promise<void>
   clipboardWrite: (text: string) => Promise<void>
+  uiGetState?: () => Promise<any>
+  uiSetState?: (state: any) => Promise<void>
 }
 
 interface Session {
@@ -227,6 +229,36 @@ class IdeBridgeServer {
           }
           break
 
+        case "uiGetState": {
+          if (!session.handlers.uiGetState) {
+            this.replyError(session, id, "uiGetState not supported")
+            break
+          }
+          const state = await session.handlers.uiGetState()
+          if (id) {
+            this.broadcastSSE(
+              session,
+              JSON.stringify({
+                replyTo: id,
+                ok: true,
+                payload: { state },
+                timestamp: Date.now(),
+              }),
+            )
+          }
+          break
+        }
+
+        case "uiSetState": {
+          if (!session.handlers.uiSetState) {
+            this.replyError(session, id, "uiSetState not supported")
+            break
+          }
+          await session.handlers.uiSetState(payload?.state)
+          this.replyOk(session, id)
+          break
+        }
+
         default:
           this.replyError(session, id, `Unknown type: ${type}`)
       }

+ 19 - 13
hosts/vscode-plugin/src/ui/WebviewController.ts

@@ -16,6 +16,8 @@ export interface WebviewControllerOptions {
   webview: vscode.Webview
   context: vscode.ExtensionContext
   settingsManager?: SettingsManager
+  uiGetState?: () => Promise<any>
+  uiSetState?: (state: any) => Promise<void>
 }
 
 export class WebviewController {
@@ -27,11 +29,15 @@ export class WebviewController {
   private connection?: BackendConnection
   private disposables: vscode.Disposable[] = []
   private bridgeSessionId: string | null = null
+  private uiGetState?: () => Promise<any>
+  private uiSetState?: (state: any) => Promise<void>
 
   constructor(opts: WebviewControllerOptions) {
     this.webview = opts.webview
     this.context = opts.context
     this.settingsManager = opts.settingsManager
+    this.uiGetState = opts.uiGetState
+    this.uiSetState = opts.uiSetState
   }
 
   getCommunicationBridge(): CommunicationBridge | undefined {
@@ -55,19 +61,21 @@ export class WebviewController {
       })
 
       // Make PathInserter aware of the active communication bridge
-      try {
-        PathInserter.setCommunicationBridge(this.communicationBridge)
-      } catch {}
+      // NOTE: PathInserter is now set by container visibility (editor panel / sidebar).
 
       // Create bridge session with handlers from CommunicationBridge
-      const session = await bridgeServer.createSession({
-        openFile: (path) => this.communicationBridge!.handleOpenFile(path),
-        openUrl: (url) => this.communicationBridge!.handleOpenUrl(url),
-        reloadPath: (path) => this.communicationBridge!.handleReloadPath(path),
-        clipboardWrite: async (text) => {
-          await vscode.env.clipboard.writeText(text)
+      const session = await bridgeServer.createSession(
+        {
+          openFile: (path) => this.communicationBridge!.handleOpenFile(path),
+          openUrl: (url) => this.communicationBridge!.handleOpenUrl(url),
+          reloadPath: (path) => this.communicationBridge!.handleReloadPath(path),
+          clipboardWrite: async (text) => {
+            await vscode.env.clipboard.writeText(text)
+          },
+          uiGetState: this.uiGetState,
+          uiSetState: this.uiSetState,
         },
-      })
+      )
       this.bridgeSessionId = session.sessionId
 
       // Tell CommunicationBridge to route ideBridge messages through SSE
@@ -248,9 +256,7 @@ export class WebviewController {
     try {
       this.communicationBridge?.dispose()
     } catch {}
-    try {
-      PathInserter.clearCommunicationBridge()
-    } catch {}
+    // NOTE: container owns PathInserter pointer
     if (this.bridgeSessionId) {
       bridgeServer.removeSession(this.bridgeSessionId)
       this.bridgeSessionId = null

+ 22 - 0
hosts/vscode-plugin/src/ui/WebviewManager.ts

@@ -6,6 +6,7 @@ import { SettingsManager } from "../settings/SettingsManager"
 import { CommunicationBridge } from "./CommunicationBridge"
 import { errorHandler } from "../utils/ErrorHandler"
 import { logger } from "../globals"
+import { PathInserter } from "../utils/PathInserter"
 
 /**
  * Webview management - handles VSCode webview panel lifecycle and content
@@ -19,6 +20,7 @@ export class WebviewManager {
   private settingsManager?: SettingsManager
   private communicationBridge?: CommunicationBridge
   private controller?: WebviewController
+  private uiState: any
 
   /**
    * Create and configure a webview panel for the OpenCode UI
@@ -81,6 +83,8 @@ export class WebviewManager {
       (e) => {
         if (e.webviewPanel.visible) {
           logger.appendLine("Webview panel became visible")
+          const bridge = this.controller?.getCommunicationBridge()
+          if (bridge) PathInserter.setCommunicationBridge(bridge)
         } else {
           logger.appendLine("Webview panel became hidden")
         }
@@ -142,6 +146,10 @@ export class WebviewManager {
         webview: this.panel.webview,
         context: this.context!,
         settingsManager: this.settingsManager,
+        uiGetState: async () => this.uiState,
+        uiSetState: async (state) => {
+          this.uiState = state
+        },
       })
       // Keep references for compatibility APIs
       this.communicationBridge = this.controller.getCommunicationBridge?.()
@@ -149,6 +157,12 @@ export class WebviewManager {
       // Load UI via controller
       await this.controller.load(connection)
 
+      // Prefer routing commands to this panel when visible
+      if (this.panel.visible) {
+        const bridge = this.controller.getCommunicationBridge()
+        if (bridge) PathInserter.setCommunicationBridge(bridge)
+      }
+
       // Get UI mode from settings with error handling
       let uiMode = "Terminal"
       try {
@@ -211,6 +225,14 @@ export class WebviewManager {
       } catch {}
       this.controller = undefined
     }
+
+    // Clear command routing pointer if it pointed to this controller
+    try {
+      const current = PathInserter.getCommunicationBridge()
+      if (current && current === this.communicationBridge) {
+        PathInserter.clearCommunicationBridge()
+      }
+    } catch {}
     if (this.communicationBridge) {
       this.communicationBridge.dispose()
       this.communicationBridge = undefined

+ 4 - 0
hosts/vscode-plugin/src/utils/PathInserter.ts

@@ -12,6 +12,10 @@ import { logger } from "../globals"
 export class PathInserter {
   private static communicationBridge: CommunicationBridge | undefined
 
+  static getCommunicationBridge(): CommunicationBridge | undefined {
+    return this.communicationBridge
+  }
+
   /**
    * Set the communication bridge for path operations
    * @param bridge The communication bridge to use for communication

+ 30 - 1
packages/opencode/webgui/src/App.tsx

@@ -16,12 +16,14 @@ import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"
 import { ideBridge } from "./lib/ideBridge"
 import { extractPathsFromDrop } from "./lib/dnd"
 import { initKeyboardHandler, destroyKeyboardHandler } from "./lib/keyboardHandler"
+import { uiBridgeSubscribe, uiBridgeUpdate, type UiBridgeState } from "./state/uiBridgeState"
 
 const isMac = typeof navigator !== "undefined" && navigator.platform.includes("Mac")
 
 // Inner component that uses MessagesContext
 function AppInner({ connectionState }: { connectionState: ConnectionState }) {
-  const { currentSession, sessions, newVirtual, switchSession, isCreating, error, clearError } = useSession()
+  const { currentSession, sessions, newVirtual, switchSession, isCreating, error, clearError, restoreSelections } =
+    useSession()
   const { loadSessionMessages } = useMessages()
   const { showToast } = useToast()
   const compactHeaderRef = useRef<{ toggleSessionDropdown: () => void }>(null)
@@ -37,6 +39,32 @@ function AppInner({ connectionState }: { connectionState: ConnectionState }) {
   const [isHelpOpen, setIsHelpOpen] = useState(false)
   const [isSettingsOpen, setIsSettingsOpen] = useState(false)
 
+  const [bridge, setBridge] = useState<UiBridgeState | null>(null)
+  const restored = useRef({ session: false, selections: false })
+
+  useEffect(() => uiBridgeSubscribe((s) => setBridge(s)), [])
+
+  useEffect(() => {
+    if (restored.current.session) return
+    if (!bridge?.sessionID) return
+    restored.current.session = true
+    void switchSession(bridge.sessionID)
+  }, [bridge?.sessionID, switchSession])
+
+  useEffect(() => {
+    if (restored.current.selections) return
+    if (!bridge) return
+    const has = !!(bridge.agent || bridge.variant || (bridge.providerId && bridge.modelId))
+    if (!has) return
+    restored.current.selections = true
+    restoreSelections({
+      providerId: bridge.providerId,
+      modelId: bridge.modelId,
+      agent: bridge.agent,
+      variant: bridge.variant,
+    })
+  }, [bridge, restoreSelections])
+
   const handleNewSession = useCallback(() => {
     newVirtual()
   }, [newVirtual])
@@ -169,6 +197,7 @@ function AppInner({ connectionState }: { connectionState: ConnectionState }) {
     if (currentSession?.id) {
       console.log("[App] Current session changed, loading messages:", currentSession.id)
       loadSessionMessages(currentSession.id)
+      uiBridgeUpdate({ sessionID: currentSession.id })
       // Focus message input when session changes
       setTimeout(() => {
         messageInputRef.current?.focus()

+ 7 - 1
packages/opencode/webgui/src/components/CompactHeader/SessionDropdown.tsx

@@ -1,5 +1,6 @@
 import type { Session } from "@opencode-ai/sdk/client"
 import { SessionList } from "./SessionList"
+import { uiBridgeUpdate } from "../../state/uiBridgeState"
 
 interface SessionDropdownProps {
   sessions: Session[]
@@ -64,6 +65,11 @@ export function SessionDropdown({
 }: SessionDropdownProps) {
   if (!isDropdownOpen) return null
 
+  const handleSelect = (sessionId: string) => {
+    uiBridgeUpdate({ sessionID: sessionId })
+    onSessionSelect(sessionId)
+  }
+
   return (
     <div className="absolute top-full left-0 right-0 w-full bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 shadow-lg z-50 max-h-96 flex flex-col">
       {/* Search input and select mode toggle */}
@@ -120,7 +126,7 @@ export function SessionDropdown({
         selectedSessionRef={selectedSessionRef}
         sessionListRef={sessionListRef}
         sharingSessionId={sharingSessionId}
-        onSessionSelect={onSessionSelect}
+        onSessionSelect={handleSelect}
         onEditStart={onEditStart}
         onEditSave={onEditSave}
         onEditCancel={onEditCancel}

+ 11 - 0
packages/opencode/webgui/src/components/MessageInput/EditorToolbar.tsx

@@ -1,8 +1,10 @@
+import { useEffect } from "react"
 import { ModelSelector } from "../ModelSelector"
 import { AgentSelector } from "../AgentSelector"
 import { VariantSelector } from "../VariantSelector"
 import { IconButton } from "../common"
 import { MessageActions } from "./MessageActions"
+import { uiBridgeUpdate } from "../../state/uiBridgeState"
 
 interface EditorToolbarProps {
   selectedProviderId: string | undefined
@@ -53,6 +55,15 @@ export function EditorToolbar({
   onVariantSelect,
   isReasoningModel,
 }: EditorToolbarProps) {
+  useEffect(() => {
+    uiBridgeUpdate({
+      providerId: selectedProviderId ?? null,
+      modelId: selectedModelId ?? null,
+      agent: selectedAgent ?? null,
+      variant: selectedVariant ?? null,
+    })
+  }, [selectedProviderId, selectedModelId, selectedAgent, selectedVariant])
+
   return (
     <div className="h-8 px-2 flex items-center justify-between border-t border-gray-100 dark:border-gray-800">
       <div className="flex items-center gap-1">

+ 18 - 1
packages/opencode/webgui/src/components/MessageInput/index.tsx

@@ -20,6 +20,7 @@ import { useDragDrop } from "./hooks/useDragDrop"
 import { useEditorKeyboard } from "./hooks/useEditorKeyboard"
 import { useMessageParts } from "./hooks/useMessageParts"
 import { insertPlainWithMentionsImpl } from "./utils"
+import { uiBridgeSubscribe, uiBridgeUpdate } from "../../state/uiBridgeState"
 
 interface MessageInputProps {
   sessionID: string | null
@@ -79,13 +80,17 @@ const MessageInputInner = forwardRef<
   // Providers state for variants computation
   const [providers, setProviders] = useState<Provider[]>([])
 
+  const [isRestoring, setIsRestoring] = useState(false)
+  const restored = useRef(false)
+
   const handleEditorChange = useCallback((editorState: EditorState) => {
     editorState.read(() => {
       const root = $getRoot()
       const textContent = root.getTextContent()
       setIsEmpty(textContent.trim().length === 0)
+      if (!isRestoring) uiBridgeUpdate({ input: textContent })
     })
-  }, [])
+  }, [isRestoring])
 
   const resolveToAbsolutePath = useCallback(
     (path: string | undefined): string => {
@@ -145,6 +150,18 @@ const MessageInputInner = forwardRef<
 
   useEditorKeyboard({ editor, contentEditableRef, parseWithRange, onSubmit: handleSubmit })
 
+  // Restore input from IDE bridge state
+  useEffect(() => {
+    return uiBridgeSubscribe((s) => {
+      if (restored.current) return
+      if (!s.input) return
+      restored.current = true
+      setIsRestoring(true)
+      insertPlainWithMentionsImpl(editor, parseWithRange, s.input, { replace: true })
+      setTimeout(() => setIsRestoring(false), 0)
+    })
+  }, [editor, parseWithRange])
+
   // Expose methods to parent
   useImperativeHandle(
     ref,

+ 58 - 2
packages/opencode/webgui/src/lib/ideBridge.ts

@@ -47,12 +47,26 @@ class IdeBridge {
       return
     }
 
+    this.eventSource.addEventListener("connected", (ev: MessageEvent) => {
+      try {
+        // ignore payload (kept for compatibility)
+        JSON.parse(String(ev.data))
+      } catch {
+      }
+    })
+
     this.eventSource.onopen = () => {
       this.ready = true
       this.reconnectDelay = 1000
       this.connectErrorLogged = false
       console.log("[ideBridge] Connected", { bridgeBase })
       this.flushQueue()
+
+      void this.getState().then((state) => {
+        try {
+          window.dispatchEvent(new CustomEvent("opencode:ui-bridge-state", { detail: { state } }))
+        } catch {}
+      })
     }
 
     this.eventSource.onmessage = (ev) => {
@@ -77,6 +91,23 @@ class IdeBridge {
     }
   }
 
+  onReady(handler: () => void) {
+    const run = () => {
+      try {
+        handler()
+      } catch {}
+    }
+
+    if (this.ready) {
+      run()
+      return () => {}
+    }
+
+    const listener = () => run()
+    window.addEventListener("opencode:idebridge-ready", listener)
+    return () => window.removeEventListener("opencode:idebridge-ready", listener)
+  }
+
   private scheduleReconnect() {
     if (this.reconnectScheduled) return
     this.reconnectScheduled = true
@@ -130,6 +161,8 @@ class IdeBridge {
   private async doSend(msg: Message, retryCount = 0) {
     if (!bridgeBase || !token) return
 
+    const quiet = msg.type === "uiGetState" || msg.type === "uiSetState"
+
     try {
       const response = await fetch(`${bridgeBase}/send?token=${encodeURIComponent(token)}`, {
         method: "POST",
@@ -138,14 +171,14 @@ class IdeBridge {
       })
 
       if (!response.ok) {
-        console.warn("[ideBridge] Send failed with status:", response.status)
+        if (!quiet) console.warn("[ideBridge] Send failed with status:", response.status)
         // Requeue on server errors (5xx) with limited retries
         if (response.status >= 500 && retryCount < 3) {
           this.requeueWithBackoff(msg, retryCount)
         }
       }
     } catch (e) {
-      console.warn("[ideBridge] Send failed:", e)
+      if (!quiet) console.warn("[ideBridge] Send failed:", e)
       // Network error - requeue with backoff
       if (retryCount < 3) {
         this.requeueWithBackoff(msg, retryCount)
@@ -185,6 +218,29 @@ class IdeBridge {
     for (const msg of q) {
       this.doSend(msg)
     }
+
+    try {
+      window.dispatchEvent(new Event("opencode:idebridge-ready"))
+    } catch {}
+  }
+
+  async getState<T = any>(): Promise<T | null> {
+    try {
+      const res = await this.request<{ state: T }>("uiGetState")
+      const state = (res as any)?.payload?.state
+      return (state ?? null) as T | null
+    } catch {
+      return null
+    }
+  }
+
+  async setState(state: any): Promise<boolean> {
+    try {
+      const res = await this.request("uiSetState", { state })
+      return !!(res as any)?.ok
+    } catch {
+      return false
+    }
   }
 }
 

+ 11 - 0
packages/opencode/webgui/src/main.tsx

@@ -4,6 +4,7 @@ import "./index.css"
 import App from "./App.tsx"
 import { ideBridge } from "./lib/ideBridge"
 import { installTooltipPolyfillBridge } from "./lib/tooltipPolyfill"
+import { uiBridgeEnable, uiBridgeHydrate } from "./state/uiBridgeState"
 import { SessionProvider } from "./state/SessionContext.tsx"
 import { ToastProvider } from "./state/ToastContext.tsx"
 import { ErrorBoundary } from "./components/ErrorBoundary.tsx"
@@ -12,6 +13,16 @@ import { IdeBridgeProvider } from "./state/IdeBridgeContext"
 import { ProvidersProvider } from "./state/ProvidersContext"
 import { initGlobalDnD } from "./lib/dnd"
 
+window.addEventListener(
+  "opencode:ui-bridge-state",
+  (ev) => {
+    const state = (ev as CustomEvent<{ state?: unknown }>).detail?.state
+    if (state) uiBridgeHydrate(state)
+    uiBridgeEnable()
+  },
+  { once: true },
+)
+
 ideBridge.init()
 installTooltipPolyfillBridge()
 initGlobalDnD()

+ 47 - 0
packages/opencode/webgui/src/state/SessionContext.tsx

@@ -52,6 +52,14 @@ interface SessionContextState {
   selectedVariant: string | undefined
   setSelectedVariant: (variant: string | undefined) => Promise<void>
 
+  // IDE bridge restore (does not persist to server)
+  restoreSelections: (state: {
+    providerId: string | null
+    modelId: string | null
+    agent: string | null
+    variant: string | null
+  }) => void
+
   // Virtual session tracking
   isVirtualSession: boolean
 
@@ -413,6 +421,44 @@ export function SessionProvider({ children }: SessionProviderProps) {
         setSelectedAgentState(newAgent)
         localStorage.setItem("opencode_selected_agent", newAgent)
       }
+  },
+    [selectedAgent, selectedProviderId, selectedModelId],
+  )
+
+  const restoreSelections = useCallback(
+    (state: { providerId: string | null; modelId: string | null; agent: string | null; variant: string | null }) => {
+      if (typeof state.agent === "string" && state.agent !== selectedAgent) {
+        setSelectedAgentState(state.agent)
+        localStorage.setItem("opencode_selected_agent", state.agent)
+      }
+
+      const nextProvider = typeof state.providerId === "string" ? state.providerId : undefined
+      const nextModel = typeof state.modelId === "string" ? state.modelId : undefined
+      const hasModel = !!(nextProvider && nextModel)
+
+      if (hasModel && nextProvider !== selectedProviderId) {
+        setSelectedProviderId(nextProvider)
+        if (nextProvider) localStorage.setItem("opencode_selected_provider", nextProvider)
+        if (!nextProvider) localStorage.removeItem("opencode_selected_provider")
+      }
+
+      if (hasModel && nextModel !== selectedModelId) {
+        setSelectedModelId(nextModel)
+        if (nextModel) localStorage.setItem("opencode_selected_model", nextModel)
+        if (!nextModel) localStorage.removeItem("opencode_selected_model")
+      }
+
+      if (typeof state.variant === "string") setSelectedVariantState(state.variant)
+
+      if (typeof state.variant === "string" && nextProvider && nextModel) {
+        const key = `${nextProvider}/${nextModel}`
+        const variant = state.variant
+        setVariantMap((prev) => {
+          const next = { ...prev }
+          next[key] = variant
+          return next
+        })
+      }
     },
     [selectedAgent, selectedProviderId, selectedModelId],
   )
@@ -985,6 +1031,7 @@ export function SessionProvider({ children }: SessionProviderProps) {
     setSelectedAgent,
     selectedVariant,
     setSelectedVariant,
+    restoreSelections,
     isVirtualSession,
     newVirtual,
     createSession,

+ 119 - 0
packages/opencode/webgui/src/state/uiBridgeState.ts

@@ -0,0 +1,119 @@
+import { ideBridge } from "../lib/ideBridge"
+
+export type UiBridgeState = {
+  v: 1
+  sessionID: string | null
+  providerId: string | null
+  modelId: string | null
+  agent: string | null
+  variant: string | null
+  input: string | null
+}
+
+const empty: UiBridgeState = {
+  v: 1,
+  sessionID: null,
+  providerId: null,
+  modelId: null,
+  agent: null,
+  variant: null,
+  input: null,
+}
+
+const store = {
+  state: empty,
+  json: JSON.stringify(empty),
+  listeners: new Set<(s: UiBridgeState) => void>(),
+  enabled: false,
+}
+
+function emit(next: UiBridgeState) {
+  store.listeners.forEach((fn) => {
+    try {
+      fn(next)
+    } catch {}
+  })
+}
+
+function sanitizeSession(sessionID: string | null): string | null {
+  if (!sessionID) return null
+  if (sessionID.startsWith("virtual-")) return null
+  return sessionID
+}
+
+function encode(next: UiBridgeState) {
+  return JSON.stringify(next)
+}
+
+function send(next: UiBridgeState) {
+  if (!store.enabled) return
+  if (!ideBridge.isInstalled()) return
+  void ideBridge.setState(next)
+}
+
+export function uiBridgeState(): UiBridgeState {
+  return store.state
+}
+
+export function uiBridgeHydrate(raw: unknown): UiBridgeState {
+  const obj = raw && typeof raw === "object" ? (raw as any) : null
+
+  const next: UiBridgeState = {
+    v: 1,
+    sessionID: sanitizeSession(
+      typeof obj?.sessionID === "string"
+        ? obj.sessionID
+        : typeof obj?.sessionId === "string"
+          ? obj.sessionId
+          : null,
+    ),
+    providerId: typeof obj?.providerId === "string" ? obj.providerId : typeof obj?.providerID === "string" ? obj.providerID : null,
+    modelId: typeof obj?.modelId === "string" ? obj.modelId : typeof obj?.modelID === "string" ? obj.modelID : null,
+    agent: typeof obj?.agent === "string" ? obj.agent : null,
+    variant: typeof obj?.variant === "string" ? obj.variant : null,
+    input: typeof obj?.input === "string" ? obj.input : null,
+  }
+
+  store.state = next
+  store.json = encode(next)
+  emit(next)
+  return next
+}
+
+export function uiBridgeSubscribe(fn: (s: UiBridgeState) => void) {
+  store.listeners.add(fn)
+  try {
+    fn(store.state)
+  } catch {}
+  return () => {
+    store.listeners.delete(fn)
+  }
+}
+
+export function uiBridgeEnable() {
+  if (store.enabled) return
+  store.enabled = true
+  send(store.state)
+}
+
+export function uiBridgeUpdate(patch: Partial<Omit<UiBridgeState, "v">>): UiBridgeState {
+  const next: UiBridgeState = {
+    ...store.state,
+    sessionID: sanitizeSession(
+      typeof patch.sessionID === "string" ? patch.sessionID : patch.sessionID === null ? null : store.state.sessionID,
+    ),
+    providerId: typeof patch.providerId === "string" ? patch.providerId : patch.providerId === null ? null : store.state.providerId,
+    modelId: typeof patch.modelId === "string" ? patch.modelId : patch.modelId === null ? null : store.state.modelId,
+    agent: typeof patch.agent === "string" ? patch.agent : patch.agent === null ? null : store.state.agent,
+    variant: typeof patch.variant === "string" ? patch.variant : patch.variant === null ? null : store.state.variant,
+    input: typeof patch.input === "string" ? patch.input : patch.input === null ? null : store.state.input,
+  }
+
+  const json = encode(next)
+  store.state = next
+  if (json === store.json) return next
+  store.json = json
+  send(next)
+  emit(next)
+  return next
+}