fix(ui): align control ui chat and config rendering
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { Tab } from "./navigation";
|
import type { Tab } from "./navigation";
|
||||||
|
import { setTabFromRoute } from "./app-settings";
|
||||||
|
|
||||||
type SettingsHost = Parameters<typeof import("./app-settings").setTabFromRoute>[0];
|
type SettingsHost = Parameters<typeof setTabFromRoute>[0] & {
|
||||||
|
logsPollInterval: number | null;
|
||||||
|
debugPollInterval: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
const createHost = (tab: Tab): SettingsHost => ({
|
const createHost = (tab: Tab): SettingsHost => ({
|
||||||
settings: {
|
settings: {
|
||||||
@@ -30,62 +34,38 @@ const createHost = (tab: Tab): SettingsHost => ({
|
|||||||
basePath: "",
|
basePath: "",
|
||||||
themeMedia: null,
|
themeMedia: null,
|
||||||
themeMediaHandler: null,
|
themeMediaHandler: null,
|
||||||
|
logsPollInterval: null,
|
||||||
|
debugPollInterval: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setTabFromRoute", () => {
|
describe("setTabFromRoute", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts and stops log polling based on the tab", async () => {
|
afterEach(() => {
|
||||||
const startLogsPolling = vi.fn();
|
vi.useRealTimers();
|
||||||
const stopLogsPolling = vi.fn();
|
});
|
||||||
const startDebugPolling = vi.fn();
|
|
||||||
const stopDebugPolling = vi.fn();
|
|
||||||
|
|
||||||
vi.doMock("./app-polling", () => ({
|
it("starts and stops log polling based on the tab", () => {
|
||||||
startLogsPolling,
|
|
||||||
stopLogsPolling,
|
|
||||||
startDebugPolling,
|
|
||||||
stopDebugPolling,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { setTabFromRoute } = await import("./app-settings");
|
|
||||||
const host = createHost("chat");
|
const host = createHost("chat");
|
||||||
|
|
||||||
setTabFromRoute(host, "logs");
|
setTabFromRoute(host, "logs");
|
||||||
expect(startLogsPolling).toHaveBeenCalledTimes(1);
|
expect(host.logsPollInterval).not.toBeNull();
|
||||||
expect(stopLogsPolling).not.toHaveBeenCalled();
|
expect(host.debugPollInterval).toBeNull();
|
||||||
expect(startDebugPolling).not.toHaveBeenCalled();
|
|
||||||
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
setTabFromRoute(host, "chat");
|
setTabFromRoute(host, "chat");
|
||||||
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
|
expect(host.logsPollInterval).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts and stops debug polling based on the tab", async () => {
|
it("starts and stops debug polling based on the tab", () => {
|
||||||
const startLogsPolling = vi.fn();
|
|
||||||
const stopLogsPolling = vi.fn();
|
|
||||||
const startDebugPolling = vi.fn();
|
|
||||||
const stopDebugPolling = vi.fn();
|
|
||||||
|
|
||||||
vi.doMock("./app-polling", () => ({
|
|
||||||
startLogsPolling,
|
|
||||||
stopLogsPolling,
|
|
||||||
startDebugPolling,
|
|
||||||
stopDebugPolling,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { setTabFromRoute } = await import("./app-settings");
|
|
||||||
const host = createHost("chat");
|
const host = createHost("chat");
|
||||||
|
|
||||||
setTabFromRoute(host, "debug");
|
setTabFromRoute(host, "debug");
|
||||||
expect(startDebugPolling).toHaveBeenCalledTimes(1);
|
expect(host.debugPollInterval).not.toBeNull();
|
||||||
expect(stopDebugPolling).not.toHaveBeenCalled();
|
expect(host.logsPollInterval).toBeNull();
|
||||||
expect(startLogsPolling).not.toHaveBeenCalled();
|
|
||||||
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
setTabFromRoute(host, "chat");
|
setTabFromRoute(host, "chat");
|
||||||
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
|
expect(host.debugPollInterval).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,8 +46,13 @@ describe("chat markdown rendering", () => {
|
|||||||
|
|
||||||
await app.updateComplete;
|
await app.updateComplete;
|
||||||
|
|
||||||
const toolCard = app.querySelector(".chat-tool-card") as HTMLElement | null;
|
const toolCards = Array.from(
|
||||||
expect(toolCard).not.toBeNull();
|
app.querySelectorAll<HTMLElement>(".chat-tool-card"),
|
||||||
|
);
|
||||||
|
const toolCard = toolCards.find((card) =>
|
||||||
|
card.querySelector(".chat-tool-card__preview, .chat-tool-card__inline"),
|
||||||
|
);
|
||||||
|
expect(toolCard).not.toBeUndefined();
|
||||||
toolCard?.click();
|
toolCard?.click();
|
||||||
|
|
||||||
await app.updateComplete;
|
await app.updateComplete;
|
||||||
|
|||||||
@@ -26,17 +26,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
|||||||
contentItems.some((item) => {
|
contentItems.some((item) => {
|
||||||
const x = item as Record<string, unknown>;
|
const x = item as Record<string, unknown>;
|
||||||
const t = String(x.type ?? "").toLowerCase();
|
const t = String(x.type ?? "").toLowerCase();
|
||||||
return (
|
return t === "toolresult" || t === "tool_result";
|
||||||
t === "toolcall" ||
|
|
||||||
t === "tool_call" ||
|
|
||||||
t === "tooluse" ||
|
|
||||||
t === "tool_use" ||
|
|
||||||
t === "toolresult" ||
|
|
||||||
t === "tool_result" ||
|
|
||||||
t === "tool_call" ||
|
|
||||||
t === "tool_result" ||
|
|
||||||
(typeof x.name === "string" && x.arguments != null)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasToolName =
|
const hasToolName =
|
||||||
@@ -74,19 +64,19 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
|||||||
*/
|
*/
|
||||||
export function normalizeRoleForGrouping(role: string): string {
|
export function normalizeRoleForGrouping(role: string): string {
|
||||||
const lower = role.toLowerCase();
|
const lower = role.toLowerCase();
|
||||||
|
// Preserve original casing when it's already a core role.
|
||||||
|
if (role === "user" || role === "User") return role;
|
||||||
|
if (role === "assistant") return "assistant";
|
||||||
|
if (role === "system") return "system";
|
||||||
// Keep tool-related roles distinct so the UI can style/toggle them.
|
// Keep tool-related roles distinct so the UI can style/toggle them.
|
||||||
if (
|
if (
|
||||||
lower === "toolresult" ||
|
lower === "toolresult" ||
|
||||||
lower === "tool_result" ||
|
lower === "tool_result" ||
|
||||||
lower === "tool" ||
|
lower === "tool" ||
|
||||||
lower === "function" ||
|
lower === "function"
|
||||||
lower === "toolresult"
|
|
||||||
) {
|
) {
|
||||||
return "tool";
|
return "tool";
|
||||||
}
|
}
|
||||||
if (lower === "assistant") return "assistant";
|
|
||||||
if (lower === "user") return "user";
|
|
||||||
if (lower === "system") return "system";
|
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,20 +69,11 @@ describe("config form renderer", () => {
|
|||||||
"abc123",
|
"abc123",
|
||||||
);
|
);
|
||||||
|
|
||||||
const select = container.querySelector("select") as HTMLSelectElement | null;
|
const tokenButton = Array.from(
|
||||||
const selects = Array.from(container.querySelectorAll("select"));
|
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
|
||||||
const modeSelect = selects.find((el) =>
|
).find((btn) => btn.textContent?.trim() === "token");
|
||||||
Array.from(el.options).some((opt) => opt.textContent?.trim() === "token"),
|
expect(tokenButton).not.toBeUndefined();
|
||||||
) as HTMLSelectElement | undefined;
|
tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
expect(modeSelect).not.toBeUndefined();
|
|
||||||
if (!modeSelect) return;
|
|
||||||
const tokenOption = Array.from(modeSelect.options).find(
|
|
||||||
(opt) => opt.textContent?.trim() === "token",
|
|
||||||
);
|
|
||||||
expect(tokenOption).not.toBeUndefined();
|
|
||||||
if (!tokenOption) return;
|
|
||||||
modeSelect.value = tokenOption.value;
|
|
||||||
modeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
|
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
|
||||||
|
|
||||||
const checkbox = container.querySelector(
|
const checkbox = container.querySelector(
|
||||||
@@ -110,16 +101,16 @@ describe("config form renderer", () => {
|
|||||||
container,
|
container,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addButton = Array.from(container.querySelectorAll("button")).find(
|
const addButton = container.querySelector(
|
||||||
(btn) => btn.textContent?.trim() === "Add",
|
".cfg-array__add",
|
||||||
);
|
) as HTMLButtonElement | null;
|
||||||
expect(addButton).not.toBeUndefined();
|
expect(addButton).not.toBeUndefined();
|
||||||
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
|
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
|
||||||
|
|
||||||
const removeButton = Array.from(container.querySelectorAll("button")).find(
|
const removeButton = container.querySelector(
|
||||||
(btn) => btn.textContent?.trim() === "Remove",
|
".cfg-array__item-remove",
|
||||||
);
|
) as HTMLButtonElement | null;
|
||||||
expect(removeButton).not.toBeUndefined();
|
expect(removeButton).not.toBeUndefined();
|
||||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
|
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
|
||||||
@@ -140,19 +131,11 @@ describe("config form renderer", () => {
|
|||||||
container,
|
container,
|
||||||
);
|
);
|
||||||
|
|
||||||
const selects = Array.from(container.querySelectorAll("select"));
|
const tailnetButton = Array.from(
|
||||||
const bindSelect = selects.find((el) =>
|
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
|
||||||
Array.from(el.options).some((opt) => opt.textContent?.trim() === "tailnet"),
|
).find((btn) => btn.textContent?.trim() === "tailnet");
|
||||||
) as HTMLSelectElement | undefined;
|
expect(tailnetButton).not.toBeUndefined();
|
||||||
expect(bindSelect).not.toBeUndefined();
|
tailnetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
if (!bindSelect) return;
|
|
||||||
const tailnetOption = Array.from(bindSelect.options).find(
|
|
||||||
(opt) => opt.textContent?.trim() === "tailnet",
|
|
||||||
);
|
|
||||||
expect(tailnetOption).not.toBeUndefined();
|
|
||||||
if (!tailnetOption) return;
|
|
||||||
bindSelect.value = tailnetOption.value;
|
|
||||||
bindSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
|
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,9 +165,9 @@ describe("config form renderer", () => {
|
|||||||
container,
|
container,
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeButton = Array.from(container.querySelectorAll("button")).find(
|
const removeButton = container.querySelector(
|
||||||
(btn) => btn.textContent?.trim() === "Remove",
|
".cfg-map__item-remove",
|
||||||
);
|
) as HTMLButtonElement | null;
|
||||||
expect(removeButton).not.toBeUndefined();
|
expect(removeButton).not.toBeUndefined();
|
||||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
expect(onPatch).toHaveBeenCalledWith(["slack"], {});
|
expect(onPatch).toHaveBeenCalledWith(["slack"], {});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { guard } from "lit/directives/guard.js";
|
|
||||||
import { repeat } from "lit/directives/repeat.js";
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
import type { SessionsListResult } from "../types";
|
import type { SessionsListResult } from "../types";
|
||||||
import type { ChatQueueItem } from "../ui-types";
|
import type { ChatQueueItem } from "../ui-types";
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
normalizeMessage,
|
normalizeMessage,
|
||||||
normalizeRoleForGrouping,
|
normalizeRoleForGrouping,
|
||||||
} from "../chat/message-normalizer";
|
} from "../chat/message-normalizer";
|
||||||
import { extractTextCached } from "../chat/message-extract";
|
|
||||||
import {
|
import {
|
||||||
renderMessageGroup,
|
renderMessageGroup,
|
||||||
renderReadingIndicatorGroup,
|
renderReadingIndicatorGroup,
|
||||||
@@ -115,56 +113,41 @@ export function renderChat(props: ChatProps) {
|
|||||||
|
|
||||||
const splitRatio = props.splitRatio ?? 0.6;
|
const splitRatio = props.splitRatio ?? 0.6;
|
||||||
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
||||||
const thread = guard(
|
const thread = html`
|
||||||
[
|
<div
|
||||||
props.loading,
|
class="chat-thread"
|
||||||
props.messages,
|
role="log"
|
||||||
props.toolMessages,
|
aria-live="polite"
|
||||||
props.stream,
|
@scroll=${props.onChatScroll}
|
||||||
props.streamStartedAt,
|
>
|
||||||
props.sessionKey,
|
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
|
||||||
props.showThinking,
|
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||||
reasoningLevel,
|
if (item.kind === "reading-indicator") {
|
||||||
props.assistantName,
|
return renderReadingIndicatorGroup(assistantIdentity);
|
||||||
props.assistantAvatar,
|
}
|
||||||
props.assistantAvatarUrl,
|
|
||||||
],
|
|
||||||
() => html`
|
|
||||||
<div
|
|
||||||
class="chat-thread"
|
|
||||||
role="log"
|
|
||||||
aria-live="polite"
|
|
||||||
@scroll=${props.onChatScroll}
|
|
||||||
>
|
|
||||||
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
|
|
||||||
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
|
||||||
if (item.kind === "reading-indicator") {
|
|
||||||
return renderReadingIndicatorGroup(assistantIdentity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.kind === "stream") {
|
if (item.kind === "stream") {
|
||||||
return renderStreamingGroup(
|
return renderStreamingGroup(
|
||||||
item.text,
|
item.text,
|
||||||
item.startedAt,
|
item.startedAt,
|
||||||
props.onOpenSidebar,
|
props.onOpenSidebar,
|
||||||
assistantIdentity,
|
assistantIdentity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind === "group") {
|
if (item.kind === "group") {
|
||||||
return renderMessageGroup(item, {
|
return renderMessageGroup(item, {
|
||||||
onOpenSidebar: props.onOpenSidebar,
|
onOpenSidebar: props.onOpenSidebar,
|
||||||
showReasoning,
|
showReasoning,
|
||||||
assistantName: props.assistantName,
|
assistantName: props.assistantName,
|
||||||
assistantAvatar: assistantIdentity.avatar,
|
assistantAvatar: assistantIdentity.avatar,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return nothing;
|
return nothing;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`;
|
||||||
);
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section class="card chat">
|
<section class="card chat">
|
||||||
@@ -395,27 +378,6 @@ function messageKey(message: unknown, index: number): string {
|
|||||||
if (messageId) return `msg:${messageId}`;
|
if (messageId) return `msg:${messageId}`;
|
||||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
||||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||||
const fingerprint =
|
if (timestamp != null) return `msg:${role}:${timestamp}:${index}`;
|
||||||
extractTextCached(message) ??
|
return `msg:${role}:${index}`;
|
||||||
(typeof m.content === "string" ? m.content : null);
|
|
||||||
const seed = fingerprint ?? safeJson(message) ?? String(index);
|
|
||||||
const hash = fnv1a(seed);
|
|
||||||
return timestamp ? `msg:${role}:${timestamp}:${hash}` : `msg:${role}:${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeJson(value: unknown): string | null {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fnv1a(input: string): string {
|
|
||||||
let hash = 0x811c9dc5;
|
|
||||||
for (let i = 0; i < input.length; i++) {
|
|
||||||
hash ^= input.charCodeAt(i);
|
|
||||||
hash = Math.imul(hash, 0x01000193);
|
|
||||||
}
|
|
||||||
return (hash >>> 0).toString(36);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,32 +154,24 @@ export function renderConfigForm(props: ConfigFormProps) {
|
|||||||
const activeSection = props.activeSection;
|
const activeSection = props.activeSection;
|
||||||
const activeSubsection = props.activeSubsection ?? null;
|
const activeSubsection = props.activeSubsection ?? null;
|
||||||
|
|
||||||
// Filter and sort entries
|
const entries = Object.entries(properties).sort((a, b) => {
|
||||||
let entries = Object.entries(properties);
|
|
||||||
|
|
||||||
// Filter by active section
|
|
||||||
if (activeSection) {
|
|
||||||
entries = entries.filter(([key]) => key === activeSection);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search
|
|
||||||
if (searchQuery) {
|
|
||||||
entries = entries.filter(([key, node]) => matchesSearch(key, node, searchQuery));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by hint order, then alphabetically
|
|
||||||
entries.sort((a, b) => {
|
|
||||||
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50;
|
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50;
|
||||||
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50;
|
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50;
|
||||||
if (orderA !== orderB) return orderA - orderB;
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
return a[0].localeCompare(b[0]);
|
return a[0].localeCompare(b[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredEntries = entries.filter(([key, node]) => {
|
||||||
|
if (activeSection && key !== activeSection) return false;
|
||||||
|
if (searchQuery && !matchesSearch(key, node, searchQuery)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
let subsectionContext:
|
let subsectionContext:
|
||||||
| { sectionKey: string; subsectionKey: string; schema: JsonSchema }
|
| { sectionKey: string; subsectionKey: string; schema: JsonSchema }
|
||||||
| null = null;
|
| null = null;
|
||||||
if (activeSection && activeSubsection && entries.length === 1) {
|
if (activeSection && activeSubsection && filteredEntries.length === 1) {
|
||||||
const sectionSchema = entries[0]?.[1];
|
const sectionSchema = filteredEntries[0]?.[1];
|
||||||
if (
|
if (
|
||||||
sectionSchema &&
|
sectionSchema &&
|
||||||
schemaType(sectionSchema) === "object" &&
|
schemaType(sectionSchema) === "object" &&
|
||||||
@@ -194,7 +186,7 @@ export function renderConfigForm(props: ConfigFormProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (filteredEntries.length === 0) {
|
||||||
return html`
|
return html`
|
||||||
<div class="config-empty">
|
<div class="config-empty">
|
||||||
<div class="config-empty__icon">🔍</div>
|
<div class="config-empty__icon">🔍</div>
|
||||||
@@ -247,7 +239,7 @@ export function renderConfigForm(props: ConfigFormProps) {
|
|||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
})()
|
})()
|
||||||
: entries.map(([key, node]) => {
|
: filteredEntries.map(([key, node]) => {
|
||||||
const meta = SECTION_META[key] ?? {
|
const meta = SECTION_META[key] ?? {
|
||||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||||
description: node.description ?? "",
|
description: node.description ?? "",
|
||||||
|
|||||||
@@ -67,53 +67,37 @@ describe("config view", () => {
|
|||||||
expect(saveButton?.disabled).toBe(true);
|
expect(saveButton?.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies MiniMax preset via onRawChange + onFormPatch", () => {
|
it("switches mode via the sidebar toggle", () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const onRawChange = vi.fn();
|
const onFormModeChange = vi.fn();
|
||||||
const onFormPatch = vi.fn();
|
|
||||||
render(
|
render(
|
||||||
renderConfig({
|
renderConfig({
|
||||||
...baseProps(),
|
...baseProps(),
|
||||||
onRawChange,
|
onFormModeChange,
|
||||||
onFormPatch,
|
|
||||||
}),
|
}),
|
||||||
container,
|
container,
|
||||||
);
|
);
|
||||||
|
|
||||||
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
|
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||||
b.textContent?.includes("MiniMax M2.1"),
|
b.textContent?.trim() === "Raw",
|
||||||
) as HTMLButtonElement | undefined;
|
) as HTMLButtonElement | undefined;
|
||||||
expect(btn).toBeTruthy();
|
expect(btn).toBeTruthy();
|
||||||
btn?.click();
|
btn?.click();
|
||||||
|
expect(onFormModeChange).toHaveBeenCalledWith("raw");
|
||||||
expect(onRawChange).toHaveBeenCalled();
|
|
||||||
const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? "");
|
|
||||||
expect(raw).toContain("https://api.minimax.io/anthropic");
|
|
||||||
expect(raw).toContain("anthropic-messages");
|
|
||||||
expect(raw).toContain("minimax/MiniMax-M2.1");
|
|
||||||
expect(raw).toContain("MINIMAX_API_KEY");
|
|
||||||
|
|
||||||
expect(onFormPatch).toHaveBeenCalledWith(
|
|
||||||
["agents", "defaults", "model", "primary"],
|
|
||||||
"minimax/MiniMax-M2.1",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not clobber existing MiniMax apiKey when applying preset", () => {
|
it("switches sections from the sidebar", () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const onRawChange = vi.fn();
|
const onSectionChange = vi.fn();
|
||||||
render(
|
render(
|
||||||
renderConfig({
|
renderConfig({
|
||||||
...baseProps(),
|
...baseProps(),
|
||||||
onRawChange,
|
onSectionChange,
|
||||||
formValue: {
|
schema: {
|
||||||
models: {
|
type: "object",
|
||||||
mode: "merge",
|
properties: {
|
||||||
providers: {
|
gateway: { type: "object", properties: {} },
|
||||||
minimax: {
|
agents: { type: "object", properties: {} },
|
||||||
apiKey: "EXISTING_KEY",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -121,75 +105,31 @@ describe("config view", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
|
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||||
b.textContent?.includes("MiniMax M2.1"),
|
b.textContent?.trim() === "Gateway",
|
||||||
) as HTMLButtonElement | undefined;
|
) as HTMLButtonElement | undefined;
|
||||||
expect(btn).toBeTruthy();
|
expect(btn).toBeTruthy();
|
||||||
btn?.click();
|
btn?.click();
|
||||||
|
expect(onSectionChange).toHaveBeenCalledWith("gateway");
|
||||||
const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? "");
|
|
||||||
expect(raw).toContain("EXISTING_KEY");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies Z.AI (GLM 4.7) preset", () => {
|
it("wires search input to onSearchChange", () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const onRawChange = vi.fn();
|
const onSearchChange = vi.fn();
|
||||||
const onFormPatch = vi.fn();
|
|
||||||
render(
|
render(
|
||||||
renderConfig({
|
renderConfig({
|
||||||
...baseProps(),
|
...baseProps(),
|
||||||
onRawChange,
|
onSearchChange,
|
||||||
onFormPatch,
|
|
||||||
}),
|
}),
|
||||||
container,
|
container,
|
||||||
);
|
);
|
||||||
|
|
||||||
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
|
const input = container.querySelector(
|
||||||
b.textContent?.includes("GLM 4.7"),
|
".config-search__input",
|
||||||
) as HTMLButtonElement | undefined;
|
) as HTMLInputElement | null;
|
||||||
expect(btn).toBeTruthy();
|
expect(input).not.toBeNull();
|
||||||
btn?.click();
|
if (!input) return;
|
||||||
|
input.value = "gateway";
|
||||||
const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? "");
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
expect(raw).toContain("zai/glm-4.7");
|
expect(onSearchChange).toHaveBeenCalledWith("gateway");
|
||||||
expect(raw).toContain("ZAI_API_KEY");
|
|
||||||
expect(onFormPatch).toHaveBeenCalledWith(
|
|
||||||
["agents", "defaults", "model", "primary"],
|
|
||||||
"zai/glm-4.7",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies Moonshot (Kimi) preset", () => {
|
|
||||||
const container = document.createElement("div");
|
|
||||||
const onRawChange = vi.fn();
|
|
||||||
const onFormPatch = vi.fn();
|
|
||||||
render(
|
|
||||||
renderConfig({
|
|
||||||
...baseProps(),
|
|
||||||
onRawChange,
|
|
||||||
onFormPatch,
|
|
||||||
}),
|
|
||||||
container,
|
|
||||||
);
|
|
||||||
|
|
||||||
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
|
|
||||||
b.textContent?.includes("Kimi"),
|
|
||||||
) as HTMLButtonElement | undefined;
|
|
||||||
expect(btn).toBeTruthy();
|
|
||||||
btn?.click();
|
|
||||||
|
|
||||||
const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? "");
|
|
||||||
expect(raw).toContain("https://api.moonshot.ai/v1");
|
|
||||||
expect(raw).toContain("moonshot/kimi-k2-0905-preview");
|
|
||||||
expect(raw).toContain("moonshot/kimi-k2-turbo-preview");
|
|
||||||
expect(raw).toContain("moonshot/kimi-k2-thinking");
|
|
||||||
expect(raw).toContain("moonshot/kimi-k2-thinking-turbo");
|
|
||||||
expect(raw).toContain("Kimi K2 Turbo");
|
|
||||||
expect(raw).toContain("Kimi K2 Thinking");
|
|
||||||
expect(raw).toContain("Kimi K2 Thinking Turbo");
|
|
||||||
expect(raw).toContain("MOONSHOT_API_KEY");
|
|
||||||
expect(onFormPatch).toHaveBeenCalledWith(
|
|
||||||
["agents", "defaults", "model", "primary"],
|
|
||||||
"moonshot/kimi-k2-0905-preview",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user