дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(agents): group all composer commands and skills (#2163)
The shared twelve-result cap hid commands late in the order, and commands and skills looked the same. Command records now carry a source (stored tasks default to command; disk skills and Codex $skills are marked as skills), and the popup shows every match under Commands and Skills headers in the existing virtualized list, with keyboard selection skipping headers. File completion keeps its cap. Fixes #2145
Цей коміт міститься в:
1 джерело
92b5d2dafd
коміт
a243c255fe
13 files changed
+244
-77
No files matched your search
@@ -404,27 +404,15 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, calc(100vw - 12px));
|
||||
border-radius: var(--radius-popup);
|
||||
cursor: default;
|
||||
}
|
||||
.items {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--h-row);
|
||||
margin: 0 4px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-row);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.active {
|
||||
background: var(--bg-list-selected);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.itemName {
|
||||
flex: none;
|
||||
|
||||
@@ -22,7 +22,18 @@ import { invokeLocal } from "@/platform/tauriCore";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { basename } from "@/platform/project";
|
||||
import { notifications, OWNS_FILE_DROP } from "@/workbench/contributions";
|
||||
import { FILES_DRAG, Icon, IconButton, InlineTag, Popup, fileIconName, registerDropTarget } from "@/ui";
|
||||
import {
|
||||
FILES_DRAG,
|
||||
Icon,
|
||||
IconButton,
|
||||
InlineTag,
|
||||
List,
|
||||
Popup,
|
||||
firstSelectable,
|
||||
listNavigate,
|
||||
fileIconName,
|
||||
registerDropTarget,
|
||||
} from "@/ui";
|
||||
import {
|
||||
IMAGE_EXTENSIONS,
|
||||
MAX_IMAGE_BYTES,
|
||||
@@ -52,7 +63,12 @@ import { isImeKey } from "@/platform/ime";
|
||||
const MAX_PROMPT_HEIGHT = 280;
|
||||
const MAX_ITEMS = 12;
|
||||
|
||||
type Item = { kind: "file"; path: string; rel: string; dir?: boolean } | { kind: "command"; command: AgentCommand };
|
||||
type Item =
|
||||
| { kind: "file"; path: string; rel: string; dir?: boolean }
|
||||
| { kind: "command"; command: AgentCommand }
|
||||
| { kind: "section"; source: "command" | "skill" };
|
||||
|
||||
const isSection = (item: Item) => item.kind === "section";
|
||||
|
||||
export interface ComposerProps {
|
||||
/** Store key of the draft. */
|
||||
@@ -194,10 +210,19 @@ export function Composer(props: ComposerProps) {
|
||||
const items: Item[] = useMemo(() => {
|
||||
if (!completion) return [];
|
||||
if (completion.kind === "file") return files;
|
||||
return matchCommands(props.commands, completion.query)
|
||||
.slice(0, MAX_ITEMS)
|
||||
.map((command) => ({ kind: "command", command }));
|
||||
const out: Item[] = [];
|
||||
let source: "command" | "skill" | undefined;
|
||||
for (const command of matchCommands(props.commands, completion.query)) {
|
||||
const next = command.source ?? "command";
|
||||
if (source !== next) {
|
||||
source = next;
|
||||
out.push({ kind: "section", source });
|
||||
}
|
||||
out.push({ kind: "command", command });
|
||||
}
|
||||
return out;
|
||||
}, [completion, files, props.commands]);
|
||||
const activeIndex = items[active] && !isSection(items[active]!) ? active : firstSelectable(items, isSection);
|
||||
const open = !!completion && items.length > 0;
|
||||
const completionAnchor = useCompletionAnchor(ref, open ? completion.to : null, text);
|
||||
|
||||
@@ -216,7 +241,7 @@ export function Composer(props: ComposerProps) {
|
||||
|
||||
/** Enter or a click references the item; Tab on a folder keeps completing inside it. */
|
||||
const accept = (item: Item | undefined, descend = false) => {
|
||||
if (!item || !completion) return;
|
||||
if (!item || item.kind === "section" || !completion) return;
|
||||
if (item.kind === "file") {
|
||||
const rel = item.dir ? `${item.rel}/` : item.rel;
|
||||
// A folder stays referenced while its `@folder/` is left as typed (`toPrompt`).
|
||||
@@ -375,13 +400,13 @@ export function Composer(props: ComposerProps) {
|
||||
if (open) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const d = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive((a) => (a + d + items.length) % items.length);
|
||||
const next = listNavigate(e.key, { items, active: activeIndex, isSection, wrap: true });
|
||||
if (next !== null) setActive(next);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
accept(items[active], e.key === "Tab");
|
||||
accept(items[activeIndex], e.key === "Tab");
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
@@ -441,41 +466,45 @@ export function Composer(props: ComposerProps) {
|
||||
closeOnOutsideClick={false}
|
||||
onClose={() => setCompletion(null)}
|
||||
className={styles.popup}
|
||||
role="listbox"
|
||||
aria-label={tx(completion.kind === "file" ? "Files" : "Commands")}
|
||||
role="presentation"
|
||||
>
|
||||
<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={item.dir ? "folder" : fileIconName(basename(item.rel), item.rel)} size={16} />
|
||||
<span className={styles.itemName}>
|
||||
{basename(item.rel)}
|
||||
{item.dir && "/"}
|
||||
</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>
|
||||
<List
|
||||
items={items}
|
||||
getKey={(item) =>
|
||||
item.kind === "section"
|
||||
? `section:${item.source}`
|
||||
: item.kind === "file"
|
||||
? item.path
|
||||
: `${item.command.source ?? "command"}:${item.command.name}`
|
||||
}
|
||||
isSection={isSection}
|
||||
renderSection={(item) => item.kind === "section" && tx(item.source === "skill" ? "Skills" : "Commands")}
|
||||
activeIndex={activeIndex}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(item) => accept(item)}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
aria-label={tx(completion.kind === "file" ? "Files" : "Commands")}
|
||||
className={styles.items}
|
||||
renderItem={(item) =>
|
||||
item.kind === "section" ? null : item.kind === "file" ? (
|
||||
<>
|
||||
<Icon name={item.dir ? "folder" : fileIconName(basename(item.rel), item.rel)} size={16} />
|
||||
<span className={styles.itemName}>
|
||||
{basename(item.rel)}
|
||||
{item.dir && "/"}
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
{images.length > 0 && (
|
||||
|
||||
@@ -121,6 +121,27 @@ describe("agent composer", () => {
|
||||
expect(names("")).toHaveLength(cmds.length);
|
||||
});
|
||||
|
||||
it("keeps all commands and skills and ranks matches within each source", () => {
|
||||
const cmds = Array.from({ length: 600 }, (_, i) => ({
|
||||
name: `item-${i}`,
|
||||
description: "",
|
||||
hint: null,
|
||||
source: i % 2 ? ("command" as const) : ("skill" as const),
|
||||
}));
|
||||
const all = matchCommands(cmds, "");
|
||||
expect(all).toHaveLength(600);
|
||||
expect(all.slice(0, 300)).toEqual(cmds.filter((c) => c.source === "command"));
|
||||
expect(all.slice(300)).toEqual(cmds.filter((c) => c.source === "skill"));
|
||||
const mixed = [
|
||||
{ ...cmds[0]!, name: "pre-review" },
|
||||
{ ...cmds[1]!, name: "pre-review" },
|
||||
{ ...cmds[0]!, name: "review" },
|
||||
{ ...cmds[1]!, name: "review" },
|
||||
];
|
||||
expect(matchCommands(mixed, "rev")).toEqual([mixed[3], mixed[1], mixed[2], mixed[0]]);
|
||||
expect(matchCommands(mixed, "missing")).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends only the files still mentioned", () => {
|
||||
const prompt = toPrompt({
|
||||
text: " look at @src/a.rs:3-5 and @b.rs ",
|
||||
|
||||
@@ -36,7 +36,12 @@ import tagStyles from "@/ui/InlineTag.module.css";
|
||||
import { HOME, updateComposer, useAgents } from "./store";
|
||||
import { emptyDraft } from "./composerModel";
|
||||
|
||||
const command = (name: string, description: string): AgentCommand => ({ name, description, hint: null });
|
||||
const command = (name: string, description: string, source: "command" | "skill" = "command"): AgentCommand => ({
|
||||
name,
|
||||
description,
|
||||
hint: null,
|
||||
source,
|
||||
});
|
||||
const claude: AgentProvider = {
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
@@ -76,6 +81,53 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("New Task composer commands", () => {
|
||||
it("reaches commands after the first twelve with the keyboard", async () => {
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: Array.from({ length: 30 }, (_, i) => command(`command-${i}`, "")),
|
||||
});
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
fireEvent.change(box, { target: { value: "/" } });
|
||||
await screen.findByRole("listbox", { name: "Commands" });
|
||||
for (let i = 0; i < 29; i++) fireEvent.keyDown(box, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(box).toHaveValue("/command-29 ");
|
||||
});
|
||||
|
||||
it("groups commands before skills, skips headers, wraps and hides empty groups", async () => {
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [command("triage", "", "skill"), command("compact", ""), command("review", "", "skill")],
|
||||
});
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
fireEvent.change(box, { target: { value: "/" } });
|
||||
const list = await screen.findByRole("listbox", { name: "Commands" });
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByRole("option")
|
||||
.map((o) => o.textContent),
|
||||
).toEqual(["/compact", "/triage", "/review"]);
|
||||
expect(list.textContent).toBe("Commands/compactSkills/triage/review");
|
||||
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/compact");
|
||||
fireEvent.keyDown(box, { key: "ArrowDown" });
|
||||
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/triage");
|
||||
fireEvent.keyDown(box, { key: "ArrowUp" });
|
||||
fireEvent.keyDown(box, { key: "ArrowUp" });
|
||||
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/review");
|
||||
fireEvent.keyDown(box, { key: "ArrowDown" });
|
||||
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/compact");
|
||||
fireEvent.change(box, { target: { value: "/tri" } });
|
||||
expect(list.textContent).toBe("Skills/triage");
|
||||
fireEvent.keyDown(box, { key: "Tab" });
|
||||
expect(box).toHaveValue("/triage ");
|
||||
fireEvent.change(box, { target: { value: "/com" } });
|
||||
expect((await screen.findByRole("listbox", { name: "Commands" })).textContent).toBe("Commands/compact");
|
||||
});
|
||||
|
||||
it("names the icon-only Start action and disables it while starting", async () => {
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [] });
|
||||
let finish!: (value: unknown) => void;
|
||||
|
||||
@@ -85,7 +85,7 @@ export function matchCommands(commands: readonly AgentCommand[], query: string):
|
||||
return commands
|
||||
.map((command) => ({ command, rank: rank(command.name) }))
|
||||
.filter((m) => m.rank >= 0)
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.sort((a, b) => Number(a.command.source === "skill") - Number(b.command.source === "skill") || a.rank - b.rank)
|
||||
.map((m) => m.command);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача