fix(skills): harden slash skill activation across chat channels (#3466)

* support slash skill activation

* format slash skill activation

* Preserve slash skill activation with uploads

* Address slash skill review feedback

* Address slash skill follow-up review

* Fix lazy slash skill storage resolution

* Keep slash skill activation out of system prompt

* Address slash skill review issues

* fix: harden slash skill command handling

* feat(frontend): add slash skill autocomplete

* fix: address slash skill review feedback

* fix: preserve slash skill text for IM uploads
This commit is contained in:
DanielWalnut
2026-06-09 23:07:17 +08:00
committed by GitHub
parent 18bbb82f07
commit 16391e35ab
31 changed files with 2758 additions and 57 deletions
+193 -5
View File
@@ -20,6 +20,7 @@ import {
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
} from "react";
import {
@@ -59,6 +60,8 @@ import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import type { Skill } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
@@ -86,6 +89,48 @@ import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
const MAX_SKILL_SUGGESTIONS = 6;
function getLeadingSlashSkillQuery(value: string): string | null {
if (!value.startsWith("/")) {
return null;
}
const query = value.slice(1);
if (query.includes("/") || /\s/.test(query)) {
return null;
}
return query;
}
function getMatchingSkillSuggestions(skills: Skill[], query: string): Skill[] {
const normalizedQuery = query.toLowerCase();
return skills
.map((skill, index) => ({
skill,
index,
name: skill.name.toLowerCase(),
}))
.filter(({ skill, name }) => {
if (!skill.enabled) {
return false;
}
return !normalizedQuery || name.includes(normalizedQuery);
})
.sort((a, b) => {
const aStartsWith = a.name.startsWith(normalizedQuery);
const bStartsWith = b.name.startsWith(normalizedQuery);
if (aStartsWith !== bStartsWith) {
return aStartsWith ? -1 : 1;
}
return a.index - b.index;
})
.slice(0, MAX_SKILL_SUGGESTIONS)
.map(({ skill }) => skill);
}
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
@@ -153,11 +198,17 @@ export function InputBox({
const { models } = useModels();
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const { skills } = useSkills();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
const [textareaFocused, setTextareaFocused] = useState(false);
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
useState<string | null>(null);
const lastGeneratedForAiIdRef = useRef<string | null>(null);
const wasStreamingRef = useRef(false);
const messagesRef = useRef(thread.messages);
@@ -347,9 +398,98 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const slashSkillQuery = useMemo(
() => getLeadingSlashSkillQuery(textInput.value ?? ""),
[textInput.value],
);
const skillSuggestions = useMemo(
() =>
slashSkillQuery === null
? []
: getMatchingSkillSuggestions(skills, slashSkillQuery),
[skills, slashSkillQuery],
);
const showSkillSuggestions =
!disabled &&
textareaFocused &&
slashSkillQuery !== null &&
skillSuggestions.length > 0 &&
dismissedSkillSuggestionValue !== textInput.value;
useEffect(() => {
setSkillSuggestionIndex(0);
}, [slashSkillQuery, skillSuggestions.length]);
const applySkillSuggestion = useCallback(
(skill: Skill) => {
const nextValue = `/${skill.name} `;
textInput.setInput(nextValue);
setDismissedSkillSuggestionValue(nextValue);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.focus();
textarea.setSelectionRange(nextValue.length, nextValue.length);
});
},
[textInput],
);
const handleSkillSuggestionKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (!showSkillSuggestions) {
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setSkillSuggestionIndex(
(index) => (index + 1) % skillSuggestions.length,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSkillSuggestionIndex(
(index) =>
(index - 1 + skillSuggestions.length) % skillSuggestions.length,
);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
if (event.shiftKey) {
return;
}
event.preventDefault();
const selectedSkill = skillSuggestions[skillSuggestionIndex];
if (selectedSkill) {
applySkillSuggestion(selectedSkill);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setDismissedSkillSuggestionValue(textInput.value);
}
},
[
applySkillSuggestion,
showSkillSuggestions,
skillSuggestionIndex,
skillSuggestions,
textInput.value,
],
);
const showFollowups =
!disabled &&
!isWelcomeMode &&
!showSkillSuggestions &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
@@ -478,6 +618,48 @@ export function InputBox({
</div>
</div>
)}
{showSkillSuggestions && (
<div className="absolute right-0 bottom-full left-0 z-40 mb-2 px-1">
<div
aria-label="Skill suggestions"
className="bg-popover/95 text-popover-foreground border-border max-h-72 overflow-y-auto rounded-xl border p-1 shadow-lg backdrop-blur-sm"
role="listbox"
>
{skillSuggestions.map((skill, index) => {
const selected = index === skillSuggestionIndex;
return (
<button
aria-selected={selected}
className={cn(
"flex min-h-12 w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors",
selected
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/70 hover:text-accent-foreground",
)}
key={skill.name}
onClick={() => applySkillSuggestion(skill)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setSkillSuggestionIndex(index)}
role="option"
type="button"
>
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
/{skill.name}
</span>
{skill.description && (
<span className="text-muted-foreground block truncate text-xs">
{skill.description}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
)}
<PromptInput
className={cn(
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
@@ -506,6 +688,10 @@ export function InputBox({
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
defaultValue={initialValue}
onBlur={() => setTextareaFocused(false)}
onFocus={() => setTextareaFocused(true)}
onKeyDown={handleSkillSuggestionKeyDown}
ref={textareaRef}
/>
</PromptInputBody>
<PromptInputFooter className="flex">
@@ -860,11 +1046,13 @@ export function InputBox({
)}
</PromptInput>
{isWelcomeMode && searchParams.get("mode") !== "skill" && (
<div className="flex items-center justify-center pt-2">
<SuggestionList />
</div>
)}
{isWelcomeMode &&
searchParams.get("mode") !== "skill" &&
!showSkillSuggestions && (
<div className="flex items-center justify-center pt-2">
<SuggestionList />
</div>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>