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.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"})
|
||||
_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,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ClipboardEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -37,7 +39,6 @@ import {
|
|||
PromptInputActionMenuTrigger,
|
||||
PromptInputAttachment,
|
||||
PromptInputAttachments,
|
||||
PromptInputBody,
|
||||
PromptInputButton,
|
||||
PromptInputFooter,
|
||||
PromptInputHeader,
|
||||
|
|
@ -82,6 +83,7 @@ import { threadTokenUsageQueryKey } from "@/core/threads/token-usage";
|
|||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import {
|
||||
formatUploadSize,
|
||||
splitUnsupportedUploadFiles,
|
||||
useUploadLimits,
|
||||
validateUploadLimits,
|
||||
type UploadLimits,
|
||||
|
|
@ -126,10 +128,51 @@ import {
|
|||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar";
|
||||
import { SlashSkillChip } from "./slash-skill-chip";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
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(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
|
|
@ -272,6 +315,8 @@ export function InputBox({
|
|||
const { data: uploadLimits } = useUploadLimits(threadId);
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const inlineSkillTextRef = useRef<HTMLSpanElement | null>(null);
|
||||
const inlineSkillComposingRef = useRef(false);
|
||||
const goalRequestStateRef = useRef(createGoalRequestState());
|
||||
const compactRequestStateRef = useRef(createGoalRequestState());
|
||||
const inputPolishRequestRef = useRef<{
|
||||
|
|
@ -297,6 +342,8 @@ export function InputBox({
|
|||
} | null>(null);
|
||||
const [textareaFocused, setTextareaFocused] = useState(false);
|
||||
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
|
||||
const [selectedSlashSkill, setSelectedSlashSkill] =
|
||||
useState<SlashSuggestion | null>(null);
|
||||
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
|
||||
useState<string | null>(null);
|
||||
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
||||
|
|
@ -451,6 +498,7 @@ export function InputBox({
|
|||
useEffect(() => {
|
||||
promptHistoryIndexRef.current = null;
|
||||
promptHistoryDraftRef.current = "";
|
||||
setSelectedSlashSkill(null);
|
||||
setInputPolishUndo(null);
|
||||
}, [threadId]);
|
||||
|
||||
|
|
@ -804,9 +852,15 @@ export function InputBox({
|
|||
toast.info(t.inputBox.pleaseWaitStreaming);
|
||||
return Promise.reject(new Error("streaming"));
|
||||
}
|
||||
const messageWithSlashSkill = selectedSlashSkill
|
||||
? {
|
||||
...message,
|
||||
text: `/${selectedSlashSkill.name} ${message.text}`,
|
||||
}
|
||||
: message;
|
||||
const submitAction = getInputSubmitAction({
|
||||
text: message.text,
|
||||
fileCount: message.files.length,
|
||||
text: messageWithSlashSkill.text,
|
||||
fileCount: messageWithSlashSkill.files.length,
|
||||
status,
|
||||
});
|
||||
if (submitAction.kind === "goal") {
|
||||
|
|
@ -836,12 +890,16 @@ export function InputBox({
|
|||
if (submitAction.kind === "empty") {
|
||||
return;
|
||||
}
|
||||
return submitThreadMessage(message);
|
||||
await submitThreadMessage(messageWithSlashSkill);
|
||||
if (selectedSlashSkill) {
|
||||
setSelectedSlashSkill(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
handleCompactCommand,
|
||||
handleGoalCommand,
|
||||
onStop,
|
||||
selectedSlashSkill,
|
||||
status,
|
||||
submitThreadMessage,
|
||||
t.inputBox.pleaseWaitStreaming,
|
||||
|
|
@ -917,6 +975,7 @@ export function InputBox({
|
|||
const showSkillSuggestions =
|
||||
!disabled &&
|
||||
textareaFocused &&
|
||||
!selectedSlashSkill &&
|
||||
slashSkillQuery !== null &&
|
||||
skillSuggestions.length > 0 &&
|
||||
dismissedSkillSuggestionValue !== textInput.value;
|
||||
|
|
@ -951,6 +1010,16 @@ export function InputBox({
|
|||
|
||||
const applySkillSuggestion = useCallback(
|
||||
(suggestion: SlashSuggestion) => {
|
||||
if (suggestion.kind === "skill") {
|
||||
setSelectedSlashSkill(suggestion);
|
||||
textInput.setInput("");
|
||||
setDismissedSkillSuggestionValue(null);
|
||||
requestAnimationFrame(() => {
|
||||
focusContentEditableEnd(inlineSkillTextRef.current);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = `/${suggestion.name} `;
|
||||
textInput.setInput(nextValue);
|
||||
setDismissedSkillSuggestionValue(nextValue);
|
||||
|
|
@ -967,7 +1036,7 @@ export function InputBox({
|
|||
);
|
||||
|
||||
const handleSkillSuggestionKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
(event: KeyboardEvent<HTMLElement>) => {
|
||||
if (!showSkillSuggestions) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1119,13 +1188,14 @@ export function InputBox({
|
|||
}, [inputPolishUndo, inputPolishUndoAvailable, setPromptHistoryValue]);
|
||||
|
||||
const handlePromptHistoryKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
(event: KeyboardEvent<HTMLElement>) => {
|
||||
if (
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
isIMEComposing(event) ||
|
||||
selectedSlashSkill ||
|
||||
promptHistory.length === 0 ||
|
||||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
||||
) {
|
||||
|
|
@ -1169,18 +1239,46 @@ export function InputBox({
|
|||
promptHistoryIndexRef.current = 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(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
(event: KeyboardEvent<HTMLElement>) => {
|
||||
handleSkillSuggestionKeyDown(event);
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
handleSelectedSlashSkillKeyDown(event);
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
handlePromptHistoryKeyDown(event);
|
||||
},
|
||||
[handlePromptHistoryKeyDown, handleSkillSuggestionKeyDown],
|
||||
[
|
||||
handlePromptHistoryKeyDown,
|
||||
handleSelectedSlashSkillKeyDown,
|
||||
handleSkillSuggestionKeyDown,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePromptTextareaChange = useCallback(() => {
|
||||
|
|
@ -1190,10 +1288,108 @@ export function InputBox({
|
|||
promptHistoryDraftRef.current = "";
|
||||
}, [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 =
|
||||
!disabled &&
|
||||
!isWelcomeMode &&
|
||||
!showSkillSuggestions &&
|
||||
!selectedSlashSkill &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0);
|
||||
|
||||
|
|
@ -1458,20 +1654,67 @@ export function InputBox({
|
|||
/>
|
||||
)}
|
||||
</PromptInputHeader>
|
||||
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
|
||||
<PromptInputTextarea
|
||||
className={cn("size-full")}
|
||||
disabled={composerLocked}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={initialValue}
|
||||
onBlur={() => setTextareaFocused(false)}
|
||||
onChange={handlePromptTextareaChange}
|
||||
onFocus={() => setTextareaFocused(true)}
|
||||
onKeyDown={handlePromptTextareaKeyDown}
|
||||
ref={textareaRef}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<div className="min-h-16 w-full min-w-0 px-3 py-3">
|
||||
{selectedSlashSkill ? (
|
||||
<div
|
||||
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"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
focusContentEditableEnd(inlineSkillTextRef.current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SlashSkillChip
|
||||
name={selectedSlashSkill.name}
|
||||
className="mr-2 max-w-[min(11rem,45%)] align-top"
|
||||
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">
|
||||
<PromptInputTools className="min-w-0 flex-1 flex-wrap">
|
||||
{/* TODO: Add more connectors here
|
||||
|
|
@ -1878,6 +2121,7 @@ export function InputBox({
|
|||
|
||||
{isWelcomeMode &&
|
||||
searchParams.get("mode") !== "skill" &&
|
||||
!selectedSlashSkill &&
|
||||
!showSkillSuggestions && (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<SuggestionList onSelectPlaceholder={onSelectPlaceholder} />
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ import {
|
|||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { readReferenceMessageContexts } from "@/core/sidecar";
|
||||
import {
|
||||
parseSlashSkillReference,
|
||||
resolveSlashSkillDisplay,
|
||||
} from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { SafeReasoningContent } from "@/core/streamdown/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -51,6 +56,7 @@ import { WorkspaceChangeBadge } from "../changes";
|
|||
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
|
||||
import { SlashSkillChip } from "../slash-skill-chip";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { createMarkdownLinkComponent } from "./markdown-link";
|
||||
|
|
@ -212,6 +218,42 @@ function MessageImage({
|
|||
|
||||
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_({
|
||||
className,
|
||||
message,
|
||||
|
|
@ -378,9 +420,7 @@ function MessageContent_({
|
|||
{filesList}
|
||||
{contentToDisplay && (
|
||||
<AIElementMessageContent className="w-full max-w-full">
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{contentToDisplay}
|
||||
</div>
|
||||
<HumanMessageText content={contentToDisplay} />
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
</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 "./slash";
|
||||
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";
|
||||
|
||||
type IMEKeyboardEvent = KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>;
|
||||
type IMEKeyboardEvent = KeyboardEvent<HTMLElement>;
|
||||
|
||||
export function isIMEComposing(
|
||||
event: IMEKeyboardEvent,
|
||||
|
|
|
|||
|
|
@ -179,6 +179,29 @@ test.describe("Chat workspace", () => {
|
|||
});
|
||||
|
||||
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);
|
||||
|
|
@ -194,7 +217,18 @@ test.describe("Chat workspace", () => {
|
|||
|
||||
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 }) => {
|
||||
|
|
@ -299,7 +333,10 @@ test.describe("Chat workspace", () => {
|
|||
await textarea.press("ArrowDown");
|
||||
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 ({
|
||||
|
|
|
|||
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