mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 14:23:58 +00:00
feat(webui): improve slash command actions
This commit is contained in:
parent
3f0098839e
commit
92915ea424
@ -21,6 +21,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Brain,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@ -30,6 +31,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
@ -98,9 +100,11 @@ interface ThreadComposerProps {
|
|||||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
"book-open": BookOpen,
|
"book-open": BookOpen,
|
||||||
|
brain: Brain,
|
||||||
"circle-help": CircleHelp,
|
"circle-help": CircleHelp,
|
||||||
history: History,
|
history: History,
|
||||||
"rotate-cw": RotateCw,
|
"rotate-cw": RotateCw,
|
||||||
|
shield: Shield,
|
||||||
sparkles: Sparkles,
|
sparkles: Sparkles,
|
||||||
square: Square,
|
square: Square,
|
||||||
"square-pen": SquarePen,
|
"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_GAP_PX = 8;
|
||||||
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
|
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
|
||||||
const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
|
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";
|
type SlashPalettePlacement = "above" | "below";
|
||||||
|
|
||||||
@ -132,10 +138,41 @@ type MentionCandidate =
|
|||||||
| { kind: "cli"; name: string; app: CliAppInfo }
|
| { kind: "cli"; name: string; app: CliAppInfo }
|
||||||
| { kind: "mcp"; name: string; preset: McpPresetInfo };
|
| { kind: "mcp"; name: string; preset: McpPresetInfo };
|
||||||
|
|
||||||
|
interface SlashPaletteCommand extends SlashCommand {
|
||||||
|
detail: string;
|
||||||
|
badge?: string;
|
||||||
|
recent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function slashCommandI18nKey(command: string): string {
|
function slashCommandI18nKey(command: string): string {
|
||||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
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) {
|
function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) {
|
||||||
if (!(target instanceof Element) || deltaY === 0) return;
|
if (!(target instanceof Element) || deltaY === 0) return;
|
||||||
let el: HTMLElement | null = target.parentElement;
|
let el: HTMLElement | null = target.parentElement;
|
||||||
@ -450,6 +487,7 @@ export function ThreadComposer({
|
|||||||
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
|
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
|
||||||
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
||||||
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
||||||
|
const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents());
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -534,26 +572,86 @@ export function ThreadComposer({
|
|||||||
return commandToken.toLowerCase();
|
return commandToken.toLowerCase();
|
||||||
}, [disabled, slashMenuDismissed, value]);
|
}, [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 [];
|
if (slashQuery === null) return [];
|
||||||
return slashCommands
|
const withDetails = visibleSlashCommands
|
||||||
.filter((command) => {
|
.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 = [
|
const haystack = [
|
||||||
command.command,
|
command.command,
|
||||||
command.title,
|
command.title,
|
||||||
command.description,
|
command.description,
|
||||||
command.argHint ?? "",
|
command.argHint ?? "",
|
||||||
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
|
title,
|
||||||
defaultValue: "",
|
description,
|
||||||
}),
|
|
||||||
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, {
|
|
||||||
defaultValue: "",
|
|
||||||
}),
|
|
||||||
].join(" ").toLowerCase();
|
].join(" ").toLowerCase();
|
||||||
return haystack.includes(slashQuery);
|
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);
|
.slice(0, 8);
|
||||||
}, [slashCommands, slashQuery, t]);
|
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
|
||||||
|
|
||||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||||
@ -746,13 +844,30 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const chooseSlashCommand = useCallback(
|
const chooseSlashCommand = useCallback(
|
||||||
(command: SlashCommand) => {
|
(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);
|
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||||
setSlashMenuDismissed(true);
|
setSlashMenuDismissed(true);
|
||||||
setCliAppMenuDismissed(false);
|
setCliAppMenuDismissed(false);
|
||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
resizeTextarea();
|
resizeTextarea();
|
||||||
},
|
},
|
||||||
[resizeTextarea],
|
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chooseMentionCandidate = useCallback(
|
const chooseMentionCandidate = useCallback(
|
||||||
@ -1307,12 +1422,12 @@ function ComposerCliMentionOverlay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
interface SlashCommandPaletteProps {
|
interface SlashCommandPaletteProps {
|
||||||
commands: SlashCommand[];
|
commands: SlashPaletteCommand[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
layout: SlashPaletteLayout;
|
layout: SlashPaletteLayout;
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
onHover: (index: number) => void;
|
onHover: (index: number) => void;
|
||||||
onChoose: (command: SlashCommand) => void;
|
onChoose: (command: SlashPaletteCommand) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CliAppMentionPaletteProps {
|
interface CliAppMentionPaletteProps {
|
||||||
@ -1532,14 +1647,11 @@ function SlashCommandPalette({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
"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",
|
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)]",
|
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
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 }}>
|
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||||
{commands.map((command, index) => {
|
{commands.map((command, index) => {
|
||||||
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
|
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
|
||||||
@ -1563,49 +1675,42 @@ function SlashCommandPalette({
|
|||||||
onChoose(command);
|
onChoose(command);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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
|
selected
|
||||||
? "bg-primary/10 text-foreground"
|
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
|
||||||
: "text-foreground/86 hover:bg-accent/55",
|
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border",
|
"flex h-7 w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors",
|
||||||
selected
|
selected && "text-foreground",
|
||||||
? "border-primary/25 bg-primary/12 text-primary"
|
|
||||||
: "border-border/65 bg-muted/45 text-muted-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||||
<span className="flex min-w-0 items-baseline gap-2">
|
<span className="min-w-0 truncate text-[13.5px] font-semibold tracking-normal text-foreground">
|
||||||
<span className="font-mono text-[13px] font-semibold text-foreground">
|
{title}
|
||||||
{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>
|
||||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
<span className="min-w-0 truncate text-[13px] text-muted-foreground">
|
||||||
{description}
|
{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>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,20 @@ function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
|||||||
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
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 {
|
interface ThreadShellProps {
|
||||||
session: ChatSummary | null;
|
session: ChatSummary | null;
|
||||||
title: string;
|
title: string;
|
||||||
@ -219,19 +233,28 @@ export function ThreadShell({
|
|||||||
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
||||||
// so rendering converges to the same shape as a manual refresh.
|
// so rendering converges to the same shape as a manual refresh.
|
||||||
setMessages((prev) => {
|
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 (hasNewCanonicalHistory && historical.length > 0) {
|
||||||
|
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||||
const normalized = projectWebuiThreadMessages(historical);
|
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||||
messageCacheRef.current.set(chatId, normalized);
|
return normalizedHistory;
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
|
if (cached && cached.length > 0) {
|
||||||
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
|
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);
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||||
const next = projectWebuiThreadMessages(historical);
|
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
||||||
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
|
return normalizedHistory;
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [loading, chatId, historical, historyVersion]);
|
}, [loading, chatId, historical, historyVersion]);
|
||||||
|
|||||||
@ -534,6 +534,16 @@
|
|||||||
"navigateHint": "↑↓ Navigate",
|
"navigateHint": "↑↓ Navigate",
|
||||||
"selectHint": "Enter/Tab Select",
|
"selectHint": "Enter/Tab Select",
|
||||||
"closeHint": "Esc Close",
|
"closeHint": "Esc Close",
|
||||||
|
"badges": {
|
||||||
|
"current": "Current",
|
||||||
|
"recent": "Recent"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "Goal is running",
|
||||||
|
"goalReady": "Start a sustained objective",
|
||||||
|
"history": "Show recent messages",
|
||||||
|
"stopRunning": "Running now"
|
||||||
|
},
|
||||||
"commands": {
|
"commands": {
|
||||||
"new": {
|
"new": {
|
||||||
"title": "New chat",
|
"title": "New chat",
|
||||||
@ -551,6 +561,10 @@
|
|||||||
"title": "Show status",
|
"title": "Show status",
|
||||||
"description": "Display runtime, provider, and channel status."
|
"description": "Display runtime, provider, and channel status."
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "Model",
|
||||||
|
"description": "Show or switch the active model preset."
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Show conversation history",
|
"title": "Show conversation history",
|
||||||
"description": "Print the last N persisted conversation messages."
|
"description": "Print the last N persisted conversation messages."
|
||||||
@ -574,6 +588,10 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Show help",
|
"title": "Show help",
|
||||||
"description": "List available slash commands."
|
"description": "List available slash commands."
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "Pairing",
|
||||||
|
"description": "Manage pairing requests."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -551,6 +551,10 @@
|
|||||||
"title": "Mostrar estado",
|
"title": "Mostrar estado",
|
||||||
"description": "Muestra el estado del runtime, provider y channels."
|
"description": "Muestra el estado del runtime, provider y channels."
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "Modelo",
|
||||||
|
"description": "Muestra o cambia el preset de modelo activo."
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Mostrar historial",
|
"title": "Mostrar historial",
|
||||||
"description": "Imprime los últimos N mensajes persistidos de la conversación."
|
"description": "Imprime los últimos N mensajes persistidos de la conversación."
|
||||||
@ -574,7 +578,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Mostrar ayuda",
|
"title": "Mostrar ayuda",
|
||||||
"description": "Lista los comandos slash disponibles."
|
"description": "Lista los comandos slash disponibles."
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "Emparejamiento",
|
||||||
|
"description": "Gestiona solicitudes de emparejamiento."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "Actual",
|
||||||
|
"recent": "Reciente"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "El objetivo está en curso",
|
||||||
|
"goalReady": "Iniciar un objetivo sostenido",
|
||||||
|
"history": "Mostrar mensajes recientes",
|
||||||
|
"stopRunning": "En ejecución"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "Procesando…",
|
"encoding": "Procesando…",
|
||||||
|
|||||||
@ -551,6 +551,10 @@
|
|||||||
"title": "Afficher l’état",
|
"title": "Afficher l’état",
|
||||||
"description": "Afficher l’état du runtime, du provider et des channels."
|
"description": "Afficher l’état du runtime, du provider et des channels."
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "Modèle",
|
||||||
|
"description": "Afficher ou changer le préréglage de modèle actif."
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Afficher l’historique",
|
"title": "Afficher l’historique",
|
||||||
"description": "Afficher les N derniers messages persistés de la conversation."
|
"description": "Afficher les N derniers messages persistés de la conversation."
|
||||||
@ -574,7 +578,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Afficher l’aide",
|
"title": "Afficher l’aide",
|
||||||
"description": "Lister les commandes slash disponibles."
|
"description": "Lister les commandes slash disponibles."
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "Appairage",
|
||||||
|
"description": "Gérer les demandes d’appairage."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "Actuel",
|
||||||
|
"recent": "Récent"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "L’objectif est en cours",
|
||||||
|
"goalReady": "Démarrer un objectif durable",
|
||||||
|
"history": "Afficher les messages récents",
|
||||||
|
"stopRunning": "En cours"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "Traitement…",
|
"encoding": "Traitement…",
|
||||||
|
|||||||
@ -551,6 +551,10 @@
|
|||||||
"title": "Tampilkan status",
|
"title": "Tampilkan status",
|
||||||
"description": "Tampilkan status runtime, provider, dan channel."
|
"description": "Tampilkan status runtime, provider, dan channel."
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "Model",
|
||||||
|
"description": "Tampilkan atau ganti preset model aktif."
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Tampilkan riwayat",
|
"title": "Tampilkan riwayat",
|
||||||
"description": "Cetak N pesan percakapan tersimpan terbaru."
|
"description": "Cetak N pesan percakapan tersimpan terbaru."
|
||||||
@ -574,7 +578,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Tampilkan bantuan",
|
"title": "Tampilkan bantuan",
|
||||||
"description": "Daftar perintah slash yang tersedia."
|
"description": "Daftar perintah slash yang tersedia."
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "Pemasangan",
|
||||||
|
"description": "Kelola permintaan pemasangan."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "Saat ini",
|
||||||
|
"recent": "Terbaru"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "Tujuan sedang berjalan",
|
||||||
|
"goalReady": "Mulai tujuan berkelanjutan",
|
||||||
|
"history": "Tampilkan pesan terbaru",
|
||||||
|
"stopRunning": "Sedang berjalan"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "Memproses…",
|
"encoding": "Memproses…",
|
||||||
|
|||||||
@ -551,6 +551,10 @@
|
|||||||
"title": "ステータスを表示",
|
"title": "ステータスを表示",
|
||||||
"description": "ランタイム、provider、channel の状態を表示します。"
|
"description": "ランタイム、provider、channel の状態を表示します。"
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "モデル",
|
||||||
|
"description": "有効なモデルプリセットを表示または切り替えます。"
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "会話履歴を表示",
|
"title": "会話履歴を表示",
|
||||||
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
|
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
|
||||||
@ -574,7 +578,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "ヘルプを表示",
|
"title": "ヘルプを表示",
|
||||||
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "ペアリング",
|
||||||
|
"description": "ペアリングリクエストを管理します。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "現在",
|
||||||
|
"recent": "最近"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "目標が実行中",
|
||||||
|
"goalReady": "継続的な目標を開始",
|
||||||
|
"history": "最近のメッセージを表示",
|
||||||
|
"stopRunning": "実行中"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "処理中…",
|
"encoding": "処理中…",
|
||||||
|
|||||||
@ -551,6 +551,10 @@
|
|||||||
"title": "상태 보기",
|
"title": "상태 보기",
|
||||||
"description": "런타임, provider, channel 상태를 표시합니다."
|
"description": "런타임, provider, channel 상태를 표시합니다."
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "모델",
|
||||||
|
"description": "활성 모델 프리셋을 보거나 전환합니다."
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "대화 기록 보기",
|
"title": "대화 기록 보기",
|
||||||
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
|
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
|
||||||
@ -574,7 +578,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "도움말 보기",
|
"title": "도움말 보기",
|
||||||
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "페어링",
|
||||||
|
"description": "페어링 요청을 관리합니다."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "현재",
|
||||||
|
"recent": "최근"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "목표 실행 중",
|
||||||
|
"goalReady": "지속 목표 시작",
|
||||||
|
"history": "최근 메시지 보기",
|
||||||
|
"stopRunning": "실행 중"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "처리 중…",
|
"encoding": "처리 중…",
|
||||||
|
|||||||
@ -551,6 +551,10 @@
|
|||||||
"title": "Hiển thị trạng thái",
|
"title": "Hiển thị trạng thái",
|
||||||
"description": "Hiển thị trạng thái runtime, provider và channel."
|
"description": "Hiển thị trạng thái runtime, provider và channel."
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "Mô hình",
|
||||||
|
"description": "Hiển thị hoặc chuyển preset mô hình đang hoạt động."
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Hiển thị lịch sử",
|
"title": "Hiển thị lịch sử",
|
||||||
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
|
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
|
||||||
@ -574,7 +578,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "Hiển thị trợ giúp",
|
"title": "Hiển thị trợ giúp",
|
||||||
"description": "Liệt kê các lệnh slash có sẵn."
|
"description": "Liệt kê các lệnh slash có sẵn."
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "Ghép nối",
|
||||||
|
"description": "Quản lý yêu cầu ghép nối."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "Hiện tại",
|
||||||
|
"recent": "Gần đây"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "Mục tiêu đang chạy",
|
||||||
|
"goalReady": "Bắt đầu mục tiêu duy trì",
|
||||||
|
"history": "Hiển thị tin nhắn gần đây",
|
||||||
|
"stopRunning": "Đang chạy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "Đang xử lý…",
|
"encoding": "Đang xử lý…",
|
||||||
|
|||||||
@ -533,6 +533,16 @@
|
|||||||
"navigateHint": "↑↓ 选择",
|
"navigateHint": "↑↓ 选择",
|
||||||
"selectHint": "Enter/Tab 填入",
|
"selectHint": "Enter/Tab 填入",
|
||||||
"closeHint": "Esc 关闭",
|
"closeHint": "Esc 关闭",
|
||||||
|
"badges": {
|
||||||
|
"current": "当前",
|
||||||
|
"recent": "最近"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "目标正在运行",
|
||||||
|
"goalReady": "开始一个持续目标",
|
||||||
|
"history": "查看最近消息",
|
||||||
|
"stopRunning": "正在运行"
|
||||||
|
},
|
||||||
"commands": {
|
"commands": {
|
||||||
"new": {
|
"new": {
|
||||||
"title": "新建对话",
|
"title": "新建对话",
|
||||||
@ -550,6 +560,10 @@
|
|||||||
"title": "查看状态",
|
"title": "查看状态",
|
||||||
"description": "显示运行时、服务商和通道状态。"
|
"description": "显示运行时、服务商和通道状态。"
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "模型",
|
||||||
|
"description": "查看或切换当前模型预设。"
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "查看对话历史",
|
"title": "查看对话历史",
|
||||||
"description": "打印最近 N 条已持久化的对话消息。"
|
"description": "打印最近 N 条已持久化的对话消息。"
|
||||||
@ -573,6 +587,10 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "查看帮助",
|
"title": "查看帮助",
|
||||||
"description": "列出可用的斜杠命令。"
|
"description": "列出可用的斜杠命令。"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "配对",
|
||||||
|
"description": "管理配对请求。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -551,6 +551,10 @@
|
|||||||
"title": "查看狀態",
|
"title": "查看狀態",
|
||||||
"description": "顯示執行環境、provider 和 channel 狀態。"
|
"description": "顯示執行環境、provider 和 channel 狀態。"
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"title": "模型",
|
||||||
|
"description": "查看或切換目前模型預設。"
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "查看對話歷史",
|
"title": "查看對話歷史",
|
||||||
"description": "列印最近 N 則已持久化的對話訊息。"
|
"description": "列印最近 N 則已持久化的對話訊息。"
|
||||||
@ -574,7 +578,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"title": "查看說明",
|
"title": "查看說明",
|
||||||
"description": "列出可用的斜線命令。"
|
"description": "列出可用的斜線命令。"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"title": "配對",
|
||||||
|
"description": "管理配對請求。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "目前",
|
||||||
|
"recent": "最近"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"goalActive": "目標正在執行",
|
||||||
|
"goalReady": "開始一個持續目標",
|
||||||
|
"history": "查看最近訊息",
|
||||||
|
"stopRunning": "正在執行"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encoding": "處理中…",
|
"encoding": "處理中…",
|
||||||
|
|||||||
@ -8,6 +8,20 @@ import { resources } from "@/i18n";
|
|||||||
|
|
||||||
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
|
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
|
||||||
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
|
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
|
||||||
|
const SLASH_COMMAND_KEYS = [
|
||||||
|
"new",
|
||||||
|
"stop",
|
||||||
|
"restart",
|
||||||
|
"status",
|
||||||
|
"model",
|
||||||
|
"history",
|
||||||
|
"dream",
|
||||||
|
"dream_log",
|
||||||
|
"dream_restore",
|
||||||
|
"goal",
|
||||||
|
"help",
|
||||||
|
"pairing",
|
||||||
|
];
|
||||||
const SETTINGS_NAV_KEYS = [
|
const SETTINGS_NAV_KEYS = [
|
||||||
"overview",
|
"overview",
|
||||||
"appearance",
|
"appearance",
|
||||||
@ -18,6 +32,33 @@ const SETTINGS_NAV_KEYS = [
|
|||||||
"advanced",
|
"advanced",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenResource(value: unknown, prefix = ""): Map<string, unknown> {
|
||||||
|
const out = new Map<string, unknown>();
|
||||||
|
if (!isRecord(value)) return out;
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (isRecord(child)) {
|
||||||
|
for (const [childPath, childValue] of flattenResource(child, path)) {
|
||||||
|
out.set(childPath, childValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.set(path, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolationKeys(value: unknown): string[] {
|
||||||
|
if (typeof value !== "string") return [];
|
||||||
|
return Array.from(value.matchAll(/{{\s*([\w.-]+)\s*}}/g))
|
||||||
|
.map((match) => match[1])
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
describe("webui i18n", () => {
|
describe("webui i18n", () => {
|
||||||
it("switches UI copy and document locale through the language switcher", async () => {
|
it("switches UI copy and document locale through the language switcher", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@ -72,6 +113,46 @@ describe("webui i18n", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps every locale aligned with the English resource shape", () => {
|
||||||
|
const reference = flattenResource(resources.en.common);
|
||||||
|
for (const [locale, resource] of Object.entries(resources)) {
|
||||||
|
if (locale === "en") continue;
|
||||||
|
const current = flattenResource(resource.common);
|
||||||
|
const missing = Array.from(reference.keys()).filter((key) => !current.has(key));
|
||||||
|
const extra = Array.from(current.keys()).filter((key) => !reference.has(key));
|
||||||
|
const interpolationMismatches = Array.from(reference.entries())
|
||||||
|
.filter(([key]) => current.has(key))
|
||||||
|
.filter(([key, value]) =>
|
||||||
|
interpolationKeys(value).join(",") !== interpolationKeys(current.get(key)).join(",")
|
||||||
|
)
|
||||||
|
.map(([key]) => key);
|
||||||
|
|
||||||
|
expect({ locale, missing, extra, interpolationMismatches }).toEqual({
|
||||||
|
locale,
|
||||||
|
missing: [],
|
||||||
|
extra: [],
|
||||||
|
interpolationMismatches: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps slash commands localized for every registered locale", () => {
|
||||||
|
for (const resource of Object.values(resources)) {
|
||||||
|
const slash = resource.common.thread.composer.slash;
|
||||||
|
expect(slash.badges.current).toBeTruthy();
|
||||||
|
expect(slash.badges.recent).toBeTruthy();
|
||||||
|
expect(slash.details.goalActive).toBeTruthy();
|
||||||
|
expect(slash.details.goalReady).toBeTruthy();
|
||||||
|
expect(slash.details.history).toBeTruthy();
|
||||||
|
expect(slash.details.stopRunning).toBeTruthy();
|
||||||
|
for (const key of SLASH_COMMAND_KEYS) {
|
||||||
|
const command = slash.commands[key as keyof typeof slash.commands];
|
||||||
|
expect(command.title).toBeTruthy();
|
||||||
|
expect(command.description).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps settings navigation localized for every registered locale", () => {
|
it("keeps settings navigation localized for every registered locale", () => {
|
||||||
for (const resource of Object.values(resources)) {
|
for (const resource of Object.values(resources)) {
|
||||||
const common = resource.common;
|
const common = resource.common;
|
||||||
|
|||||||
@ -115,6 +115,7 @@ const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
Object.defineProperty(window, "innerHeight", {
|
Object.defineProperty(window, "innerHeight", {
|
||||||
value: ORIGINAL_INNER_HEIGHT,
|
value: ORIGINAL_INNER_HEIGHT,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@ -258,6 +259,80 @@ describe("ThreadComposer", () => {
|
|||||||
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders slash commands as direct actions with current status", () => {
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
modelLabel="deepseek-v4-pro"
|
||||||
|
slashCommands={[
|
||||||
|
{
|
||||||
|
command: "/model",
|
||||||
|
title: "Switch model preset",
|
||||||
|
description: "Show or switch the active model preset.",
|
||||||
|
icon: "brain",
|
||||||
|
argHint: "[preset]",
|
||||||
|
},
|
||||||
|
COMMANDS[1],
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||||
|
target: { value: "/" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("option", { name: /Model deepseek-v4-pro/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Current")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/model [preset]")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes stop as an immediate slash action while streaming", () => {
|
||||||
|
const onStop = vi.fn();
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
onStop={onStop}
|
||||||
|
isStreaming
|
||||||
|
placeholder="Type your message..."
|
||||||
|
slashCommands={[COMMANDS[1]]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "/" } });
|
||||||
|
|
||||||
|
expect(screen.getByRole("option", { name: /Stop current task/i })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(onStop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders recent slash commands first for the blank slash menu", () => {
|
||||||
|
window.localStorage.setItem("nanobot.webui.slashCommandRecents", JSON.stringify(["/history"]));
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
slashCommands={COMMANDS}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||||
|
target: { value: "/" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens the CLI app mention palette and inserts the selected app", () => {
|
it("opens the CLI app mention palette and inserts the selected app", () => {
|
||||||
const onSend = vi.fn();
|
const onSend = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@ -340,6 +340,84 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
|
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a live first command reply when the initial history snapshot is stale", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
|
||||||
|
let resolveThread:
|
||||||
|
| ((value: { ok: boolean; status: number; json: () => Promise<unknown> }) => void)
|
||||||
|
| null = null;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("websocket%3Achat-new/webui-thread")) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveThread = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={null}
|
||||||
|
title="nanobot"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onCreateChat={onCreateChat}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||||
|
target: { value: "/model" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-new")}
|
||||||
|
title="Chat chat-new"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onCreateChat={onCreateChat}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(client.sendMessage).toHaveBeenCalledWith("chat-new", "/model", undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
client._emitChat("chat-new", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-new",
|
||||||
|
text: "## Model\n- Current model: `Ring-2.6-1T`",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/Current model/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveThread?.(
|
||||||
|
httpJson(transcriptFromSimpleMessages([{ role: "user", content: "/model" }])),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/Current model/)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
it("sends quick action prompts from the empty thread landing", async () => {
|
it("sends quick action prompts from the empty thread landing", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user