feat(frontend): reserve slash menu for built-in commands (#6154)

* feat(frontend): reserve slash menu for built-in commands

* chore(frontend): remove unused skill removal translations
This commit is contained in:
Wenchao An 2026-10-01 19:31:31 +08:00 committed by GitHub
parent d3e8e78f3f
commit 9f5897cef2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 399 additions and 661 deletions

View File

@ -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

View File

@ -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 <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` 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 <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` 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`.

View File

@ -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 <objective>` 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 {

View File

@ -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<SlashSuggestion | null>(null);
const [commandSuggestionIndex, setCommandSuggestionIndex] = useState(0);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
const [dismissedCommandSuggestionValue, setDismissedCommandSuggestionValue] =
useState<string | null>(null);
const lastGeneratedForAiIdRef = useRef<string | null>(null);
const wasStreamingRef = useRef(false);
@ -655,17 +654,15 @@ export function InputBox({
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null,
);
const builtinSlashCommands = useMemo<SlashSuggestion[]>(
const builtinSlashCommands = useMemo<SlashCommandSuggestion[]>(
() => [
{
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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLSpanElement>) => {
@ -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({
/>
</div>
)}
{showSkillSuggestions && !showMentions && (
{showCommandSuggestions && !showMentions && (
<div className="absolute right-0 bottom-full left-0 z-40 mb-2 px-1">
<div
aria-label="Skill suggestions"
aria-label="Command suggestions"
className="bg-popover/95 text-popover-foreground border-border max-h-72 overflow-y-auto rounded-xl border p-1 shadow-lg backdrop-blur-sm"
role="listbox"
>
{skillSuggestions.map((suggestion, index) => {
const selected = index === skillSuggestionIndex;
{commandSuggestions.map((suggestion, index) => {
const selected = index === commandSuggestionIndex;
return (
<button
aria-selected={selected}
@ -3013,18 +2900,14 @@ export function InputBox({
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/70 hover:text-accent-foreground",
)}
key={`${suggestion.kind}:${suggestion.name}`}
onClick={() => applySkillSuggestion(suggestion)}
key={suggestion.name}
onClick={() => applyCommandSuggestion(suggestion)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setSkillSuggestionIndex(index)}
onMouseEnter={() => setCommandSuggestionIndex(index)}
role="option"
type="button"
>
{suggestion.kind === "builtin" ? (
<TargetIcon className="text-muted-foreground size-4 shrink-0" />
) : (
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
)}
<TargetIcon className="text-muted-foreground size-4 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
/{suggestion.name}
@ -3144,7 +3027,7 @@ export function InputBox({
)}
</PromptInputHeader>
<div className="min-h-16 w-full min-w-0 px-3 py-3">
{hasInlineReferences || inlineEditorActive || selectedSlashSkill ? (
{hasInlineReferences || inlineEditorActive ? (
<div
className="max-h-48 min-h-6 w-full min-w-0 cursor-text overflow-y-auto text-base leading-7 break-words whitespace-pre-wrap md:text-sm"
onClick={(event) => {
@ -3153,15 +3036,6 @@ export function InputBox({
}
}}
>
{selectedSlashSkill && (
<SlashSkillChip
name={selectedSlashSkill.name}
className="mr-2 border-0 bg-transparent px-0 align-baseline font-sans text-base text-blue-600 shadow-none"
disabled={composerLocked}
onRemove={clearSelectedSlashSkill}
removeLabel={t.inputBox.mentionRemoveSkill}
/>
)}
<span
aria-label={t.inputBox.placeholder}
aria-multiline="true"
@ -3207,9 +3081,7 @@ export function InputBox({
role="textbox"
suppressContentEditableWarning
className={cn(
selectedSlashSkill
? "inline min-h-6 outline-none"
: "block min-h-6 outline-none",
"block min-h-6 outline-none",
"before:text-muted-foreground before:pointer-events-none",
"data-[empty=true]:before:content-[attr(data-placeholder)]",
composerLocked && "cursor-not-allowed opacity-50",
@ -3615,8 +3487,7 @@ export function InputBox({
{isWelcomeMode &&
searchParams.get("mode") !== "skill" &&
!selectedSlashSkill &&
!showSkillSuggestions &&
!showCommandSuggestions &&
!showMentions && (
<div className="flex items-center justify-center pt-2">
<SuggestionList onSelectPlaceholder={onSelectPlaceholder} />

View File

@ -363,7 +363,6 @@ export const enUS: Translations = {
mentionMultipleSkills:
"Select up to 16 skills per message; select a checked skill again to remove it.",
mentionNoProject: "Open a project chat to reference its documents.",
mentionRemoveSkill: "Remove skill",
mentionUnavailable: "File unavailable",
mentionClose: "Close references",

View File

@ -316,7 +316,6 @@ export interface Translations {
mentionAttachFailed: string;
mentionMultipleSkills: string;
mentionNoProject: string;
mentionRemoveSkill: string;
mentionUnavailable: string;
mentionClose: string;

View File

@ -346,7 +346,6 @@ export const zhCN: Translations = {
mentionMultipleSkills:
"每条消息最多选择 16 个技能,再次选择已勾选技能可移除。",
mentionNoProject: "在项目对话中可引用该项目的文档。",
mentionRemoveSkill: "移除技能",
mentionUnavailable: "文件不可用",
mentionClose: "关闭引用面板",

View File

@ -311,21 +311,19 @@ test.describe("Chat workspace", () => {
);
});
test("restores a selected slash skill draft after reload", async ({
page,
}) => {
test("restores an inline skill draft after reload", async ({ page }) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
await textarea.fill("@dat");
await textarea.press("Enter");
await expect(page.getByText("@data-analysis")).toBeVisible();
await expect(page.getByTestId("inline-skill-reference")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
await skillInput.fill("Analyze the latest results");
await skillInput.pressSequentially("Analyze the latest results");
await expect
.poll(() =>
page.evaluate(() => Object.values(window.sessionStorage).join("\n")),
@ -334,12 +332,12 @@ test.describe("Chat workspace", () => {
await page.reload();
await expect(page.getByText("@data-analysis")).toBeVisible();
await expect(page.getByTestId("inline-skill-reference")).toBeVisible();
await expect(
page.getByRole("textbox", {
name: /how can i assist you/i,
}),
).toHaveText("Analyze the latest results");
).toHaveText("✦data-analysis Analyze the latest results");
});
test("continues without draft persistence when sessionStorage is blocked", async ({
@ -627,187 +625,165 @@ test.describe("Chat workspace", () => {
releasePolish();
});
test("suggests matching skills after a leading slash", async ({ page }) => {
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await expect(
page.getByRole("option", { name: /disabled-skill/i }),
).toBeHidden();
await textarea.press("Enter");
await expect(page.getByText("@data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
await expect(skillInput).toBeVisible();
await skillInput.fill("summarize this dataset");
await skillInput.press("Enter");
await expect
.poll(() => submittedText)
.toBe("/data-analysis summarize this dataset");
});
test("reopens the skill list with a slash after a skill is selected", async ({
test("suggests only builtin commands after a leading slash", async ({
page,
}) => {
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
submittedText = textFromMessageContent(
body.input?.messages?.at(-1)?.content,
);
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
const input = page.getByPlaceholder(/how can i assist you/i);
await input.fill("/");
const list = page.getByRole("listbox", { name: "Command suggestions" });
await expect(list.getByRole("option")).toHaveCount(2);
await expect(list.getByRole("option", { name: /^\/goal / })).toBeVisible();
await expect(
page.getByRole("option", { name: /data-analysis/i }),
list.getByRole("option", { name: /^\/compact / }),
).toBeVisible();
await textarea.press("Enter");
await expect(page.getByText("@data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
await expect(skillInput).toBeVisible();
await skillInput.pressSequentially("/");
const dataAnalysis = page.getByRole("option", { name: /data-analysis/i });
const frontendDesign = page.getByRole("option", {
name: /frontend-design/i,
});
await expect(dataAnalysis).toBeVisible();
await expect(frontendDesign).toBeVisible();
// Builtin commands own the whole composer line, so they stay out of the
// list while a skill is selected even though an empty query matches them.
await expect(page.getByRole("option", { name: /goal/i })).toBeHidden();
await skillInput.pressSequentially("fro");
await expect(frontendDesign).toHaveAttribute("aria-selected", "true");
await skillInput.press("Enter");
await expect(page.getByText("@frontend-design")).toBeVisible();
await expect(page.getByText("@data-analysis")).toBeHidden();
await skillInput.pressSequentially("polish the composer");
await skillInput.press("Enter");
await expect
.poll(() => submittedText)
.toBe("/frontend-design polish the composer");
await input.fill("/dat");
await expect(list).toBeHidden();
await expect(page.getByTestId("inline-skill-reference")).toBeHidden();
});
test("does not offer a skill whose name a slash command owns", async ({
test("submits a legacy skill query literally without choosing a skill", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const input = page.getByPlaceholder(/how can i assist you/i);
await input.fill("/dat");
const request = page.waitForRequest((request) =>
request.url().includes("/runs/stream"),
);
await input.press("Enter");
const body = (await request).postDataJSON();
const message = body.input.messages.at(-1);
expect(textFromMessageContent(message.content)).toBe("/dat");
expect(message.additional_kwargs?.skill_references).toBeUndefined();
});
test("selects a skill named after a builtin through the mention picker", async ({
page,
}) => {
// Registered after the shared mock, so it wins: nothing rejects these
// names when the skill is created.
await page.route("**/api/skills", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
json: {
skills: [
{
name: "data-analysis",
description: "Analyze structured data and produce charts.",
category: "public",
enabled: true,
},
{
name: "compact",
description: "A custom skill named after a builtin command.",
description: "A compact skill",
category: "custom",
enabled: true,
},
{
name: "status",
description: "A custom skill named after a reserved command.",
description: "A status skill",
category: "custom",
enabled: true,
},
{
name: "a--b",
description: "An invalid skill name",
category: "custom",
enabled: true,
},
],
}),
},
}),
);
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/comp");
// Reserved outside chip mode: the builtin is offered, the skill is not.
await expect(
page.getByRole("option", { name: /compact/i }),
).toHaveAccessibleName(/Compact earlier context/i);
// A contract-reserved name has no builtin standing in for it, so the list
// is empty rather than showing a skill both slash parsers would refuse.
await textarea.fill("/stat");
await expect(page.getByRole("option", { name: /status/i })).toBeHidden();
await textarea.fill("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.press("Enter");
await expect(page.getByText("@data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
let compactCalls = 0;
await page.route("**/api/threads/*/compact", (route) => {
compactCalls += 1;
return route.fulfill({ json: {} });
});
await skillInput.pressSequentially("/comp");
await page.goto("/workspace/chats/new");
const input = page.getByRole("textbox", { name: /how can i assist you/i });
await input.fill("@");
await expect(
page.getByRole("option", { name: "compact A compact skill" }),
).toBeVisible();
await expect(
page.getByRole("option", { name: "status A status skill" }),
).toBeHidden();
await expect(
page.getByRole("option", { name: "a--b An invalid skill name" }),
).toBeHidden();
await input.fill("@comp");
await page.getByRole("option", { name: "compact A compact skill" }).click();
await expect(page.getByTestId("inline-skill-reference")).toHaveText(
"✦compact",
);
await input.pressSequentially("Analyze this");
const request = page.waitForRequest((request) =>
request.url().includes("/runs/stream"),
);
await input.press("Enter");
const message = (await request).postDataJSON().input.messages.at(-1);
expect(textFromMessageContent(message.content)).toBe(
"@compact Analyze this",
);
expect(message.additional_kwargs.skill_references).toEqual(["compact"]);
expect(compactCalls).toBe(0);
});
// Reserved in chip mode too. Selecting it would set a `/compact` chip that
// `parseCompactCommand` intercepts on submit, so context compaction would
// run instead of the skill.
await expect(page.getByRole("option", { name: /compact/i })).toBeHidden();
test("selects a goal command after removing an inline skill", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const input = page.getByRole("textbox", { name: /how can i assist you/i });
await input.fill("@dat");
await page.getByRole("option", { name: /data-analysis/i }).click();
await input.press("ControlOrMeta+A");
await input.press("Backspace");
await expect(page.getByTestId("inline-skill-reference")).toBeHidden();
await input.pressSequentially("/go");
await input.press("Enter");
await input.pressSequentially("finish the tests");
await expect(input).toHaveText("/goal finish the tests");
const goalRequest = page.waitForRequest(
(request) =>
request.method() === "PUT" && request.url().endsWith("/goal"),
);
await input.press("Enter");
expect((await goalRequest).postDataJSON()).toEqual({
objective: "finish the tests",
});
});
await skillInput.fill("/stat");
await expect(page.getByRole("option", { name: /status/i })).toBeHidden();
test("compact commands call the context endpoint without starting a run", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Existing chat",
messages: [
{ id: "existing-task", type: "human", content: "A first task" },
],
},
],
});
let compactCalls = 0;
let streamCalls = 0;
page.on("request", (request) => {
if (request.method() === "POST" && request.url().includes("/runs/stream"))
streamCalls += 1;
});
await page.route("**/api/threads/*/compact", (route) => {
compactCalls += 1;
return route.fulfill({ json: { compacted: true } });
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
const input = page.getByRole("textbox", { name: /how can i assist you/i });
await expect(input).toBeVisible();
for (const [index, command] of ["/compact", "/context compact"].entries()) {
await input.fill(command);
if (command === "/compact") {
await input.press("Enter");
await expect(input).toHaveValue("/compact ");
}
await input.press("Enter");
await expect.poll(() => compactCalls).toBe(index + 1);
await expect(input).toHaveValue("");
}
expect(streamCalls).toBe(0);
});
test("goal command sets a goal and starts an agent run", async ({ page }) => {
@ -915,7 +891,7 @@ test.describe("Chat workspace", () => {
expect(overlaps).toBe(false);
});
test("uses arrow keys to navigate skill suggestions before prompt history", async ({
test("uses arrow keys to navigate command suggestions before prompt history", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
@ -925,38 +901,35 @@ test.describe("Chat workspace", () => {
await textarea.fill("/");
const dataAnalysis = page.getByRole("option", {
name: /data-analysis/i,
const goal = page.getByRole("option", {
name: /^\/goal /,
});
const frontendDesign = page.getByRole("option", {
name: /frontend-design/i,
const compact = page.getByRole("option", {
name: /^\/compact /,
});
await expect(dataAnalysis).toBeVisible();
await expect(frontendDesign).toBeVisible();
await expect(dataAnalysis).toHaveAttribute("aria-selected", "true");
await expect(goal).toBeVisible();
await expect(compact).toBeVisible();
await expect(goal).toHaveAttribute("aria-selected", "true");
await textarea.press("ArrowDown");
await expect(textarea).toHaveValue("/");
await expect(dataAnalysis).toHaveAttribute("aria-selected", "false");
await expect(frontendDesign).toHaveAttribute("aria-selected", "true");
await expect(goal).toHaveAttribute("aria-selected", "false");
await expect(compact).toHaveAttribute("aria-selected", "true");
await textarea.press("ArrowUp");
await expect(textarea).toHaveValue("/");
await expect(dataAnalysis).toHaveAttribute("aria-selected", "true");
await expect(frontendDesign).toHaveAttribute("aria-selected", "false");
await expect(goal).toHaveAttribute("aria-selected", "true");
await expect(compact).toHaveAttribute("aria-selected", "false");
await textarea.press("ArrowDown");
await textarea.press("Enter");
await expect(page.getByText("@frontend-design")).toBeVisible();
await expect(
page.getByRole("textbox", { name: /how can i assist you/i }),
).toBeVisible();
await expect(textarea).toHaveValue("/compact ");
});
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
test("keeps Shift+Enter as newline while command suggestions are visible", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
@ -964,20 +937,16 @@ test.describe("Chat workspace", () => {
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.fill("/go");
await expect(page.getByRole("option", { name: /^\/goal / })).toBeVisible();
await textarea.press("Shift+Enter");
await expect(textarea).toHaveValue("/dat\n");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
await expect(textarea).toHaveValue("/go\n");
await expect(page.getByRole("option", { name: /^\/goal / })).toBeHidden();
});
test("does not suggest skills for slash text away from the prompt start", async ({
test("does not suggest commands for slash text away from the prompt start", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
@ -985,11 +954,9 @@ test.describe("Chat workspace", () => {
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("please /dat");
await textarea.fill("please /goal");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
await expect(page.getByRole("option", { name: /^\/goal / })).toBeHidden();
});
test("sending a message triggers API call and shows response", async ({

View File

@ -102,7 +102,7 @@ afterEach(() => {
});
describe("InputBox agent skill draft hydration", () => {
it("waits for the agent scope before restoring a saved skill chip", async () => {
it("waits for the agent scope before restoring a legacy selection as an inline reference", async () => {
window.sessionStorage.setItem(
draftKey,
JSON.stringify({ version: 1, text: "topic", skillName: "research" }),
@ -158,9 +158,9 @@ describe("InputBox agent skill draft hydration", () => {
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
expect(screen.getByTestId("inline-skill-reference")).toBeTruthy();
});
expect(screen.getByRole("textbox").textContent).toBe("topic");
expect(screen.getByRole("textbox").textContent).toBe("✦research topic");
});
it("preserves inherit semantics when an agent fetch fails", async () => {
@ -175,7 +175,7 @@ describe("InputBox agent skill draft hydration", () => {
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
expect(screen.getByTestId("inline-skill-reference")).toBeTruthy();
});
});
});

View File

@ -45,9 +45,9 @@ rs.mock("@/core/skills/hooks", () => ({
}),
}));
// Each test gets its own thread id: the composer persists the selected skill in
// Each test gets its own thread id: the composer persists its text in
// a debounced, thread-scoped draft, and a shared id would let the first test's
// selection land in the second test's storage key.
// draft land in the second test's storage key.
function renderComposer(threadId: string) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
@ -83,14 +83,14 @@ function renderComposer(threadId: string) {
}
/** Focus the composer and type a leading slash query that opens the catalog. */
function openSkillCatalog(container: HTMLElement): HTMLTextAreaElement {
function openCommandCatalog(container: HTMLElement): HTMLTextAreaElement {
const textarea = container.querySelector("textarea");
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error("composer textarea not rendered");
}
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "/res" } });
screen.getByRole("listbox", { name: "Skill suggestions" });
fireEvent.change(textarea, { target: { value: "/go" } });
screen.getByRole("listbox", { name: "Command suggestions" });
return textarea;
}
@ -100,22 +100,22 @@ afterEach(() => {
cleanup();
});
describe("InputBox skill suggestion IME handling", () => {
describe("InputBox command suggestion IME handling", () => {
it("leaves Enter to the IME candidate window while the catalog is open", () => {
const { container } = renderComposer("thread-ime-composing");
const textarea = openSkillCatalog(container);
const textarea = openCommandCatalog(container);
// 229 is the keyCode browsers report for a keydown consumed by an active
// IME composition; the catalog must not claim it.
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 229 });
expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
expect(textarea.value).toBe("/res");
expect(textarea.value).toBe("/go");
});
it("does not apply a suggestion for the Enter that follows compositionend", () => {
const { container } = renderComposer("thread-ime-composition-end");
const textarea = openSkillCatalog(container);
const textarea = openCommandCatalog(container);
// Safari reports the confirming Enter after compositionend, with neither
// isComposing nor keyCode 229 set.
@ -123,12 +123,12 @@ describe("InputBox skill suggestion IME handling", () => {
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 });
expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
expect(textarea.value).toBe("/res");
expect(textarea.value).toBe("/go");
});
it("still selects the highlighted skill once the confirm window has passed", () => {
it("still selects the highlighted command once the confirm window has passed", () => {
const { container } = renderComposer("thread-ime-after-window");
const textarea = openSkillCatalog(container);
const textarea = openCommandCatalog(container);
const endedAt = Date.now();
const now = rs.spyOn(Date, "now");
now.mockReturnValue(endedAt);
@ -137,15 +137,15 @@ describe("InputBox skill suggestion IME handling", () => {
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 });
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
expect(textarea.value).toBe("/goal ");
});
it("still selects the highlighted skill on a plain Enter", () => {
it("still selects the highlighted command on a plain Enter", () => {
const { container } = renderComposer("thread-plain-enter");
const textarea = openSkillCatalog(container);
const textarea = openCommandCatalog(container);
fireEvent.keyDown(textarea, { key: "Enter" });
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
expect(textarea.value).toBe("/goal ");
});
});

View File

@ -99,7 +99,7 @@ afterEach(() => {
cleanup();
});
describe("InputBox skill suggestions with an empty (denied) catalog", () => {
describe("InputBox command suggestions with an empty (denied) catalog", () => {
it("offers the builtin commands when '/' is typed with zero visible skills", () => {
const { container } = renderComposer();
@ -108,7 +108,7 @@ describe("InputBox skill suggestions with an empty (denied) catalog", () => {
// The dropdown survives the empty catalog and offers exactly the builtin
// commands (/goal, /compact) — no phantom skill rows, no crash.
const listbox = screen.getByRole("listbox", {
name: "Skill suggestions",
name: "Command suggestions",
});
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
const names = options.map((option) => option.textContent ?? "");
@ -125,7 +125,7 @@ describe("InputBox skill suggestions with an empty (denied) catalog", () => {
typeSlashQuery(container, "/nosuch");
expect(
screen.queryByRole("listbox", { name: "Skill suggestions" }),
screen.queryByRole("listbox", { name: "Command suggestions" }),
).toBeNull();
});
});

View File

@ -10,8 +10,8 @@ import {
finishGoalRequest,
getGoalObjectiveCounter,
getInputSubmitAction,
getLeadingSlashSkillQuery,
getMatchingSkillSuggestions,
getLeadingSlashCommandQuery,
getMatchingSlashCommands,
GOAL_OBJECTIVE_COUNTER_VISIBLE_AT,
isAbortError,
isCurrentGoalRequest,
@ -20,9 +20,9 @@ import {
parseCompactCommand,
parseGoalCommand,
readGoalResponseError,
type SlashSuggestion,
type SlashCommandSuggestion,
} from "@/components/workspace/input-box-helpers";
import { RESERVED_SLASH_SKILL_NAMES, type Skill } from "@/core/skills";
import type { Skill } from "@/core/skills";
function makeSkill(name: string, enabled = true): Skill {
return {
@ -34,13 +34,12 @@ function makeSkill(name: string, enabled = true): Skill {
// Builtin command names are bare (no leading slash); the composer renders them
// as `/${name}`. Mirror that shape here.
const builtins: SlashSuggestion[] = [
const builtins: SlashCommandSuggestion[] = [
{
name: "goal",
description: "Set, show, or clear an active goal",
kind: "builtin",
},
{ name: "new", description: "Start a new thread", kind: "builtin" },
{ name: "compact", description: "Compact earlier context" },
];
describe("parseGoalCommand", () => {
@ -283,16 +282,16 @@ describe("canPolishInput", () => {
});
});
describe("getLeadingSlashSkillQuery", () => {
describe("getLeadingSlashCommandQuery", () => {
it("returns the query for a leading slash token", () => {
expect(getLeadingSlashSkillQuery("/rev")).toBe("rev");
expect(getLeadingSlashSkillQuery("/")).toBe("");
expect(getLeadingSlashCommandQuery("/rev")).toBe("rev");
expect(getLeadingSlashCommandQuery("/")).toBe("");
});
it("returns null when there is no leading slash or the token is not bare", () => {
expect(getLeadingSlashSkillQuery("rev")).toBeNull();
expect(getLeadingSlashSkillQuery("/rev now")).toBeNull();
expect(getLeadingSlashSkillQuery("/a/b")).toBeNull();
expect(getLeadingSlashCommandQuery("rev")).toBeNull();
expect(getLeadingSlashCommandQuery("/rev now")).toBeNull();
expect(getLeadingSlashCommandQuery("/a/b")).toBeNull();
});
});
@ -321,104 +320,27 @@ describe("filterSkillsForAgent", () => {
});
});
describe("getMatchingSkillSuggestions", () => {
it("offers only builtin commands when the catalog is empty (denied role)", () => {
// A role whose `skills` policy allows nothing gets an empty catalog from
// GET /api/skills (resource-level listing filter); the composer must
// still offer the builtin commands rather than lose the whole dropdown.
const result = getMatchingSkillSuggestions([], "", builtins);
expect(result.map((s) => `${s.kind}:${s.name}`)).toEqual([
"builtin:goal",
"builtin:new",
]);
});
it("returns an empty list when an empty catalog matches nothing", () => {
expect(getMatchingSkillSuggestions([], "deep", builtins)).toEqual([]);
});
it("excludes disabled skills and ranks prefix matches first", () => {
const skills = [
makeSkill("deep-research"),
makeSkill("review"),
makeSkill("reviewer-disabled", false),
];
const result = getMatchingSkillSuggestions(skills, "rev", []);
expect(result.map((s) => s.name)).toEqual(["review"]);
expect(result.every((s) => s.kind === "skill")).toBe(true);
});
it("includes matching builtin commands after skills", () => {
const result = getMatchingSkillSuggestions(
[makeSkill("goal-helper")],
"goal",
builtins,
);
expect(result.map((s) => s.name)).toContain("goal-helper");
expect(result.map((s) => s.name)).toContain("goal");
});
it("excludes skills that collide with builtin command names", () => {
const result = getMatchingSkillSuggestions(
[makeSkill("goal"), makeSkill("goal-helper")],
"goal",
builtins,
);
expect(result.map((s) => `${s.kind}:${s.name}`)).toEqual([
"skill:goal-helper",
"builtin:goal",
]);
});
it("excludes skills that collide with a reserved slash name", () => {
// The picker must not offer what the slash parsers refuse. Both sides drop
// these names, so such a skill can never activate — picking it would send
// literal text to the model with nothing loaded.
for (const reserved of RESERVED_SLASH_SKILL_NAMES) {
if (reserved === "context") {
continue;
}
const result = getMatchingSkillSuggestions(
[makeSkill(reserved), makeSkill(`${reserved}-helper`)],
reserved,
builtins,
);
expect(
result.filter((s) => s.kind === "skill").map((s) => s.name),
).toEqual([`${reserved}-helper`]);
}
});
it("keeps reserved names out even when no builtin commands are passed", () => {
const result = getMatchingSkillSuggestions([makeSkill("status")], "", []);
expect(result).toEqual([]);
});
it("keeps a context skill available because only its compact alias is reserved", () => {
const result = getMatchingSkillSuggestions(
[makeSkill("context")],
"context",
[],
);
describe("getMatchingSlashCommands", () => {
it("offers the builtin commands for an empty query", () => {
expect(
result.map((suggestion) => `${suggestion.kind}:${suggestion.name}`),
).toEqual(["skill:context"]);
getMatchingSlashCommands("", builtins).map((item) => item.name),
).toEqual(["goal", "compact"]);
});
it("caps the number of suggestions", () => {
const skills = Array.from({ length: 10 }, (_, i) =>
makeSkill(`skill-${i}`),
);
const result = getMatchingSkillSuggestions(skills, "", []);
expect(result.length).toBeLessThanOrEqual(6);
it("matches command names case-insensitively", () => {
expect(
getMatchingSlashCommands("GO", builtins).map((item) => item.name),
).toEqual(["goal"]);
});
it("matches command descriptions", () => {
expect(
getMatchingSlashCommands("context", builtins).map((item) => item.name),
).toEqual(["compact"]);
});
it("returns no commands for an unmatched skill query", () => {
expect(getMatchingSlashCommands("research", builtins)).toEqual([]);
});
});
@ -518,14 +440,3 @@ describe("findSuggestionTemplatePlaceholder", () => {
expect(findSuggestionTemplatePlaceholder("no placeholder here")).toBeNull();
});
});
it("never offers filesystem-discovered names outside the activation grammar", () => {
const names = ["research", "a--b", "a_b", "Research", "a-", "context"];
expect(
getMatchingSkillSuggestions(
names.map((name) => makeSkill(name)),
"",
[],
).map((item) => item.name),
).toEqual(["research", "context"]);
});

View File

@ -38,6 +38,16 @@ rs.mock("@/core/models/hooks", () => ({
}),
}));
const skillCatalog = [
{
name: "research",
description: "Research a topic",
category: "general",
license: "MIT",
enabled: true,
editable: false,
},
];
const skillsQuery = {
isLoading: false,
error: null as Error | null,
@ -45,16 +55,7 @@ const skillsQuery = {
};
rs.mock("@/core/skills/hooks", () => ({
useSkills: () => ({
skills: [
{
name: "research",
description: "Research a topic",
category: "general",
license: "MIT",
enabled: true,
editable: false,
},
],
skills: skillCatalog,
...skillsQuery,
}),
}));
@ -167,6 +168,7 @@ rs.mock("@/core/threads/hooks", () => ({
}));
beforeEach(() => {
skillCatalog.splice(1);
capability.enabled = true;
capability.maxReferences = 3;
capability.isLoading = false;
@ -218,6 +220,49 @@ function enterMention(
}
describe("unified composer mentions", () => {
it("offers only backend-accepted skill names while allowing compact in the mention picker", () => {
const rejectedNames = ["a--b", "a_b", "Research", "a-", "goal", "status"];
for (const name of [...rejectedNames, "compact"]) {
skillCatalog.push({
...skillCatalog[0]!,
name,
description: "Test skill",
});
}
const { container } = renderComposer("accepted-mention-names");
enterMention(container, "@");
expect(
screen.getByRole("option", { name: "compact Test skill" }),
).toBeTruthy();
for (const name of rejectedNames) {
expect(
screen.queryByRole("option", { name: `${name} Test skill` }),
).toBeNull();
}
});
it("offers only builtin commands after a leading slash", () => {
const { container } = renderComposer("builtin-slash-menu");
enterMention(container, "/");
expect(screen.getAllByRole("option")).toHaveLength(2);
expect(screen.getByRole("option", { name: /^\/goal / })).toBeTruthy();
expect(screen.getByRole("option", { name: /^\/compact / })).toBeTruthy();
});
it("keeps a legacy skill query as text without selecting a skill", async () => {
const submit = rs.fn();
const { container } = renderComposer("literal-slash-skill", submit);
const input = enterMention(container, "/res");
expect(screen.queryByRole("listbox")).toBeNull();
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(submit).toHaveBeenCalled());
expect(submit.mock.calls[0]![0].text).toBe("/res");
expect(
submit.mock.calls[0]![1].additionalKwargs?.skill_references,
).toBeUndefined();
expect(screen.queryByTestId("inline-skill-reference")).toBeNull();
});
it("keeps a skill inline in the middle and sends its explicit activation metadata", async () => {
const submit = rs.fn();
const { container } = renderComposer("skill-mention", submit);
@ -522,7 +567,7 @@ describe("reference review regressions", () => {
expect(option.id).not.toBe("");
expect(input.getAttribute("aria-activedescendant")).toBe(option.id);
});
it("locks legacy skill removal until attachment migration settles", async () => {
it("locks a migrated inline skill until attachment migration settles", async () => {
let finish!: (value: unknown) => void;
attach.mockReturnValueOnce(
new Promise((resolve) => {
@ -536,14 +581,14 @@ describe("reference review regressions", () => {
onReferenceFileAttached: materialized,
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy(),
expect(screen.getByTestId("inline-skill-reference")).toBeTruthy(),
);
fireEvent.click(screen.getByTestId("mention-button"));
fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
await waitFor(() => expect(attach).toHaveBeenCalled());
const remove = screen.getByRole("button", { name: "Remove skill" });
expect(remove.hasAttribute("disabled")).toBe(true);
fireEvent.click(remove);
expect(screen.getByRole("textbox").getAttribute("contenteditable")).toBe(
"false",
);
finish({
filename: "report.pdf",
size_bytes: 10,
@ -551,7 +596,7 @@ describe("reference review regressions", () => {
artifact_url: "/artifact",
});
await waitFor(() => expect(materialized).toHaveBeenCalledTimes(1));
expect(container.textContent).toContain("@research");
expect(container.textContent).toContain("✦research");
});
});