From 0057ef6336958886ee01333af5cf91709f24c209 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 11 Nov 2025 08:55:20 -0600 Subject: [PATCH] fix(desktop): prompt input not clearing, attachments flaky --- .../desktop/src/components/prompt-input.tsx | 163 ++++++------------ packages/desktop/src/context/session.tsx | 63 +------ 2 files changed, 61 insertions(+), 165 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 5ae56f82..06382214 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -64,7 +64,6 @@ export const PromptInput: Component = (props) => { const handleFileSelect = (path: string | undefined) => { if (!path) return addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 }) - setStore("popoverIsOpen", false) } const { flat, active, onInput, onKeyDown, refetch } = useFilteredList({ @@ -114,16 +113,6 @@ export const PromptInput: Component = (props) => { ), ) - createEffect( - on( - () => session.prompt.cursor(), - (cursor) => { - if (cursor === undefined) return - queueMicrotask(() => setCursorPosition(editorRef, cursor)) - }, - ), - ) - const parseFromDOM = (): Prompt => { const newParts: Prompt = [] let position = 0 @@ -173,118 +162,70 @@ export const PromptInput: Component = (props) => { } const addPart = (part: ContentPart) => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return + const cursorPosition = getCursorPosition(editorRef) const prompt = session.prompt.current() const rawText = prompt.map((p) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) - const startIndex = atMatch ? atMatch.index! : cursorPosition - const endIndex = atMatch ? cursorPosition : cursorPosition + if (part.type === "file") { + const pill = document.createElement("span") + pill.textContent = part.content + pill.setAttribute("data-type", "file") + pill.setAttribute("data-path", part.path) + pill.setAttribute("contenteditable", "false") + pill.style.userSelect = "text" + pill.style.cursor = "default" - const pushText = (acc: { parts: ContentPart[]; runningIndex: number }, value: string) => { - if (!value) return - const last = acc.parts[acc.parts.length - 1] - if (last && last.type === "text") { - acc.parts[acc.parts.length - 1] = { - type: "text", - content: last.content + value, - start: last.start, - end: last.end + value.length, + const gap = document.createTextNode(" ") + const range = selection.getRangeAt(0) + + if (atMatch) { + let node: Node | null = range.startContainer + let offset = range.startOffset + let runningLength = 0 + + const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) + let currentNode = walker.nextNode() + while (currentNode) { + const textContent = currentNode.textContent || "" + if (runningLength + textContent.length >= atMatch.index!) { + const localStart = atMatch.index! - runningLength + const localEnd = cursorPosition - runningLength + if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) { + range.setStart(currentNode, localStart) + range.setEnd(currentNode, Math.min(localEnd, textContent.length)) + break + } + } + runningLength += textContent.length + currentNode = walker.nextNode() } - return } - acc.parts.push({ type: "text", content: value, start: acc.runningIndex, end: acc.runningIndex + value.length }) + + range.deleteContents() + range.insertNode(gap) + range.insertNode(pill) + range.setStartAfter(gap) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } else if (part.type === "text") { + const textNode = document.createTextNode(part.content) + const range = selection.getRangeAt(0) + range.deleteContents() + range.insertNode(textNode) + range.setStartAfter(textNode) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) } - const { - parts: nextParts, - inserted, - cursorPositionAfter, - } = prompt.reduce( - (acc, item) => { - if (acc.inserted) { - acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length }) - acc.runningIndex += item.content.length - return acc - } - - const nextIndex = acc.runningIndex + item.content.length - if (nextIndex <= startIndex) { - acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length }) - acc.runningIndex = nextIndex - return acc - } - - if (item.type !== "text") { - acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length }) - acc.runningIndex = nextIndex - return acc - } - - const headLength = Math.max(0, startIndex - acc.runningIndex) - const tailLength = Math.max(0, endIndex - acc.runningIndex) - const head = item.content.slice(0, headLength) - const tail = item.content.slice(tailLength) - - pushText(acc, head) - acc.runningIndex += head.length - - if (part.type === "text") { - pushText(acc, part.content) - acc.runningIndex += part.content.length - } - if (part.type !== "text") { - acc.parts.push({ ...part, start: acc.runningIndex, end: acc.runningIndex + part.content.length }) - acc.runningIndex += part.content.length - } - - const needsGap = Boolean(atMatch) - const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail - pushText(acc, rest) - acc.runningIndex += rest.length - - const baseCursor = startIndex + part.content.length - const cursorAddition = needsGap && rest.length > 0 ? 1 : 0 - acc.cursorPositionAfter = baseCursor + cursorAddition - - acc.inserted = true - return acc - }, - { - parts: [] as ContentPart[], - runningIndex: 0, - inserted: false, - cursorPositionAfter: cursorPosition + part.content.length, - }, - ) - - if (!inserted) { - const baseParts = prompt.filter((item) => !(item.type === "text" && item.content === "")) - const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0) - const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex } - if (part.type === "text") { - pushText(appendedAcc, part.content) - } - if (part.type !== "text") { - appendedAcc.parts.push({ - ...part, - start: appendedAcc.runningIndex, - end: appendedAcc.runningIndex + part.content.length, - }) - } - const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : DEFAULT_PROMPT - const nextCursor = rawText.length + part.content.length - session.prompt.set(next, nextCursor) - setStore("popoverIsOpen", false) - queueMicrotask(() => setCursorPosition(editorRef, nextCursor)) - return - } - - session.prompt.set(nextParts, cursorPositionAfter) + handleInput() setStore("popoverIsOpen", false) - - queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter)) } const abort = () => diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx index 02c859a5..0a517e0d 100644 --- a/packages/desktop/src/context/session.tsx +++ b/packages/desktop/src/context/session.tsx @@ -22,65 +22,20 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex active?: string opened: string[] } + prompt: Prompt + cursor?: number }>({ tabs: { opened: [], }, + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, }), { name: seed, }, ) - const [promptStore, setPromptStore] = createStore<{ - prompt: Prompt - cursor?: number - }>({ - prompt: clonePrompt(DEFAULT_PROMPT), - }) - - const key = createMemo(() => props.sessionId ?? "new-session") - const [ready, setReady] = createSignal(false) - const prefix = "session-prompt:" - - createEffect( - on( - key, - (value) => { - setReady(false) - const record = localStorage.getItem(prefix + value) - if (!record) { - setPromptStore("prompt", clonePrompt(DEFAULT_PROMPT)) - setPromptStore("cursor", undefined) - setReady(true) - return - } - const payload = JSON.parse(record) as { prompt?: Prompt; cursor?: number } - const parts = payload.prompt ?? DEFAULT_PROMPT - const cursor = typeof payload.cursor === "number" ? payload.cursor : undefined - setPromptStore("prompt", clonePrompt(parts)) - setPromptStore("cursor", cursor) - setReady(true) - }, - { defer: true }, - ), - ) - - createEffect(() => { - if (!ready()) return - const value = key() - const isDefault = isPromptEqual(promptStore.prompt, DEFAULT_PROMPT) - if (isDefault && (promptStore.cursor === undefined || promptStore.cursor <= 0)) { - localStorage.removeItem(prefix + value) - return - } - const next = JSON.stringify({ - prompt: clonePrompt(promptStore.prompt), - cursor: promptStore.cursor, - }) - localStorage.setItem(prefix + value, next) - }) - createEffect(() => { if (!props.sessionId) return sync.session.sync(props.sessionId) @@ -149,14 +104,14 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex working, diffs, prompt: { - current: createMemo(() => promptStore.prompt), - cursor: createMemo(() => promptStore.cursor), - dirty: createMemo(() => !isPromptEqual(promptStore.prompt, DEFAULT_PROMPT)), + current: createMemo(() => persist.prompt), + cursor: createMemo(() => persist.cursor), + dirty: createMemo(() => !isPromptEqual(persist.prompt, DEFAULT_PROMPT)), set(prompt: Prompt, cursorPosition?: number) { const next = clonePrompt(prompt) batch(() => { - setPromptStore("prompt", next) - if (cursorPosition !== undefined) setPromptStore("cursor", cursorPosition) + setPersist("prompt", next) + if (cursorPosition !== undefined) setPersist("cursor", cursorPosition) }) }, },