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.
This commit is contained in:
Vincent Koc
2026-06-01 08:56:14 +01:00
committed by GitHub
parent 3b64ea83e8
commit 6627b4fbdd
3 changed files with 87 additions and 5 deletions

View File

@@ -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<string, string>());
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`<div data-testid="chat"></div>`;
return html`<div data-testid="chat">${props.composerControls}</div>`;
},
}));
vi.mock("./app-render.helpers.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("./app-render.helpers.ts")>();
return {
...actual,
renderChatControls: renderChatControlsMock,
};
});
vi.mock("./icons.ts", () => ({
icons: {},
}));
@@ -218,10 +228,12 @@ function createState(overrides: Partial<AppViewState> = {}): 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: {

View File

@@ -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<T>(
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:

View File

@@ -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<typeof guard>;
workspaceFiles?: {
agentId: string;
list: AgentsFilesListResult | null;