feat(webui): improve slash command actions
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user