fix(ui): align control ui chat and config rendering

This commit is contained in:
Peter Steinberger
2026-01-24 03:55:33 +00:00
parent 951a4ea065
commit dfa80e1e5d
7 changed files with 123 additions and 271 deletions

View File

@@ -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 { 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 => ({
settings: {
@@ -30,62 +34,38 @@ const createHost = (tab: Tab): SettingsHost => ({
basePath: "",
themeMedia: null,
themeMediaHandler: null,
logsPollInterval: null,
debugPollInterval: null,
});
describe("setTabFromRoute", () => {
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
});
it("starts and stops log polling based on the tab", async () => {
const startLogsPolling = vi.fn();
const stopLogsPolling = vi.fn();
const startDebugPolling = vi.fn();
const stopDebugPolling = vi.fn();
afterEach(() => {
vi.useRealTimers();
});
vi.doMock("./app-polling", () => ({
startLogsPolling,
stopLogsPolling,
startDebugPolling,
stopDebugPolling,
}));
const { setTabFromRoute } = await import("./app-settings");
it("starts and stops log polling based on the tab", () => {
const host = createHost("chat");
setTabFromRoute(host, "logs");
expect(startLogsPolling).toHaveBeenCalledTimes(1);
expect(stopLogsPolling).not.toHaveBeenCalled();
expect(startDebugPolling).not.toHaveBeenCalled();
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
expect(host.logsPollInterval).not.toBeNull();
expect(host.debugPollInterval).toBeNull();
setTabFromRoute(host, "chat");
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
expect(host.logsPollInterval).toBeNull();
});
it("starts and stops debug polling based on the tab", async () => {
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");
it("starts and stops debug polling based on the tab", () => {
const host = createHost("chat");
setTabFromRoute(host, "debug");
expect(startDebugPolling).toHaveBeenCalledTimes(1);
expect(stopDebugPolling).not.toHaveBeenCalled();
expect(startLogsPolling).not.toHaveBeenCalled();
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
expect(host.debugPollInterval).not.toBeNull();
expect(host.logsPollInterval).toBeNull();
setTabFromRoute(host, "chat");
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
expect(host.debugPollInterval).toBeNull();
});
});

View File

@@ -46,8 +46,13 @@ describe("chat markdown rendering", () => {
await app.updateComplete;
const toolCard = app.querySelector(".chat-tool-card") as HTMLElement | null;
expect(toolCard).not.toBeNull();
const toolCards = Array.from(
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();
await app.updateComplete;

View File

@@ -26,17 +26,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
contentItems.some((item) => {
const x = item as Record<string, unknown>;
const t = String(x.type ?? "").toLowerCase();
return (
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)
);
return t === "toolresult" || t === "tool_result";
});
const hasToolName =
@@ -74,19 +64,19 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
*/
export function normalizeRoleForGrouping(role: string): string {
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.
if (
lower === "toolresult" ||
lower === "tool_result" ||
lower === "tool" ||
lower === "function" ||
lower === "toolresult"
lower === "function"
) {
return "tool";
}
if (lower === "assistant") return "assistant";
if (lower === "user") return "user";
if (lower === "system") return "system";
return role;
}

View File

@@ -69,20 +69,11 @@ describe("config form renderer", () => {
"abc123",
);
const select = container.querySelector("select") as HTMLSelectElement | null;
const selects = Array.from(container.querySelectorAll("select"));
const modeSelect = selects.find((el) =>
Array.from(el.options).some((opt) => opt.textContent?.trim() === "token"),
) as HTMLSelectElement | undefined;
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 }));
const tokenButton = Array.from(
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
).find((btn) => btn.textContent?.trim() === "token");
expect(tokenButton).not.toBeUndefined();
tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
const checkbox = container.querySelector(
@@ -110,16 +101,16 @@ describe("config form renderer", () => {
container,
);
const addButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Add",
);
const addButton = container.querySelector(
".cfg-array__add",
) as HTMLButtonElement | null;
expect(addButton).not.toBeUndefined();
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
const removeButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Remove",
);
const removeButton = container.querySelector(
".cfg-array__item-remove",
) as HTMLButtonElement | null;
expect(removeButton).not.toBeUndefined();
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
@@ -140,19 +131,11 @@ describe("config form renderer", () => {
container,
);
const selects = Array.from(container.querySelectorAll("select"));
const bindSelect = selects.find((el) =>
Array.from(el.options).some((opt) => opt.textContent?.trim() === "tailnet"),
) as HTMLSelectElement | undefined;
expect(bindSelect).not.toBeUndefined();
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 }));
const tailnetButton = Array.from(
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
).find((btn) => btn.textContent?.trim() === "tailnet");
expect(tailnetButton).not.toBeUndefined();
tailnetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
});
@@ -182,9 +165,9 @@ describe("config form renderer", () => {
container,
);
const removeButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Remove",
);
const removeButton = container.querySelector(
".cfg-map__item-remove",
) as HTMLButtonElement | null;
expect(removeButton).not.toBeUndefined();
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["slack"], {});

View File

@@ -1,5 +1,4 @@
import { html, nothing } from "lit";
import { guard } from "lit/directives/guard.js";
import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types";
import type { ChatQueueItem } from "../ui-types";
@@ -8,7 +7,6 @@ import {
normalizeMessage,
normalizeRoleForGrouping,
} from "../chat/message-normalizer";
import { extractTextCached } from "../chat/message-extract";
import {
renderMessageGroup,
renderReadingIndicatorGroup,
@@ -115,56 +113,41 @@ export function renderChat(props: ChatProps) {
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const thread = guard(
[
props.loading,
props.messages,
props.toolMessages,
props.stream,
props.streamStartedAt,
props.sessionKey,
props.showThinking,
reasoningLevel,
props.assistantName,
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);
}
const thread = 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") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
);
}
if (item.kind === "group") {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}
if (item.kind === "group") {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}
return nothing;
})}
</div>
`,
);
return nothing;
})}
</div>
`;
return html`
<section class="card chat">
@@ -395,27 +378,6 @@ function messageKey(message: unknown, index: number): string {
if (messageId) return `msg:${messageId}`;
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
const role = typeof m.role === "string" ? m.role : "unknown";
const fingerprint =
extractTextCached(message) ??
(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);
if (timestamp != null) return `msg:${role}:${timestamp}:${index}`;
return `msg:${role}:${index}`;
}

View File

@@ -154,32 +154,24 @@ export function renderConfigForm(props: ConfigFormProps) {
const activeSection = props.activeSection;
const activeSubsection = props.activeSubsection ?? null;
// Filter and sort entries
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 entries = Object.entries(properties).sort((a, b) => {
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50;
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50;
if (orderA !== orderB) return orderA - orderB;
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:
| { sectionKey: string; subsectionKey: string; schema: JsonSchema }
| null = null;
if (activeSection && activeSubsection && entries.length === 1) {
const sectionSchema = entries[0]?.[1];
if (activeSection && activeSubsection && filteredEntries.length === 1) {
const sectionSchema = filteredEntries[0]?.[1];
if (
sectionSchema &&
schemaType(sectionSchema) === "object" &&
@@ -194,7 +186,7 @@ export function renderConfigForm(props: ConfigFormProps) {
}
}
if (entries.length === 0) {
if (filteredEntries.length === 0) {
return html`
<div class="config-empty">
<div class="config-empty__icon">🔍</div>
@@ -247,7 +239,7 @@ export function renderConfigForm(props: ConfigFormProps) {
</section>
`;
})()
: entries.map(([key, node]) => {
: filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",

View File

@@ -67,53 +67,37 @@ describe("config view", () => {
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 onRawChange = vi.fn();
const onFormPatch = vi.fn();
const onFormModeChange = vi.fn();
render(
renderConfig({
...baseProps(),
onRawChange,
onFormPatch,
onFormModeChange,
}),
container,
);
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("MiniMax M2.1"),
b.textContent?.trim() === "Raw",
) as HTMLButtonElement | undefined;
expect(btn).toBeTruthy();
btn?.click();
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",
);
expect(onFormModeChange).toHaveBeenCalledWith("raw");
});
it("does not clobber existing MiniMax apiKey when applying preset", () => {
it("switches sections from the sidebar", () => {
const container = document.createElement("div");
const onRawChange = vi.fn();
const onSectionChange = vi.fn();
render(
renderConfig({
...baseProps(),
onRawChange,
formValue: {
models: {
mode: "merge",
providers: {
minimax: {
apiKey: "EXISTING_KEY",
},
},
onSectionChange,
schema: {
type: "object",
properties: {
gateway: { type: "object", properties: {} },
agents: { type: "object", properties: {} },
},
},
}),
@@ -121,75 +105,31 @@ describe("config view", () => {
);
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("MiniMax M2.1"),
b.textContent?.trim() === "Gateway",
) as HTMLButtonElement | undefined;
expect(btn).toBeTruthy();
btn?.click();
const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? "");
expect(raw).toContain("EXISTING_KEY");
expect(onSectionChange).toHaveBeenCalledWith("gateway");
});
it("applies Z.AI (GLM 4.7) preset", () => {
it("wires search input to onSearchChange", () => {
const container = document.createElement("div");
const onRawChange = vi.fn();
const onFormPatch = vi.fn();
const onSearchChange = vi.fn();
render(
renderConfig({
...baseProps(),
onRawChange,
onFormPatch,
onSearchChange,
}),
container,
);
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("GLM 4.7"),
) as HTMLButtonElement | undefined;
expect(btn).toBeTruthy();
btn?.click();
const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? "");
expect(raw).toContain("zai/glm-4.7");
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",
);
const input = container.querySelector(
".config-search__input",
) as HTMLInputElement | null;
expect(input).not.toBeNull();
if (!input) return;
input.value = "gateway";
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(onSearchChange).toHaveBeenCalledWith("gateway");
});
});