fix(core): better state handling of editor context (#25911)

This commit is contained in:
James Long 2026-05-05 15:53:05 -04:00 committed by GitHub
parent 8a797ed9a1
commit 8e182c7782
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 140 additions and 51 deletions

View file

@ -173,8 +173,7 @@ export function Prompt(props: PromptProps) {
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const editorContextLabelState = createMemo(() => editor.labelState())
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
@ -916,9 +915,8 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
const editorParts =
editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
editorSelection && editor.labelState() === "pending"
? [
{
id: PartID.ascending(),
@ -996,7 +994,7 @@ export function Prompt(props: PromptProps) {
],
})
.catch(() => {})
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
if (editorParts.length > 0) editor.markSelectionSent()
}
history.append({
...store.prompt,
@ -1011,13 +1009,15 @@ export function Prompt(props: PromptProps) {
props.onSubmit?.()
// temporary hack to make sure the message is sent
if (!props.sessionID)
if (!props.sessionID) {
if (editorParts.length > 0) editor.preserveSelectionFromNewSession()
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
}
input.clear()
return true
}
@ -1608,16 +1608,9 @@ export function Prompt(props: PromptProps) {
</Switch>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text
fg={theme.secondary}
onMouseOver={() => setEditorContextHover(true)}
onMouseOut={() => setEditorContextHover(false)}
onMouseUp={dismissEditorContext}
>
{editorContextHover() ? `x ${file()}` : file()}
</text>
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
)}
</Show>
<Switch>

View file

@ -87,6 +87,7 @@ const EditorServerInfoSchema = z.object({
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
export type EditorMention = z.infer<typeof EditorMentionSchema>
export type EditorLabelState = "pending" | "sent" | "none"
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
type EditorConnection = {
@ -111,10 +112,12 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
selectionSent: boolean
server: EditorServerInfo | undefined
}>({
status: "disabled",
selection: undefined,
selectionSent: false,
server: undefined,
})
@ -126,8 +129,24 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
let directory = process.cwd()
let preserveSelectionOnReconnect = false
const pending = new Map<number, string>()
const setSelection = (selection: EditorSelection | undefined) => {
const changed = editorSelectionKey(selection) !== editorSelectionKey(store.selection)
setStore("selection", selection)
if (changed) setStore("selectionSent", false)
}
const clearSelectionForReconnect = (options?: { resetZedSelectionKey?: boolean }) => {
if (preserveSelectionOnReconnect) {
preserveSelectionOnReconnect = false
return
}
if (options?.resetZedSelectionKey) lastZedSelectionKey = undefined
setSelection(undefined)
}
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
@ -158,7 +177,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setSelection(selection)
setStore("status", selection ? "connected" : "disabled")
}
})
@ -198,7 +217,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
setSelection({ ...selection.data, source: "websocket" })
return
}
@ -252,12 +271,13 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || process.cwd()
if (directory === resolved) return
const sameDirectory = directory === resolved
clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
if (sameDirectory) return
directory = resolved
attempt = 0
pending.clear()
lastZedSelectionKey = undefined
if (reconnect) clearTimeout(reconnect)
reconnect = undefined
if (socket) {
@ -266,7 +286,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
current.close()
}
setStore("status", "disabled")
setStore("selection", undefined)
setStore("server", undefined)
connect()
}
@ -293,7 +312,19 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
},
clearSelection() {
lastZedSelectionKey = undefined
setStore("selection", undefined)
zedSelection = undefined
setSelection(undefined)
},
preserveSelectionFromNewSession() {
preserveSelectionOnReconnect = true
},
markSelectionSent() {
if (!store.selection) return
setStore("selectionSent", true)
},
labelState(): EditorLabelState {
if (!store.selection) return "none"
return store.selectionSent ? "sent" : "pending"
},
onMention(listener: (mention: EditorMention) => void) {
mentionListeners.add(listener)
@ -303,7 +334,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return store.server
},
reconnect(directory?: string) {
setStore("selection", undefined)
reconnectWithDirectory(directory)
},
}

View file

@ -1,5 +1,5 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal } from "solid-js"
import { createEffect, createSignal, onMount } from "solid-js"
import { Logo } from "../component/logo"
import { useProject } from "../context/project"
import { useSync } from "../context/sync"
@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { useEditorContext } from "@tui/context/editor"
let once = false
const placeholder = {
@ -24,8 +25,13 @@ export function Home() {
const [ref, setRef] = createSignal<PromptRef | undefined>()
const args = useArgs()
const local = useLocal()
const editor = useEditorContext()
let sent = false
onMount(() => {
editor.clearSelection()
})
const bind = (r: PromptRef | undefined) => {
setRef(r)
promptRef.set(r)

View file

@ -59,6 +59,39 @@ function createWebSocketImpl(...sockets: FakeWebSocket[]) {
} as unknown as typeof WebSocket
}
function sendSelection(socket: FakeWebSocket, filePath: string, text = "foo") {
socket.message(
JSON.stringify({
jsonrpc: "2.0",
method: "selection_changed",
params: {
text,
filePath,
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
}),
)
}
function expectedSelection(filePath: string, text = "foo") {
return {
filePath,
source: "websocket" as const,
ranges: [
{
text,
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
],
}
}
test("useEditorContext reconnect switches editor server by session directory", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
@ -93,12 +126,18 @@ test("useEditorContext reconnect switches editor server by session directory", a
await nextTick()
expect(firstSocket.closed).toBeFalse()
sendSelection(firstSocket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("pending")
mounted.editor.reconnect(sessionDirectory)
await nextTick()
expect(firstSocket.closed).toBeTrue()
expect(secondSocket.closed).toBeFalse()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
@ -131,7 +170,7 @@ test("useEditorContext favors configured port over lock files", async () => {
mounted.dispose()
})
test("useEditorContext resets selection when reconnecting", async () => {
test("useEditorContext clears selection when reconnecting", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
@ -169,45 +208,66 @@ test("useEditorContext resets selection when reconnecting", async () => {
},
}),
)
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 },
},
},
}),
)
sendSelection(socket, path.join(startupDirectory, "file.ts"))
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({
filePath: path.join(startupDirectory, "file.ts"),
source: "websocket",
ranges: [
{
text: "foo",
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
],
})
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("pending")
mounted.editor.markSelectionSent()
expect(mounted.editor.labelState()).toBe("sent")
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
test("useEditorContext preserves selection for the next reconnect when requested", 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()
sendSelection(socket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
mounted.editor.markSelectionSent()
mounted.editor.preserveSelectionFromNewSession()
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("sent")
mounted.editor.reconnect(startupDirectory)
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})