From 0f26b72d0720f426d573ee2c23df249559e05d00 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:37:25 +0800 Subject: [PATCH] fix(agents): anchor composer completions below the caret (#278) --- src/features/agents/Composer.module.css | 27 +++--- src/features/agents/Composer.tsx | 83 +++++++++++-------- src/features/agents/composerCommands.test.tsx | 18 ++++ src/features/agents/useCompletionAnchor.ts | 69 +++++++++++++++ 4 files changed, 152 insertions(+), 45 deletions(-) create mode 100644 src/features/agents/useCompletionAnchor.ts diff --git a/src/features/agents/Composer.module.css b/src/features/agents/Composer.module.css index 5e0c3e3d..d051b3d8 100644 --- a/src/features/agents/Composer.module.css +++ b/src/features/agents/Composer.module.css @@ -98,21 +98,26 @@ opacity: 1; } -/* `@` / `/` completion, above the box like a code-completion popup. */ -.popup { - position: absolute; +/* `@` / `/` completion; Popup keeps the list within the viewport. */ +.caretMirror { + position: fixed; + top: 0; left: 0; - bottom: calc(100% + 4px); - z-index: 10; - width: min(560px, 100%); + box-sizing: border-box; + visibility: hidden; + pointer-events: none; + white-space: pre-wrap; + overflow-wrap: break-word; +} +.popup { + width: min(560px, calc(100vw - 12px)); + border-radius: var(--radius-popup); + cursor: default; +} +.items { max-height: 300px; overflow: auto; padding: 4px 0; - border: 1px solid var(--border-popup); - border-radius: var(--radius-popup); - background: var(--bg-popup); - box-shadow: var(--shadow-popup); - cursor: default; } .item { display: flex; diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index a04c2509..b08558d8 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -10,7 +10,7 @@ import { call, commands, isTauri } from "@/platform/ipc"; import { useTx } from "@/platform/i18n"; import { basename } from "@/platform/project"; import { notifications, OWNS_FILE_DROP } from "@/workbench/contributions"; -import { Icon, IconButton, fileIconName } from "@/ui"; +import { Icon, IconButton, Popup, fileIconName } from "@/ui"; import { IMAGE_EXTENSIONS, MAX_IMAGE_BYTES, @@ -27,6 +27,7 @@ import { type FileRef, } from "./composerModel"; import { composerDraft, updateComposer, useAgents } from "./store"; +import { useCompletionAnchor } from "./useCompletionAnchor"; import styles from "./Composer.module.css"; import { isImeKey } from "@/platform/ime"; @@ -127,13 +128,14 @@ export function Composer(props: ComposerProps) { .map((command) => ({ kind: "command", command })); }, [completion, files, props.commands]); const open = !!completion && items.length > 0; + const completionAnchor = useCompletionAnchor(ref, open ? completion.to : null, text); - const setText = (value: string, caret?: number) => { + const setText = (value: string, caret?: number, restoreSelection = true) => { updateComposer(id, { text: value }); const c = caret ?? value.length; setCompletion(completionAt(value, c)); setActive(0); - if (caret !== undefined) { + if (caret !== undefined && restoreSelection) { requestAnimationFrame(() => { ref.current?.focus(); ref.current?.setSelectionRange(caret, caret); @@ -281,36 +283,49 @@ export function Composer(props: ComposerProps) { {...OWNS_FILE_DROP} onClick={() => ref.current?.focus()} > - {open && ( -
- {items.map((item, i) => ( -
setActive(i)} - onMouseDown={(e) => { - e.preventDefault(); - accept(item); - }} - > - {item.kind === "file" ? ( - <> - - {basename(item.rel)} - {item.rel} - - ) : ( - <> - /{item.command.name} - {item.command.hint && {item.command.hint}} - {item.command.description} - - )} -
- ))} -
+ {open && completionAnchor && ( + setCompletion(null)} + className={styles.popup} + role="listbox" + aria-label={tx(completion.kind === "file" ? "Files" : "Commands")} + > +
+ {items.map((item, i) => ( +
setActive(i)} + onMouseDown={(e) => { + e.preventDefault(); + accept(item); + }} + > + {item.kind === "file" ? ( + <> + + {basename(item.rel)} + {item.rel} + + ) : ( + <> + /{item.command.name} + {item.command.hint && {item.command.hint}} + {item.command.description} + + )} +
+ ))} +
+
)} {images.length > 0 && (
e.stopPropagation()}> @@ -339,7 +354,7 @@ export function Composer(props: ComposerProps) { value={text} aria-label={tx("Message")} placeholder={tx(props.placeholder)} - onChange={(e) => setText(e.target.value, undefined)} + onChange={(e) => setText(e.target.value, e.target.selectionStart, false)} onSelect={(e) => setCompletion(completionAt(e.currentTarget.value, e.currentTarget.selectionStart))} onBlur={() => setCompletion(null)} onKeyDown={onKeyDown} diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index 046eade7..5b96c859 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { AgentCommand, AgentProvider } from "@/bindings"; const agentCommands = vi.fn(); @@ -86,4 +87,21 @@ describe("New Task composer commands", () => { fireEvent.change(box, { target: { value: "/com" } }); expect(await screen.findByRole("option")).toHaveTextContent("/compact"); }); + + it("completes at the caret without replacing the rest of the message", async () => { + const user = userEvent.setup(); + agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] }); + render(); + await waitFor(() => expect(agentCommands).toHaveBeenCalled()); + const box = screen.getByRole("textbox"); + box.focus(); + fireEvent.change(box, { target: { value: "/com keep this context", selectionStart: 4, selectionEnd: 4 } }); + expect(await screen.findByRole("option")).toHaveTextContent("/compact"); + expect(box).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(box).toHaveValue("/compact keep this context"); + await waitFor(() => expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull()); + expect(agentCreate).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/agents/useCompletionAnchor.ts b/src/features/agents/useCompletionAnchor.ts new file mode 100644 index 00000000..de96f4e6 --- /dev/null +++ b/src/features/agents/useCompletionAnchor.ts @@ -0,0 +1,69 @@ +import { useLayoutEffect, useState, type RefObject } from "react"; +import type { VirtualElement } from "@floating-ui/react-dom"; +import styles from "./Composer.module.css"; + +const TEXT_STYLES = [ + "fontFamily", + "fontSize", + "fontWeight", + "fontStyle", + "fontVariant", + "lineHeight", + "letterSpacing", + "wordSpacing", + "textIndent", + "textAlign", + "textTransform", + "tabSize", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", +] as const; + +/** A textarea has no text DOM to measure; mirror its wrapping and scroll offset. */ +function caretRect(textarea: HTMLTextAreaElement, caret: number): DOMRect { + const computed = getComputedStyle(textarea); + const mirror = document.createElement("div"); + mirror.className = styles.caretMirror ?? ""; + for (const property of TEXT_STYLES) mirror.style[property] = computed[property]; + mirror.style.width = `${textarea.clientWidth}px`; + mirror.textContent = textarea.value.slice(0, caret); + const marker = document.createElement("span"); + marker.textContent = textarea.value.slice(caret) || "\u200b"; + mirror.append(marker); + document.body.append(mirror); + try { + const box = textarea.getBoundingClientRect(); + const lineHeight = parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.5; + return new DOMRect( + box.left + textarea.clientLeft + marker.offsetLeft - textarea.scrollLeft, + box.top + textarea.clientTop + marker.offsetTop - textarea.scrollTop, + 0, + lineHeight, + ); + } finally { + mirror.remove(); + } +} + +export function useCompletionAnchor( + ref: RefObject, + caret: number | null, + text: string, +): VirtualElement | null { + const [anchor, setAnchor] = useState(null); + useLayoutEffect(() => { + const textarea = ref.current; + if (!textarea || caret === null) { + setAnchor(null); + return; + } + const update = () => + setAnchor({ contextElement: textarea, getBoundingClientRect: () => caretRect(textarea, caret) }); + update(); + textarea.addEventListener("scroll", update); + return () => textarea.removeEventListener("scroll", update); + }, [ref, caret, text]); + return anchor; +}