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:
Xubin Ren
2026-05-07 00:20:28 +08:00
committed by Xubin Ren
co-authored by Cursor
parent 49c07aa45a
commit ac18a8baad
20 changed files with 1258 additions and 38 deletions
+40 -1
View File
@@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteSession, fetchSessionMessages, listSessions, updateSettings } from "@/lib/api";
import {
deleteSession,
fetchSessionMessages,
listSessions,
listSlashCommands,
updateSettings,
} from "@/lib/api";
describe("webui API helpers", () => {
beforeEach(() => {
@@ -72,4 +78,37 @@ describe("webui API helpers", () => {
},
]);
});
it("maps slash command metadata from the commands endpoint", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
commands: [
{
command: "/history",
title: "Show conversation history",
description: "Print the last N messages.",
icon: "history",
arg_hint: "[n]",
},
],
}),
} as Response);
await expect(listSlashCommands("tok")).resolves.toEqual([
{
command: "/history",
title: "Show conversation history",
description: "Print the last N messages.",
icon: "history",
argHint: "[n]",
},
]);
expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
});
+15
View File
@@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => {
@@ -41,4 +44,16 @@ describe("webui i18n", () => {
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
});
it("keeps welcome quick actions localized for every registered locale", () => {
for (const resource of Object.values(resources)) {
const empty = resource.common.thread.empty;
expect(empty.greeting).toBeTruthy();
for (const key of QUICK_ACTION_KEYS) {
const action = empty.quickActions[key as keyof typeof empty.quickActions];
expect(action.title).toBeTruthy();
expect(action.prompt).toBeTruthy();
}
}
});
});
+49 -1
View File
@@ -1,7 +1,24 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { SlashCommand } from "@/lib/types";
const COMMANDS: SlashCommand[] = [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn.",
icon: "square",
},
{
command: "/history",
title: "Show conversation history",
description: "Print the last N persisted messages.",
icon: "history",
argHint: "[n]",
},
];
describe("ThreadComposer", () => {
it("renders a readonly hero model composer when provided", () => {
@@ -43,4 +60,35 @@ describe("ThreadComposer", () => {
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
});
it("opens a slash command palette and inserts the selected command", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
slashCommands={COMMANDS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/" } });
expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("/history ");
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
});
+134
View File
@@ -434,6 +434,138 @@ describe("ThreadShell", () => {
});
});
it("keeps live assistant replies after visiting the blank new-chat page", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
return httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
// Simulate a stale history response that has not persisted the
// just-received assistant reply yet.
messages: [{ role: "user", content: "hello" }],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument());
await act(async () => {
client._emitChat("chat-a", {
event: "message",
chat_id: "chat-a",
text: "live assistant reply",
});
});
expect(screen.getByText("live assistant reply")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
});
expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
});
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
});
it("does not open slash commands on the blank welcome page", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/commands")) {
return httpJson({
commands: [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn.",
icon: "square",
},
],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/" },
});
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -454,6 +586,7 @@ describe("ThreadShell", () => {
// No banner yet: only appears once the client emits a matching error.
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await act(async () => {});
await act(async () => {
client._emitError({ kind: "message_too_big" });
});
@@ -485,6 +618,7 @@ describe("ThreadShell", () => {
),
);
await act(async () => {});
await act(async () => {
client._emitError({ kind: "message_too_big" });
});