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

This commit is contained in:
James Long
2026-04-29 15:11:44 -04:00
committed by GitHub
parent c480006554
commit 293877cb7e
6 changed files with 480 additions and 165 deletions
@@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event" 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 { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store" import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind" 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) 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 let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) { export function Prompt(props: PromptProps) {
@@ -135,6 +147,7 @@ export function Prompt(props: PromptProps) {
if (!file) return if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) 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 [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider) const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right)) const hasRightContent = createMemo(() => Boolean(props.right))
@@ -748,7 +761,9 @@ export function Prompt(props: PromptProps) {
const currentMode = store.mode const currentMode = store.mode
const variant = local.model.variant.current() const variant = local.model.variant.current()
const editorSelection = fileContextEnabled() ? editor.selection() : undefined const editorSelection = fileContextEnabled() ? editor.selection() : undefined
const editorParts = editorSelection const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
const editorParts =
editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey
? [ ? [
{ {
id: PartID.ascending(), id: PartID.ascending(),
@@ -840,7 +855,7 @@ export function Prompt(props: PromptProps) {
], ],
}) })
.catch(() => {}) .catch(() => {})
editor.clearSelection() lastSubmittedEditorSelectionKey = editorSelectionKey
} }
history.append({ history.append({
...store.prompt, ...store.prompt,
@@ -75,8 +75,9 @@ type EditorLockFile = {
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({ export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext", name: "EditorContext",
init: () => { init: (props: { WebSocketImpl?: typeof WebSocket }) => {
const mentionListeners = new Set<(mention: EditorMention) => void>() const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected" status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined selection: EditorSelection | undefined
@@ -87,7 +88,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server: undefined, server: undefined,
}) })
onMount(() => {
let socket: WebSocket | undefined let socket: WebSocket | undefined
let closed = false let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined let reconnect: ReturnType<typeof setTimeout> | undefined
@@ -95,10 +95,11 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
let requestID = 0 let requestID = 0
let zedSelection: Promise<void> | undefined let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined let lastZedSelectionKey: string | undefined
let directory = process.cwd()
const pending = new Map<number, string>() const pending = new Map<number, string>()
const send = (payload: JsonRpcMessage) => { const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload })) socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
} }
@@ -108,24 +109,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
send({ id: requestID, method, params }) 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 scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
const connect = () => { const connect = () => {
if (closed) return if (closed) return
const connection = resolveEditorConnection() const connection = resolveEditorConnection(directory)
if (!connection) { if (!connection) {
const dbPath = resolveZedDbPath() const dbPath = resolveZedDbPath()
if (!dbPath) { if (!dbPath) {
@@ -133,7 +120,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
scheduleReconnect() scheduleReconnect()
return return
} }
zedSelection ??= resolveZedSelection(dbPath) zedSelection ??= resolveZedSelection(dbPath, directory)
.then((result) => { .then((result) => {
if (closed || socket) return if (closed || socket) return
if (result.type === "unavailable") return if (result.type === "unavailable") return
@@ -156,7 +143,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
} }
setStore("status", "connecting") setStore("status", "connecting")
const current = openEditorSocket(connection) const current = openEditorSocket(connection, WebSocketImpl)
socket = current socket = current
current.addEventListener("open", () => { current.addEventListener("open", () => {
@@ -219,6 +206,42 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
}) })
} }
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() connect()
onCleanup(() => { onCleanup(() => {
@@ -230,7 +253,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return { return {
enabled() { enabled() {
return Boolean(resolveEditorConnection() || resolveZedDbPath()) return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
}, },
connected() { connected() {
return store.status === "connected" return store.status === "connected"
@@ -248,6 +271,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server() { server() {
return store.server return store.server
}, },
reconnect(directory?: string) {
setStore("selection", undefined)
reconnectWithDirectory(directory)
},
} }
}, },
}) })
@@ -260,8 +287,16 @@ function parsePort(value: string | undefined) {
return parsed return parsed
} }
function resolveEditorConnection(): EditorConnection | undefined { function resolveEditorConnection(directory: string): EditorConnection | undefined {
const lock = resolveEditorLockFile() 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) { if (lock) {
return { return {
url: `ws://127.0.0.1:${lock.port}`, url: `ws://127.0.0.1:${lock.port}`,
@@ -269,16 +304,9 @@ function resolveEditorConnection(): EditorConnection | undefined {
source: `lock:${lock.port}`, 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") const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[] let entries: string[]
@@ -288,10 +316,9 @@ function resolveEditorLockFile() {
return return
} }
const cwd = process.cwd() // longest workspace folder that contains the active session directory; 0 if none match
// longest workspace folder that contains cwd; 0 if none match
const bestMatchLength = (lock: EditorLockFile) => 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 const locks = entries
.filter((entry) => entry.endsWith(".lock")) .filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry))) .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 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
} }
function openEditorSocket(connection: EditorConnection) { function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
if (!connection.authToken) return new WebSocket(connection.url) if (!connection.authToken) return new WebSocketImpl(connection.url)
return new WebSocket(connection.url, { return new WebSocketImpl(connection.url, {
headers: { headers: {
"x-claude-code-ide-authorization": connection.authToken, "x-claude-code-ide-authorization": connection.authToken,
}, },
@@ -50,6 +50,7 @@ import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill" import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command" import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog" import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
@@ -179,6 +180,7 @@ export function Session() {
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast() const toast = useToast()
const sdk = useSDK() const sdk = useSDK()
const editor = useEditorContext()
createEffect(() => { createEffect(() => {
const sessionID = route.sessionID const sessionID = route.sessionID
@@ -206,6 +208,7 @@ export function Session() {
await sync.bootstrap({ fatal: false }) await sync.bootstrap({ fatal: false })
} catch {} } catch {}
} }
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID) await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => { })().catch((error) => {
@@ -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
View File
@@ -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))
}
}