1
0
Эх сурвалжийг харах

fix(core): reconnect editor context for session directory (#24984)

James Long 3 сар өмнө
parent
commit
293877cb7e

+ 45 - 30
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route"
 import { useProject } from "@tui/context/project"
 import { useSync } from "@tui/context/sync"
 import { useEvent } from "@tui/context/event"
-import { useEditorContext } from "@tui/context/editor"
+import { useEditorContext, type EditorSelection } from "@tui/context/editor"
 import { MessageID, PartID } from "@/session/schema"
 import { createStore, produce, unwrap } from "solid-js/store"
 import { useKeybind } from "@tui/context/keybind"
@@ -84,6 +84,18 @@ function fadeColor(color: RGBA, alpha: number) {
   return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
 }
 
+function getEditorSelectionKey(selection: EditorSelection) {
+  return [
+    selection.filePath,
+    selection.text,
+    selection.source ?? "",
+    selection.selection.start.line,
+    selection.selection.start.character,
+    selection.selection.end.line,
+    selection.selection.end.character,
+  ].join("-")
+}
+
 let stashed: { prompt: PromptInfo; cursor: number } | undefined
 
 export function Prompt(props: PromptProps) {
@@ -135,6 +147,7 @@ export function Prompt(props: PromptProps) {
     if (!file) return
     return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
   })
+  let lastSubmittedEditorSelectionKey: string | undefined
   const [auto, setAuto] = createSignal<AutocompleteRef>()
   const currentProviderLabel = createMemo(() => local.model.parsed().provider)
   const hasRightContent = createMemo(() => Boolean(props.right))
@@ -748,36 +761,38 @@ export function Prompt(props: PromptProps) {
     const currentMode = store.mode
     const variant = local.model.variant.current()
     const editorSelection = fileContextEnabled() ? editor.selection() : undefined
-    const editorParts = editorSelection
-      ? [
-          {
-            id: PartID.ascending(),
-            type: "text" as const,
-            text: (() => {
-              const start = editorSelection.selection.start
-              const end = editorSelection.selection.end
-
-              let text = ""
-              if (start.line === end.line && start.character === end.character) {
-                text = `Note: The user opened the file "${editorSelection.filePath}".`
-              } else if (start.line === end.line) {
-                text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
-              } else {
-                text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
-              }
+    const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
+    const editorParts =
+      editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey
+        ? [
+            {
+              id: PartID.ascending(),
+              type: "text" as const,
+              text: (() => {
+                const start = editorSelection.selection.start
+                const end = editorSelection.selection.end
+
+                let text = ""
+                if (start.line === end.line && start.character === end.character) {
+                  text = `Note: The user opened the file "${editorSelection.filePath}".`
+                } else if (start.line === end.line) {
+                  text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
+                } else {
+                  text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
+                }
 
-              return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
-            })(),
-            synthetic: true,
-            metadata: {
-              kind: "editor_context",
-              source: editorSelection.source ?? "editor",
-              filePath: editorSelection.filePath,
-              selection: editorSelection.selection,
+                return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
+              })(),
+              synthetic: true,
+              metadata: {
+                kind: "editor_context",
+                source: editorSelection.source ?? "editor",
+                filePath: editorSelection.filePath,
+                selection: editorSelection.selection,
+              },
             },
-          },
-        ]
-      : []
+          ]
+        : []
 
     if (store.mode === "shell") {
       void sdk.client.session.shell({
@@ -840,7 +855,7 @@ export function Prompt(props: PromptProps) {
           ],
         })
         .catch(() => {})
-      editor.clearSelection()
+      lastSubmittedEditorSelectionKey = editorSelectionKey
     }
     history.append({
       ...store.prompt,

+ 161 - 134
packages/opencode/src/cli/cmd/tui/context/editor.ts

@@ -75,8 +75,9 @@ type EditorLockFile = {
 
 export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
   name: "EditorContext",
-  init: () => {
+  init: (props: { WebSocketImpl?: typeof WebSocket }) => {
     const mentionListeners = new Set<(mention: EditorMention) => void>()
+    const WebSocketImpl = props.WebSocketImpl ?? WebSocket
     const [store, setStore] = createStore<{
       status: "disabled" | "connecting" | "connected"
       selection: EditorSelection | undefined
@@ -87,138 +88,160 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
       server: undefined,
     })
 
-    onMount(() => {
-      let socket: WebSocket | undefined
-      let closed = false
-      let reconnect: ReturnType<typeof setTimeout> | undefined
-      let attempt = 0
-      let requestID = 0
-      let zedSelection: Promise<void> | undefined
-      let lastZedSelectionKey: string | undefined
-      const pending = new Map<number, string>()
-
-      const send = (payload: JsonRpcMessage) => {
-        if (!socket || socket.readyState !== WebSocket.OPEN) return
-        socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
-      }
+    let socket: WebSocket | undefined
+    let closed = false
+    let reconnect: ReturnType<typeof setTimeout> | undefined
+    let attempt = 0
+    let requestID = 0
+    let zedSelection: Promise<void> | undefined
+    let lastZedSelectionKey: string | undefined
+    let directory = process.cwd()
+    const pending = new Map<number, string>()
+
+    const send = (payload: JsonRpcMessage) => {
+      if (!socket || socket.readyState !== 1) return
+      socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
+    }
 
-      const request = (method: string, params?: unknown) => {
-        requestID += 1
-        pending.set(requestID, method)
-        send({ id: requestID, method, params })
-      }
+    const request = (method: string, params?: unknown) => {
+      requestID += 1
+      pending.set(requestID, method)
+      send({ id: requestID, method, params })
+    }
 
-      const scheduleReconnect = () => {
-        if (closed) return
-        if (reconnect) clearTimeout(reconnect)
-        attempt += 1
-        const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
-        reconnect = setTimeout(connect, delay)
-      }
+    const connect = () => {
+      if (closed) return
 
-      const scheduleZedPoll = () => {
-        if (closed) return
-        if (reconnect) clearTimeout(reconnect)
-        reconnect = setTimeout(connect, 1000)
+      const connection = resolveEditorConnection(directory)
+      if (!connection) {
+        const dbPath = resolveZedDbPath()
+        if (!dbPath) {
+          setStore("status", "disabled")
+          scheduleReconnect()
+          return
+        }
+        zedSelection ??= resolveZedSelection(dbPath, directory)
+          .then((result) => {
+            if (closed || socket) return
+            if (result.type === "unavailable") return
+            const selection = result.type === "selection" ? result.selection : undefined
+            const key = editorSelectionKey(selection)
+            if (key !== lastZedSelectionKey) {
+              lastZedSelectionKey = key
+              setStore("selection", selection)
+              setStore("status", selection ? "connected" : "disabled")
+            }
+          })
+          .catch(() => {
+            // Keep the last known Zed selection for transient polling failures.
+          })
+          .finally(() => {
+            zedSelection = undefined
+          })
+        scheduleZedPoll()
+        return
       }
 
-      const connect = () => {
-        if (closed) return
+      setStore("status", "connecting")
+      const current = openEditorSocket(connection, WebSocketImpl)
+      socket = current
 
-        const connection = resolveEditorConnection()
-        if (!connection) {
-          const dbPath = resolveZedDbPath()
-          if (!dbPath) {
-            setStore("status", "disabled")
-            scheduleReconnect()
-            return
-          }
-          zedSelection ??= resolveZedSelection(dbPath)
-            .then((result) => {
-              if (closed || socket) return
-              if (result.type === "unavailable") return
-              const selection = result.type === "selection" ? result.selection : undefined
-              const key = editorSelectionKey(selection)
-              if (key !== lastZedSelectionKey) {
-                lastZedSelectionKey = key
-                setStore("selection", selection)
-                setStore("status", selection ? "connected" : "disabled")
-              }
-            })
-            .catch(() => {
-              // Keep the last known Zed selection for transient polling failures.
-            })
-            .finally(() => {
-              zedSelection = undefined
-            })
-          scheduleZedPoll()
+      current.addEventListener("open", () => {
+        if (socket !== current) {
+          current.close()
           return
         }
 
-        setStore("status", "connecting")
-        const current = openEditorSocket(connection)
-        socket = current
-
-        current.addEventListener("open", () => {
-          if (socket !== current) {
-            current.close()
-            return
-          }
-
-          attempt = 0
-          setStore("status", "connected")
-          request("initialize", {
-            protocolVersion: MCP_PROTOCOL_VERSION,
-            capabilities: {},
-            clientInfo: { name: "opencode", version: "0.0.0" },
-          })
+        attempt = 0
+        setStore("status", "connected")
+        request("initialize", {
+          protocolVersion: MCP_PROTOCOL_VERSION,
+          capabilities: {},
+          clientInfo: { name: "opencode", version: "0.0.0" },
         })
+      })
 
-        current.addEventListener("message", (event) => {
-          const message = parseMessage(event.data)
-          if (!message) return
-
-          const selection =
-            message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
-          if (selection?.success) {
-            setStore("selection", { ...selection.data, source: "websocket" })
-            return
-          }
-
-          const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
-          if (mention?.success) {
-            mentionListeners.forEach((listener) => listener(mention.data))
-            return
-          }
-
-          if (typeof message.id !== "number") return
-
-          const method = pending.get(message.id)
-          if (!method) return
-
-          pending.delete(message.id)
-          if (message.error) return
-
-          const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
-          if (initialize?.success) {
-            setStore("server", initialize.data)
-            send({ method: "notifications/initialized" })
-            return
-          }
-        })
+      current.addEventListener("message", (event) => {
+        const message = parseMessage(event.data)
+        if (!message) return
 
-        current.addEventListener("close", () => {
-          if (socket !== current) return
+        const selection =
+          message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
+        if (selection?.success) {
+          setStore("selection", { ...selection.data, source: "websocket" })
+          return
+        }
 
-          socket = undefined
-          pending.clear()
-          if (closed) return
+        const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
+        if (mention?.success) {
+          mentionListeners.forEach((listener) => listener(mention.data))
+          return
+        }
 
-          setStore("status", "connecting")
-          scheduleReconnect()
-        })
+        if (typeof message.id !== "number") return
+
+        const method = pending.get(message.id)
+        if (!method) return
+
+        pending.delete(message.id)
+        if (message.error) return
+
+        const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
+        if (initialize?.success) {
+          setStore("server", initialize.data)
+          send({ method: "notifications/initialized" })
+          return
+        }
+      })
+
+      current.addEventListener("close", () => {
+        if (socket !== current) return
+
+        socket = undefined
+        pending.clear()
+        if (closed) return
+
+        setStore("status", "connecting")
+        scheduleReconnect()
+      })
+    }
+
+    const scheduleReconnect = () => {
+      if (closed) return
+      if (reconnect) clearTimeout(reconnect)
+      attempt += 1
+      const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
+      reconnect = setTimeout(connect, delay)
+    }
+
+    const scheduleZedPoll = () => {
+      if (closed) return
+      if (reconnect) clearTimeout(reconnect)
+      reconnect = setTimeout(connect, 1000)
+    }
+
+    const reconnectWithDirectory = (nextDirectory?: string) => {
+      const resolved = nextDirectory || process.cwd()
+      if (directory === resolved) return
+
+      directory = resolved
+      attempt = 0
+      pending.clear()
+      lastZedSelectionKey = undefined
+      if (reconnect) clearTimeout(reconnect)
+      reconnect = undefined
+      if (socket) {
+        const current = socket
+        socket = undefined
+        current.close()
       }
+      setStore("status", "disabled")
+      setStore("selection", undefined)
+      setStore("server", undefined)
+      connect()
+    }
 
+    onMount(() => {
       connect()
 
       onCleanup(() => {
@@ -230,7 +253,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
 
     return {
       enabled() {
-        return Boolean(resolveEditorConnection() || resolveZedDbPath())
+        return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
       },
       connected() {
         return store.status === "connected"
@@ -248,6 +271,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
       server() {
         return store.server
       },
+      reconnect(directory?: string) {
+        setStore("selection", undefined)
+        reconnectWithDirectory(directory)
+      },
     }
   },
 })
@@ -260,8 +287,16 @@ function parsePort(value: string | undefined) {
   return parsed
 }
 
-function resolveEditorConnection(): EditorConnection | undefined {
-  const lock = resolveEditorLockFile()
+function resolveEditorConnection(directory: string): EditorConnection | undefined {
+  const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
+  if (port) {
+    return {
+      url: `ws://127.0.0.1:${port}`,
+      source: `env:${port}`,
+    }
+  }
+
+  const lock = resolveEditorLockFile(directory)
   if (lock) {
     return {
       url: `ws://127.0.0.1:${lock.port}`,
@@ -269,16 +304,9 @@ function resolveEditorConnection(): EditorConnection | undefined {
       source: `lock:${lock.port}`,
     }
   }
-
-  const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
-  if (!port) return
-  return {
-    url: `ws://127.0.0.1:${port}`,
-    source: `env:${port}`,
-  }
 }
 
-function resolveEditorLockFile() {
+function resolveEditorLockFile(activeDirectory: string) {
   const directory = path.join(os.homedir(), ".claude", "ide")
   let entries: string[]
 
@@ -288,10 +316,9 @@ function resolveEditorLockFile() {
     return
   }
 
-  const cwd = process.cwd()
-  // longest workspace folder that contains cwd; 0 if none match
+  // longest workspace folder that contains the active session directory; 0 if none match
   const bestMatchLength = (lock: EditorLockFile) =>
-    Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd)))
+    Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
   const locks = entries
     .filter((entry) => entry.endsWith(".lock"))
     .map((entry) => readEditorLockFile(path.join(directory, entry)))
@@ -343,10 +370,10 @@ function pathContainsLength(parent: string, child: string) {
   return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
 }
 
-function openEditorSocket(connection: EditorConnection) {
-  if (!connection.authToken) return new WebSocket(connection.url)
+function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
+  if (!connection.authToken) return new WebSocketImpl(connection.url)
 
-  return new WebSocket(connection.url, {
+  return new WebSocketImpl(connection.url, {
     headers: {
       "x-claude-code-ide-authorization": connection.authToken,
     },

+ 3 - 0
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -50,6 +50,7 @@ import type { QuestionTool } from "@/tool/question"
 import type { SkillTool } from "@/tool/skill"
 import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { useSDK } from "@tui/context/sdk"
+import { useEditorContext } from "@tui/context/editor"
 import { useCommandDialog } from "@tui/component/dialog-command"
 import type { DialogContext } from "@tui/ui/dialog"
 import { useKeybind } from "@tui/context/keybind"
@@ -179,6 +180,7 @@ export function Session() {
   const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
   const toast = useToast()
   const sdk = useSDK()
+  const editor = useEditorContext()
 
   createEffect(() => {
     const sessionID = route.sessionID
@@ -206,6 +208,7 @@ export function Session() {
           await sync.bootstrap({ fatal: false })
         } catch {}
       }
+      editor.reconnect(result.data.directory)
       await sync.session.sync(sessionID)
       if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
     })().catch((error) => {

+ 0 - 0
packages/opencode/test/cli/tui/editor-context.test.ts → packages/opencode/test/cli/tui/editor-context-zed.test.ts


+ 224 - 0
packages/opencode/test/cli/tui/editor-context.test.tsx

@@ -0,0 +1,224 @@
+import { mkdir, writeFile } from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { afterEach, expect, spyOn, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { EditorContextProvider, useEditorContext } from "../../../src/cli/cmd/tui/context/editor"
+import { tmpdir } from "../../fixture/fixture"
+import { FakeWebSocket } from "../../lib/websocket"
+
+const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
+const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
+
+afterEach(() => {
+  process.env.CLAUDE_CODE_SSE_PORT = originalClaudePort
+  process.env.OPENCODE_EDITOR_SSE_PORT = originalOpencodePort
+})
+
+function nextTick() {
+  return new Promise<void>((resolve) => queueMicrotask(resolve))
+}
+
+function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
+  let editor!: ReturnType<typeof useEditorContext>
+  let dispose!: () => void
+
+  createRoot((nextDispose) => {
+    dispose = nextDispose
+
+    const Consumer = () => {
+      editor = useEditorContext()
+      return null
+    }
+
+    return (
+      <EditorContextProvider WebSocketImpl={WebSocketImpl}>
+        <Consumer />
+      </EditorContextProvider>
+    )
+  })
+
+  return {
+    editor,
+    dispose,
+  }
+}
+
+function createWebSocketImpl(...sockets: FakeWebSocket[]) {
+  let index = 0
+
+  return class {
+    constructor(url: string, options?: { headers?: Record<string, string> }) {
+      const socket = sockets[index]
+      index += 1
+      expect(socket).toBeDefined()
+      expect(url).toBe(socket!.url)
+      expect(options).toEqual(socket!.options)
+      return socket as unknown as object
+    }
+  } as unknown as typeof WebSocket
+}
+
+test("useEditorContext reconnect switches editor server by session directory", async () => {
+  await using tmp = await tmpdir()
+  const startupDirectory = path.join(tmp.path, "startup")
+  const sessionDirectory = path.join(tmp.path, "session")
+  const ideDirectory = path.join(tmp.path, ".claude", "ide")
+  await mkdir(startupDirectory, { recursive: true })
+  await mkdir(sessionDirectory, { recursive: true })
+  await mkdir(ideDirectory, { recursive: true })
+  await writeFile(
+    path.join(ideDirectory, "3001.lock"),
+    JSON.stringify({
+      transport: "ws",
+      workspaceFolders: [startupDirectory],
+    }),
+  )
+  await writeFile(
+    path.join(ideDirectory, "3002.lock"),
+    JSON.stringify({
+      transport: "ws",
+      workspaceFolders: [sessionDirectory],
+    }),
+  )
+
+  process.env.CLAUDE_CODE_SSE_PORT = undefined
+  process.env.OPENCODE_EDITOR_SSE_PORT = undefined
+  spyOn(process, "cwd").mockImplementation(() => startupDirectory)
+  spyOn(os, "homedir").mockImplementation(() => tmp.path)
+  const firstSocket = new FakeWebSocket("ws://127.0.0.1:3001")
+  const secondSocket = new FakeWebSocket("ws://127.0.0.1:3002")
+
+  const mounted = mountEditorContext(createWebSocketImpl(firstSocket, secondSocket))
+  await nextTick()
+
+  expect(firstSocket.closed).toBeFalse()
+
+  mounted.editor.reconnect(sessionDirectory)
+  await nextTick()
+
+  expect(firstSocket.closed).toBeTrue()
+  expect(secondSocket.closed).toBeFalse()
+
+  mounted.dispose()
+})
+
+test("useEditorContext favors configured port over lock files", async () => {
+  await using tmp = await tmpdir()
+  const startupDirectory = path.join(tmp.path, "startup")
+  const ideDirectory = path.join(tmp.path, ".claude", "ide")
+  await mkdir(startupDirectory, { recursive: true })
+  await mkdir(ideDirectory, { recursive: true })
+  await writeFile(
+    path.join(ideDirectory, "3001.lock"),
+    JSON.stringify({
+      transport: "ws",
+      workspaceFolders: [startupDirectory],
+    }),
+  )
+
+  process.env.CLAUDE_CODE_SSE_PORT = "4010"
+  process.env.OPENCODE_EDITOR_SSE_PORT = undefined
+  spyOn(process, "cwd").mockImplementation(() => startupDirectory)
+  spyOn(os, "homedir").mockImplementation(() => tmp.path)
+  const socket = new FakeWebSocket("ws://127.0.0.1:4010")
+
+  const mounted = mountEditorContext(createWebSocketImpl(socket))
+  await nextTick()
+
+  expect(socket.closed).toBeFalse()
+
+  mounted.dispose()
+})
+
+test("useEditorContext resets selection when reconnecting", async () => {
+  await using tmp = await tmpdir()
+  const startupDirectory = path.join(tmp.path, "startup")
+  const ideDirectory = path.join(tmp.path, ".claude", "ide")
+  await mkdir(startupDirectory, { recursive: true })
+  await mkdir(ideDirectory, { recursive: true })
+  await writeFile(
+    path.join(ideDirectory, "3001.lock"),
+    JSON.stringify({
+      transport: "ws",
+      workspaceFolders: [startupDirectory],
+    }),
+  )
+
+  process.env.CLAUDE_CODE_SSE_PORT = undefined
+  process.env.OPENCODE_EDITOR_SSE_PORT = undefined
+  spyOn(process, "cwd").mockImplementation(() => startupDirectory)
+  spyOn(os, "homedir").mockImplementation(() => tmp.path)
+  const socket = new FakeWebSocket("ws://127.0.0.1:3001")
+
+  const mounted = mountEditorContext(createWebSocketImpl(socket))
+  await nextTick()
+
+  expect(socket.closed).toBeFalse()
+  expect(mounted.editor.selection()).toBeUndefined()
+  expect(mounted.editor.connected()).toBeFalse()
+
+  socket.open()
+  socket.message(
+    JSON.stringify({
+      jsonrpc: "2.0",
+      id: 1,
+      result: {
+        protocolVersion: "2025-11-25",
+        serverInfo: { name: "test", version: "0.0.0" },
+      },
+    }),
+  )
+  socket.message(
+    JSON.stringify({
+      jsonrpc: "2.0",
+      method: "selection_changed",
+      params: {
+        text: "foo",
+        filePath: path.join(startupDirectory, "file.ts"),
+        selection: {
+          start: { line: 1, character: 1 },
+          end: { line: 1, character: 4 },
+        },
+      },
+    }),
+  )
+
+  expect(mounted.editor.connected()).toBeTrue()
+  expect(mounted.editor.server()).toEqual({
+    protocolVersion: "2025-11-25",
+    serverInfo: { name: "test", version: "0.0.0" },
+  })
+  expect(mounted.editor.selection()).toEqual({
+    text: "foo",
+    filePath: path.join(startupDirectory, "file.ts"),
+    source: "websocket",
+    selection: {
+      start: { line: 1, character: 1 },
+      end: { line: 1, character: 4 },
+    },
+  })
+
+  mounted.editor.reconnect(startupDirectory)
+
+  expect(socket.closed).toBeFalse()
+  expect(mounted.editor.connected()).toBeTrue()
+  expect(mounted.editor.selection()).toBeUndefined()
+
+  mounted.dispose()
+})
+
+test("useEditorContext connects with OPENCODE_EDITOR_SSE_PORT", async () => {
+  await using tmp = await tmpdir()
+  process.env.CLAUDE_CODE_SSE_PORT = undefined
+  process.env.OPENCODE_EDITOR_SSE_PORT = "4020"
+  spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const socket = new FakeWebSocket("ws://127.0.0.1:4020")
+
+  const mounted = mountEditorContext(createWebSocketImpl(socket))
+  await nextTick()
+
+  expect(socket.closed).toBeFalse()
+
+  mounted.dispose()
+})

+ 46 - 0
packages/opencode/test/lib/websocket.ts

@@ -0,0 +1,46 @@
+export class FakeWebSocket {
+  static CONNECTING = 0
+  static OPEN = 1
+  static CLOSING = 2
+  static CLOSED = 3
+
+  readyState = FakeWebSocket.CONNECTING
+  closed = false
+  sent: string[] = []
+  listeners = new Map<string, Set<(event: { data?: unknown }) => void>>()
+
+  constructor(
+    readonly url: string,
+    readonly options?: { headers?: Record<string, string> },
+  ) {}
+
+  addEventListener(type: string, listener: (event: { data?: unknown }) => void) {
+    const current = this.listeners.get(type) ?? new Set<(event: { data?: unknown }) => void>()
+    current.add(listener)
+    this.listeners.set(type, current)
+  }
+
+  send(data: string) {
+    this.sent.push(data)
+  }
+
+  close() {
+    if (this.readyState === FakeWebSocket.CLOSED) return
+    this.closed = true
+    this.readyState = FakeWebSocket.CLOSED
+    this.emit("close", {})
+  }
+
+  open() {
+    this.readyState = FakeWebSocket.OPEN
+    this.emit("open", {})
+  }
+
+  message(data: unknown) {
+    this.emit("message", { data })
+  }
+
+  emit(type: string, event: { data?: unknown }) {
+    this.listeners.get(type)?.forEach((listener) => listener(event))
+  }
+}