Add hooks, @file mentions, session search/fork/export, follow-scroll
- Hooks ([[hooks]]): pre_tool, post_tool, user_prompt, stop; sandboxed, exit 2 blocks - @file mentions with autocomplete; mentioned files/folders are attached to the prompt - Session full-text search in the sidebar, fork into a copy, export as Markdown/JSON - Chat follows the newest message until the user scrolls up; jump-to-latest button - SVG activity spark and thinking icon instead of Unicode/emoji glyphs - File search ranks name matches first and returns relative paths Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec1b9b4925
commit
013695722a
17 files changed
+911
-41
No files matched your search
@@ -58,6 +58,9 @@ export function Composer({
|
||||
const [files, setFiles] = useState<{ name: string; path: string; size: number }[]>([]);
|
||||
const [uploading, setUploading] = useState(0);
|
||||
const [active, setActive] = useState(0);
|
||||
// @file mentions: token before the caret and matching files.
|
||||
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
|
||||
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
|
||||
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
|
||||
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
|
||||
|
||||
@@ -69,15 +72,57 @@ export function Composer({
|
||||
el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.4)}px`;
|
||||
}, [text]);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const updateMention = (value: string, caret: number) => {
|
||||
const m = value.slice(0, caret).match(/(?:^|\s)@([\w./~+\-]*)$/);
|
||||
setMention(m ? { start: caret - m[1].length - 1, end: caret, q: m[1] } : undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mention || mention.q.length < 1) {
|
||||
setMentionHits([]);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const timer = setTimeout(() => {
|
||||
fetch(`/api/files/search?path=${encodeURIComponent(session.cwd)}&q=${encodeURIComponent(mention.q)}`, { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((hits: { rel: string; is_dir: boolean }[]) => alive && setMentionHits(hits.slice(0, 12)))
|
||||
.catch(() => {});
|
||||
}, 120);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [mention?.q, session.cwd]);
|
||||
|
||||
const suggestions = useMemo((): { key: string; label: string; description: string; apply: () => void }[] => {
|
||||
if (mention && mentionHits.length) {
|
||||
return mentionHits.map((h) => ({
|
||||
key: h.rel,
|
||||
label: `@${h.rel}${h.is_dir ? "/" : ""}`,
|
||||
description: h.is_dir ? t("composer.folder") : "",
|
||||
apply: () => {
|
||||
const insert = `@${h.rel}${h.is_dir ? "/" : ""} `;
|
||||
const next = text.slice(0, mention.start) + insert + text.slice(mention.end);
|
||||
setText(next);
|
||||
setMention(undefined);
|
||||
setMentionHits([]);
|
||||
const caret = mention.start + insert.length;
|
||||
requestAnimationFrame(() => ref.current?.setSelectionRange(caret, caret));
|
||||
},
|
||||
}));
|
||||
}
|
||||
const m = text.match(/^\/(\S*)$/);
|
||||
if (!m) return [];
|
||||
const all: Suggestion[] = [
|
||||
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
|
||||
...(cmdData?.commands ?? []),
|
||||
];
|
||||
return all.filter((c) => c.name.startsWith(m[1])).slice(0, 12);
|
||||
}, [text, cmdData, t]);
|
||||
return all
|
||||
.filter((c) => c.name.startsWith(m[1]))
|
||||
.slice(0, 12)
|
||||
.map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) }));
|
||||
}, [text, cmdData, t, mention, mentionHits]);
|
||||
|
||||
const send = () => {
|
||||
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
|
||||
@@ -97,7 +142,13 @@ export function Composer({
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
setText(`/${suggestions[Math.min(active, suggestions.length - 1)].name} `);
|
||||
suggestions[Math.min(active, suggestions.length - 1)].apply();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMention(undefined);
|
||||
setMentionHits([]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -158,17 +209,17 @@ export function Composer({
|
||||
<div className="suggest" role="listbox">
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={s.name}
|
||||
key={s.key}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={i === active ? "active" : ""}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setText(`/${s.name} `);
|
||||
s.apply();
|
||||
ref.current?.focus();
|
||||
}}
|
||||
>
|
||||
<span className="name">/{s.name}</span>
|
||||
<span className="name">{s.label}</span>
|
||||
<span className="desc">{s.description}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -208,7 +259,9 @@ export function Composer({
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setActive(0);
|
||||
updateMention(e.target.value, e.target.selectionStart ?? e.target.value.length);
|
||||
}}
|
||||
onClick={(e) => updateMention(e.currentTarget.value, e.currentTarget.selectionStart ?? 0)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={(e) => {
|
||||
const pasted = [...e.clipboardData.files];
|
||||
|
||||
Reference in new issue
Block a user