feat(composer): inline @file and /skill tags, commands anywhere, project drag-and-drop (#2066)

Closes #1981

@file references and /skill commands become inline tags anywhere in the prompt, in Home and in a conversation. Tags are edited as a unit (Backspace/Delete, arrows) and are not committed mid-IME composition. Files and folders dragged from the Project view become references, mapped into the task worktree; paths outside the project, traversal and relative paths are rejected, and a deleted reference can be dropped again.

Co-authored-by: panda-student <[email protected]>
This commit is contained in:
SunZhiQiang authored and GitHub committed 2026-11-08 05:54:24 +08:00
1 parent 45a1132ece
commit ff7473a277
17 files changed
+545 -115

No files matched your search

+12 -5
View File
@@ -57,6 +57,11 @@ import { CodeBreadcrumbs } from "./StatusItems";
import styles from "./EditorArea.module.css";
import { tx } from "@/platform/i18n";
/** The documents of a project-files drag: folders (trailing `/`) are not opened as editors. */
function droppedFiles(payload: string): string[] {
return payload.split("\n").filter((p) => !!p && !p.endsWith("/"));
}
export function EditorAreaView() {
const layout = useTabs((s) => s.layout);
const empty = useTabs((s) => Object.values(s.panes).every((p) => p.tabs.length === 0));
@@ -77,14 +82,14 @@ function EmptyEditorArea() {
if (!el) return;
return registerDropTarget(el, {
accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG || acceptsEditorDrop(type),
over: () => setOver(true),
over: (data) => setOver(data.type !== FILES_DRAG || droppedFiles(data.payload).length > 0),
leave: () => setOver(false),
drop: (data) => {
setOver(false);
const paneId = Object.keys(useTabs.getState().panes)[0];
if (!paneId) return;
if (data.type === FILES_DRAG) {
void editorController().openFilesIn(data.payload.split("\n").filter(Boolean), paneId, "center");
void editorController().openFilesIn(droppedFiles(data.payload), paneId, "center");
return;
}
if (data.type !== EDITOR_TAB_MIME) {
@@ -253,14 +258,16 @@ function PaneView({ paneId }: { paneId: string }) {
const zoneAt = (x: number, y: number) => dropZoneAt(x, y, el.getBoundingClientRect());
return registerDropTarget(el, {
accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG || acceptsEditorDrop(type),
over: (data, x, y) =>
setDropZone(data.type === EDITOR_TAB_MIME || data.type === FILES_DRAG ? zoneAt(x, y) : "center"),
over: (data, x, y) => {
if (data.type === FILES_DRAG) setDropZone(droppedFiles(data.payload).length ? zoneAt(x, y) : null);
else setDropZone(data.type === EDITOR_TAB_MIME ? zoneAt(x, y) : "center");
},
leave: () => setDropZone(null),
drop: (data, x, y) => {
const zone = zoneAt(x, y);
setDropZone(null);
if (data.type === FILES_DRAG) {
void ctrl.openFilesIn(data.payload.split("\n").filter(Boolean), paneId, zone);
void ctrl.openFilesIn(droppedFiles(data.payload), paneId, zone);
return;
}
if (data.type !== EDITOR_TAB_MIME) {
+74 -7
View File
@@ -16,7 +16,35 @@
inset 0 0 0 1px var(--focus-ring),
0 0 0 1px var(--focus-ring);
}
/* A file or folder dragged over it from the Project view. */
.fileOver {
box-shadow:
inset 0 0 0 1px var(--focus-ring),
0 0 0 1px var(--focus-ring);
}
.field {
position: relative;
}
/* Tags under the transparent textarea: same font, metrics and padding, so the caret
and selection of the real textarea line up with the painted text. */
.mirror {
position: absolute;
inset: 0;
overflow: hidden;
padding: 10px 12px 2px;
font: inherit;
font-size: var(--fs-ui);
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--fg-strong);
pointer-events: none;
-webkit-user-select: none;
user-select: none;
}
.textarea {
position: relative;
z-index: 1;
display: block;
width: 100%;
min-height: 44px;
@@ -24,11 +52,13 @@
border: 0;
outline: none;
background: transparent;
color: var(--fg-strong);
color: transparent;
caret-color: var(--fg-strong);
font: inherit;
font-size: var(--fs-ui);
line-height: 1.5;
padding: 10px 12px 2px;
overflow-wrap: anywhere;
-webkit-user-select: text;
user-select: text;
}
@@ -38,6 +68,13 @@
.textarea::placeholder {
color: var(--fg-faint);
}
/* The textarea's own glyphs are transparent so the mirror above shows the tags; the
selection would then paint an empty highlight. Paint the selected glyphs here instead —
the opaque selection background covers the mirror underneath. */
.textarea::selection {
background: var(--bg-editor-selection);
color: var(--fg-strong);
}
.bar {
display: flex;
flex-wrap: wrap;
@@ -58,9 +95,20 @@
display: flex;
flex: none;
align-items: center;
gap: 2px;
gap: 4px;
margin-left: auto;
}
/* Attach/reference buttons share the send button's 24px footprint and chrome. */
.composerButton {
width: var(--h-control);
height: var(--h-control);
border-radius: var(--radius-small);
background: var(--bg-elev);
box-shadow: inset 0 0 0 1px var(--border);
}
.composerButton:hover:not(:disabled) {
background: var(--bg-hover);
}
.spacer {
flex: 1;
}
@@ -235,9 +283,11 @@
border-top: 1px solid var(--border);
}
@container agent-composer (max-width: 600px) {
/* Narrow: keep one row of icons next to the agent picker. The model/mode pickers
collapse into the settings menu, so nothing has to wrap onto a second line. */
.bar {
flex-direction: column;
align-items: stretch;
flex-direction: row;
align-items: center;
gap: 6px;
}
.toolbar {
@@ -247,10 +297,22 @@
.toolbar > .configPicker {
flex: 0 1 auto;
min-width: 0;
max-width: calc((100% - 28px) / 2);
}
.configPicker > button {
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The agent picker keeps its icon and truncates its name instead of taking the row. */
.toolbar > :first-child {
flex: 0 1 auto;
min-width: 0;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inlineSetting {
display: none;
@@ -261,8 +323,13 @@
margin-left: auto;
}
.actions {
width: 100%;
width: auto;
flex: none;
margin-left: 0;
justify-content: flex-end;
}
.actions .spacer {
display: none;
}
}
.pickerValue {
+182 -44
View File
@@ -3,7 +3,17 @@
* (project index search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle
* the agent's mode. Its draft lives in the store under `id`.
*/
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import clsx from "clsx";
import type { AgentCommand, AgentConfigOption, AgentImage } from "@/bindings";
import { call, commands, isTauri } from "@/platform/ipc";
@@ -12,7 +22,7 @@ import { invokeLocal } from "@/platform/tauriCore";
import { useTx } from "@/platform/i18n";
import { basename } from "@/platform/project";
import { notifications, OWNS_FILE_DROP } from "@/workbench/contributions";
import { Icon, IconButton, Popup, fileIconName } from "@/ui";
import { FILES_DRAG, Icon, IconButton, InlineTag, Popup, fileIconName, registerDropTarget } from "@/ui";
import {
IMAGE_EXTENSIONS,
MAX_IMAGE_BYTES,
@@ -25,9 +35,11 @@ import {
imageFromDataUrl,
matchCommands,
mentionOf,
messageTokens,
modeOption,
nextValue,
recall,
toPrompt,
type Completion,
type FileRef,
} from "./composerModel";
@@ -73,13 +85,16 @@ export function Composer(props: ComposerProps) {
const draft = useAgents((s) => s.composers.get(id));
const text = draft?.text ?? "";
const images = draft?.images ?? [];
const tokens = useMemo(() => messageTokens(text), [text]);
const [preview, setPreview] = useState<AgentImage | null>(null);
const ref = useRef<HTMLTextAreaElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const imageInput = useRef<HTMLInputElement>(null);
const [completion, setCompletion] = useState<Completion | null>(null);
const [files, setFiles] = useState<Item[]>([]);
const [active, setActive] = useState(0);
const composing = useRef(false);
const recalled = useRef<{ index: number; stash: string }>({ index: -1, stash: "" });
useEffect(() => {
@@ -103,6 +118,17 @@ export function Composer(props: ComposerProps) {
el.style.height = `${Math.min(el.scrollHeight, MAX_PROMPT_HEIGHT)}px`;
}, [text]);
// A textarea that scrolls loses content width to its scrollbar: give the mirror the same
// width and offset, or a long prompt wraps a line earlier and the painted text drifts.
const syncMirror = useCallback(() => {
const el = ref.current;
const mirror = mirrorRef.current;
if (!el || !mirror) return;
mirror.style.right = `${el.offsetWidth - el.clientWidth}px`;
mirror.scrollTop = el.scrollTop;
}, []);
useLayoutEffect(syncMirror, [text, syncMirror]);
// `@` completion: project file search, mapped into the task's directory.
useEffect(() => {
if (completion?.kind !== "file") return;
@@ -131,8 +157,23 @@ export function Composer(props: ComposerProps) {
return;
}
Promise.all([call(commands.findFiles(query, MAX_ITEMS)), call(commands.findDirs(query, MAX_ITEMS))])
.then(([found, dirs]) => {
.then(async ([found, dirs]) => {
if (!alive) return;
// A bare `@`: the index has nothing to match on, so list the root instead of
// showing no popup at all.
if (!query && found.length + dirs.length === 0) {
const top = await call(commands.pathCompletions(`${root}/_`, "")).catch(() => []);
if (!alive) return;
setFiles(
top.slice(0, MAX_ITEMS).map((f) => ({
kind: "file",
rel: f.name,
dir: f.dir,
path: resolveTyped(root, f.name),
})),
);
return;
}
setFiles(
mergeMatches(found, dirs, MAX_ITEMS).map((f) => ({
kind: "file",
@@ -163,7 +204,7 @@ export function Composer(props: ComposerProps) {
const setText = (value: string, caret?: number, restoreSelection = true) => {
updateComposer(id, { text: value });
const c = caret ?? value.length;
setCompletion(completionAt(value, c));
setCompletion(composing.current ? null : completionAt(value, c));
setActive(0);
if (caret !== undefined && restoreSelection) {
requestAnimationFrame(() => {
@@ -189,15 +230,50 @@ export function Composer(props: ComposerProps) {
const next = applyCompletion(text, completion, mentionOf(rel));
setText(next.text, next.caret);
} else {
// Inserted in place: the order of the message is kept, and the text is tagged
// where it stands (see the mirror below the textarea).
const next = applyCompletion(text, completion, `/${item.command.name}`);
setText(next.text, next.caret);
}
setCompletion(null);
};
const addImages = (list: AgentImage[]) => {
if (list.length) updateComposer(id, { images: [...composerDraft(id).images, ...list] });
};
const [fileOver, setFileOver] = useState(false);
/** Adds paths as `@` references (a file or a folder), each once. */
const addPaths = useCallback(
(paths: readonly string[]) => {
const current = composerDraft(id);
const known = new Set(toPrompt(current).files);
const files = current.files.filter((f) => known.has(f.path));
const refs: FileRef[] = [];
for (const raw of paths) {
const source = resolveTyped("", raw.replace(/\\/g, "/"));
const base = [root, projectRoot]
.filter(Boolean)
.map((path) => resolveTyped(path, "."))
.find((path) => source === path || source.startsWith(`${path}/`));
if (!base || !/^(\/|[A-Za-z]:[\\/])/.test(raw)) continue;
const rel = source === base ? "." : source.slice(base.length + 1);
const path = resolveTyped(root, rel);
if (known.has(path)) continue;
known.add(path);
refs.push({ path, rel });
}
if (!refs.length) return;
const mentions = refs.map((r) => mentionOf(r.rel)).join(" ");
const glue = current.text && !/\s$/.test(current.text) ? " " : "";
updateComposer(id, { text: `${current.text}${glue}${mentions} `, files: [...files, ...refs] });
},
[id, root, projectRoot],
);
const addImages = useCallback(
(list: AgentImage[]) => {
if (list.length) updateComposer(id, { images: [...composerDraft(id).images, ...list] });
},
[id],
);
const readImageFiles = (picked: Iterable<File>) => {
for (const file of picked) {
@@ -228,7 +304,7 @@ export function Composer(props: ComposerProps) {
const box = boxRef.current?.getBoundingClientRect();
const { x, y } = nativeDropPoint(p.position);
if (!box || x < box.left || x > box.right || y < box.top || y > box.bottom) return;
const refs: FileRef[] = [];
const paths: string[] = [];
for (const path of p.paths) {
if (IMAGE_EXTENSIONS.test(path)) {
void invokeLocal<string>("fs_read_image", { path }).then((url) => {
@@ -236,15 +312,10 @@ export function Composer(props: ComposerProps) {
if (image) addImages([image]);
});
} else {
refs.push({ path, rel: path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path });
paths.push(path);
}
}
if (refs.length) {
const current = composerDraft(id);
const mentions = refs.map((r) => mentionOf(r.rel)).join(" ");
const glue = current.text && !/\s$/.test(current.text) ? " " : "";
updateComposer(id, { text: `${current.text}${glue}${mentions} `, files: [...current.files, ...refs] });
}
addPaths(paths);
})
.then((u) => {
if (disposed) u();
@@ -255,11 +326,52 @@ export function Composer(props: ComposerProps) {
disposed = true;
unlisten?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-subscribe only when the target changes
}, [id, root]);
}, [addPaths, addImages]);
// Files and folders dragged from the Project view: the same `@` references.
useEffect(() => {
const el = boxRef.current;
if (!el) return;
return registerDropTarget(el, {
accepts: (type) => type === FILES_DRAG,
over: () => setFileOver(true),
leave: () => setFileOver(false),
drop: (data) => {
setFileOver(false);
addPaths(data.payload.split("\n").filter(Boolean));
},
});
}, [addPaths]);
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (isImeKey(e.nativeEvent)) return;
if (composing.current || isImeKey(e.nativeEvent)) return;
const el = e.currentTarget;
if (!e.altKey && !e.metaKey && !e.ctrlKey && el.selectionStart === el.selectionEnd) {
const backward = e.key === "Backspace" || e.key === "ArrowLeft";
const forward = e.key === "Delete" || e.key === "ArrowRight";
if (backward || forward) {
let offset = 0;
for (const token of tokens) {
const end = offset + token.value.length;
const caret = el.selectionStart;
if (token.kind !== "text" && (backward ? caret > offset && caret <= end : caret >= offset && caret < end)) {
e.preventDefault();
if (e.key === "Backspace" || e.key === "Delete") {
setText(text.slice(0, offset) + text.slice(end), offset);
} else {
const next = backward ? offset : end;
el.setSelectionRange(
e.shiftKey ? Math.min(caret, next) : next,
e.shiftKey ? Math.max(caret, next) : next,
);
}
setCompletion(null);
return;
}
offset = end;
}
}
}
if (open) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
@@ -315,7 +427,7 @@ export function Composer(props: ComposerProps) {
return (
<div
ref={boxRef}
className={clsx(styles.composer, props.className)}
className={clsx(styles.composer, fileOver && styles.fileOver, props.className)}
{...OWNS_FILE_DROP}
onClick={() => ref.current?.focus()}
>
@@ -386,35 +498,61 @@ export function Composer(props: ComposerProps) {
<ImagePreview image={preview} onClose={() => setPreview(null)} />
</div>
)}
<textarea
ref={ref}
className={styles.textarea}
rows={2}
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
value={text}
aria-label={tx("Message")}
placeholder={tx(props.placeholder)}
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}
onPaste={(e) => {
const pasted = [...e.clipboardData.files].filter((f) => f.type.startsWith("image/"));
if (pasted.length) {
e.preventDefault();
readImageFiles(pasted);
}
}}
/>
<div className={styles.field}>
{/* The text is painted here with `@file` / `/skill` tags; the textarea above it
keeps native editing, selection and the caret. */}
<div ref={mirrorRef} className={styles.mirror} aria-hidden="true">
{tokens.map((token, i) =>
token.kind === "text" ? (
<Fragment key={i}>{token.value}</Fragment>
) : (
<InlineTag key={i} accent={token.kind === "command"}>
{token.value}
</InlineTag>
),
)}
{"\u200b"}
</div>
<textarea
ref={ref}
className={styles.textarea}
rows={2}
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
value={text}
aria-label={tx("Message")}
placeholder={tx(props.placeholder)}
onChange={(e) => setText(e.target.value, e.target.selectionStart, false)}
// The controlled text, not the event's value: closing the completion popup can emit a
// stale select event with the old text.
onSelect={(e) => setCompletion(composing.current ? null : completionAt(text, e.currentTarget.selectionStart))}
onCompositionStart={() => {
composing.current = true;
setCompletion(null);
}}
onCompositionEnd={() => {
composing.current = false;
}}
onBlur={() => setCompletion(null)}
onKeyDown={onKeyDown}
onScroll={syncMirror}
onPaste={(e) => {
const pasted = [...e.clipboardData.files].filter((f) => f.type.startsWith("image/"));
if (pasted.length) {
e.preventDefault();
readImageFiles(pasted);
}
}}
/>
</div>
<div className={styles.bar} onClick={(e) => e.stopPropagation()}>
<div className={styles.toolbar}>{props.toolbar}</div>
<div className={styles.actions}>
<IconButton
icon="file"
icon="paperclip"
title="Reference a File (@)"
size="small"
className={styles.composerButton}
onClick={() => {
const glue = text && !/\s$/.test(text) ? " " : "";
setText(`${text}${glue}@`, text.length + glue.length + 1);
@@ -423,7 +561,7 @@ export function Composer(props: ComposerProps) {
<IconButton
icon="file-image"
title="Attach Image…"
size="small"
className={styles.composerButton}
onClick={() => imageInput.current?.click()}
/>
<input
-1
View File
@@ -70,7 +70,6 @@
gap: 8px;
}
.docked .where {
order: -1;
flex-wrap: nowrap;
min-width: 0;
gap: 6px;
+40 -39
View File
@@ -105,45 +105,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
<T>What should the agent do?</T>
</h1>
)}
<Composer
id={HOME}
autoFocus
placeholder={
docked
? "Describe a task…"
: "Describe a task for the agent. @ references a file or folder, / runs a command"
}
root={root}
projectRoot={root}
commands={agentCommands}
history={history}
config={config}
onConfig={setDraftOption}
onSubmit={start}
disabled={!canStart}
toolbar={
<>
<Select
variant="ghost"
aria-label="Agent"
value={provider?.id ?? null}
options={providerOptions}
onChange={(id) =>
id === ADD_AGENT ? void executeAction("agents.addAgent") : updateDraft({ provider: id, config: {} })
}
/>
<ConfigPickers config={config} onChange={setDraftOption} />
</>
}
actions={
<SubmitButton
label={starting ? "Starting…" : "Start"}
pending={starting}
disabled={!canStart}
onClick={start}
/>
}
/>
{/* Above the box, like the running session's state line. */}
<div className={styles.where}>
{members.length > 1 ? (
<Select
@@ -212,6 +174,45 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
</>
)}
</div>
<Composer
id={HOME}
autoFocus
placeholder={
docked
? "Describe a task…"
: "Describe a task for the agent. @ references a file or folder, / runs a command"
}
root={root}
projectRoot={root}
commands={agentCommands}
history={history}
config={config}
onConfig={setDraftOption}
onSubmit={start}
disabled={!canStart}
toolbar={
<>
<Select
variant="ghost"
aria-label="Agent"
value={provider?.id ?? null}
options={providerOptions}
onChange={(id) =>
id === ADD_AGENT ? void executeAction("agents.addAgent") : updateDraft({ provider: id, config: {} })
}
/>
<ConfigPickers config={config} onChange={setDraftOption} />
</>
}
actions={
<SubmitButton
label={starting ? "Starting…" : "Start"}
pending={starting}
disabled={!canStart}
onClick={start}
/>
}
/>
{probe && (!docked || probe.error) && (
<p className={probe.error ? styles.warning : styles.note}>
{probe.error
+2 -2
View File
@@ -91,10 +91,10 @@ describe("mergeMatches", () => {
});
describe("agent composer", () => {
it("completes @files anywhere and /commands as the first word", () => {
it("completes @files and /commands anywhere at a word boundary", () => {
expect(completionAt("fix @src/le", 11)).toEqual({ kind: "file", query: "src/le", from: 4, to: 11 });
expect(completionAt("/rev", 4)).toEqual({ kind: "command", query: "rev", from: 0, to: 4 });
expect(completionAt("run /rev", 8)).toBeNull();
expect(completionAt("run /rev", 8)).toEqual({ kind: "command", query: "rev", from: 4, to: 8 });
expect(completionAt("a@b", 3)).toBeNull();
const c = completionAt("see @le and", 7)!;
expect(applyCompletion("see @le and", c, "@src/lexer.rs")).toEqual({ text: "see @src/lexer.rs and", caret: 18 });
@@ -31,6 +31,8 @@ import { useProject } from "@/platform/project";
import type { ProjectInfo } from "@/bindings";
import { defaultTaskProject } from "./project";
import { HomeEditor } from "./HomeEditor";
import composerStyles from "./Composer.module.css";
import tagStyles from "@/ui/InlineTag.module.css";
import { HOME, updateComposer, useAgents } from "./store";
import { emptyDraft } from "./composerModel";
@@ -123,6 +125,16 @@ describe("New Task composer commands", () => {
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ prompt: { text: "/triage dry-run" }, provider: "claude" });
});
it("tags file references and skills where they stand", async () => {
agentCommands.mockResolvedValue({ status: "ok", data: [command("triage", "One pass over issues")] });
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
fireEvent.change(screen.getByRole("textbox"), { target: { value: "12 @Cargo.toml and /triage" } });
const mirror = document.querySelector(`.${composerStyles.mirror}`)!;
expect(within(mirror as HTMLElement).getByText("@Cargo.toml")).toHaveClass(tagStyles.tag!);
expect(within(mirror as HTMLElement).getByText("/triage")).toHaveClass(tagStyles.accent!);
});
it("offers nothing the project does not have", async () => {
agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] });
render(<HomeEditor />);
@@ -150,6 +162,17 @@ describe("New Task composer commands", () => {
await waitFor(() => expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull());
expect(agentCreate).not.toHaveBeenCalled();
});
it("completes a command typed after other text in place", async () => {
agentCommands.mockResolvedValue({ status: "ok", data: [command("triage", "One pass over open issues and PRs")] });
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
const box = screen.getByRole("textbox");
fireEvent.change(box, { target: { value: "fix the failing build /tri" } });
await screen.findByRole("listbox", { name: "Commands" });
fireEvent.keyDown(box, { key: "Enter" });
expect(useAgents.getState().composers.get(HOME)?.text).toBe("fix the failing build /triage ");
});
});
describe("workspace task project", () => {
+119 -3
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FILES_DRAG, dragSession } from "@/ui";
const findFiles = vi.fn();
const findDirs = vi.fn();
@@ -27,12 +28,12 @@ import { emptyDraft, toPrompt } from "./composerModel";
const ID = "folders-test";
const ok = <T,>(data: T) => Promise.resolve({ status: "ok", data });
function renderComposer() {
function renderComposer(root = "/p") {
render(
<Composer
id={ID}
placeholder="Ask"
root="/p"
root={root}
projectRoot="/p"
commands={[]}
history={[]}
@@ -51,7 +52,72 @@ beforeEach(() => {
pathCompletions.mockReset().mockReturnValue(ok([{ name: "lib", dir: true }]));
});
describe("Composer dropped paths", () => {
function drop(box: HTMLElement, payload: string) {
const restore = document.elementFromPoint;
document.elementFromPoint = () => box;
try {
act(() => {
const drag = dragSession({ type: FILES_DRAG, payload });
expect(drag.drop(0, 0)).toBe(true);
});
} finally {
document.elementFromPoint = restore;
}
}
it("references files and folders dragged from the Project view", () => {
const box = renderComposer();
const restore = document.elementFromPoint;
document.elementFromPoint = () => box;
try {
act(() => {
// Folders carry a trailing slash, as the Project view marks them.
const drag = dragSession({ type: FILES_DRAG, payload: "/p/src/a.ts\n/p/src/dir/" });
expect(drag.drop(0, 0)).toBe(true);
});
} finally {
document.elementFromPoint = restore;
}
expect(composerDraft(ID).files).toEqual([
{ path: "/p/src/a.ts", rel: "src/a.ts" },
{ path: "/p/src/dir", rel: "src/dir" },
]);
expect(box.value).toBe("@src/a.ts @src/dir ");
});
it("deduplicates live references and allows a deleted reference to be dropped again", () => {
const box = renderComposer();
drop(box, "/p/src/a.ts\n/p/src/a.ts\n/p/src/dir/");
drop(box, "/p/src/a.ts\n/p/src/dir/");
expect(box.value).toBe("@src/a.ts @src/dir ");
fireEvent.change(box, { target: { value: "@src/dir " } });
drop(box, "/p/src/a.ts");
expect(box.value).toBe("@src/dir @src/a.ts ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/dir", "/p/src/a.ts"]);
});
it("maps project files and folders into the task worktree, rejecting external paths", () => {
const box = renderComposer("/worktree");
drop(box, "/p/src/a.ts\n/p/src/dir/\n/external/a.ts\n/p-other/a.ts\n/p/../external/b.ts\nrelative.ts");
expect(box.value).toBe("@src/a.ts @src/dir ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/worktree/src/a.ts", "/worktree/src/dir"]);
});
});
describe("Composer @ folders", () => {
it("lists the root for a bare @ when the index has no results", async () => {
findFiles.mockReturnValue(ok([]));
findDirs.mockReturnValue(ok([]));
const box = renderComposer();
await userEvent.type(box, "@");
expect(await screen.findByRole("option")).toHaveTextContent("lib/");
expect(pathCompletions).toHaveBeenCalledWith("/p/_", "");
await userEvent.keyboard("{Enter}");
expect(box.value).toBe("@lib/ ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
});
it("lists index folders among files and references one on Enter", async () => {
const box = renderComposer();
await userEvent.type(box, "see @agen");
@@ -82,3 +148,53 @@ describe("Composer @ folders", () => {
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
});
});
describe("Composer tag editing", () => {
it.each(["@src/a.ts", "/review"])("deletes %s as a unit", async (tag) => {
const box = renderComposer();
const user = userEvent.setup();
fireEvent.change(box, { target: { value: `see ${tag} next` } });
box.focus();
box.setSelectionRange(4 + tag.length, 4 + tag.length);
await user.keyboard("{Backspace}");
expect(box.value).toBe("see next");
await waitFor(() => expect(box.selectionStart).toBe(4));
});
it.each(["@src/a.ts", "/review"])("moves across %s with the arrow keys", async (tag) => {
const box = renderComposer();
const user = userEvent.setup();
fireEvent.change(box, { target: { value: `see ${tag} next` } });
box.focus();
box.setSelectionRange(4, 4);
await user.keyboard("{ArrowRight}");
expect(box.selectionStart).toBe(4 + tag.length);
await user.keyboard("{ArrowLeft}");
expect(box.selectionStart).toBe(4);
await user.keyboard("{Delete}");
expect(box.value).toBe("see next");
});
it("leaves IME keys and composition text to the input method", () => {
const box = renderComposer();
fireEvent.change(box, { target: { value: "@src/a.ts" } });
const before = box.value;
box.setSelectionRange(before.length, before.length);
expect(fireEvent.keyDown(box, { key: "Backspace", isComposing: true })).toBe(true);
expect(fireEvent.keyDown(box, { key: "Backspace", keyCode: 229 })).toBe(true);
fireEvent.compositionStart(box);
fireEvent.change(box, { target: { value: "@中文" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
fireEvent.compositionEnd(box);
expect(box.value).toBe("@中文");
});
it("pastes plain text through the native textarea", async () => {
const box = renderComposer();
const user = userEvent.setup();
box.focus();
await user.paste("look @src/a.ts\nthen /review please");
expect(box.value).toBe("look @src/a.ts\nthen /review please");
});
});
+24 -5
View File
@@ -27,20 +27,39 @@ export interface ComposerDraft {
export const emptyDraft = (): ComposerDraft => ({ text: "", files: [], images: [] });
export type MessageToken = { kind: "text" | "file" | "command"; value: string };
/**
* A message's text split into plain runs, `@file` references and `/command` skills. A
* command is a `/word` at the start or after whitespace; a reference likewise after `@`.
*/
export function messageTokens(text: string): MessageToken[] {
const out: MessageToken[] = [];
let last = 0;
for (const match of text.matchAll(/(^|\s)([@/][^\s@]+)/g)) {
const token = match[2]!;
const start = match.index! + match[1]!.length;
const command = /^\/[A-Za-z][\w:.-]*$/.test(token);
if (token.startsWith("/") && !command) continue;
if (start > last) out.push({ kind: "text", value: text.slice(last, start) });
out.push({ kind: command ? "command" : "file", value: token });
last = start + token.length;
}
if (last < text.length) out.push({ kind: "text", value: text.slice(last) });
return out;
}
export type Completion =
| { kind: "file"; query: string; from: number; to: number }
| { kind: "command"; query: string; from: number; to: number };
/** The `@file` or leading `/command` being typed at `caret`, if any. */
/** The `@file` or `/command` being typed at `caret`, if any; both may follow other text. */
export function completionAt(text: string, caret: number): Completion | null {
const before = text.slice(0, caret);
const word = /(^|\s)([@/])([^\s@]*)$/.exec(before);
if (!word) return null;
const from = caret - word[3]!.length - 1;
// Commands only as the message's first word.
if (word[2] === "/")
return before.slice(0, from).trim() === "" ? { kind: "command", query: word[3]!, from, to: caret } : null;
return { kind: "file", query: word[3]!, from, to: caret };
return { kind: word[2] === "/" ? "command" : "file", query: word[3]!, from, to: caret };
}
/** Replaces the completion with `insert` plus a space; returns the text and the caret after it. */
+31 -5
View File
@@ -436,7 +436,7 @@ export function openTask(task: string) {
showDockedTask(task);
return;
}
useAgents.setState({ lastTask: task, homeOpen: false });
useAgents.setState({ lastTask: task, homeOpen: false, lastComposer: task });
openTaskInEditor(task);
}
@@ -447,7 +447,7 @@ function openTaskInEditor(task: string) {
}
function showDockedTask(task: string) {
useAgents.setState({ docked: task, lastTask: task, homeOpen: false });
useAgents.setState({ docked: task, lastTask: task, homeOpen: false, lastComposer: task });
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(sessionKey(task));
}
@@ -507,6 +507,7 @@ export function openTaskInEditorLocation(task: string) {
/** Opens the New Task page; `prompt` pre-fills it. */
export function openHome(prompt?: string) {
const project = useProject.getState().project;
useAgents.setState({ lastComposer: HOME });
if (project?.workspace) {
const previous = useAgents.getState().draft.projectRoot;
const projectRoot = defaultTaskProject(project, editorService().activePath(), previous);
@@ -674,13 +675,38 @@ export const sendQueuedNow = (task: string, queued: string) => call(commands.age
export const setTaskConfig = (task: string, option: string, value: string) =>
call(commands.agentSetConfig(task, option, value));
/** The composer the user is looking at: the Agents surface on show, else the one typed in last. */
function activeComposerKey(): string {
const s = useAgents.getState();
// The tool window shows one conversation at a time; its inline New Task page wins.
if (s.sessionLocation === "toolWindow") {
if (s.homeOpen) return HOME;
if (s.docked && s.tasks.has(s.docked)) return s.docked;
}
const active = editorService().activeInput?.();
if (active?.kind === HOME_KIND) return HOME;
if (active?.kind === SESSION_KIND) {
const task = (active.data as { task?: unknown } | undefined)?.task;
if (typeof task === "string" && s.tasks.has(task)) return task;
}
// No agent tab has focus (the Project view has it): the one used last wins.
for (const tab of editorService().openTabs?.() ?? []) {
if (tab.input.kind === HOME_KIND) return HOME;
if (tab.input.kind === SESSION_KIND) {
const task = (tab.input.data as { task?: unknown } | undefined)?.task;
if (typeof task === "string" && s.tasks.has(task)) return task;
}
}
return s.lastComposer !== HOME && s.tasks.has(s.lastComposer) ? s.lastComposer : HOME;
}
/**
* Adds `@file` references (with an optional line range) to the composer typed in last and
* shows it: IDEA-style "send this to the chat".
* Adds `@file` references (with an optional line range) to the composer on show (else the one
* typed in last) and shows it: IDEA-style "send this to the chat".
*/
export function addReferences(refs: Array<{ path: string; rel: string; mention: string }>) {
const s = useAgents.getState();
const key = s.lastComposer !== HOME && s.tasks.has(s.lastComposer) ? s.lastComposer : HOME;
const key = activeComposerKey();
const task = key === HOME ? null : s.tasks.get(key)!;
const current = composerDraft(key);
// A worktree task sees the same files under its own directory.
+7 -3
View File
@@ -591,7 +591,9 @@ export function ProjectView() {
startY = e.clientY;
let active = false;
let paths: string[] = [];
// Files (not folders) can also be dropped on an editor to open them there.
// Files and folders can be dropped on the agent composer as references; an
// editor opens only files, so folders carry the trailing slash `findDirs`
// uses and the editor leaves them out.
let files: DragSession | null = null;
const move = (ev: PointerEvent) => {
// A press with no button held is a reordered macOS tap, not a drag (see pointerDrag.ts).
@@ -609,8 +611,10 @@ export function ProjectView() {
const nodes = keys.map((k) => m.byKey.get(k)).filter((n): n is ProjectNode => !!n && !n.root && !n.placeholder);
paths = nodes.map((n) => n.path);
if (!paths.length) return cleanup();
const filePaths = nodes.filter((n) => !n.dir).map((n) => n.path);
if (filePaths.length) files = dragSession({ type: FILES_DRAG, payload: filePaths.join("\n") });
files = dragSession({
type: FILES_DRAG,
payload: nodes.map((n) => (n.dir ? `${n.path}/` : n.path)).join("\n"),
});
}
const target = folderAt(containerRef.current, ev.clientX, ev.clientY);
const valid = target && !paths.some((p) => target.path === p || isInside(target.path, p));
+16
View File
@@ -0,0 +1,16 @@
/* Equal negative margins preserve native textarea wrapping and caret positions. */
.tag {
padding: 1px 3px;
margin: 0 -3px;
border-radius: var(--radius-selection);
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
color: var(--fg-strong);
background: color-mix(in srgb, var(--fg) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 20%, transparent);
}
.accent {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 20%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import clsx from "clsx";
import styles from "./InlineTag.module.css";
/** A text-sized tag that preserves glyph positions in mirrored inputs. */
export function InlineTag({ children, accent = false }: { children: ReactNode; accent?: boolean }) {
return <span className={clsx(styles.tag, accent && styles.accent)}>{children}</span>;
}
+1
View File
@@ -67,6 +67,7 @@ const ICONS = {
"file-json": "fileTypes/json",
"file-md": "fileTypes/markdown",
"file-image": "fileTypes/image",
paperclip: "openidea/general/paperclip",
"file-config": "fileTypes/config",
"file-python": "fileTypes/python",
"file-jupyter": "fileTypes/jupyter",
+1
View File
@@ -40,3 +40,4 @@ export { useSelectionBoundary } from "./useSelectionBoundary";
export { useSanitizedField } from "./useSanitizedField";
export * from "./rowSelection";
export * from "./LogPane";
export * from "./InlineTag";
+4
View File
@@ -0,0 +1,4 @@
<!-- OpenIDEA: attach / reference a file (paperclip) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.29 7.37l-6.13 6.13a4 4 0 0 1-5.66-5.66l6.13-6.13a2.67 2.67 0 0 1 3.77 3.77l-6.13 6.13a1.33 1.33 0 0 1-1.89-1.89l5.66-5.65" stroke="#6C707E" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

+1 -1
View File
@@ -13,7 +13,7 @@ export interface DragData {
payload: string;
}
/** Project files dragged from the Project view: `payload` is the paths joined by `\n`. */
/** Project files dragged from the Project view: `payload` is the paths joined by `\n`, folders ending with `/`. */
export const FILES_DRAG = "application/x-openidea-files";
export interface DropTarget {