diff --git a/assets/js/collaborative-editor/components/AIAssistantPanel.tsx b/assets/js/collaborative-editor/components/AIAssistantPanel.tsx index 383ea29684..209ee4831e 100644 --- a/assets/js/collaborative-editor/components/AIAssistantPanel.tsx +++ b/assets/js/collaborative-editor/components/AIAssistantPanel.tsx @@ -15,6 +15,7 @@ import { useSelectedRunId } from '../hooks/useHistory'; import { useContentLocked, useIsNewWorkflow } from '../hooks/useSessionContext'; import { describeLifecycleError } from '../lib/errors'; import { notifications } from '../lib/notifications'; +import type { AISkill } from '../types/sessionContext'; import { AlertDialog } from './AlertDialog'; import { ChatInput } from './ChatInput'; @@ -61,6 +62,8 @@ interface AIAssistantPanelProps { * Switch the current workflow to draft, for the live workflow notice */ switchToDraft?: () => Promise; + /** Skills a leading slash command can invoke */ + skills?: AISkill[]; } interface MessageOptions { @@ -100,6 +103,7 @@ export function AIAssistantPanel({ connectionState = 'connected', aiLimit = null, switchToDraft = () => Promise.resolve(undefined), + skills, }: AIAssistantPanelProps) { const [view, setView] = useState<'chat' | 'sessions'>( sessionId ? 'chat' : 'sessions' @@ -477,6 +481,7 @@ export function AIAssistantPanel({ placeholder={placeholderText} disabledMessage={disabledMessage} selectedRunId={selectedRunId} + skills={skills} /> {/* About AI Assistant Modal */} diff --git a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx index 700e2fb767..8cf914a3d6 100644 --- a/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx +++ b/assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx @@ -54,6 +54,7 @@ import { useSession, } from '../hooks/useSession'; import { + useAISkills, useIsNewWorkflow, useLimits, useProject, @@ -185,6 +186,7 @@ export function AIAssistantPanelWrapper({ const user = useUser(); const workflow = useWorkflowState(state => state.workflow); const limits = useLimits(); + const skills = useAISkills(); // AI can apply changes if: not readonly OR is a new workflow (being created) const canApplyChanges = !isReadOnly || isNewWorkflow; @@ -802,6 +804,7 @@ export function AIAssistantPanelWrapper({ connectionState={sessionId ? connectionState : 'connected'} aiLimit={limits.ai_assistant ?? null} switchToDraft={switchToDraft} + skills={skills} > MAX_MESSAGE_LENGTH; - const showCount = input.length >= COUNT_FROM; + const [skill, setSkill] = useState(null); + const [highlighted, setHighlighted] = useState(0); + const [dismissedMenuFor, setDismissedMenuFor] = useState(null); + + const content = skill + ? `/${skill.name} ${input.trim()}`.trim() + : input.trim(); + const tooLong = content.length > MAX_MESSAGE_LENGTH; + const showCount = content.length >= COUNT_FROM; + const canSend = !!content && !isLoading && !isDisabled && !tooLong; + + // A command is only one while it is the first token, so the menu closes + // at the first whitespace. + const menuMatches = + !skill && /^\/\S*$/.test(input) && dismissedMenuFor !== input + ? skills.filter(s => s.name.startsWith(input.slice(1))) + : []; + const activeIndex = Math.min(highlighted, menuMatches.length - 1); + const activeSkill: AISkill | undefined = menuMatches[activeIndex]; + const menuOpen = activeSkill !== undefined; const [attachLogs, setAttachLogs] = useState(() => { if (!storageKey) { @@ -202,9 +226,28 @@ export function ChatInput({ prevIsLoadingRef.current = isLoading; }, [isLoading, enableAutoFocus]); + const chooseSkill = (chosen: AISkill, rest = '') => { + setSkill(chosen); + setInput(rest); + setHighlighted(0); + textareaRef.current?.focus(); + }; + + const handleChange = (value: string) => { + // Typing the space after a known name, or pasting a whole command, turns + // it into a pill; a bare `/qa` stays text until then, like `/qafoo`. + const parsed = skill ? null : parseSlashCommand(value, skills); + if (parsed && /^\/\S+\s/.test(value)) { + chooseSkill(parsed.skill, parsed.rest); + return; + } + setInput(value); + setHighlighted(0); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!input.trim() || isLoading || isDisabled || tooLong) return; + if (!canSend) return; const options: MessageOptions = {}; // The run rides along so what we promise to attach and what the backend @@ -215,11 +258,45 @@ export function ChatInput({ options.follow_run_id = selectedRunId; } - onSendMessage?.(input.trim(), options); + onSendMessage?.(content, options); setInput(''); + setSkill(null); }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (activeSkill) { + const move = { ArrowDown: 1, ArrowUp: -1 }[e.key]; + if (move) { + e.preventDefault(); + setHighlighted( + (activeIndex + move + menuMatches.length) % menuMatches.length + ); + return; + } + if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { + e.preventDefault(); + chooseSkill(activeSkill); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setDismissedMenuFor(input); + return; + } + } + + const { selectionStart, selectionEnd } = e.currentTarget; + if ( + skill && + e.key === 'Backspace' && + selectionStart === 0 && + selectionEnd === 0 + ) { + e.preventDefault(); + setSkill(null); + return; + } + if ( e.key === 'Enter' && !e.shiftKey && @@ -241,11 +318,49 @@ export function ChatInput({ side="top" >
+ {menuOpen && ( +
+ {menuMatches.map((s, i) => ( +
{ + e.preventDefault(); + chooseSkill(s); + }} + onMouseEnter={() => setHighlighted(i)} + className={cn( + 'flex items-baseline gap-2 px-3 py-2 cursor-pointer', + i === activeIndex && 'bg-gray-100' + )} + > + + /{s.name} + + + {s.description} + +
+ ))} +
+ )}
)} -