mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
feat(frontend): render slash-skill activations as inline chips (#3981)
* feat(frontend): render slash-skill activations as inline chips Show an explicit `/skill` activation as a compact inline chip in both the composer and the chat transcript instead of raw slash text. - Composer: selecting a skill suggestion stores it as a removable chip aligned inline with the textarea; the leading `/skill ` prefix is reattached only at submit time, so the backend activation protocol is unchanged. Backspace on an empty input or the chip's close button clears it; history navigation is disabled while a chip is active. - Transcript: human messages that begin with `/skill` render the skill as a read-only chip followed by the task text. - Add a shared `core/skills/slash.ts` (`parseSlashSkillReference` + `resolveSlashSkillDisplay`) mirroring the backend `slash.py` gate, so the transcript only shows a chip when the skill actually exists and is enabled. This removes a duplicated regex/reserved-name list and keeps display semantics consistent with backend activation. Add unit tests for the shared slash parser and extend the chat e2e to assert the composer still submits `/skill <task>` after showing a chip. * chore(frontend): format chat e2e test * refactor(skills): address slash-skill chip review feedback Follow-up to the inline slash-skill chip PR, resolving three second-order review findings: - Drive the reserved-command set and skill-name grammar from a shared contracts/slash_skill_contract.json instead of a hand-copied "keep in sync" pair. slash.ts and slash.py now reference the fixture, and contract tests on both sides fail CI if either drifts. - Extract a shared SlashSkillChip so the composer and transcript chips stay in lockstep, and normalize the off-scale /8 and /12 opacity steps to the standard /10 and /20 tokens. - Split HumanMessageText into a pure parse gate plus a slash-only subtree that owns the useSkills() lookup, so a skill-enabled toggle no longer re-renders every plain-text human turn. Verified: frontend eslint + tsc clean, pnpm test 572 pass (incl. new slash-contract test); backend slash contract + slash-skills tests 31 pass. * style(tests): sort slash skill contract imports * fix(composer): inline the slash-skill text so the chip aligns with input Address the "composer body layout change" review on #3981 by rendering the active skill as an inline chip in the same text flow as the prompt, rather than a separate flex row that drifted the box model across states. - Render the chip + prompt inside one leading-6 wrapper and edit the prompt through a `contentEditable` span, so the chip sits inline with the first line and long/multi-line input wraps naturally back to the container edge. - Align the chip with `align-top`: its h-6 (24px) height matches the text line height, so chip and first-line centers coincide exactly (measured delta 0), fixing the chip being raised above the baseline. - Restore the placeholder in chip mode via a `data-empty` CSS `::before`, which also gives the empty editable span width so it is no longer treated as hidden. - Widen the IME helper to `HTMLElement` and route the span's keydown/paste through the shared skill-suggestion, prompt-history, backspace-to-clear, and IME-composition handlers so contentEditable behaves like the textarea. - Extend chat.spec.ts to drive the inline skill editor instead of the textarea after a chip is shown. * style(frontend): fix composer class order formatting * fix(composer): break long unbroken input inside the slash-skill row The inline slash-skill editor wrapped with `break-words` (overflow-wrap: break-word), which only moves an over-long token to the next line before breaking it. A long unbroken string therefore started on the line below the chip, and when the string contained a break opportunity such as a hyphen the browser wrapped there and pushed the remaining run to the next line, leaving a wide gap on the right. Switch to `break-all` (word-break: break-all) so the text fills each line from the chip and packs tightly regardless of hyphens or CJK.
This commit is contained in:
parent
01dc067997
commit
c640b52a7d
12 changed files with 625 additions and 29 deletions
|
|
@ -6,6 +6,14 @@ from dataclasses import dataclass
|
||||||
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
|
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
|
||||||
from deerflow.skills.types import Skill
|
from deerflow.skills.types import Skill
|
||||||
|
|
||||||
|
#: Composer control commands that own the leading slash and must never be
|
||||||
|
#: treated as ``/skill`` activations. These values plus :data:`_SLASH_SKILL_RE`
|
||||||
|
#: are mirrored by the frontend display parser in
|
||||||
|
#: ``frontend/src/core/skills/slash.ts``; both sides are pinned to the shared
|
||||||
|
#: fixture at ``contracts/slash_skill_contract.json`` by contract tests
|
||||||
|
#: (``tests/test_slash_skill_contract.py`` here, ``slash-contract.test.ts`` on
|
||||||
|
#: the frontend), so a reserved command or grammar change in only one language
|
||||||
|
#: fails CI.
|
||||||
RESERVED_SLASH_SKILL_NAMES = frozenset({"bootstrap", "goal", "help", "memory", "models", "new", "status"})
|
RESERVED_SLASH_SKILL_NAMES = frozenset({"bootstrap", "goal", "help", "memory", "models", "new", "status"})
|
||||||
_SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)")
|
_SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)")
|
||||||
|
|
||||||
|
|
|
||||||
37
backend/tests/test_slash_skill_contract.py
Normal file
37
backend/tests/test_slash_skill_contract.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Contract tests for the leading ``/skill`` activation gate.
|
||||||
|
|
||||||
|
Pins the backend parser's reserved-command set and skill-name grammar to the
|
||||||
|
shared fixture at ``contracts/slash_skill_contract.json``. The frontend display
|
||||||
|
parser (``frontend/src/core/skills/slash.ts``) is pinned to the same fixture by
|
||||||
|
``frontend/tests/unit/core/skills/slash-contract.test.ts``, so a reserved
|
||||||
|
command added on one side—or a grammar change—cannot silently drift the two
|
||||||
|
languages apart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from deerflow.skills.slash import _SLASH_SKILL_RE, RESERVED_SLASH_SKILL_NAMES
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
_CONTRACT_PATH = _REPO_ROOT / "contracts" / "slash_skill_contract.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_contract() -> dict:
|
||||||
|
return json.loads(_CONTRACT_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_file_exists():
|
||||||
|
assert _CONTRACT_PATH.is_file(), f"missing shared fixture: {_CONTRACT_PATH}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserved_names_match_contract():
|
||||||
|
contract = _load_contract()
|
||||||
|
assert set(RESERVED_SLASH_SKILL_NAMES) == set(contract["reserved_slash_skill_names"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_name_pattern_matches_contract():
|
||||||
|
contract = _load_contract()
|
||||||
|
assert _SLASH_SKILL_RE.pattern == contract["skill_name_pattern"]
|
||||||
6
contracts/slash_skill_contract.json
Normal file
6
contracts/slash_skill_contract.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"description": "Cross-language contract fixture for the leading /skill activation gate. The backend parser (deerflow/skills/slash.py) and the frontend display parser (frontend/src/core/skills/slash.ts) must agree on which leading /word tokens are reserved control commands and on the exact skill-name grammar, so the transcript only renders an activation chip for text the backend would actually treat as a /skill activation.",
|
||||||
|
"reserved_slash_skill_names": ["bootstrap", "goal", "help", "memory", "models", "new", "status"],
|
||||||
|
"skill_name_pattern": "^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\\s+|$)"
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,8 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
type ClipboardEvent,
|
||||||
|
type FormEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -37,7 +39,6 @@ import {
|
||||||
PromptInputActionMenuTrigger,
|
PromptInputActionMenuTrigger,
|
||||||
PromptInputAttachment,
|
PromptInputAttachment,
|
||||||
PromptInputAttachments,
|
PromptInputAttachments,
|
||||||
PromptInputBody,
|
|
||||||
PromptInputButton,
|
PromptInputButton,
|
||||||
PromptInputFooter,
|
PromptInputFooter,
|
||||||
PromptInputHeader,
|
PromptInputHeader,
|
||||||
|
|
@ -82,6 +83,7 @@ import { threadTokenUsageQueryKey } from "@/core/threads/token-usage";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import {
|
import {
|
||||||
formatUploadSize,
|
formatUploadSize,
|
||||||
|
splitUnsupportedUploadFiles,
|
||||||
useUploadLimits,
|
useUploadLimits,
|
||||||
validateUploadLimits,
|
validateUploadLimits,
|
||||||
type UploadLimits,
|
type UploadLimits,
|
||||||
|
|
@ -126,10 +128,51 @@ import {
|
||||||
import { useThread } from "./messages/context";
|
import { useThread } from "./messages/context";
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar";
|
import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar";
|
||||||
|
import { SlashSkillChip } from "./slash-skill-chip";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||||
|
|
||||||
|
function focusContentEditableEnd(element: HTMLElement | null) {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.focus();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
range.collapse(false);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertPlainTextAtSelection(container: HTMLElement, text: string) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const ancestor = range.commonAncestorContainer;
|
||||||
|
if (ancestor !== container && !container.contains(ancestor)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
range.deleteContents();
|
||||||
|
const node = document.createTextNode(text);
|
||||||
|
range.insertNode(node);
|
||||||
|
range.setStartAfter(node);
|
||||||
|
range.setEndAfter(node);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function getResolvedMode(
|
function getResolvedMode(
|
||||||
mode: InputMode | undefined,
|
mode: InputMode | undefined,
|
||||||
supportsThinking: boolean,
|
supportsThinking: boolean,
|
||||||
|
|
@ -272,6 +315,8 @@ export function InputBox({
|
||||||
const { data: uploadLimits } = useUploadLimits(threadId);
|
const { data: uploadLimits } = useUploadLimits(threadId);
|
||||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const inlineSkillTextRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const inlineSkillComposingRef = useRef(false);
|
||||||
const goalRequestStateRef = useRef(createGoalRequestState());
|
const goalRequestStateRef = useRef(createGoalRequestState());
|
||||||
const compactRequestStateRef = useRef(createGoalRequestState());
|
const compactRequestStateRef = useRef(createGoalRequestState());
|
||||||
const inputPolishRequestRef = useRef<{
|
const inputPolishRequestRef = useRef<{
|
||||||
|
|
@ -297,6 +342,8 @@ export function InputBox({
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [textareaFocused, setTextareaFocused] = useState(false);
|
const [textareaFocused, setTextareaFocused] = useState(false);
|
||||||
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
|
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
|
||||||
|
const [selectedSlashSkill, setSelectedSlashSkill] =
|
||||||
|
useState<SlashSuggestion | null>(null);
|
||||||
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
|
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
|
||||||
useState<string | null>(null);
|
useState<string | null>(null);
|
||||||
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
||||||
|
|
@ -451,6 +498,7 @@ export function InputBox({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
promptHistoryIndexRef.current = null;
|
promptHistoryIndexRef.current = null;
|
||||||
promptHistoryDraftRef.current = "";
|
promptHistoryDraftRef.current = "";
|
||||||
|
setSelectedSlashSkill(null);
|
||||||
setInputPolishUndo(null);
|
setInputPolishUndo(null);
|
||||||
}, [threadId]);
|
}, [threadId]);
|
||||||
|
|
||||||
|
|
@ -804,9 +852,15 @@ export function InputBox({
|
||||||
toast.info(t.inputBox.pleaseWaitStreaming);
|
toast.info(t.inputBox.pleaseWaitStreaming);
|
||||||
return Promise.reject(new Error("streaming"));
|
return Promise.reject(new Error("streaming"));
|
||||||
}
|
}
|
||||||
|
const messageWithSlashSkill = selectedSlashSkill
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
text: `/${selectedSlashSkill.name} ${message.text}`,
|
||||||
|
}
|
||||||
|
: message;
|
||||||
const submitAction = getInputSubmitAction({
|
const submitAction = getInputSubmitAction({
|
||||||
text: message.text,
|
text: messageWithSlashSkill.text,
|
||||||
fileCount: message.files.length,
|
fileCount: messageWithSlashSkill.files.length,
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
if (submitAction.kind === "goal") {
|
if (submitAction.kind === "goal") {
|
||||||
|
|
@ -836,12 +890,16 @@ export function InputBox({
|
||||||
if (submitAction.kind === "empty") {
|
if (submitAction.kind === "empty") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return submitThreadMessage(message);
|
await submitThreadMessage(messageWithSlashSkill);
|
||||||
|
if (selectedSlashSkill) {
|
||||||
|
setSelectedSlashSkill(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
handleCompactCommand,
|
handleCompactCommand,
|
||||||
handleGoalCommand,
|
handleGoalCommand,
|
||||||
onStop,
|
onStop,
|
||||||
|
selectedSlashSkill,
|
||||||
status,
|
status,
|
||||||
submitThreadMessage,
|
submitThreadMessage,
|
||||||
t.inputBox.pleaseWaitStreaming,
|
t.inputBox.pleaseWaitStreaming,
|
||||||
|
|
@ -917,6 +975,7 @@ export function InputBox({
|
||||||
const showSkillSuggestions =
|
const showSkillSuggestions =
|
||||||
!disabled &&
|
!disabled &&
|
||||||
textareaFocused &&
|
textareaFocused &&
|
||||||
|
!selectedSlashSkill &&
|
||||||
slashSkillQuery !== null &&
|
slashSkillQuery !== null &&
|
||||||
skillSuggestions.length > 0 &&
|
skillSuggestions.length > 0 &&
|
||||||
dismissedSkillSuggestionValue !== textInput.value;
|
dismissedSkillSuggestionValue !== textInput.value;
|
||||||
|
|
@ -951,6 +1010,16 @@ export function InputBox({
|
||||||
|
|
||||||
const applySkillSuggestion = useCallback(
|
const applySkillSuggestion = useCallback(
|
||||||
(suggestion: SlashSuggestion) => {
|
(suggestion: SlashSuggestion) => {
|
||||||
|
if (suggestion.kind === "skill") {
|
||||||
|
setSelectedSlashSkill(suggestion);
|
||||||
|
textInput.setInput("");
|
||||||
|
setDismissedSkillSuggestionValue(null);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
focusContentEditableEnd(inlineSkillTextRef.current);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nextValue = `/${suggestion.name} `;
|
const nextValue = `/${suggestion.name} `;
|
||||||
textInput.setInput(nextValue);
|
textInput.setInput(nextValue);
|
||||||
setDismissedSkillSuggestionValue(nextValue);
|
setDismissedSkillSuggestionValue(nextValue);
|
||||||
|
|
@ -967,7 +1036,7 @@ export function InputBox({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSkillSuggestionKeyDown = useCallback(
|
const handleSkillSuggestionKeyDown = useCallback(
|
||||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
(event: KeyboardEvent<HTMLElement>) => {
|
||||||
if (!showSkillSuggestions) {
|
if (!showSkillSuggestions) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1119,13 +1188,14 @@ export function InputBox({
|
||||||
}, [inputPolishUndo, inputPolishUndoAvailable, setPromptHistoryValue]);
|
}, [inputPolishUndo, inputPolishUndoAvailable, setPromptHistoryValue]);
|
||||||
|
|
||||||
const handlePromptHistoryKeyDown = useCallback(
|
const handlePromptHistoryKeyDown = useCallback(
|
||||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
(event: KeyboardEvent<HTMLElement>) => {
|
||||||
if (
|
if (
|
||||||
event.altKey ||
|
event.altKey ||
|
||||||
event.ctrlKey ||
|
event.ctrlKey ||
|
||||||
event.metaKey ||
|
event.metaKey ||
|
||||||
event.shiftKey ||
|
event.shiftKey ||
|
||||||
isIMEComposing(event) ||
|
isIMEComposing(event) ||
|
||||||
|
selectedSlashSkill ||
|
||||||
promptHistory.length === 0 ||
|
promptHistory.length === 0 ||
|
||||||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
||||||
) {
|
) {
|
||||||
|
|
@ -1169,18 +1239,46 @@ export function InputBox({
|
||||||
promptHistoryIndexRef.current = nextIndex;
|
promptHistoryIndexRef.current = nextIndex;
|
||||||
setPromptHistoryValue(promptHistory[nextIndex] ?? "");
|
setPromptHistoryValue(promptHistory[nextIndex] ?? "");
|
||||||
},
|
},
|
||||||
[promptHistory, setPromptHistoryValue, textInput.value],
|
[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],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePromptTextareaKeyDown = useCallback(
|
const handlePromptTextareaKeyDown = useCallback(
|
||||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
(event: KeyboardEvent<HTMLElement>) => {
|
||||||
handleSkillSuggestionKeyDown(event);
|
handleSkillSuggestionKeyDown(event);
|
||||||
if (event.defaultPrevented) {
|
if (event.defaultPrevented) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handleSelectedSlashSkillKeyDown(event);
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
handlePromptHistoryKeyDown(event);
|
handlePromptHistoryKeyDown(event);
|
||||||
},
|
},
|
||||||
[handlePromptHistoryKeyDown, handleSkillSuggestionKeyDown],
|
[
|
||||||
|
handlePromptHistoryKeyDown,
|
||||||
|
handleSelectedSlashSkillKeyDown,
|
||||||
|
handleSkillSuggestionKeyDown,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePromptTextareaChange = useCallback(() => {
|
const handlePromptTextareaChange = useCallback(() => {
|
||||||
|
|
@ -1190,10 +1288,108 @@ export function InputBox({
|
||||||
promptHistoryDraftRef.current = "";
|
promptHistoryDraftRef.current = "";
|
||||||
}, [abortInputPolishRequest]);
|
}, [abortInputPolishRequest]);
|
||||||
|
|
||||||
|
const updateInlineSkillTextInput = useCallback(
|
||||||
|
(element: HTMLElement) => {
|
||||||
|
promptHistoryIndexRef.current = null;
|
||||||
|
promptHistoryDraftRef.current = "";
|
||||||
|
textInput.setInput(element.textContent ?? "");
|
||||||
|
},
|
||||||
|
[textInput],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSlashSkill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = inlineSkillTextRef.current;
|
||||||
|
if (element && element.textContent !== textInput.value) {
|
||||||
|
element.textContent = textInput.value;
|
||||||
|
}
|
||||||
|
}, [selectedSlashSkill, textInput.value]);
|
||||||
|
|
||||||
|
const handleInlineSkillInput = useCallback(
|
||||||
|
(event: FormEvent<HTMLSpanElement>) => {
|
||||||
|
updateInlineSkillTextInput(event.currentTarget);
|
||||||
|
},
|
||||||
|
[updateInlineSkillTextInput],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInlineSkillPaste = useCallback(
|
||||||
|
(event: ClipboardEvent<HTMLSpanElement>) => {
|
||||||
|
const pastedFiles = Array.from(event.clipboardData.items)
|
||||||
|
.filter((item) => item.kind === "file")
|
||||||
|
.flatMap((item) => {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
return file ? [file] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pastedFiles.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
const { accepted, message } = splitUnsupportedUploadFiles(pastedFiles);
|
||||||
|
if (message) {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
if (accepted.length > 0) {
|
||||||
|
attachments.add(accepted);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = event.clipboardData.getData("text/plain");
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
if (insertPlainTextAtSelection(event.currentTarget, text)) {
|
||||||
|
updateInlineSkillTextInput(event.currentTarget);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attachments, updateInlineSkillTextInput],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInlineSkillKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLSpanElement>) => {
|
||||||
|
handleSelectedSlashSkillKeyDown(event);
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "Enter") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIMEComposing(event, inlineSkillComposingRef.current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (insertPlainTextAtSelection(event.currentTarget, "\n")) {
|
||||||
|
updateInlineSkillTextInput(event.currentTarget);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.currentTarget.closest("form")?.requestSubmit();
|
||||||
|
},
|
||||||
|
[handleSelectedSlashSkillKeyDown, updateInlineSkillTextInput],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearSelectedSlashSkill = useCallback(() => {
|
||||||
|
setSelectedSlashSkill(null);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const showFollowups =
|
const showFollowups =
|
||||||
!disabled &&
|
!disabled &&
|
||||||
!isWelcomeMode &&
|
!isWelcomeMode &&
|
||||||
!showSkillSuggestions &&
|
!showSkillSuggestions &&
|
||||||
|
!selectedSlashSkill &&
|
||||||
!followupsHidden &&
|
!followupsHidden &&
|
||||||
(followupsLoading || followups.length > 0);
|
(followupsLoading || followups.length > 0);
|
||||||
|
|
||||||
|
|
@ -1458,20 +1654,67 @@ export function InputBox({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</PromptInputHeader>
|
</PromptInputHeader>
|
||||||
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
|
<div className="min-h-16 w-full min-w-0 px-3 py-3">
|
||||||
<PromptInputTextarea
|
{selectedSlashSkill ? (
|
||||||
className={cn("size-full")}
|
<div
|
||||||
disabled={composerLocked}
|
className="max-h-48 min-h-6 w-full min-w-0 cursor-text overflow-y-auto text-base leading-6 break-all whitespace-pre-wrap md:text-sm"
|
||||||
placeholder={t.inputBox.placeholder}
|
onClick={(event) => {
|
||||||
autoFocus={autoFocus}
|
if (event.target === event.currentTarget) {
|
||||||
defaultValue={initialValue}
|
focusContentEditableEnd(inlineSkillTextRef.current);
|
||||||
onBlur={() => setTextareaFocused(false)}
|
}
|
||||||
onChange={handlePromptTextareaChange}
|
}}
|
||||||
onFocus={() => setTextareaFocused(true)}
|
>
|
||||||
onKeyDown={handlePromptTextareaKeyDown}
|
<SlashSkillChip
|
||||||
ref={textareaRef}
|
name={selectedSlashSkill.name}
|
||||||
/>
|
className="mr-2 max-w-[min(11rem,45%)] align-top"
|
||||||
</PromptInputBody>
|
onRemove={clearSelectedSlashSkill}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-label={t.inputBox.placeholder}
|
||||||
|
aria-multiline="true"
|
||||||
|
contentEditable={!composerLocked}
|
||||||
|
data-empty={textInput.value.length === 0}
|
||||||
|
data-placeholder={t.inputBox.placeholder}
|
||||||
|
data-slot="input-group-control"
|
||||||
|
onBlur={() => setTextareaFocused(false)}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
inlineSkillComposingRef.current = false;
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
inlineSkillComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onFocus={() => setTextareaFocused(true)}
|
||||||
|
onInput={handleInlineSkillInput}
|
||||||
|
onKeyDown={handleInlineSkillKeyDown}
|
||||||
|
onPaste={handleInlineSkillPaste}
|
||||||
|
aria-placeholder={t.inputBox.placeholder}
|
||||||
|
ref={inlineSkillTextRef}
|
||||||
|
role="textbox"
|
||||||
|
suppressContentEditableWarning
|
||||||
|
className={cn(
|
||||||
|
"outline-none",
|
||||||
|
"before:text-muted-foreground before:pointer-events-none",
|
||||||
|
"data-[empty=true]:before:content-[attr(data-placeholder)]",
|
||||||
|
composerLocked && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
tabIndex={composerLocked ? -1 : 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PromptInputTextarea
|
||||||
|
className="min-h-6! w-full min-w-0 p-0! leading-6!"
|
||||||
|
disabled={composerLocked}
|
||||||
|
placeholder={t.inputBox.placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
defaultValue={initialValue}
|
||||||
|
onBlur={() => setTextareaFocused(false)}
|
||||||
|
onChange={handlePromptTextareaChange}
|
||||||
|
onFocus={() => setTextareaFocused(true)}
|
||||||
|
onKeyDown={handlePromptTextareaKeyDown}
|
||||||
|
ref={textareaRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<PromptInputFooter className="flex flex-wrap gap-2 sm:flex-nowrap">
|
<PromptInputFooter className="flex flex-wrap gap-2 sm:flex-nowrap">
|
||||||
<PromptInputTools className="min-w-0 flex-1 flex-wrap">
|
<PromptInputTools className="min-w-0 flex-1 flex-wrap">
|
||||||
{/* TODO: Add more connectors here
|
{/* TODO: Add more connectors here
|
||||||
|
|
@ -1878,6 +2121,7 @@ export function InputBox({
|
||||||
|
|
||||||
{isWelcomeMode &&
|
{isWelcomeMode &&
|
||||||
searchParams.get("mode") !== "skill" &&
|
searchParams.get("mode") !== "skill" &&
|
||||||
|
!selectedSlashSkill &&
|
||||||
!showSkillSuggestions && (
|
!showSkillSuggestions && (
|
||||||
<div className="flex items-center justify-center pt-2">
|
<div className="flex items-center justify-center pt-2">
|
||||||
<SuggestionList onSelectPlaceholder={onSelectPlaceholder} />
|
<SuggestionList onSelectPlaceholder={onSelectPlaceholder} />
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ import {
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { readReferenceMessageContexts } from "@/core/sidecar";
|
import { readReferenceMessageContexts } from "@/core/sidecar";
|
||||||
|
import {
|
||||||
|
parseSlashSkillReference,
|
||||||
|
resolveSlashSkillDisplay,
|
||||||
|
} from "@/core/skills";
|
||||||
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { SafeReasoningContent } from "@/core/streamdown/components";
|
import { SafeReasoningContent } from "@/core/streamdown/components";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -51,6 +56,7 @@ import { WorkspaceChangeBadge } from "../changes";
|
||||||
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
|
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
|
||||||
|
import { SlashSkillChip } from "../slash-skill-chip";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { createMarkdownLinkComponent } from "./markdown-link";
|
import { createMarkdownLinkComponent } from "./markdown-link";
|
||||||
|
|
@ -212,6 +218,42 @@ function MessageImage({
|
||||||
|
|
||||||
const clientTurnDurations = new Map<string, number>();
|
const clientTurnDurations = new Map<string, number>();
|
||||||
|
|
||||||
|
function HumanMessageText({ content }: { content: string }) {
|
||||||
|
// `parseSlashSkillReference` is a pure regex gate (no data subscription), so
|
||||||
|
// the overwhelmingly common plain-text human message never subscribes to the
|
||||||
|
// skills query. Only a message that literally looks like a `/skill …`
|
||||||
|
// activation mounts `HumanSlashSkillText`, which owns the `useSkills()`
|
||||||
|
// lookup. This keeps a skill-enabled toggle from re-rendering every human
|
||||||
|
// turn — only the few slash-candidate turns react to catalog changes.
|
||||||
|
const reference = useMemo(() => parseSlashSkillReference(content), [content]);
|
||||||
|
|
||||||
|
if (!reference) {
|
||||||
|
return <div className="break-words whitespace-pre-wrap">{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <HumanSlashSkillText content={content} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HumanSlashSkillText({ content }: { content: string }) {
|
||||||
|
const { skills } = useSkills();
|
||||||
|
const slashSkill = resolveSlashSkillDisplay(content, skills);
|
||||||
|
|
||||||
|
if (!slashSkill) {
|
||||||
|
return <div className="break-words whitespace-pre-wrap">{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-full min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<SlashSkillChip name={slashSkill.name} />
|
||||||
|
{slashSkill.remainingText && (
|
||||||
|
<span className="min-w-0 flex-1 break-words whitespace-pre-wrap">
|
||||||
|
{slashSkill.remainingText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MessageContent_({
|
function MessageContent_({
|
||||||
className,
|
className,
|
||||||
message,
|
message,
|
||||||
|
|
@ -378,9 +420,7 @@ function MessageContent_({
|
||||||
{filesList}
|
{filesList}
|
||||||
{contentToDisplay && (
|
{contentToDisplay && (
|
||||||
<AIElementMessageContent className="w-full max-w-full">
|
<AIElementMessageContent className="w-full max-w-full">
|
||||||
<div className="break-words whitespace-pre-wrap">
|
<HumanMessageText content={contentToDisplay} />
|
||||||
{contentToDisplay}
|
|
||||||
</div>
|
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
49
frontend/src/components/workspace/slash-skill-chip.tsx
Normal file
49
frontend/src/components/workspace/slash-skill-chip.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared visual for a `/skill` activation, used both as a removable chip in the
|
||||||
|
* composer and as a read-only chip in the chat transcript. Keeping a single
|
||||||
|
* source of truth means the two stay in lockstep instead of drifting apart in
|
||||||
|
* their Tailwind classes.
|
||||||
|
*/
|
||||||
|
const CHIP_BASE_CLASS =
|
||||||
|
"border-primary/20 bg-primary/10 text-primary inline-flex h-6 shrink-0 items-center rounded-md border px-1.5 font-mono text-xs leading-none font-medium shadow-xs";
|
||||||
|
|
||||||
|
export function SlashSkillChip({
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
onRemove,
|
||||||
|
removeLabel,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
className?: string;
|
||||||
|
/** When provided, the chip renders as a removable button with a close icon. */
|
||||||
|
onRemove?: () => void;
|
||||||
|
removeLabel?: string;
|
||||||
|
}) {
|
||||||
|
if (onRemove) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={removeLabel ?? `Remove /${name}`}
|
||||||
|
className={cn(
|
||||||
|
CHIP_BASE_CLASS,
|
||||||
|
"hover:bg-primary/20 cursor-pointer gap-1 transition-colors",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={onRemove}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">/{name}</span>
|
||||||
|
<XIcon className="text-primary/70 size-2.5 shrink-0" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn(CHIP_BASE_CLASS, "max-w-full", className)}>
|
||||||
|
/{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./api";
|
export * from "./api";
|
||||||
|
export * from "./slash";
|
||||||
export * from "./type";
|
export * from "./type";
|
||||||
|
|
|
||||||
69
frontend/src/core/skills/slash.ts
Normal file
69
frontend/src/core/skills/slash.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { Skill } from "./type";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composer control commands that own the leading slash. They must never be
|
||||||
|
* shown as skill activations. These values plus {@link SLASH_SKILL_RE} mirror
|
||||||
|
* the backend gate in `deerflow/skills/slash.py`; both sides are pinned to the
|
||||||
|
* shared fixture at `contracts/slash_skill_contract.json` by contract tests
|
||||||
|
* (`tests/unit/core/skills/slash-contract.test.ts` here,
|
||||||
|
* `tests/test_slash_skill_contract.py` on the backend), so adding a reserved
|
||||||
|
* command or changing the name grammar in only one language fails CI.
|
||||||
|
*/
|
||||||
|
export const RESERVED_SLASH_SKILL_NAMES = new Set([
|
||||||
|
"bootstrap",
|
||||||
|
"goal",
|
||||||
|
"help",
|
||||||
|
"memory",
|
||||||
|
"models",
|
||||||
|
"new",
|
||||||
|
"status",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const SLASH_SKILL_RE = /^\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)/;
|
||||||
|
|
||||||
|
export type SlashSkillReference = {
|
||||||
|
name: string;
|
||||||
|
remainingText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse strict `/skill-name task` syntax, ignoring reserved control commands.
|
||||||
|
* Mirrors the backend `parse_slash_skill_reference`; returns null when the text
|
||||||
|
* is not a slash-skill activation.
|
||||||
|
*/
|
||||||
|
export function parseSlashSkillReference(
|
||||||
|
text: string,
|
||||||
|
): SlashSkillReference | null {
|
||||||
|
const match = SLASH_SKILL_RE.exec(text);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = match[1];
|
||||||
|
if (!name || RESERVED_SLASH_SKILL_NAMES.has(name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
remainingText: text.slice(match[0].length).replace(/^\s+/, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a slash-skill reference against the enabled skill catalog, matching
|
||||||
|
* the backend `resolve_slash_skill` gate: only an installed + enabled skill
|
||||||
|
* activates. Returns null when the text is not a slash command or the skill is
|
||||||
|
* unknown/disabled, so callers fall back to plain-text rendering.
|
||||||
|
*/
|
||||||
|
export function resolveSlashSkillDisplay(
|
||||||
|
text: string,
|
||||||
|
skills: Skill[],
|
||||||
|
): SlashSkillReference | null {
|
||||||
|
const reference = parseSlashSkillReference(text);
|
||||||
|
if (!reference) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const enabled = skills.some(
|
||||||
|
(skill) => skill.enabled && skill.name === reference.name,
|
||||||
|
);
|
||||||
|
return enabled ? reference : null;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { KeyboardEvent } from "react";
|
import type { KeyboardEvent } from "react";
|
||||||
|
|
||||||
type IMEKeyboardEvent = KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>;
|
type IMEKeyboardEvent = KeyboardEvent<HTMLElement>;
|
||||||
|
|
||||||
export function isIMEComposing(
|
export function isIMEComposing(
|
||||||
event: IMEKeyboardEvent,
|
event: IMEKeyboardEvent,
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,29 @@ test.describe("Chat workspace", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("suggests matching skills after a leading slash", async ({ page }) => {
|
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");
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||||
|
|
@ -194,7 +217,18 @@ test.describe("Chat workspace", () => {
|
||||||
|
|
||||||
await textarea.press("Enter");
|
await textarea.press("Enter");
|
||||||
|
|
||||||
await expect(textarea).toHaveValue("/data-analysis ");
|
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("goal command sets a goal and starts an agent run", async ({ page }) => {
|
test("goal command sets a goal and starts an agent run", async ({ page }) => {
|
||||||
|
|
@ -299,7 +333,10 @@ test.describe("Chat workspace", () => {
|
||||||
await textarea.press("ArrowDown");
|
await textarea.press("ArrowDown");
|
||||||
await textarea.press("Enter");
|
await textarea.press("Enter");
|
||||||
|
|
||||||
await expect(textarea).toHaveValue("/frontend-design ");
|
await expect(page.getByText("/frontend-design")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: /how can i assist you/i }),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
|
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
|
||||||
|
|
|
||||||
39
frontend/tests/unit/core/skills/slash-contract.test.ts
Normal file
39
frontend/tests/unit/core/skills/slash-contract.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "@rstest/core";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RESERVED_SLASH_SKILL_NAMES,
|
||||||
|
SLASH_SKILL_RE,
|
||||||
|
} from "@/core/skills/slash";
|
||||||
|
|
||||||
|
interface ContractFile {
|
||||||
|
reserved_slash_skill_names: string[];
|
||||||
|
skill_name_pattern: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTRACT_PATH = resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../../../../contracts/slash_skill_contract.json",
|
||||||
|
);
|
||||||
|
const CONTRACT: ContractFile = JSON.parse(
|
||||||
|
readFileSync(CONTRACT_PATH, "utf-8"),
|
||||||
|
) as ContractFile;
|
||||||
|
|
||||||
|
describe("slash-skill contract", () => {
|
||||||
|
it("reserved names match the shared contract fixture", () => {
|
||||||
|
expect([...RESERVED_SLASH_SKILL_NAMES].sort()).toEqual(
|
||||||
|
[...CONTRACT.reserved_slash_skill_names].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skill-name grammar matches the shared contract fixture", () => {
|
||||||
|
// The contract stores the Python-canonical pattern (an unescaped `/`).
|
||||||
|
// Normalize both through the RegExp constructor so the comparison is not
|
||||||
|
// tripped by JS escaping the delimiter to `\/` in `.source`.
|
||||||
|
expect(SLASH_SKILL_RE.source).toBe(
|
||||||
|
new RegExp(CONTRACT.skill_name_pattern).source,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
frontend/tests/unit/core/skills/slash.test.ts
Normal file
66
frontend/tests/unit/core/skills/slash.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { describe, expect, it } from "@rstest/core";
|
||||||
|
|
||||||
|
import type { Skill } from "@/core/skills";
|
||||||
|
import {
|
||||||
|
parseSlashSkillReference,
|
||||||
|
resolveSlashSkillDisplay,
|
||||||
|
} from "@/core/skills/slash";
|
||||||
|
|
||||||
|
function makeSkill(name: string, enabled = true): Skill {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: `${name} description`,
|
||||||
|
enabled,
|
||||||
|
} as Skill;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseSlashSkillReference", () => {
|
||||||
|
it("parses a leading /skill and captures the remaining text", () => {
|
||||||
|
expect(parseSlashSkillReference("/data-analysis summarize this")).toEqual({
|
||||||
|
name: "data-analysis",
|
||||||
|
remainingText: "summarize this",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a bare /skill with no task text", () => {
|
||||||
|
expect(parseSlashSkillReference("/data-analysis")).toEqual({
|
||||||
|
name: "data-analysis",
|
||||||
|
remainingText: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores reserved control commands", () => {
|
||||||
|
expect(parseSlashSkillReference("/goal ship it")).toBeNull();
|
||||||
|
expect(parseSlashSkillReference("/help")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when text is not a leading slash command", () => {
|
||||||
|
expect(parseSlashSkillReference("hello /data-analysis")).toBeNull();
|
||||||
|
expect(parseSlashSkillReference("/a/b")).toBeNull();
|
||||||
|
expect(parseSlashSkillReference("plain text")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSlashSkillDisplay", () => {
|
||||||
|
const skills = [makeSkill("data-analysis"), makeSkill("frontend-design")];
|
||||||
|
|
||||||
|
it("resolves when the referenced skill exists and is enabled", () => {
|
||||||
|
expect(resolveSlashSkillDisplay("/data-analysis go", skills)).toEqual({
|
||||||
|
name: "data-analysis",
|
||||||
|
remainingText: "go",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a slash command that is not an installed skill", () => {
|
||||||
|
expect(resolveSlashSkillDisplay("/hello world", skills)).toBeNull();
|
||||||
|
expect(resolveSlashSkillDisplay("/unknown-skill do it", skills)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the skill exists but is disabled", () => {
|
||||||
|
expect(
|
||||||
|
resolveSlashSkillDisplay("/legacy-skill x", [
|
||||||
|
makeSkill("legacy-skill", false),
|
||||||
|
]),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue