feat(webui): improve slash command actions
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Square,
|
||||
SquarePen,
|
||||
@@ -98,9 +100,11 @@ interface ThreadComposerProps {
|
||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
activity: Activity,
|
||||
"book-open": BookOpen,
|
||||
brain: Brain,
|
||||
"circle-help": CircleHelp,
|
||||
history: History,
|
||||
"rotate-cw": RotateCw,
|
||||
shield: Shield,
|
||||
sparkles: Sparkles,
|
||||
square: Square,
|
||||
"square-pen": SquarePen,
|
||||
@@ -113,7 +117,9 @@ const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "
|
||||
const SLASH_PALETTE_GAP_PX = 8;
|
||||
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
|
||||
const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
|
||||
const SLASH_PALETTE_CHROME_PX = 40;
|
||||
const SLASH_PALETTE_CHROME_PX = 12;
|
||||
const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents";
|
||||
const SLASH_RECENTS_LIMIT = 5;
|
||||
|
||||
type SlashPalettePlacement = "above" | "below";
|
||||
|
||||
@@ -132,10 +138,41 @@ type MentionCandidate =
|
||||
| { kind: "cli"; name: string; app: CliAppInfo }
|
||||
| { kind: "mcp"; name: string; preset: McpPresetInfo };
|
||||
|
||||
interface SlashPaletteCommand extends SlashCommand {
|
||||
detail: string;
|
||||
badge?: string;
|
||||
recent: boolean;
|
||||
}
|
||||
|
||||
function slashCommandI18nKey(command: string): string {
|
||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
||||
}
|
||||
|
||||
function readSlashRecents(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SLASH_RECENTS_STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string").slice(0, SLASH_RECENTS_LIMIT)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function storeSlashRecents(commands: string[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
SLASH_RECENTS_STORAGE_KEY,
|
||||
JSON.stringify(commands.slice(0, SLASH_RECENTS_LIMIT)),
|
||||
);
|
||||
} catch {
|
||||
// localStorage may be unavailable in private contexts; command insertion still works.
|
||||
}
|
||||
}
|
||||
|
||||
function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) {
|
||||
if (!(target instanceof Element) || deltaY === 0) return;
|
||||
let el: HTMLElement | null = target.parentElement;
|
||||
@@ -450,6 +487,7 @@ export function ThreadComposer({
|
||||
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
|
||||
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
||||
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
||||
const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents());
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -534,26 +572,86 @@ export function ThreadComposer({
|
||||
return commandToken.toLowerCase();
|
||||
}, [disabled, slashMenuDismissed, value]);
|
||||
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
const visibleSlashCommands = useMemo(() => {
|
||||
if (!(isStreaming && onStop)) return slashCommands;
|
||||
if (slashCommands.some((command) => command.command === "/stop")) return slashCommands;
|
||||
return [
|
||||
{
|
||||
command: "/stop",
|
||||
title: "Stop current task",
|
||||
description: "Cancel the active agent turn for this chat.",
|
||||
icon: "square",
|
||||
},
|
||||
...slashCommands,
|
||||
];
|
||||
}, [isStreaming, onStop, slashCommands]);
|
||||
|
||||
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
|
||||
if (slashQuery === null) return [];
|
||||
return slashCommands
|
||||
const withDetails = visibleSlashCommands
|
||||
.filter((command) => {
|
||||
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,
|
||||
});
|
||||
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: "",
|
||||
}),
|
||||
title,
|
||||
description,
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(slashQuery);
|
||||
})
|
||||
.map((command) => {
|
||||
const commandKey = slashCommandI18nKey(command.command);
|
||||
const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
|
||||
defaultValue: command.description,
|
||||
});
|
||||
let detail = description;
|
||||
let badge: string | undefined;
|
||||
if (command.command === "/model" && modelLabel) {
|
||||
detail = modelLabel;
|
||||
badge = t("thread.composer.slash.badges.current");
|
||||
} else if (command.command === "/goal") {
|
||||
detail = goalState?.active
|
||||
? t("thread.composer.slash.details.goalActive")
|
||||
: t("thread.composer.slash.details.goalReady");
|
||||
} else if (command.command === "/stop" && isStreaming) {
|
||||
detail = t("thread.composer.slash.details.stopRunning");
|
||||
} else if (command.command === "/history") {
|
||||
detail = t("thread.composer.slash.details.history");
|
||||
}
|
||||
return {
|
||||
...command,
|
||||
detail,
|
||||
badge,
|
||||
recent: recentSlashCommands.includes(command.command),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (isStreaming) {
|
||||
if (a.command === "/stop") return -1;
|
||||
if (b.command === "/stop") return 1;
|
||||
}
|
||||
if (slashQuery !== "") return 0;
|
||||
const aRecent = recentSlashCommands.indexOf(a.command);
|
||||
const bRecent = recentSlashCommands.indexOf(b.command);
|
||||
if (aRecent !== -1 || bRecent !== -1) {
|
||||
if (aRecent === -1) return 1;
|
||||
if (bRecent === -1) return -1;
|
||||
return aRecent - bRecent;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return withDetails
|
||||
.slice(0, 8);
|
||||
}, [slashCommands, slashQuery, t]);
|
||||
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
|
||||
|
||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||
@@ -746,13 +844,30 @@ export function ThreadComposer({
|
||||
|
||||
const chooseSlashCommand = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
const nextRecents = [
|
||||
command.command,
|
||||
...recentSlashCommands.filter((item) => item !== command.command),
|
||||
].slice(0, SLASH_RECENTS_LIMIT);
|
||||
setRecentSlashCommands(nextRecents);
|
||||
storeSlashRecents(nextRecents);
|
||||
|
||||
if (command.command === "/stop" && isStreaming && onStop) {
|
||||
onStop();
|
||||
setValue("");
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
},
|
||||
[resizeTextarea],
|
||||
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
|
||||
);
|
||||
|
||||
const chooseMentionCandidate = useCallback(
|
||||
@@ -1307,12 +1422,12 @@ function ComposerCliMentionOverlay({
|
||||
);
|
||||
}
|
||||
interface SlashCommandPaletteProps {
|
||||
commands: SlashCommand[];
|
||||
commands: SlashPaletteCommand[];
|
||||
selectedIndex: number;
|
||||
layout: SlashPaletteLayout;
|
||||
isHero: boolean;
|
||||
onHover: (index: number) => void;
|
||||
onChoose: (command: SlashCommand) => void;
|
||||
onChoose: (command: SlashPaletteCommand) => void;
|
||||
}
|
||||
|
||||
interface CliAppMentionPaletteProps {
|
||||
@@ -1532,14 +1647,11 @@ function SlashCommandPalette({
|
||||
className={cn(
|
||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
||||
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
|
||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
|
||||
"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="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||
{commands.map((command, index) => {
|
||||
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
|
||||
@@ -1563,49 +1675,42 @@ function SlashCommandPalette({
|
||||
onChoose(command);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||
"flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors",
|
||||
selected
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-foreground/86 hover:bg-accent/55",
|
||||
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
|
||||
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
|
||||
)}
|
||||
>
|
||||
<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",
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors",
|
||||
selected && "text-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 className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 truncate text-[13.5px] font-semibold tracking-normal text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||
{description}
|
||||
<span className="min-w-0 truncate text-[13px] text-muted-foreground">
|
||||
{command.detail || description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 flex shrink-0 items-center gap-1.5">
|
||||
{command.badge || command.recent ? (
|
||||
<span className="hidden rounded-full bg-foreground/[0.055] px-2 py-1 text-[11px] font-medium text-muted-foreground sm:inline-flex">
|
||||
{command.badge ?? t("thread.composer.slash.badges.recent")}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-mono text-[12px] text-muted-foreground/60">
|
||||
{command.argHint ? `${command.command} ${command.argHint}` : command.command}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,20 @@ function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
||||
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
||||
}
|
||||
|
||||
function sameMessageShape(a: UIMessage, b: UIMessage): boolean {
|
||||
return (
|
||||
a.role === b.role
|
||||
&& (a.kind ?? "") === (b.kind ?? "")
|
||||
&& a.content === b.content
|
||||
);
|
||||
}
|
||||
|
||||
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
|
||||
if (current.length === 0 || snapshot.length >= current.length) return false;
|
||||
if (snapshot.length === 0) return true;
|
||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
||||
}
|
||||
|
||||
interface ThreadShellProps {
|
||||
session: ChatSummary | null;
|
||||
title: string;
|
||||
@@ -219,19 +233,28 @@ export function ThreadShell({
|
||||
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
||||
// so rendering converges to the same shape as a manual refresh.
|
||||
setMessages((prev) => {
|
||||
const normalizedHistory = projectWebuiThreadMessages(historical);
|
||||
const keepLiveMessages = (messagesToKeep: UIMessage[]) => {
|
||||
const projected = projectWebuiThreadMessages(messagesToKeep);
|
||||
messageCacheRef.current.set(chatId, projected);
|
||||
return projected;
|
||||
};
|
||||
if (hasNewCanonicalHistory && historical.length > 0) {
|
||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
const normalized = projectWebuiThreadMessages(historical);
|
||||
messageCacheRef.current.set(chatId, normalized);
|
||||
return normalized;
|
||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
}
|
||||
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
|
||||
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
|
||||
if (cached && cached.length > 0) {
|
||||
const normalizedCached = projectWebuiThreadMessages(cached);
|
||||
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
|
||||
return normalizedCached;
|
||||
}
|
||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
const next = projectWebuiThreadMessages(historical);
|
||||
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
|
||||
return next;
|
||||
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, chatId, historical, historyVersion]);
|
||||
|
||||
Reference in New Issue
Block a user