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;
+}