diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 23b35ddd3b..571df94619 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -314,7 +314,9 @@ conversation-action callbacks reject Promise returns while consuming rejections. and atomic references. Render labels as DOM text, never HTML; canonical tokens preserve draft positions. Submission expands tokens to `@label` and sends up to 16 unique skill IDs in `additional_kwargs.skill_references`; the backend checks -each against the user registry and agent allowlist. Legacy slash input remains. +each against the user registry and agent allowlist. The web slash picker lists +only `/goal` and `/compact`; skill selection uses `@`. Legacy typed slash text +continues through normal message submission for backend and channel compatibility. Project files require confirmed `additional_kwargs.files`. Conversation context is reconciled from tokens against the current capability and limit. Only successful discovery may flatten references; pending/errors preserve IDs and block reference diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 76bb8398d7..6e7257a6cf 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -123,13 +123,13 @@ Run duration is run-scoped UI metadata even though the compatibility field `addi The workspace-change card follows the same rule: it is resolved from `(threadId, runId)` alone, so every AI message of a run would render an identical copy. A run ends in more than one terminal assistant bubble whenever the model emits answer text that never gains a tool call, so `core/messages/workspace-change-anchor.ts` picks the run's last assistant bubble and `MessageListItem` renders the badge only for that anchor (#4555). Any future run-scoped display belongs in the same place — do not hang one off every message. The two anchor helpers deliberately differ in which group types they accept as a run's last position, because an anchor is only useful where the display is actually rendered: run duration is emitted by `MessageList` around every group, so it accepts any type, while the workspace-change card comes from `MessageListItem` and so restricts to `assistant`. Keep a new helper's candidate set matched to its own render site rather than unifying them. -Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores only text plus the selected slash-skill name in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. `InputBox` waits for enabled skills before restoring a skill chip, degrades a missing/disabled skill back to editable slash text, and clears the stored draft through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted. +Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores text with inline reference tokens and conversation metadata in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. New drafts write `skillName: null`; `InputBox` waits for the enabled agent skill scope before converting an older draft selection into an inline `@` reference, and degrades a missing/disabled skill back to editable slash text. The stored draft clears through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted. Conversation references (`read_conversation`, opt-in on the backend) are attached from the composer. `ReferenceConversationsButton` (`components/workspace/conversation-references/`) renders only while `/api/features` reports `conversation_references.enabled`, opens a picker over the same `useThreads()` list the sidebar uses (the current thread excluded, capped at `max_references`), and shows removable chips in the composer header. On submit the thread IDs ride `InputBoxSubmitOptions.conversationReferences` → `SendMessageOptions.conversationReferences` → run `context.conversation_references`, which the Gateway consumes at admission; the LangGraph SDK drops unknown top-level body fields, so the top-level request field is not reachable from the web UI. `core/conversation-references` also writes display-only `additional_kwargs.conversation_references` (`{thread_id, title, agent_name?}`) on the visible human message so `message-list-item.tsx` can render read-only chips linking to the source — through `pathOfThread`, so custom-agent sources route to `/workspace/agents/{agent}/chats/{id}`; that metadata grants nothing. References are per message: they are not persisted with the draft, clear on send or thread switch, and regenerate/edit of a turn runs without them unless attached again. Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned. -`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal ` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal ` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. On a project-scoped new chat (`/workspace/chats/new?project=…`), the chat page's project pre-create runs before the goal PUT via the composer's `onPrepareThread` callback: the goal endpoint materializes a missing thread row itself, and an unassigned row would make the later idempotent thread create return it without assigning the project. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until an incremental goal update or final state reload arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409. +The web `/` picker lists only `/goal` and `/compact`; skills are selected through the inline `@` picker. `/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal ` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal ` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. On a project-scoped new chat (`/workspace/chats/new?project=…`), the chat page's project pre-create runs before the goal PUT via the composer's `onPrepareThread` callback: the goal endpoint materializes a missing thread row itself, and an unassigned row would make the later idempotent thread create return it without assigning the project. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until an incremental goal update or final state reload arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409. Composer `runs:create` gating belongs in `InputBox.handleSubmit` after command classification and before dispatch. Reject message and goal-set actions before `onPrepareThread`, goal persistence, success feedback, or draft clearing. Goal status/clear and compact do not start runs; preserve their endpoint-specific gates. Regression coverage lives in `input-box-send-gating.dom.test.tsx`. diff --git a/frontend/src/components/workspace/input-box-helpers.ts b/frontend/src/components/workspace/input-box-helpers.ts index 2ac8bf3919..13059fd2e7 100644 --- a/frontend/src/components/workspace/input-box-helpers.ts +++ b/frontend/src/components/workspace/input-box-helpers.ts @@ -1,15 +1,9 @@ -import { - parseSlashSkillReference, - RESERVED_SLASH_SKILL_NAMES, - type Skill, -} from "@/core/skills"; +import type { Skill } from "@/core/skills"; export { SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN, findSuggestionTemplatePlaceholder, } from "@/core/suggestions/placeholders"; -export const MAX_SKILL_SUGGESTIONS = 6; - // Mirror of the backend raw request limit (`ThreadGoalRequest.objective` // max_length and `MAX_GOAL_OBJECTIVE_CHARS` in backend goal.py). Kept here so // the composer can reject an over-length `/goal ` before issuing the @@ -56,10 +50,9 @@ export function getGoalObjectiveCounter( }; } -export type SlashSuggestion = { +export type SlashCommandSuggestion = { name: string; description: string; - kind: "builtin" | "skill"; }; export type GoalCommand = @@ -152,7 +145,7 @@ export function isAbortError(error: unknown): boolean { ); } -export function getLeadingSlashSkillQuery(value: string): string | null { +export function getLeadingSlashCommandQuery(value: string): string | null { if (!value.startsWith("/")) { return null; } @@ -177,64 +170,16 @@ export function filterSkillsForAgent( return skills.filter((skill) => allowedNames.has(skill.name)); } -export function getMatchingSkillSuggestions( - skills: Skill[], +export function getMatchingSlashCommands( query: string, - builtinCommands: SlashSuggestion[], -): SlashSuggestion[] { + commands: SlashCommandSuggestion[], +): SlashCommandSuggestion[] { const normalizedQuery = query.toLowerCase(); - // A name the slash parser refuses must not be offered here either. Builtin - // names remain unavailable, while `context` is only reserved for the exact - // `/context compact` alias and can therefore still be a skill suggestion. - const reservedNames = new Set([ - ...RESERVED_SLASH_SKILL_NAMES, - ...builtinCommands.map(({ name }) => name.toLowerCase()), - ]); - - const builtinMatches = builtinCommands.filter(({ name, description }) => { - if (!normalizedQuery) { - return true; - } - return ( + return commands.filter( + ({ name, description }) => name.toLowerCase().includes(normalizedQuery) || - description.toLowerCase().includes(normalizedQuery) - ); - }); - - const skillMatches = skills - .map((skill, index) => ({ - skill, - index, - name: skill.name.toLowerCase(), - })) - .filter(({ skill, name }) => { - if ( - !skill.enabled || - parseSlashSkillReference(`/${skill.name}`)?.name !== skill.name - ) { - return false; - } - if (reservedNames.has(name) && name !== "context") { - return false; - } - return !normalizedQuery || name.includes(normalizedQuery); - }) - .sort((a, b) => { - const aStartsWith = a.name.startsWith(normalizedQuery); - const bStartsWith = b.name.startsWith(normalizedQuery); - if (aStartsWith !== bStartsWith) { - return aStartsWith ? -1 : 1; - } - return a.index - b.index; - }) - .slice(0, MAX_SKILL_SUGGESTIONS) - .map(({ skill }) => ({ - name: skill.name, - description: skill.description, - kind: "skill" as const, - })); - - return [...skillMatches, ...builtinMatches].slice(0, MAX_SKILL_SUGGESTIONS); + description.toLowerCase().includes(normalizedQuery), + ); } export function parseGoalCommand(value: string): GoalCommand | null { diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 326f7e5ba3..2804ab6993 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -35,6 +35,7 @@ import { type FormEvent, type KeyboardEvent, } from "react"; +import { flushSync } from "react-dom"; import { toast } from "sonner"; import { @@ -101,6 +102,7 @@ import { buildReferenceMessageMetadata, type SidecarContext, } from "@/core/sidecar"; +import { parseSlashSkillReference } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; import { DEFAULT_MAX_SUGGESTIONS } from "@/core/suggestions/api"; import { useSuggestionsConfig } from "@/core/suggestions/hooks"; @@ -156,15 +158,15 @@ import { filterSkillsForAgent, getGoalObjectiveCounter, getInputSubmitAction, - getLeadingSlashSkillQuery, - getMatchingSkillSuggestions, + getLeadingSlashCommandQuery, + getMatchingSlashCommands, type GoalCommand, isAbortError, isCurrentGoalRequest, isGoalObjectiveTooLong, MAX_GOAL_OBJECTIVE_CHARS, readGoalResponseError, - type SlashSuggestion, + type SlashCommandSuggestion, } from "./input-box-helpers"; import { inlineReferences, @@ -192,7 +194,6 @@ import { ModelPickerTrigger, } from "./model-picker-content"; import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar"; -import { SlashSkillChip } from "./slash-skill-chip"; import { Tooltip } from "./tooltip"; const COMPOSER_DRAFT_SAVE_DELAY_MS = 300; @@ -586,11 +587,9 @@ export function InputBox({ rewrittenText: string; } | null>(null); const [textareaFocused, setTextareaFocused] = useState(false); - const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0); - const [selectedSlashSkill, setSelectedSlashSkill] = - useState(null); + const [commandSuggestionIndex, setCommandSuggestionIndex] = useState(0); const [hydratedDraftKey, setHydratedDraftKey] = useState(null); - const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] = + const [dismissedCommandSuggestionValue, setDismissedCommandSuggestionValue] = useState(null); const lastGeneratedForAiIdRef = useRef(null); const wasStreamingRef = useRef(false); @@ -655,17 +654,15 @@ export function InputBox({ const [pendingSuggestion, setPendingSuggestion] = useState( null, ); - const builtinSlashCommands = useMemo( + const builtinSlashCommands = useMemo( () => [ { name: "goal", description: t.inputBox.goalCommandDescription, - kind: "builtin", }, { name: "compact", description: t.inputBox.compactCommandDescription, - kind: "builtin", }, ], [t.inputBox.compactCommandDescription, t.inputBox.goalCommandDescription], @@ -869,14 +866,22 @@ export function InputBox({ agentSkillsLoading ? [] : filterSkillsForAgent(skills, agentSkillNames), [agentSkillNames, agentSkillsLoading, skills], ); + const mentionSkills = useMemo( + () => + agentScopedSkills.filter( + (skill) => + parseSlashSkillReference(`/${skill.name}`)?.name === skill.name, + ), + [agentScopedSkills], + ); const enabledSkillNames = useMemo( () => new Set( - agentScopedSkills + mentionSkills .filter((skill) => skill.enabled) .map((skill) => skill.name), ), - [agentScopedSkills], + [mentionSkills], ); const cancelDraftSaveTimer = useCallback(() => { if (draftSaveTimerRef.current === null) { @@ -1000,7 +1005,6 @@ export function InputBox({ promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setTextInput(""); - setSelectedSlashSkill(null); setConversationReferences([]); setMentionQuery(null); setMentionButtonOpen(false); @@ -1044,8 +1048,19 @@ export function InputBox({ } const resolvedDraft = resolveComposerDraft(savedDraft, enabledSkillNames); + const restoredText = + resolvedDraft.skillName && + !inlineReferences(resolvedDraft.text).some( + (ref) => ref.kind === "skill" && ref.id === resolvedDraft.skillName, + ) + ? referenceToken( + "skill", + resolvedDraft.skillName, + resolvedDraft.skillName, + ) + (resolvedDraft.text ? ` ${resolvedDraft.text}` : "") + : resolvedDraft.text; setConversationReferences(savedDraft.conversationReferences ?? []); - const existingReferences = inlineReferences(resolvedDraft.text); + const existingReferences = inlineReferences(restoredText); const legacyReferences = (savedDraft.conversationReferences ?? []).filter( (ref) => !existingReferences.some( @@ -1058,21 +1073,7 @@ export function InputBox({ (ref) => referenceToken("conversation", ref.threadId, ref.title) + " ", ) - .join("") + resolvedDraft.text, - ); - const restoredSkill = resolvedDraft.skillName - ? agentScopedSkills.find( - (skill) => skill.enabled && skill.name === resolvedDraft.skillName, - ) - : undefined; - setSelectedSlashSkill( - restoredSkill - ? { - name: restoredSkill.name, - description: restoredSkill.description, - kind: "skill", - } - : null, + .join("") + restoredText, ); setHydratedDraftKey(draftKey); }, [ @@ -1094,8 +1095,7 @@ export function InputBox({ const draft: ComposerDraft = { text: textInput.value ?? "", - skillName: - selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null, + skillName: null, }; const timer = scheduleDraftSave(draft, draftKey); return () => { @@ -1107,13 +1107,7 @@ export function InputBox({ draftSaveTimerRef.current = null; } }; - }, [ - draftKey, - hydratedDraftKey, - scheduleDraftSave, - selectedSlashSkill, - textInput.value, - ]); + }, [draftKey, hydratedDraftKey, scheduleDraftSave, textInput.value]); useEffect(() => { const goalRequestState = goalRequestStateRef.current; @@ -1456,12 +1450,6 @@ export function InputBox({ .map((ref) => ref.id), ), ]; - if ( - skillReferences.length && - selectedSlashSkill?.kind === "skill" && - !skillReferences.includes(selectedSlashSkill.name) - ) - skillReferences.unshift(selectedSlashSkill.name); if (skillReferences.length > MAX_EXPLICIT_SKILLS) { toast.warning(t.inputBox.mentionMultipleSkills); return Promise.reject(new Error("Too many skill references.")); @@ -1469,8 +1457,7 @@ export function InputBox({ pendingDraftSubmissionRef.current = { key: draftKey, text: textInput.value, - skillName: - selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null, + skillName: null, }; const additionalKwargs = { ...(skillReferences.length @@ -1505,10 +1492,7 @@ export function InputBox({ acceptedDraftRef.current = { key: draftKey, text: textInput.value, - skillName: - selectedSlashSkill?.kind === "skill" - ? selectedSlashSkill.name - : null, + skillName: null, }; pendingDraftSubmissionRef.current = null; latestDraftRef.current = null; @@ -1545,7 +1529,6 @@ export function InputBox({ [ context, textInput.value, - selectedSlashSkill, conversationReferences, draftKey, invalidateDraftSaveTimer, @@ -1593,21 +1576,15 @@ export function InputBox({ } abortVoiceInput(); message = { ...message, text: readableReferences(message.text) }; - const messageWithSlashSkill = selectedSlashSkill - ? { - ...message, - text: `/${selectedSlashSkill.name} ${message.text}`, - } - : message; const submitAction = getInputSubmitAction({ - text: messageWithSlashSkill.text, + text: message.text, // Staged project-shelf attachments count exactly like uploaded // files: submitThreadMessage maps them into the outgoing message's // ``additional_kwargs.files``, so an attachment-only submit must not // read as empty, and /goal or /compact must not intercept while an // attach chip or explicit conversation reference is present. fileCount: - messageWithSlashSkill.files.length + + message.files.length + projectAttachments.length + conversationReferences.length + inlineRefs.length, @@ -1689,10 +1666,7 @@ export function InputBox({ if (submitAction.kind === "empty") { return; } - await submitThreadMessage(messageWithSlashSkill); - if (selectedSlashSkill) { - setSelectedSlashSkill(null); - } + await submitThreadMessage(message); }, [ abortVoiceInput, @@ -1703,7 +1677,6 @@ export function InputBox({ projectAttachments.length, conversationReferences.length, inlineRefs.length, - selectedSlashSkill, status, submitThreadMessage, t.inputBox.goalTooLong, @@ -1763,47 +1736,27 @@ export function InputBox({ setTimeout(() => requestFormSubmit(), 0); }, [pendingSuggestion, requestFormSubmit, textInput]); - const slashSkillQuery = useMemo( - () => getLeadingSlashSkillQuery(textInput.value ?? ""), + const slashCommandQuery = useMemo( + () => getLeadingSlashCommandQuery(textInput.value ?? ""), [textInput.value], ); const goalObjectiveCounter = useMemo( () => getGoalObjectiveCounter(textInput.value ?? ""), [textInput.value], ); - const skillSuggestions = useMemo(() => { - if (slashSkillQuery === null) { - return []; - } - const matches = getMatchingSkillSuggestions( - agentScopedSkills, - slashSkillQuery, - builtinSlashCommands, - ); - // Builtin commands own the whole composer line, so they cannot be combined - // with a skill activation: `/goal` behind a selected skill would submit as - // chat text instead of running the command. Drop them from the result - // rather than withholding them from the helper, which needs the list to - // reserve their names — a skill named after a builtin is unusable for the - // mirrored reason, its submitted text runs the command, not the skill. - return selectedSlashSkill || hasInlineReferences - ? matches.filter(({ kind }) => kind === "skill") - : matches; - }, [ - agentScopedSkills, - builtinSlashCommands, - hasInlineReferences, - selectedSlashSkill, - slashSkillQuery, - ]); - // A selected skill does not close the catalog: `/` reopens it so a skill can - // be found by browsing and swapped without first clearing the chip. - const showSkillSuggestions = + const commandSuggestions = useMemo( + () => + slashCommandQuery === null || hasInlineReferences + ? [] + : getMatchingSlashCommands(slashCommandQuery, builtinSlashCommands), + [builtinSlashCommands, hasInlineReferences, slashCommandQuery], + ); + const showCommandSuggestions = !disabled && textareaFocused && - slashSkillQuery !== null && - skillSuggestions.length > 0 && - dismissedSkillSuggestionValue !== textInput.value; + slashCommandQuery !== null && + commandSuggestions.length > 0 && + dismissedCommandSuggestionValue !== textInput.value; const isComposerDisabled = disabled === true; const isMockThread = isMock === true; const composerLocked = isComposerDisabled || polishingInput || mentionBusy; @@ -1886,11 +1839,9 @@ export function InputBox({ : null; const selected = selectionId && - (inlineReferences(originalText).some( + inlineReferences(originalText).some( (ref) => ref.kind === selection.kind && ref.id === selectionId, - ) || - (selection.kind === "skill" && - selectedSlashSkill?.name === selectionId)); + ); if (selected) { let next = mentionQuery ? originalText.slice(0, mentionQuery.start) + @@ -1900,11 +1851,6 @@ export function InputBox({ if (ref.kind === selection.kind && ref.id === selectionId) next = next.slice(0, ref.start) + next.slice(ref.end); } - if ( - selection.kind === "skill" && - selectedSlashSkill?.name === selectionId - ) - setSelectedSlashSkill(null); if (selection.kind === "conversation") setConversationReferences((previous) => previous.filter((ref) => ref.threadId !== selectionId), @@ -2032,7 +1978,7 @@ export function InputBox({ }); writeComposerDraft(storage, nextKey, { text: nextText, - skillName: selectedSlashSkill?.name ?? null, + skillName: null, ...(conversationReferences.length ? { conversationReferences } : {}), }); invalidateDraftSaveTimer(); @@ -2055,7 +2001,6 @@ export function InputBox({ draftThreadId, invalidateDraftSaveTimer, onReferenceFileAttached, - selectedSlashSkill, user?.id, disabled, focusComposerAt, @@ -2100,7 +2045,7 @@ export function InputBox({ polishingInput || (!inputPolishUndoAvailable && (status === "streaming" || - slashSkillQuery !== null || + slashCommandQuery !== null || !canPolishInput(textInput.value ?? ""))); const speechRecognitionConstructor = useMemo( () => @@ -2290,55 +2235,47 @@ export function InputBox({ }, [abortVoiceInput, threadId]); useEffect(() => { - setSkillSuggestionIndex(0); - }, [slashSkillQuery, skillSuggestions.length]); - - const applySkillSuggestion = useCallback( - (suggestion: SlashSuggestion) => { - if (suggestion.kind === "skill") { - setSelectedSlashSkill(suggestion); - textInput.setInput(""); - setDismissedSkillSuggestionValue(null); - requestAnimationFrame(() => { - focusContentEditableEnd(inlineSkillTextRef.current); - }); - return; - } + setCommandSuggestionIndex(0); + }, [slashCommandQuery, commandSuggestions.length]); + const applyCommandSuggestion = useCallback( + (suggestion: SlashCommandSuggestion) => { const nextValue = `/${suggestion.name} `; - textInput.setInput(nextValue); - setDismissedSkillSuggestionValue(nextValue); - requestAnimationFrame(() => { - const textarea = textareaRef.current; - if (!textarea) { - return; - } - textarea.focus(); - textarea.setSelectionRange(nextValue.length, nextValue.length); + // Commit the controlled text before moving the caret so React's selection + // restoration cannot put fast typing back inside the command name. + flushSync(() => { + textInput.setInput(nextValue); + setDismissedCommandSuggestionValue(nextValue); }); + const editor = inlineSkillTextRef.current; + if (editor) { + focusReferenceAt(editor, nextValue.length); + } else { + focusComposerAt(nextValue.length); + } }, - [textInput], + [focusComposerAt, textInput], ); - const handleSkillSuggestionKeyDown = useCallback( + const handleCommandSuggestionKeyDown = useCallback( (event: KeyboardEvent) => { - if (!showSkillSuggestions) { + if (!showCommandSuggestions) { return; } if (event.key === "ArrowDown") { event.preventDefault(); - setSkillSuggestionIndex( - (index) => (index + 1) % skillSuggestions.length, + setCommandSuggestionIndex( + (index) => (index + 1) % commandSuggestions.length, ); return; } if (event.key === "ArrowUp") { event.preventDefault(); - setSkillSuggestionIndex( + setCommandSuggestionIndex( (index) => - (index - 1 + skillSuggestions.length) % skillSuggestions.length, + (index - 1 + commandSuggestions.length) % commandSuggestions.length, ); return; } @@ -2348,23 +2285,23 @@ export function InputBox({ return; } event.preventDefault(); - const selectedSkill = skillSuggestions[skillSuggestionIndex]; - if (selectedSkill) { - applySkillSuggestion(selectedSkill); + const selectedCommand = commandSuggestions[commandSuggestionIndex]; + if (selectedCommand) { + applyCommandSuggestion(selectedCommand); } return; } if (event.key === "Escape") { event.preventDefault(); - setDismissedSkillSuggestionValue(textInput.value); + setDismissedCommandSuggestionValue(textInput.value); } }, [ - applySkillSuggestion, - showSkillSuggestions, - skillSuggestionIndex, - skillSuggestions, + applyCommandSuggestion, + showCommandSuggestions, + commandSuggestionIndex, + commandSuggestions, textInput.value, ], ); @@ -2484,7 +2421,6 @@ export function InputBox({ event.metaKey || event.shiftKey || isIMEComposing(event) || - selectedSlashSkill || promptHistory.length === 0 || (event.key !== "ArrowUp" && event.key !== "ArrowDown") ) { @@ -2528,27 +2464,7 @@ export function InputBox({ promptHistoryIndexRef.current = nextIndex; setPromptHistoryValue(promptHistory[nextIndex] ?? ""); }, - [promptHistory, selectedSlashSkill, setPromptHistoryValue, textInput.value], - ); - - const handleSelectedSlashSkillKeyDown = useCallback( - (event: KeyboardEvent) => { - if ( - event.key !== "Backspace" || - !selectedSlashSkill || - textInput.value.length > 0 || - isIMEComposing(event) - ) { - return; - } - - event.preventDefault(); - setSelectedSlashSkill(null); - requestAnimationFrame(() => { - textareaRef.current?.focus(); - }); - }, - [selectedSlashSkill, textInput.value], + [promptHistory, setPromptHistoryValue, textInput.value], ); const handlePromptTextareaKeyDown = useCallback( @@ -2561,23 +2477,17 @@ export function InputBox({ if (!isIMEComposing(event)) { if (showMentions) mentionPickerRef.current?.onKeyDown(event); if (event.defaultPrevented) return; - handleSkillSuggestionKeyDown(event); + handleCommandSuggestionKeyDown(event); if (event.defaultPrevented) { return; } } - handleSelectedSlashSkillKeyDown(event); if (event.defaultPrevented) { return; } handlePromptHistoryKeyDown(event); }, - [ - showMentions, - handlePromptHistoryKeyDown, - handleSelectedSlashSkillKeyDown, - handleSkillSuggestionKeyDown, - ], + [showMentions, handlePromptHistoryKeyDown, handleCommandSuggestionKeyDown], ); const handlePromptTextareaChange = useCallback( @@ -2597,15 +2507,13 @@ export function InputBox({ promptHistoryDraftRef.current = ""; scheduleDraftSave({ text: event.currentTarget.value, - skillName: - selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null, + skillName: null, }); }, [ abortInputPolishRequest, abortVoiceInput, scheduleDraftSave, - selectedSlashSkill, updateMentionQuery, voiceListening, ], @@ -2648,11 +2556,12 @@ export function InputBox({ const caret = referenceCaret(element); textInput.setInput(nextText); updateMentionQuery(nextText, caret); - if (!refs.length && caret !== null) focusComposerAt(caret); + // The inline editor stays mounted after its last reference is removed. + // Keep its native caret instead of scheduling a stale position that can + // overwrite the caret set by a subsequently selected command. scheduleDraftSave({ text: nextText, - skillName: - selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null, + skillName: null, }); }, [ @@ -2661,9 +2570,7 @@ export function InputBox({ abortVoiceInput, abortInputPolishRequest, setProjectAttachments, - focusComposerAt, scheduleDraftSave, - selectedSlashSkill, textInput, updateMentionQuery, voiceListening, @@ -2675,7 +2582,7 @@ export function InputBox({ const element = inlineSkillTextRef.current; if (element && !inlineSkillComposingRef.current) renderReferenceEditor(element, textInput.value); - }, [hasInlineReferences, selectedSlashSkill, textInput.value]); + }, [hasInlineReferences, textInput.value]); const handleInlineSkillInput = useCallback( (event: FormEvent) => { @@ -2730,13 +2637,12 @@ export function InputBox({ if (!isIMEComposing(event, inlineSkillComposingRef.current)) { if (showMentions) mentionPickerRef.current?.onKeyDown(event); if (event.defaultPrevented) return; - handleSkillSuggestionKeyDown(event); + handleCommandSuggestionKeyDown(event); if (event.defaultPrevented) { return; } } - handleSelectedSlashSkillKeyDown(event); if (event.defaultPrevented) { return; } @@ -2762,26 +2668,16 @@ export function InputBox({ [ showMentions, handlePromptHistoryKeyDown, - handleSelectedSlashSkillKeyDown, - handleSkillSuggestionKeyDown, + handleCommandSuggestionKeyDown, updateInlineSkillTextInput, ], ); - const clearSelectedSlashSkill = useCallback(() => { - if (mentionInFlight.current) return; - setSelectedSlashSkill(null); - requestAnimationFrame(() => { - textareaRef.current?.focus(); - }); - }, []); - const showFollowups = !disabled && !isWelcomeMode && - !showSkillSuggestions && + !showCommandSuggestions && !showMentions && - !selectedSlashSkill && !followupsHidden && // Never show stale follow-up chips while a turn is streaming: a message // sent before the previous response finished would otherwise leave the @@ -2964,19 +2860,10 @@ export function InputBox({ listId={mentionListId} query={mentionQuery?.query ?? ""} searchInput={mentionButtonOpen} - skills={agentScopedSkills.filter((skill) => - getMatchingSkillSuggestions( - [skill], - "", - builtinSlashCommands, - ).some((item) => item.kind === "skill"), - )} - selectedSkills={[ - ...inlineRefs - .filter((ref) => ref.kind === "skill") - .map((ref) => ref.id), - ...(selectedSlashSkill ? [selectedSlashSkill.name] : []), - ]} + skills={mentionSkills} + selectedSkills={inlineRefs + .filter((ref) => ref.kind === "skill") + .map((ref) => ref.id)} references={reconciledConversations.references} capability={conversationCapability} skillsLoading={skillsLoading || skillsFetching} @@ -2995,15 +2882,15 @@ export function InputBox({ /> )} - {showSkillSuggestions && !showMentions && ( + {showCommandSuggestions && !showMentions && (
- {skillSuggestions.map((suggestion, index) => { - const selected = index === skillSuggestionIndex; + {commandSuggestions.map((suggestion, index) => { + const selected = index === commandSuggestionIndex; return (