diff --git a/ui/config/control-ui-chunking.ts b/ui/config/control-ui-chunking.ts index 45bddb47497..a98137c3932 100644 --- a/ui/config/control-ui-chunking.ts +++ b/ui/config/control-ui-chunking.ts @@ -18,7 +18,9 @@ export function controlUiStableChunkName(id: string): string | undefined { // turn small route-graph changes into extra startup preload requests. if ( normalized.endsWith("/ui/src/components/config-form.shared.ts") || - normalized.endsWith("/ui/src/lib/clipboard.ts") + normalized.endsWith("/ui/src/lib/clipboard.ts") || + normalized.endsWith("/ui/src/build-info-normalizers.ts") || + normalized.endsWith("/ui/src/build-info.ts") ) { return "control-ui-shared"; } @@ -77,7 +79,9 @@ export const controlUiCodeSplitting = { normalizeModuleId(id).includes("/ui/src/") ? "control-ui-core" : "control-ui-foundation", tags: ["$initial"] as ["$initial"], priority: 10, - maxSize: 400 * 1024, + // 448 KiB packs the core graph into fewer chunks; the previous 400 KiB + // boundary split one core chunk in two, costing ~1.4 KiB startup gzip. + maxSize: 448 * 1024, }, ], }; diff --git a/ui/src/app/control-ui-chunking.test.ts b/ui/src/app/control-ui-chunking.test.ts index 5733dae1a17..dcfc1481651 100644 --- a/ui/src/app/control-ui-chunking.test.ts +++ b/ui/src/app/control-ui-chunking.test.ts @@ -26,6 +26,10 @@ describe("Control UI build chunking", () => { "control-ui-shared", ); expect(controlUiStableChunkName("/repo/ui/src/lib/clipboard.ts")).toBe("control-ui-shared"); + expect(controlUiStableChunkName("/repo/ui/src/build-info.ts")).toBe("control-ui-shared"); + expect(controlUiStableChunkName("/repo/ui/src/build-info-normalizers.ts")).toBe( + "control-ui-shared", + ); expect( controlUiStableChunkName("/tmp/openclaw-pnpm-node-modules/@noble/ed25519/index.js"), ).toBe("gateway-runtime"); @@ -37,7 +41,7 @@ describe("Control UI build chunking", () => { expect(controlUiCodeSplitting.includeDependenciesRecursively).toBe(false); expect(controlUiCodeSplitting.groups[1]).toMatchObject({ tags: ["$initial"], - maxSize: 400 * 1024, + maxSize: 448 * 1024, }); }); diff --git a/ui/src/components/app-sidebar-session-data.ts b/ui/src/components/app-sidebar-session-data.ts index 05a81b7a79d..3823ee8d50c 100644 --- a/ui/src/components/app-sidebar-session-data.ts +++ b/ui/src/components/app-sidebar-session-data.ts @@ -77,6 +77,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase { private sessionMutationEpoch = 0; private sessionsScrollElement: HTMLElement | null = null; private sessionsScrollResizeObserver: ResizeObserver | null = null; + private sessionsScrollStateFrame: number | null = null; private readonly sessionCatalogLive = new SessionCatalogLiveState(); private sessionCatalogAgentId: string | null = null; private sessionCatalogGeneration = 0; @@ -161,6 +162,10 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase { this.sessionsScrollResizeObserver?.disconnect(); this.sessionsScrollResizeObserver = null; this.sessionsScrollElement = null; + if (this.sessionsScrollStateFrame !== null) { + cancelAnimationFrame(this.sessionsScrollStateFrame); + this.sessionsScrollStateFrame = null; + } if (this.activeSessionLineageRetryTimer) { globalThis.clearTimeout(this.activeSessionLineageRetryTimer); this.activeSessionLineageRetryTimer = null; @@ -384,10 +389,25 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase { } } if (element) { - this.updateSessionsScrollState(element); + this.scheduleSessionsScrollStateSync(); } } + // Reading scrollHeight/scrollTop inside updated() forces a layout flush per + // render; one rAF-coalesced read rides the layout computed for paint anyway. + private scheduleSessionsScrollStateSync() { + if (this.sessionsScrollStateFrame !== null) { + return; + } + this.sessionsScrollStateFrame = requestAnimationFrame(() => { + this.sessionsScrollStateFrame = null; + const element = this.sessionsScrollElement; + if (element?.isConnected) { + this.updateSessionsScrollState(element); + } + }); + } + protected updateSessionsScrollState(element: HTMLElement) { const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); let nextState: SidebarSessionsScrollState = "none"; diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index aa47bff4bb9..ff919c7d1cc 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -161,6 +161,10 @@ type ChatComposerState = { composerInputIntentKey: string | null; pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null; goalExpandedId: string | null; + composerTextarea: HTMLTextAreaElement | null; + // Stable Lit ref: an inline arrow would change identity per render and force + // a layout re-measure of the textarea on every chat render, not just attach. + textareaRef: ((element?: Element) => void) | null; }; function createChatComposerState(): ChatComposerState { @@ -178,6 +182,8 @@ function createChatComposerState(): ChatComposerState { composerInputIntentKey: null, pendingClearedSubmittedDraft: null, goalExpandedId: null, + composerTextarea: null, + textareaRef: null, }; } @@ -1886,7 +1892,23 @@ export function renderChatComposer(props: ChatComposerProps) { const draftKey = composerDraftKey(props); const actionDraft = state.composingDraft?.key === draftKey ? state.composingDraft.value : visibleDraft; - let composerTextarea: HTMLTextAreaElement | null = null; + state.textareaRef ??= (element?: Element) => { + const nextTextarea = element instanceof HTMLTextAreaElement ? element : null; + const prevTextarea = state.composerTextarea; + if (prevTextarea && prevTextarea !== nextTextarea) { + disconnectTextareaOverflowObserver(prevTextarea); + } + state.composerTextarea = nextTextarea; + if (nextTextarea) { + observeTextareaOverflow(nextTextarea); + scheduleTextareaHeightAdjustment(nextTextarea); + } + }; + // The stable ref only measures on attach, so programmatic draft swaps (send + // clear, session switch, history restore) must re-measure explicitly. + if (state.composerTextarea?.isConnected && state.composerTextarea.value !== visibleDraft) { + scheduleTextareaHeightAdjustment(state.composerTextarea); + } const hasVisualAttachments = (props.attachments ?? []).some( (attachment) => !isLargePastedTextAttachment(attachment), ); @@ -2142,20 +2164,20 @@ export function renderChatComposer(props: ChatComposerProps) { commitComposerDraft(props, target.value); }; const handleSend = () => { - const draft = composerTextarea?.value ?? props.draft; + const draft = state.composerTextarea?.value ?? props.draft; if (!canSubmitDraft(draft)) { return; } commitComposerDraft(props, draft); props.onSend(); - syncComposerDraftAfterSend(composerTextarea); + syncComposerDraftAfterSend(state.composerTextarea); }; const handleVoicePrimaryAction = () => { if (props.realtimeTalkActive) { props.onToggleRealtimeTalk?.(); return; } - const liveDraft = composerTextarea?.value ?? visibleDraft; + const liveDraft = state.composerTextarea?.value ?? visibleDraft; if (liveDraft.trim() || props.attachments?.length) { handleSend(); return; @@ -2256,7 +2278,7 @@ export function renderChatComposer(props: ChatComposerProps) { onGoalEdit: (goal) => { commitComposerDraft(props, `/goal edit ${goal.objective}`); requestUpdate(); - queueMicrotask(() => composerTextarea?.focus({ preventScroll: true })); + queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true })); }, requestUpdate, })} @@ -2290,17 +2312,7 @@ export function renderChatComposer(props: ChatComposerProps) { ${renderChatAttachmentMenu({ ...props, disabled: !canCompose })}