mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(agents): anchor composer completions below the caret
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user