feat(webui): improve slash command actions

This commit is contained in:
Xubin Ren
2026-05-24 21:24:54 +08:00
parent 3f0098839e
commit 92915ea424
14 changed files with 576 additions and 52 deletions
+81
View File
@@ -8,6 +8,20 @@ import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
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 = [
"overview",
"appearance",
@@ -18,6 +32,33 @@ const SETTINGS_NAV_KEYS = [
"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", () => {
it("switches UI copy and document locale through the language switcher", async () => {
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", () => {
for (const resource of Object.values(resources)) {
const common = resource.common;
+75
View File
@@ -115,6 +115,7 @@ const ORIGINAL_INNER_HEIGHT = window.innerHeight;
afterEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
Object.defineProperty(window, "innerHeight", {
value: ORIGINAL_INNER_HEIGHT,
configurable: true,
@@ -258,6 +259,80 @@ describe("ThreadComposer", () => {
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", () => {
const onSend = vi.fn();
render(
+78
View File
@@ -340,6 +340,84 @@ describe("ThreadShell", () => {
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 () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");