From dfa80e1e5d06d9c2d4e08c7ef93a9ee6f1e7f7d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 03:55:33 +0000 Subject: [PATCH] fix(ui): align control ui chat and config rendering --- ui/src/ui/app-settings.test.ts | 60 +++++-------- ui/src/ui/chat-markdown.browser.test.ts | 9 +- ui/src/ui/chat/message-normalizer.ts | 22 ++--- ui/src/ui/config-form.browser.test.ts | 55 ++++-------- ui/src/ui/views/chat.ts | 106 +++++++--------------- ui/src/ui/views/config-form.render.ts | 30 +++---- ui/src/ui/views/config.browser.test.ts | 112 ++++++------------------ 7 files changed, 123 insertions(+), 271 deletions(-) diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index be06d7d8b..aae48df6f 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -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[0]; +type SettingsHost = Parameters[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(); }); }); diff --git a/ui/src/ui/chat-markdown.browser.test.ts b/ui/src/ui/chat-markdown.browser.test.ts index d799d7f7f..732e4b86a 100644 --- a/ui/src/ui/chat-markdown.browser.test.ts +++ b/ui/src/ui/chat-markdown.browser.test.ts @@ -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(".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; diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index aa7b39d5c..e4cbab81d 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -26,17 +26,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { contentItems.some((item) => { const x = item as Record; 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; } diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index cb40b9908..92d1982d6 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -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(".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(".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"], {}); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 534f6441c..9bae523ed 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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` -
- ${props.loading ? html`
Loading chat…
` : nothing} - ${repeat(buildChatItems(props), (item) => item.key, (item) => { - if (item.kind === "reading-indicator") { - return renderReadingIndicatorGroup(assistantIdentity); - } + const thread = html` +
+ ${props.loading ? html`
Loading chat…
` : 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; - })} -
- `, - ); + return nothing; + })} +
+ `; return html`
@@ -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}`; } diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 149b40801..da8d38d8e 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -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`
🔍
@@ -247,7 +239,7 @@ export function renderConfigForm(props: ConfigFormProps) {
`; })() - : entries.map(([key, node]) => { + : filteredEntries.map(([key, node]) => { const meta = SECTION_META[key] ?? { label: key.charAt(0).toUpperCase() + key.slice(1), description: node.description ?? "", diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index b1636d294..663784791 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -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"); }); });