feat(webui): add localized slash commands
Add a session-scoped slash command palette sourced from backend command metadata, and keep welcome-page quick actions localized across all WebUI languages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Cursor
parent
49c07aa45a
commit
ac18a8baad
@@ -7,11 +7,21 @@ import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
BookOpen,
|
||||
CircleHelp,
|
||||
History,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Sparkles,
|
||||
Square,
|
||||
SquarePen,
|
||||
Undo2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -24,6 +34,7 @@ import {
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import type { SendImage } from "@/hooks/useNanobotStream";
|
||||
import type { SlashCommand } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||
@@ -43,6 +54,23 @@ interface ThreadComposerProps {
|
||||
isStreaming?: boolean;
|
||||
modelLabel?: string | null;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
}
|
||||
|
||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
activity: Activity,
|
||||
"book-open": BookOpen,
|
||||
"circle-help": CircleHelp,
|
||||
history: History,
|
||||
"rotate-cw": RotateCw,
|
||||
sparkles: Sparkles,
|
||||
square: Square,
|
||||
"square-pen": SquarePen,
|
||||
"undo-2": Undo2,
|
||||
};
|
||||
|
||||
function slashCommandI18nKey(command: string): string {
|
||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
||||
}
|
||||
|
||||
export function ThreadComposer({
|
||||
@@ -52,10 +80,13 @@ export function ThreadComposer({
|
||||
isStreaming = false,
|
||||
modelLabel = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
@@ -119,6 +150,66 @@ export function ThreadComposer({
|
||||
&& !hasErrors
|
||||
&& (value.trim().length > 0 || readyImages.length > 0);
|
||||
|
||||
const slashQuery = useMemo(() => {
|
||||
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
||||
const commandToken = value.slice(1);
|
||||
if (/\s/.test(commandToken)) return null;
|
||||
return commandToken.toLowerCase();
|
||||
}, [disabled, slashMenuDismissed, value]);
|
||||
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (slashQuery === null) return [];
|
||||
return slashCommands
|
||||
.filter((command) => {
|
||||
const haystack = [
|
||||
command.command,
|
||||
command.title,
|
||||
command.description,
|
||||
command.argHint ?? "",
|
||||
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
|
||||
defaultValue: "",
|
||||
}),
|
||||
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, {
|
||||
defaultValue: "",
|
||||
}),
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(slashQuery);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [slashCommands, slashQuery, t]);
|
||||
|
||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCommandIndex(0);
|
||||
}, [slashQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCommandIndex >= filteredSlashCommands.length) {
|
||||
setSelectedCommandIndex(0);
|
||||
}
|
||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||
|
||||
const resizeTextarea = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
||||
el.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const chooseSlashCommand = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||
setSlashMenuDismissed(true);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
},
|
||||
[resizeTextarea],
|
||||
);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!canSend) return;
|
||||
const trimmed = value.trim();
|
||||
@@ -142,16 +233,35 @@ export function ThreadComposer({
|
||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||
// preview here without affecting the rendered message.
|
||||
clear();
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = "auto";
|
||||
el.focus();
|
||||
}
|
||||
});
|
||||
}, [canSend, clear, onSend, readyImages, value]);
|
||||
setSlashMenuDismissed(false);
|
||||
resizeTextarea();
|
||||
}, [canSend, clear, onSend, readyImages, resizeTextarea, value]);
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showSlashMenu) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedCommandIndex(
|
||||
(idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setSlashMenuDismissed(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
@@ -213,8 +323,17 @@ export function ThreadComposer({
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
||||
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
||||
>
|
||||
{showSlashMenu ? (
|
||||
<SlashCommandPalette
|
||||
commands={filteredSlashCommands}
|
||||
selectedIndex={selectedCommandIndex}
|
||||
isHero={isHero}
|
||||
onHover={setSelectedCommandIndex}
|
||||
onChoose={chooseSlashCommand}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
|
||||
@@ -257,7 +376,10 @@ export function ThreadComposer({
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setSlashMenuDismissed(false);
|
||||
}}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
@@ -367,6 +489,106 @@ export function ThreadComposer({
|
||||
);
|
||||
}
|
||||
|
||||
interface SlashCommandPaletteProps {
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
isHero: boolean;
|
||||
onHover: (index: number) => void;
|
||||
onChoose: (command: SlashCommand) => void;
|
||||
}
|
||||
|
||||
function SlashCommandPalette({
|
||||
commands,
|
||||
selectedIndex,
|
||||
isHero,
|
||||
onHover,
|
||||
onChoose,
|
||||
}: SlashCommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t("thread.composer.slash.ariaLabel")}
|
||||
className={cn(
|
||||
"absolute bottom-full left-1/2 z-30 mb-2 max-h-[22rem] w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
||||
"border-border/65 bg-popover/98 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur",
|
||||
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||
)}
|
||||
>
|
||||
<div className="px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
|
||||
{t("thread.composer.slash.label")}
|
||||
</div>
|
||||
<div className="max-h-[18rem] overflow-y-auto pr-0.5">
|
||||
{commands.map((command, index) => {
|
||||
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
|
||||
const selected = index === selectedIndex;
|
||||
const commandKey = slashCommandI18nKey(command.command);
|
||||
const title = t(`thread.composer.slash.commands.${commandKey}.title`, {
|
||||
defaultValue: command.title,
|
||||
});
|
||||
const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
|
||||
defaultValue: command.description,
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={command.command}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChoose(command);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||
selected
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-foreground/86 hover:bg-accent/55",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border",
|
||||
selected
|
||||
? "border-primary/25 bg-primary/12 text-primary"
|
||||
: "border-border/65 bg-muted/45 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="font-mono text-[13px] font-semibold text-foreground">
|
||||
{command.command}
|
||||
</span>
|
||||
{command.argHint ? (
|
||||
<span className="font-mono text-[12px] text-muted-foreground">
|
||||
{command.argHint}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate text-[13px] font-medium">
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
|
||||
<span>{t("thread.composer.slash.navigateHint")}</span>
|
||||
<span>{t("thread.composer.slash.selectHint")}</span>
|
||||
<span>{t("thread.composer.slash.closeHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttachmentChipProps {
|
||||
image: AttachedImage;
|
||||
labelRemove: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
@@ -17,7 +17,8 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
import { useSessionHistory } from "@/hooks/useSessions";
|
||||
import type { ChatSummary, UIMessage } from "@/lib/types";
|
||||
import { listSlashCommands } from "@/lib/api";
|
||||
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
interface ThreadShellProps {
|
||||
@@ -66,8 +67,9 @@ export function ThreadShell({
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
|
||||
const { client, modelName } = useClient();
|
||||
const { client, modelName, token } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const pendingFirstRef = useRef<string | null>(null);
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
const lastCachedChatIdRef = useRef<string | null>(null);
|
||||
@@ -116,17 +118,24 @@ export function ThreadShell({
|
||||
setMessages(historical);
|
||||
}, [chatId, historical, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
useLayoutEffect(() => {
|
||||
if (!chatId) {
|
||||
lastCachedChatIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (loading) return;
|
||||
// Skip the first cache write after a chat switch. During that render,
|
||||
// `messages` can still belong to the previous chat until the stream hook
|
||||
// resets its local state for the new session.
|
||||
if (lastCachedChatIdRef.current !== chatId) {
|
||||
lastCachedChatIdRef.current = chatId;
|
||||
if (messages.length > 0) {
|
||||
messageCacheRef.current.set(chatId, messages);
|
||||
}
|
||||
return;
|
||||
}
|
||||
messageCacheRef.current.set(chatId, messages);
|
||||
}, [chatId, messages]);
|
||||
}, [chatId, loading, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
@@ -146,6 +155,21 @@ export function ThreadShell({
|
||||
setBooting(false);
|
||||
}, [chatId, client, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await listSlashCommands(token);
|
||||
if (!cancelled) setSlashCommands(commands);
|
||||
} catch {
|
||||
if (!cancelled) setSlashCommands([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const handleWelcomeSend = useCallback(
|
||||
async (content: string) => {
|
||||
if (booting) return;
|
||||
@@ -222,6 +246,7 @@ export function ThreadShell({
|
||||
}
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
/>
|
||||
) : (
|
||||
<ThreadComposer
|
||||
|
||||
Reference in New Issue
Block a user