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 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();
}); });
}); });

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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"], {});

View File

@@ -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);
} }

View File

@@ -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 ?? "",

View File

@@ -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",
);
}); });
}); });