feat(web): /skill slash command with autocomplete
Trigger /<name>, dropdown lists all skills filtered by name prefix, arg passthrough sends the rest as the user message. Synthetic skill_use tool_use renders identically to model-invoked skills.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||
import { Check, Plus, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -22,8 +22,10 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||
import { DropOverlay } from '@/components/DropOverlay';
|
||||
import { AgentPicker } from '@/components/AgentPicker';
|
||||
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useSkills } from '@/hooks/useSkills';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
const MAX_ATTACHMENTS = 10;
|
||||
@@ -44,9 +46,14 @@ interface Props {
|
||||
webSearchEnabled?: boolean | null;
|
||||
onSend: (content: string) => void | Promise<void>;
|
||||
onForceSend?: (content: string) => void | Promise<void>;
|
||||
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
||||
// ChatInput calls this with the skill name + the post-name args (possibly
|
||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||
// disables slash-command dispatch (input is sent as literal text).
|
||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend }: Props) {
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -61,6 +68,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
atIdx: number;
|
||||
anchorRect: { top: number; left: number };
|
||||
} | null>(null);
|
||||
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
|
||||
// the input and stays open while the input is `/<word>` with no whitespace.
|
||||
// Disabled entirely when the caller doesn't pass onSlashCommand.
|
||||
const [slashState, setSlashState] = useState<{
|
||||
query: string;
|
||||
anchorRect: { top: number; left: number };
|
||||
} | null>(null);
|
||||
const { skills } = useSkills();
|
||||
const skillsLookup = useMemo(() => {
|
||||
const m = new Map<string, true>();
|
||||
for (const s of skills) m.set(s.name, true);
|
||||
return m;
|
||||
}, [skills]);
|
||||
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
@@ -95,6 +115,31 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
const text = value.trim();
|
||||
if (!text && attachments.length === 0) return;
|
||||
if (disabled || busy) return;
|
||||
|
||||
// Batch 9.6: slash-command dispatch. Only when no attachments and the
|
||||
// input parses to a known skill. Falls through to onSend for unknown
|
||||
// slash names (literal text) or when slash dispatch isn't wired.
|
||||
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
|
||||
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
|
||||
if (match && skillsLookup.has(match[1]!)) {
|
||||
const skillName = match[1]!;
|
||||
const args = (match[2] ?? '').trim();
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSlashCommand(skillName, args);
|
||||
setValue('');
|
||||
setAttachments([]);
|
||||
setSlashState(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Unknown skill name — fall through and send as literal text.
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const body = flattenToMessage(attachments, text);
|
||||
@@ -108,6 +153,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlashSelect(skillName: string) {
|
||||
const next = `/${skillName} `;
|
||||
setValue(next);
|
||||
setSlashState(null);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
ta.selectionStart = ta.selectionEnd = next.length;
|
||||
ta.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
|
||||
const mirror = document.createElement('div');
|
||||
const style = window.getComputedStyle(textarea);
|
||||
@@ -158,6 +216,23 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
const ta = e.target;
|
||||
const pos = ta.selectionStart;
|
||||
|
||||
// Batch 9.6: slash-command trigger. Active while the input is a single
|
||||
// slash-prefixed token with no whitespace (i.e. user is still typing the
|
||||
// skill name). Hand off to args mode the moment a space appears or the
|
||||
// slash leaves position 0.
|
||||
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
|
||||
const query = newValue.slice(1);
|
||||
if (!slashState) {
|
||||
const rect = ta.getBoundingClientRect();
|
||||
setSlashState({ query, anchorRect: { top: rect.top, left: rect.left } });
|
||||
} else if (slashState.query !== query) {
|
||||
setSlashState({ ...slashState, query });
|
||||
}
|
||||
if (mentionState?.open) setMentionState(null);
|
||||
return;
|
||||
}
|
||||
if (slashState) setSlashState(null);
|
||||
|
||||
// Check for @ trigger
|
||||
if (pos > 0 && newValue[pos - 1] === '@') {
|
||||
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
|
||||
@@ -374,6 +449,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (mentionState?.open) return;
|
||||
// SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let
|
||||
// it consume them so the textarea doesn't also submit on Enter.
|
||||
if (slashState) return;
|
||||
// IME safety: never act on Enter while an IME composition is in flight
|
||||
// (CJK input methods commit composition via Enter). Without this, the
|
||||
// first Enter of a Japanese/Chinese/Korean composition would submit
|
||||
@@ -524,6 +602,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
onClose={closeMention}
|
||||
/>
|
||||
)}
|
||||
{slashState && (
|
||||
<SkillSlashCommand
|
||||
query={slashState.query}
|
||||
skills={skills}
|
||||
anchorRect={slashState.anchorRect}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={() => setSlashState(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user