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:
Ryker_Feng 2026-07-08 21:58:33 +08:00 committed by GitHub
parent 01dc067997
commit c640b52a7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 625 additions and 29 deletions

View file

@ -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+|$)")

View 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 sideor a grammar changecannot 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"]

View 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+|$)"
}

View file

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

View file

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

View 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>
);
}

View file

@ -1,2 +1,3 @@
export * from "./api"; export * from "./api";
export * from "./slash";
export * from "./type"; export * from "./type";

View 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;
}

View file

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

View file

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

View 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,
);
});
});

View 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();
});
});