perf(renderer): extract the chat composer into a memoized Composer with stable actions and a popup store

This commit is contained in:
mesalogo
2026-10-03 17:34:57 +08:00
parent 010aafb6f4
commit 41be9f8076
8 changed files with 2118 additions and 1475 deletions
+120 -1475
View File
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { createRef, Profiler } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Conversation } from "./chat-conversation";
import { Composer, type ComposerProps } from "./Composer";
import { createComposerDraftStore } from "./composer-draft-store";
import { createComposerMenuStore } from "./composer-menu-store";
import { createConversationStores } from "./conversation-store";
import { changeUiLocale } from "./i18n";
import type { ComposerActions } from "./use-composer-actions";
function conversation(id: string, overrides: Partial<Conversation> = {}): Conversation {
return { id, title: id, updatedAt: 1, messages: [], ...overrides } as Conversation;
}
const noop = (): void => undefined;
const actions = new Proxy({}, {
get: () => noop,
}) as ComposerActions;
function setup() {
const stores = createConversationStores(
[conversation("a"), conversation("b")],
{ flushIntervalMs: 0 },
);
const drafts = createComposerDraftStore();
const props: ComposerProps = {
actions,
activeProjectId: "",
activeRuntimeSelection: undefined,
assistantExpertOptions: [],
attachmentButtonRef: createRef(),
attachmentOperations: 0,
attachments: [],
composerDrafts: drafts,
contextError: undefined,
conversationHint: "",
conversationId: "a",
conversationStore: stores.conversations,
effectiveWorkMode: "ask",
executionRunning: false,
externalInstances: [],
fileSelectionProgress: undefined,
imageReferences: [],
inputRef: createRef(),
keyboardHint: "",
knowledgeLibraries: [],
menuStore: createComposerMenuStore(),
nativeClientAvailable: false,
nativeClientContextKey: "",
projectRecoveryBlocked: false,
projectRuntimeSelection: undefined,
projectUsesManagedSsh: false,
queueItems: [],
runtime: undefined,
runtimeActionOptions: [],
runtimeAgentOptions: [],
runtimeContextCompactAvailable: false,
runtimeContextCompacting: false,
runtimeLabel: "Model",
runtimeMenuButtonRef: createRef(),
runtimeModelDetail: undefined,
runtimeNativeSnapshot: undefined,
runtimePresetOptions: [],
runtimeSettings: undefined,
runtimeStatusKey: "",
runtimeSwitching: false,
selectedContinuePreset: "",
selectedExpertId: "",
selectedRuntimeAgent: "",
selectedRuntimeCommand: "",
selectingContextFiles: false,
updateAttachmentBusy: noop,
voiceListening: false,
voiceRecording: false,
workspaceView: "chat",
workModeOptions: [],
};
const renders = vi.fn();
const view = render(
<Profiler id="composer" onRender={renders}>
<Composer {...props} />
</Profiler>,
);
renders.mockClear();
return { drafts, props, renders, rerender: view.rerender, store: stores.conversations };
}
beforeEach(async () => {
await changeUiLocale("en-US");
vi.stubGlobal("goodbuddy", {
context: { pendingParsing: vi.fn(async () => []) },
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("Composer render boundary", () => {
it("does not re-render when another conversation changes", () => {
const { renders, store } = setup();
act(() => store.set((current) => current.map((item) =>
item.id === "b" ? { ...item, title: "B", updatedAt: 2 } : item)));
expect(renders).not.toHaveBeenCalled();
});
it("does not re-render when its own conversation only gains a message", () => {
const { renders, store } = setup();
act(() => store.set((current) => current.map((item) =>
item.id === "a"
? {
...item,
updatedAt: 2,
messages: [{ id: "m1", role: "user", content: "hi", createdAt: 1, state: "done", blocks: [] } as unknown as Conversation["messages"][number]],
}
: item)));
expect(renders).not.toHaveBeenCalled();
});
it("re-renders when its conversation starts running and shows the stop button", () => {
const { renders, store } = setup();
act(() => store.set((current) => current.map((item) =>
item.id === "a"
? {
...item,
messages: [{ id: "m1", role: "assistant", content: "", createdAt: 1, state: "streaming", blocks: [] } as unknown as Conversation["messages"][number]],
}
: item)));
expect(renders).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Stop generating" })).toBeTruthy();
});
it("typing in another conversation's draft does not re-render the composer", () => {
const { drafts, renders } = setup();
act(() => drafts.set("b", "hello"));
expect(renders).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
import { ChevronDown } from "lucide-react";
import { useEffect, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
// A composer picker (expert, work mode, runtime agent/preset/action) with a
// roving-focus menu. Moved out of App.tsx unchanged.
export type ComposerMenuOption<T extends string> = {
value: T;
label: string;
description: string;
disabled?: boolean;
};
export type RuntimeActionChoice = ComposerMenuOption<string> & {
action?: { type: "command"; id: string } | { type: "prompt"; prompt: string };
};
export function ComposerMenuSelect<T extends string>({
ariaLabel,
className,
describedBy,
disabled = false,
icon,
menuOpen,
onChange,
onOpenChange,
options,
triggerLabel,
value,
}: {
ariaLabel: string;
className: string;
describedBy?: string;
disabled?: boolean;
icon: ReactNode;
menuOpen: boolean;
onChange: (value: T) => void;
onOpenChange: (open: boolean) => void;
options: readonly ComposerMenuOption<T>[];
triggerLabel?: string;
value: T;
}): React.JSX.Element {
const { t } = useTranslation("app");
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const selectedOption =
options.find((option) => option.value === value) ?? options[0];
const selectionLabel = t("composer.menuSelection", {
label: ariaLabel,
selection: selectedOption?.label ?? "",
});
useEffect(() => {
if (!menuOpen) {
return;
}
const menu = menuRef.current;
if (!menu) {
return;
}
const menuItems = Array.from(
menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
).filter((item) => !item.disabled);
const initialItem =
menuItems.find((item) => item.getAttribute("aria-checked") === "true") ??
menuItems[0];
menuItems.forEach((item) => {
item.tabIndex = item === initialItem ? 0 : -1;
});
const focusFrame = requestAnimationFrame(() => {
initialItem?.focus();
});
const isMenuTarget = (target: EventTarget | null): boolean =>
target instanceof Node &&
(menu.contains(target) || buttonRef.current?.contains(target) === true);
const dismissOnOutsidePointer = (event: PointerEvent): void => {
if (!isMenuTarget(event.target)) {
onOpenChange(false);
}
};
const dismissOnOutsideFocus = (event: FocusEvent): void => {
if (!isMenuTarget(event.target)) {
onOpenChange(false);
}
};
document.addEventListener("pointerdown", dismissOnOutsidePointer);
document.addEventListener("focusin", dismissOnOutsideFocus);
return () => {
cancelAnimationFrame(focusFrame);
document.removeEventListener("pointerdown", dismissOnOutsidePointer);
document.removeEventListener("focusin", dismissOnOutsideFocus);
};
}, [menuOpen, onOpenChange, value]);
return (
<div className={`runtime-picker composer-picker ${className}`}>
<button
aria-describedby={describedBy}
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={selectionLabel}
className="model-button composer-picker__button"
disabled={disabled}
onClick={() => onOpenChange(!menuOpen)}
onKeyDown={(event) => {
if (
!menuOpen &&
(event.key === "ArrowDown" ||
event.key === "Enter" ||
event.key === " ")
) {
event.preventDefault();
onOpenChange(true);
}
}}
ref={buttonRef}
title={selectionLabel}
type="button"
>
{icon}
<span className="model-button__label">
{triggerLabel ?? selectedOption?.label}
</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
{menuOpen && (
<div
aria-label={ariaLabel}
className="runtime-picker__menu composer-picker__menu"
onKeyDown={(event) => {
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"]',
),
).filter((item) => !item.disabled);
const currentIndex = items.indexOf(
document.activeElement as HTMLButtonElement,
);
let nextIndex: number | undefined;
if (event.key === "ArrowDown") {
nextIndex = (currentIndex + 1) % items.length;
} else if (event.key === "ArrowUp") {
nextIndex = (currentIndex - 1 + items.length) % items.length;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = items.length - 1;
} else if (event.key === "Escape") {
event.preventDefault();
onOpenChange(false);
buttonRef.current?.focus();
}
const nextItem =
nextIndex === undefined ? undefined : items.at(nextIndex);
if (nextItem) {
event.preventDefault();
items.forEach((item) => {
item.tabIndex = item === nextItem ? 0 : -1;
});
nextItem.focus();
}
}}
ref={menuRef}
role="menu"
>
{options.map((option) => (
<button
aria-checked={option.value === value}
disabled={option.disabled}
key={option.value}
onClick={() => {
onChange(option.value);
onOpenChange(false);
requestAnimationFrame(() => {
buttonRef.current?.focus();
});
}}
role="menuitemradio"
tabIndex={option.value === value ? 0 : -1}
type="button"
>
<span>{option.label}</span>
<small>{option.description}</small>
</button>
))}
</div>
)}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
/**
* Open/closed state of the composer's popups (options dialog, knowledge
* scope, the pickers and the runtime menu). It lives outside App so opening a
* menu re-renders only the composer.
*
* The state belongs to one composer context (conversation, running state and
* view). Switching context closes every popup, as the earlier
* reset-during-render in App did, without an extra App render.
*/
export type ComposerMenuId =
| "expert"
| "mode"
| "runtime-agent"
| "runtime-action"
| "runtime-preset";
export type ComposerMenuState = {
readonly optionsOpen: boolean;
readonly knowledgeScopeOpen: boolean;
readonly menu: ComposerMenuId | undefined;
readonly runtimeMenuOpen: boolean;
};
export const closedComposerMenus: ComposerMenuState = Object.freeze({
optionsOpen: false,
knowledgeScopeOpen: false,
menu: undefined,
runtimeMenuOpen: false,
});
export type ComposerMenuUpdate =
| Partial<ComposerMenuState>
| ((current: ComposerMenuState) => Partial<ComposerMenuState>);
export type ComposerMenuStore = ReturnType<typeof createComposerMenuStore>;
function sameMenus(left: ComposerMenuState, right: ComposerMenuState): boolean {
return (
left.optionsOpen === right.optionsOpen &&
left.knowledgeScopeOpen === right.knowledgeScopeOpen &&
left.menu === right.menu &&
left.runtimeMenuOpen === right.runtimeMenuOpen
);
}
export function createComposerMenuStore() {
let key = "";
let menus = closedComposerMenus;
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) listener();
};
const store = {
/** The popups of `contextKey`; closed for any other context. */
get(contextKey: string): ComposerMenuState {
return contextKey === key ? menus : closedComposerMenus;
},
/** Makes `contextKey` current; popups of an earlier context stay closed. */
retain(contextKey: string): void {
if (contextKey === key) return;
const wasOpen = menus !== closedComposerMenus;
key = contextKey;
menus = closedComposerMenus;
if (wasOpen) notify();
},
update(contextKey: string, update: ComposerMenuUpdate): void {
const current = store.get(contextKey);
const patch = typeof update === "function" ? update(current) : update;
const next = { ...current, ...patch };
key = contextKey;
if (sameMenus(current, next)) {
menus = current;
return;
}
menus = sameMenus(next, closedComposerMenus) ? closedComposerMenus : next;
notify();
},
/** Closes the pickers and the runtime menu of whatever context is current. */
closeMenus(): void {
store.update(key, { menu: undefined, runtimeMenuOpen: false });
},
closeRuntimeMenu(): void {
store.update(key, { runtimeMenuOpen: false });
},
subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
return store;
}
export function useComposerMenus(
store: ComposerMenuStore,
contextKey: string,
): [ComposerMenuState, (update: ComposerMenuUpdate) => void] {
useLayoutEffect(() => {
store.retain(contextKey);
}, [contextKey, store]);
const getSnapshot = useCallback(() => store.get(contextKey), [contextKey, store]);
const menus = useSyncExternalStore(store.subscribe, getSnapshot);
const update = useCallback(
(next: ComposerMenuUpdate): void => store.update(contextKey, next),
[contextKey, store],
);
return [menus, update];
}
+55
View File
@@ -0,0 +1,55 @@
// Composer textarea sizing and attachment size formatting, moved out of App.tsx.
export function formatAttachmentSize(size: number): string {
return `${Math.max(1, Math.ceil(size / 1024))} KB`;
}
const composerTextareaMinHeight = 72;
const composerTextareaMaxHeight = 220;
const composerTextareaSizes = new WeakMap<
HTMLTextAreaElement,
{ height: number; value: string }
>();
function clampComposerTextareaHeight(height: number): number {
return Math.max(
composerTextareaMinHeight,
Math.min(height, composerTextareaMaxHeight),
);
}
// Sizing reads scrollHeight, which forces a synchronous layout of the whole
// window. Skip the read when the result is known, and avoid the extra
// "auto" reset layout when text was only appended (it can only grow).
export function resizeComposerTextarea(textarea: HTMLTextAreaElement | null): void {
if (!textarea) {
return;
}
const value = textarea.value;
const previous = composerTextareaSizes.get(textarea);
const previousHeightApplied =
previous !== undefined &&
textarea.style.height === `${previous.height}px`;
let height: number;
if (previousHeightApplied && value === previous.value) {
height = previous.height;
} else if (
previousHeightApplied &&
previous.value !== "" &&
value.startsWith(previous.value)
) {
height =
previous.height >= composerTextareaMaxHeight
? composerTextareaMaxHeight
: clampComposerTextareaHeight(textarea.scrollHeight);
} else {
textarea.style.height = "auto";
height = clampComposerTextareaHeight(textarea.scrollHeight);
}
const nextHeight = `${height}px`;
if (textarea.style.height !== nextHeight) {
textarea.style.height = nextHeight;
}
composerTextareaSizes.set(textarea, { height, value });
}
@@ -222,6 +222,66 @@ export function useActiveConversationView(
return useConversationStoreSelector(store, selector, sameActiveConversationView, subscribe);
}
/**
* The fields of one conversation the composer shows. Unlike the active
* conversation view it leaves out the message count and artifact IDs, so a
* new message or a tool result does not re-render the composer.
*/
export type ComposerConversationView = Pick<
Conversation,
| "id" | "runtimeSelection" | "knowledgeLibraryIds" | "knowledgeRetrievalMode"
| "remote" | "contextMetrics" | "contextCompressionState"
> & { running: boolean };
export function selectComposerConversationView(
conversation: Conversation | undefined,
): ComposerConversationView | undefined {
if (!conversation) return undefined;
return {
id: conversation.id,
runtimeSelection: conversation.runtimeSelection,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
remote: conversation.remote,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
running: conversation.messages.some((message) => message.state === "streaming"),
};
}
export function sameComposerConversationView(
left: ComposerConversationView | undefined,
right: ComposerConversationView | undefined,
): boolean {
if (left === right) return true;
if (!left || !right) return false;
return left.id === right.id && left.running === right.running &&
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
(left.knowledgeLibraryIds === undefined) === (right.knowledgeLibraryIds === undefined) &&
sameData(left.runtimeSelection, right.runtimeSelection) &&
sameData(left.remote, right.remote) &&
sameData(left.contextMetrics, right.contextMetrics) &&
sameData(left.contextCompressionState, right.contextCompressionState);
}
export function useComposerConversationView(
store: ConversationStore,
conversationId: string,
): ComposerConversationView | undefined {
const selector = useCallback(
(conversations: Conversation[]) => selectComposerConversationView(
conversations.find((conversation) => conversation.id === conversationId),
),
[conversationId],
);
const subscribe = useCallback(
(listener: () => void) => store.subscribeConversation(conversationId, listener),
[conversationId, store],
);
return useConversationStoreSelector(store, selector, sameComposerConversationView, subscribe);
}
/** The conversations with these IDs, in this order; stable while unchanged. */
export function useConversationsById(
store: ConversationStore,
+55
View File
@@ -0,0 +1,55 @@
import type { SetStateAction } from "react";
import type { InteractiveWorkMode } from "../../shared/assistant-contracts";
import type { ContextAttachment } from "../../shared/contracts";
import type { RuntimeSelectionLayer } from "../../shared/runtime-selection-contracts";
import type { TerminalSnapshot } from "../../shared/terminal-contracts";
import type { ImageViewerItem } from "./ChatTimeline";
import { useStableHandlers } from "./stable-derived-value";
/**
* Everything the composer asks App to do. App passes its latest closures;
* the returned object never changes identity, so the memoized Composer does
* not re-render because App created new callbacks.
*
* Calls run the implementation of App's latest commit. Async work that must
* stay with the conversation it started in receives that conversation's ID
* as an argument instead of reading App's current one.
*/
export type ComposerActions = {
submit: () => void;
stop: () => void;
selectContextFiles: (paths?: string[]) => void;
addContext: (
action: () => Promise<ContextAttachment | ContextAttachment[]>,
) => void;
isSelectingContextFiles: () => boolean;
setContextError: (message: string | undefined) => void;
removeAttachment: (attachmentId: string) => void;
removeImageReference: (conversationId: string, artifactId: string) => void;
restoreQueueItem: (conversationId: string, itemId: string) => Promise<void>;
interruptQueueItem: (itemId: string) => Promise<void>;
removeQueueItem: (itemId: string) => Promise<void>;
queueError: (message: string) => void;
toggleVoiceInput: () => void;
compactRuntimeContext: () => void;
openImageViewer: (item: ImageViewerItem, trigger: HTMLElement) => void;
openModelSettings: () => void;
setEnabledKnowledgeLibraryIds: (update: SetStateAction<string[]>) => void;
setKnowledgeRetrievalMode: (mode: "auto" | "always") => void;
selectExpert: (expertId: string) => void;
selectRuntimeAgent: (agentId: string) => void;
selectContinuePreset: (presetId: string) => void;
selectRuntimeAction: (value: string) => void;
setWorkMode: (mode: InteractiveWorkMode) => void;
switchRuntime: (layer: RuntimeSelectionLayer | undefined) => void;
prepareNativeClientConversation: () => Promise<string>;
openNativeTerminal: (
terminal: TerminalSnapshot,
origin: { projectId: string; conversationId: string },
) => void;
notify: (notification: { tone: "error" | "success"; message: string }) => void;
};
export function useComposerActions(latest: ComposerActions): ComposerActions {
return useStableHandlers(latest);
}