fix(agents): anchor composer completions below the caret

This commit is contained in:
l0ng-ai
2026-10-29 19:42:26 +08:00
parent 74b2d5f5d7
commit 5fb2988f69
4 changed files with 152 additions and 45 deletions
+16 -11
View File
@@ -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;
+49 -34
View File
@@ -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 && (
<div className={styles.popup} role="listbox" aria-label={tx(completion.kind === "file" ? "Files" : "Commands")}>
{items.map((item, i) => (
<div
key={item.kind === "file" ? item.path : item.command.name}
role="option"
aria-selected={i === active}
className={clsx(styles.item, i === active && styles.active)}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault();
accept(item);
}}
>
{item.kind === "file" ? (
<>
<Icon name={fileIconName(basename(item.rel))} size={16} />
<span className={styles.itemName}>{basename(item.rel)}</span>
<span className={styles.itemDetail}>{item.rel}</span>
</>
) : (
<>
<span className={styles.itemName}>/{item.command.name}</span>
{item.command.hint && <span className={styles.itemHint}>{item.command.hint}</span>}
<span className={styles.itemDetail}>{item.command.description}</span>
</>
)}
</div>
))}
</div>
{open && completionAnchor && (
<Popup
open
anchor={completionAnchor}
placement="bottom-start"
autoFocus={false}
restoreFocus={false}
closeOnOutsideClick={false}
onClose={() => setCompletion(null)}
className={styles.popup}
role="listbox"
aria-label={tx(completion.kind === "file" ? "Files" : "Commands")}
>
<div className={styles.items}>
{items.map((item, i) => (
<div
key={item.kind === "file" ? item.path : item.command.name}
role="option"
aria-selected={i === active}
className={clsx(styles.item, i === active && styles.active)}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault();
accept(item);
}}
>
{item.kind === "file" ? (
<>
<Icon name={fileIconName(basename(item.rel))} size={16} />
<span className={styles.itemName}>{basename(item.rel)}</span>
<span className={styles.itemDetail}>{item.rel}</span>
</>
) : (
<>
<span className={styles.itemName}>/{item.command.name}</span>
{item.command.hint && <span className={styles.itemHint}>{item.command.hint}</span>}
<span className={styles.itemDetail}>{item.command.description}</span>
</>
)}
</div>
))}
</div>
</Popup>
)}
{images.length > 0 && (
<div className={styles.images} onClick={(e) => 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}
@@ -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(<HomeEditor />);
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();
});
});
@@ -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<HTMLTextAreaElement | null>,
caret: number | null,
text: string,
): VirtualElement | null {
const [anchor, setAnchor] = useState<VirtualElement | null>(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;
}