From 6627b4fbdd314ace2c2e33432347ac349753f077 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 08:56:14 +0100 Subject: [PATCH] perf(ui): guard chat composer controls Reduce Control UI draft-update work by guarding chat composer controls while keeping locale, session, model, settings, and busy-state invalidation. Verification: focused UI tests, format/lint/typecheck, autoreview clean, and changed gate tbx_01kt12rgjs8c077p2s0wmcsbyf. --- ui/src/ui/app-render.assistant-avatar.test.ts | 45 ++++++++++++++++++- ui/src/ui/app-render.ts | 45 ++++++++++++++++++- ui/src/ui/views/chat.ts | 2 +- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index bfcda92d9984..02ce23cfd832 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -2,6 +2,7 @@ import { html, render } from "lit"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "../i18n/index.ts"; import type { AppViewState } from "./app-view-state.ts"; import type { ChatProps } from "./views/chat.ts"; import type { QuickSettingsProps } from "./views/config-quick.ts"; @@ -13,6 +14,7 @@ const chatProps = vi.hoisted(() => ({ current: null as ChatProps | null, })); const localStorageValues = vi.hoisted(() => new Map()); +const renderChatControlsMock = vi.hoisted(() => vi.fn(() => "chat-controls")); vi.mock("../local-storage.ts", () => ({ getSafeLocalStorage: () => ({ @@ -33,10 +35,18 @@ vi.mock("./views/config-quick.ts", () => ({ vi.mock("./views/chat.ts", () => ({ renderChat: (props: ChatProps) => { chatProps.current = props; - return html`
`; + return html`
${props.composerControls}
`; }, })); +vi.mock("./app-render.helpers.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderChatControls: renderChatControlsMock, + }; +}); + vi.mock("./icons.ts", () => ({ icons: {}, })); @@ -218,10 +228,12 @@ function createState(overrides: Partial = {}): AppViewState { } as unknown as AppViewState; } -beforeEach(() => { +beforeEach(async () => { + await i18n.setLocale("en"); localStorageValues.clear(); quickSettingsProps.current = null; chatProps.current = null; + renderChatControlsMock.mockClear(); }); describe("renderApp assistant avatar routing", () => { @@ -332,6 +344,35 @@ describe("renderApp assistant avatar routing", () => { expect(chatProps.current?.error).toBe("gateway disconnected"); }); + it("does not rebuild chat composer controls for draft-only rerenders", () => { + const container = document.createElement("div"); + const state = createState({ tab: "chat", chatMessage: "" }); + + render(renderApp(state), container); + state.chatMessage = "h"; + render(renderApp(state), container); + state.chatMessage = "hello"; + render(renderApp(state), container); + + expect(renderChatControlsMock).toHaveBeenCalledTimes(1); + + state.chatSending = true; + render(renderApp(state), container); + + expect(renderChatControlsMock).toHaveBeenCalledTimes(2); + }); + + it("rebuilds chat composer controls after locale changes", async () => { + const container = document.createElement("div"); + const state = createState({ tab: "chat", chatMessage: "" }); + + render(renderApp(state), container); + await i18n.setLocale("zh-CN"); + render(renderApp(state), container); + + expect(renderChatControlsMock).toHaveBeenCalledTimes(2); + }); + it("passes security quick setting fields to Quick Settings", () => { const state = createState({ configForm: { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 60ed6c72acd5..290a08f9b60e 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; +import { guard } from "lit/directives/guard.js"; import { styleMap } from "lit/directives/style-map.js"; -import { t } from "../i18n/index.ts"; +import { i18n, t } from "../i18n/index.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; import { createChatSessionsLoadOverrides, @@ -754,6 +755,46 @@ function renderMeasured( return result; } +function renderGuardedChatControls(state: AppViewState) { + return guard( + [ + state.sessionKey, + state.connected, + state.client, + state.onboarding, + state.chatManualRefreshInFlight, + state.chatLoading, + state.chatSending, + state.chatStream, + state.chatRunId, + state.chatMobileControlsOpen, + state.sessionsHideCron ?? true, + state.sessionsResult, + state.sessionsShowArchived, + state.agentsList, + state.chatModelOverrides, + state.chatModelSwitchPromises, + state.chatModelsLoading, + state.chatModelCatalog, + state.settings.chatShowThinking, + state.settings.chatShowToolCalls, + state.settings.chatAutoScroll, + state.chatSessionPickerOpen, + state.chatSessionPickerSurface, + state.chatSessionPickerQuery, + state.chatSessionPickerAppliedQuery, + state.chatSessionPickerLoading, + state.chatSessionPickerError, + state.chatSessionPickerResult, + state.sessionSwitchNotice?.id ?? null, + state.sessionSwitchNotice?.text ?? null, + state.sessionSwitchFlashKey, + i18n.getLocale(), + ], + () => renderChatControls(state), + ); +} + function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; const parsed = parseAgentSessionKey(state.sessionKey); @@ -3149,7 +3190,7 @@ export function renderApp(state: AppViewState) { runStatus: state.chatRunStatus, onDismissError: () => dismissChatError(state), sessions: state.sessionsResult, - composerControls: renderChatControls(state), + composerControls: renderGuardedChatControls(state), workspaceFiles: { agentId: chatAgentId, list: diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index bed388b08d8c..2fecbba3dbb5 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -179,7 +179,7 @@ export type ChatProps = { onSplitRatioChange?: (ratio: number) => void; onChatScroll?: (event: Event) => void; basePath?: string; - composerControls?: TemplateResult | typeof nothing; + composerControls?: TemplateResult | typeof nothing | ReturnType; workspaceFiles?: { agentId: string; list: AgentsFilesListResult | null;