feat(webui): switch model presets from the composer (#5077)
This commit is contained in:
@@ -23,6 +23,7 @@ describe("ChatList", () => {
|
||||
session({
|
||||
chatId: "older",
|
||||
title: "Older chat",
|
||||
preview: "/model fast",
|
||||
updatedAt: "2026-05-21T10:00:00Z",
|
||||
}),
|
||||
session({
|
||||
@@ -46,6 +47,7 @@ describe("ChatList", () => {
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
showPreviews
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -54,6 +56,7 @@ describe("ChatList", () => {
|
||||
|
||||
expect(text.indexOf("Newest chat")).toBeLessThan(text.indexOf("Middle chat"));
|
||||
expect(text.indexOf("Middle chat")).toBeLessThan(text.indexOf("Older chat"));
|
||||
expect(screen.queryByText("/model fast")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a pin indicator for pinned chats", () => {
|
||||
|
||||
@@ -552,6 +552,51 @@ describe("NanobotClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("handles the silent system-command lifecycle without hiding concurrent events", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatHandler = vi.fn();
|
||||
client.onChat("chat-x", chatHandler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
const pending = client.sendSystemCommand("chat-x", " /model fast ", 1_000);
|
||||
const frame = JSON.parse(lastSocket().sent.at(-1) as string);
|
||||
expect(frame).toMatchObject({
|
||||
type: "message",
|
||||
chat_id: "chat-x",
|
||||
content: "/model fast",
|
||||
webui: true,
|
||||
});
|
||||
expect(frame.turn_id).toMatch(/^webui-system:/);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "message",
|
||||
chat_id: "chat-x",
|
||||
text: "normal reply",
|
||||
turn_id: "normal-turn",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message",
|
||||
chat_id: "chat-x",
|
||||
text: "Switched model preset to fast.",
|
||||
turn_id: frame.turn_id,
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(chatHandler).toHaveBeenCalledTimes(1);
|
||||
expect(chatHandler).toHaveBeenCalledWith(expect.objectContaining({
|
||||
text: "normal reply",
|
||||
turn_id: "normal-turn",
|
||||
}));
|
||||
const interrupted = client.sendSystemCommand("chat-x", "/model fast", 1_000);
|
||||
lastSocket().close();
|
||||
await expect(interrupted).rejects.toThrow("socket closed");
|
||||
});
|
||||
|
||||
it("sends selected assistant text as separate quoted context", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("SessionSearchDialog", () => {
|
||||
render(
|
||||
<SessionSearchDialog
|
||||
open
|
||||
sessions={[session(1)]}
|
||||
sessions={[{ ...session(1), title: "Model chat", preview: "/model fast" }]}
|
||||
activeKey={null}
|
||||
loading={false}
|
||||
onOpenChange={() => {}}
|
||||
@@ -38,6 +38,11 @@ describe("SessionSearchDialog", () => {
|
||||
expect(dialog.className).not.toContain("bg-popover/");
|
||||
expect(dialog.className).not.toContain("backdrop-blur");
|
||||
expect(screen.getByTestId("session-search-scroll")).toHaveClass("overflow-y-auto");
|
||||
expect(screen.queryByText("/model fast")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search" }), {
|
||||
target: { value: "model fast" },
|
||||
});
|
||||
expect(screen.queryByText("Model chat")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps keyboard navigation scrollable through long result lists", () => {
|
||||
|
||||
@@ -164,6 +164,7 @@ function stubVisualViewport({
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
Reflect.deleteProperty(window, "nanobotHost");
|
||||
if (ORIGINAL_MEDIA_DEVICES) {
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
@@ -291,6 +292,49 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string {
|
||||
return String.fromCharCode(...bytes.slice(offset, offset + length));
|
||||
}
|
||||
|
||||
const MODEL_PRESETS = [
|
||||
{ name: "kimi", label: "Kimi", provider: "moonshot" },
|
||||
{ name: "dflash", label: "DFlash", provider: "deepseek" },
|
||||
{ name: "dspro", label: "DS Pro", provider: "deepseek" },
|
||||
];
|
||||
|
||||
function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
||||
const onPresetChange = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
modelLabel="Kimi"
|
||||
modelPreset="kimi"
|
||||
modelProvider="moonshot"
|
||||
modelPresets={MODEL_PRESETS}
|
||||
onModelPresetChange={onPresetChange}
|
||||
placeholder={variant === "hero" ? "Ask anything..." : "Type your message..."}
|
||||
variant={variant}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
badge: screen.getByRole("spinbutton", { name: "Kimi" }),
|
||||
onPresetChange,
|
||||
};
|
||||
}
|
||||
|
||||
function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) {
|
||||
fireEvent.pointerDown(badge, {
|
||||
button,
|
||||
clientY,
|
||||
isPrimary: true,
|
||||
pointerId,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
}
|
||||
|
||||
function longPress(badge: HTMLElement, pointerId = 7) {
|
||||
pointerDown(badge, pointerId);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ThreadComposer", () => {
|
||||
it("focuses and sends a removable quoted answer excerpt", async () => {
|
||||
const onSend = vi.fn();
|
||||
@@ -386,6 +430,88 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("scrolls complete preset pills after a left-button long press and wraps", () => {
|
||||
vi.useFakeTimers();
|
||||
const { badge, onPresetChange } = renderPresetComposer();
|
||||
expect(badge).toHaveClass("h-9");
|
||||
expect(badge).toHaveStyle({ touchAction: "manipulation" });
|
||||
const idleTouchMove = new Event("touchmove", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
badge.dispatchEvent(idleTouchMove);
|
||||
expect(idleTouchMove.defaultPrevented).toBe(false);
|
||||
fireEvent.click(badge);
|
||||
pointerDown(badge);
|
||||
fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
|
||||
expect(onPresetChange).not.toHaveBeenCalled();
|
||||
|
||||
longPress(badge);
|
||||
expect(badge).toHaveAttribute("data-switching", "true");
|
||||
const viewport = screen.getByTestId("composer-model-pill-viewport");
|
||||
expect(viewport).toHaveClass("overflow-hidden", "-left-2", "-top-3", "-bottom-3");
|
||||
const track = screen.getByTestId("composer-model-pill-track");
|
||||
expect(track).toHaveClass("items-end", "gap-1");
|
||||
const activeTouchMove = new Event("touchmove", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
badge.dispatchEvent(activeTouchMove);
|
||||
expect(activeTouchMove.defaultPrevented).toBe(true);
|
||||
const pills = track.querySelectorAll<HTMLElement>(".composer-model-pill");
|
||||
expect(pills).toHaveLength(5);
|
||||
expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true);
|
||||
expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true);
|
||||
expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true);
|
||||
const centeredPill = track.querySelector<HTMLElement>("[data-preset-offset='0']");
|
||||
expect(centeredPill).toHaveTextContent("Kimi");
|
||||
expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" });
|
||||
expect(
|
||||
track.querySelector<HTMLElement>("[data-preset-offset='1']"),
|
||||
).toHaveStyle({ transform: "scale(1.0200)" });
|
||||
|
||||
fireEvent.pointerMove(badge, {
|
||||
clientY: 122,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("Kimi");
|
||||
fireEvent.pointerMove(badge, {
|
||||
clientY: 123,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("DS Pro");
|
||||
fireEvent.pointerUp(badge, {
|
||||
clientY: 123,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dspro");
|
||||
expect(badge).toHaveAttribute("data-settling", "true");
|
||||
expect(track).toHaveAttribute("data-settling", "true");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(260);
|
||||
});
|
||||
expect(badge).not.toHaveAttribute("data-switching");
|
||||
expect(badge).not.toHaveAttribute("data-settling");
|
||||
});
|
||||
|
||||
it("supports the same long-press switcher in hero mode and cancels pointercancel", () => {
|
||||
vi.useFakeTimers();
|
||||
const { badge, onPresetChange } = renderPresetComposer("hero");
|
||||
expect(badge).toHaveClass("h-8");
|
||||
longPress(badge, 9);
|
||||
expect(badge).toHaveAttribute("data-switching", "true");
|
||||
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
|
||||
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
|
||||
expect(badge).not.toHaveAttribute("data-switching");
|
||||
expect(onPresetChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("transcribes voice input into the composer without sending", async () => {
|
||||
mockVoiceRecorder();
|
||||
const onSend = vi.fn();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
|
||||
import { deriveTitle, isModelCommandText, visibleSessionPreview } from "@/lib/format";
|
||||
import {
|
||||
normalizeLegacyLongTaskMessages,
|
||||
projectWebuiThreadMessages,
|
||||
} from "@/lib/thread-display-compat";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
describe("normalizeLegacyLongTaskMessages", () => {
|
||||
@@ -17,4 +21,29 @@ describe("normalizeLegacyLongTaskMessages", () => {
|
||||
expect(out[0]!.role).toBe("tool");
|
||||
expect(out[0]!.traces).toEqual(["long_task · done"]);
|
||||
});
|
||||
|
||||
it("removes model and silent-command turns without hiding concurrent replies", () => {
|
||||
const message = (
|
||||
id: string,
|
||||
role: UIMessage["role"],
|
||||
content: string,
|
||||
turnId?: string,
|
||||
): UIMessage => ({ id, role, content, createdAt: 1, turnId });
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("model", "user", "/model fast", "model-turn"),
|
||||
message("model-reply", "assistant", "Switched model preset to fast.", "model-turn"),
|
||||
message("silent", "user", "/restart", "webui-system:restart"),
|
||||
message("reply", "assistant", "This unrelated reply stays visible.", "other-turn"),
|
||||
]);
|
||||
|
||||
expect(visible.map(({ content }) => content)).toEqual([
|
||||
"This unrelated reply stays visible.",
|
||||
]);
|
||||
expect([
|
||||
isModelCommandText("/MODEL@nanobot fast"),
|
||||
isModelCommandText("/modelish"),
|
||||
]).toEqual([true, false]);
|
||||
expect(visibleSessionPreview("Switched model preset to `fast`.")).toBe("");
|
||||
expect(deriveTitle("## Model\n- Current model: `gpt-5.5`", "New chat")).toBe("New chat");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ function makeClient() {
|
||||
for (const h of sessionUpdateHandlers) h(chatId, scope);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
sendSystemCommand: vi.fn().mockResolvedValue(undefined),
|
||||
newChat: vi.fn(),
|
||||
forkChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
@@ -387,7 +388,7 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-5.5")).toBeInTheDocument();
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
expect(screen.queryByText("ling-3.0-flash")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -406,8 +407,55 @@ describe("ThreadShell", () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle("gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
|
||||
expect(await screen.findByTitle("Fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches through every named preset while preserving call-order priority", async () => {
|
||||
const client = makeClient();
|
||||
const settings = settingsWithFastPreset();
|
||||
settings.model_presets.push({
|
||||
...settings.model_presets.at(-1)!,
|
||||
name: "extra",
|
||||
label: "Extra",
|
||||
model: "deepseek/extra",
|
||||
provider: "deepseek",
|
||||
active: false,
|
||||
is_default: false,
|
||||
});
|
||||
settings.model_call_order = ["fast"];
|
||||
|
||||
const view = (preset: string) => wrap(client, (
|
||||
<ThreadShell
|
||||
session={session("preset-order", preset)}
|
||||
title="Preset order"
|
||||
onToggleSidebar={() => {}}
|
||||
settingsSnapshot={settings}
|
||||
/>
|
||||
));
|
||||
const { rerender } = render(view("default"));
|
||||
|
||||
const badge = await screen.findByRole("spinbutton", { name: "Default" });
|
||||
expect(badge).toHaveTextContent("Default");
|
||||
fireEvent.keyDown(badge, { key: "ArrowDown" });
|
||||
|
||||
expect(client.sendSystemCommand).toHaveBeenCalledWith(
|
||||
"preset-order",
|
||||
"/model fast",
|
||||
);
|
||||
expect(await screen.findByText("Fast")).toBeInTheDocument();
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole("spinbutton", { name: "Fast" }),
|
||||
{ key: "End" },
|
||||
);
|
||||
expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
|
||||
"preset-order",
|
||||
"/model extra",
|
||||
);
|
||||
expect(await screen.findByText("Extra")).toBeInTheDocument();
|
||||
|
||||
rerender(view("fast"));
|
||||
expect(await screen.findByText("Fast")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the backend-resolved provider for an auto session preset", async () => {
|
||||
@@ -442,7 +490,7 @@ describe("ThreadShell", () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle("gpt-4 · Company Proxy")).toBeInTheDocument();
|
||||
expect(await screen.findByTitle("Fast · gpt-4 · Company Proxy")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -459,7 +507,7 @@ describe("ThreadShell", () => {
|
||||
"openai-codex/gpt-5.5",
|
||||
));
|
||||
|
||||
expect(await screen.findByText("gpt-5.5")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Default")).toBeInTheDocument();
|
||||
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
|
||||
expect(configuredBadge).not.toBeNull();
|
||||
expect(configuredBadge).toHaveClass("composer-model-badge");
|
||||
@@ -477,7 +525,7 @@ describe("ThreadShell", () => {
|
||||
const badge = logo.parentElement;
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge).toBe(configuredBadge);
|
||||
expect(screen.getByText("gpt-5.5")).toBeInTheDocument();
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
|
||||
expect(badge).toHaveAttribute("data-fallback", "true");
|
||||
expect(badge).toHaveAttribute(
|
||||
@@ -500,7 +548,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||
).toHaveAttribute("title", "gpt-5.5 · OpenAI Codex");
|
||||
).toHaveAttribute("title", "Default · gpt-5.5 · OpenAI Codex");
|
||||
expect(
|
||||
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||
).toBe(badge);
|
||||
@@ -750,6 +798,57 @@ describe("ThreadShell", () => {
|
||||
expect(onNewChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the selected landing preset before sending the first prompt", async () => {
|
||||
const client = makeClient();
|
||||
const settings = settingsWithFastPreset();
|
||||
settings.model_call_order = ["fast"];
|
||||
let resolveModelCommand!: () => void;
|
||||
client.sendSystemCommand.mockImplementation(
|
||||
() => new Promise<void>((resolve) => {
|
||||
resolveModelCommand = resolve;
|
||||
}),
|
||||
);
|
||||
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
|
||||
|
||||
const view = (currentSession: ReturnType<typeof session> | null) => wrap(client, (
|
||||
<ThreadShell
|
||||
session={currentSession}
|
||||
title={currentSession ? "New chat" : "nanobot"}
|
||||
onToggleSidebar={() => {}}
|
||||
onCreateChat={onCreateChat}
|
||||
settingsSnapshot={settings}
|
||||
/>
|
||||
));
|
||||
const { rerender } = render(view(null));
|
||||
|
||||
fireEvent.keyDown(
|
||||
await screen.findByRole("spinbutton", { name: "Default" }),
|
||||
{ key: "ArrowDown" },
|
||||
);
|
||||
expect(await screen.findByText("Fast")).toBeInTheDocument();
|
||||
expect(client.sendSystemCommand).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "use the selected model" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(client.sendSystemCommand).toHaveBeenCalledWith(
|
||||
"chat-new",
|
||||
"/model fast",
|
||||
));
|
||||
|
||||
rerender(view(session("chat-new")));
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveModelCommand();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expectSendMessageWithTurn(client, "chat-new", "use the selected model");
|
||||
});
|
||||
});
|
||||
|
||||
it("binds a pending landing message to the chat created for it", async () => {
|
||||
const client = makeClient();
|
||||
let resolveCreate: ((chatId: string) => void) | null = null;
|
||||
@@ -869,7 +968,7 @@ describe("ThreadShell", () => {
|
||||
expect(screen.queryByText(HERO_GREETING_PATTERN)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a live first command reply when the initial history snapshot is stale", async () => {
|
||||
it("hides a live first /model turn when the initial history snapshot is stale", async () => {
|
||||
const client = makeClient();
|
||||
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
|
||||
let resolveThread:
|
||||
@@ -935,8 +1034,15 @@ describe("ThreadShell", () => {
|
||||
chat_id: "chat-new",
|
||||
text: "## Model\n- Current model: `Ring-2.6-1T`",
|
||||
});
|
||||
client._emitChat("chat-new", {
|
||||
event: "message",
|
||||
chat_id: "chat-new",
|
||||
text: "This unrelated reply stays visible.",
|
||||
});
|
||||
});
|
||||
expect(screen.getByText(/Current model/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("/model")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Current model/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("This unrelated reply stays visible.")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveThread?.(
|
||||
@@ -944,7 +1050,11 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Current model/)).toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("/model")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Current model/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("This unrelated reply stays visible.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the empty thread landing focused on the composer", async () => {
|
||||
|
||||
Reference in New Issue
Block a user