mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
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:
parent
d3e8e78f3f
commit
9f5897cef2
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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",
|
||||
|
||||
|
||||
@ -316,7 +316,6 @@ export interface Translations {
|
||||
mentionAttachFailed: string;
|
||||
mentionMultipleSkills: string;
|
||||
mentionNoProject: string;
|
||||
mentionRemoveSkill: string;
|
||||
mentionUnavailable: string;
|
||||
mentionClose: string;
|
||||
|
||||
|
||||
@ -346,7 +346,6 @@ export const zhCN: Translations = {
|
||||
mentionMultipleSkills:
|
||||
"每条消息最多选择 16 个技能,再次选择已勾选技能可移除。",
|
||||
mentionNoProject: "在项目对话中可引用该项目的文档。",
|
||||
mentionRemoveSkill: "移除技能",
|
||||
mentionUnavailable: "文件不可用",
|
||||
mentionClose: "关闭引用面板",
|
||||
|
||||
|
||||
@ -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 ({
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 ");
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"]);
|
||||
});
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user