diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md
index 8c008b40..759483d5 100644
--- a/docs/roadmap/product-performance-experience-improvement-plan.md
+++ b/docs/roadmap/product-performance-experience-improvement-plan.md
@@ -605,6 +605,38 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
低成本热点:Composer 高度改为只在需要时测量、`t()` 结果或 i18next 语言码规范化做缓存、
滚动跟随按帧合并且只在贴底时执行。
+#### 2026-10-02 第二步:三处低成本热点
+
+- **Composer 高度(`App.tsx` `resizeComposerTextarea`):** 记住上次的值与高度。值未变不测量;
+ 在原文本末尾追加时不再先设 `height: auto`(只会变高),已到最大高度直接跳过测量;删除、
+ 替换、清空仍按原方式重置并测量。
+- **i18next 语言码(`i18n/index.ts`):** i18next 25 每次 `t()` 都经
+ `toResolveHierarchy`→`formatLanguageCode` 调用 `Intl.getCanonicalLocales`。初始化后对
+ `languageUtils.formatLanguageCode` 加按输入缓存(结果只取决于代码与静态初始化选项,上限
+ 64 项),翻译结果不变。
+- **滚动跟随(`ChatHistoryPane.tsx`):** 激活面板时仍同步滚到底;之后内容更新时改为每帧最多
+ 一次 `requestAnimationFrame` 中读取 `scrollHeight` 并 `scrollTo`,回调里再次确认仍贴底;
+ 卸载时取消。读者离开底部时不滚动,恢复阅读位置逻辑不变。
+- **测试:** 新增“流式三次更新只滚动一帧”(旧实现 3 次,失败)、Composer 追加与删除的测量
+ 次数、`t()` 不再调用 `Intl.getCanonicalLocales`。全量测试 5,621 通过,2 失败与本改动
+ 无关(真实 OpenCode 集成测试因免费额度返回 403;Supervisor 超时设置用例此前已失败)。
+- **`npm run perf:app` A/B(同机,改动前为临时 worktree 中的 `983cdd4` 构建,交替运行):**
+
+| 场景(4× 降速,3 轮) | 改动前 按键/点击 p50 / p95 | 改动后 |
+| --- | --- | --- |
+| 输入 120 字(新 profile) | 28–80 / 33–361 ms | 19–36 / 24–57 ms |
+| 输入 120 字(有 300 个会话) | 265–2,372 / 1,486–4,266 ms | 27–161 / 32–346 ms |
+| 首次切换会话 | 442–761 / 1,063–1,384 ms | 360–398 / 820–1,334 ms |
+| 再次切换(缓存命中) | 472–749 / 501–1,143 ms | 390–420 / 456–951 ms |
+| 流式 40 KB:Long Tasks 总时长 | 15.9–20.6 s | 15.1–17.4 s |
+| 流式期间输入 | 432–6,778 / 1,146–9,634 ms | 96–466 / 297–1,567 ms |
+
+ 不降速时(2 轮)两版各场景持平,输入 p95 均约 9 ms,流式 1 个 Long Task。4× 降速下
+ 方差很大(同版本轮间可差数倍),应看量级:有历史时输入从秒级回到百毫秒内,切换略有改善,
+ 流式本身仍是每帧卡顿——剩余成本是 App 级重渲染与 Markdown/React 提交,需 `PERF-13`。
+- **剩余:** 流式 delta 按帧合并后以 transition 提交、大面板 memo 仍待实施;之后进入
+ `PERF-13`。
+
### PERF-13 Renderer 领域 Store 迁移
- **优先级 / 状态:** P1 / 待开始
diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx
index 3371cccd..6a19c4de 100644
--- a/src/renderer/src/App.test.tsx
+++ b/src/renderer/src/App.test.tsx
@@ -3660,7 +3660,7 @@ describe("App", () => {
it("measures composer height once per input and resizes after sending", async () => {
render();
const input = await screen.findByLabelText("向 GoodBuddy 提问");
- const measureHeight = vi.fn(() =>
+ const measureHeight = vi.fn((): number =>
(input as HTMLTextAreaElement).value ? 300 : 0,
);
Object.defineProperty(input, "scrollHeight", {
@@ -3672,6 +3672,21 @@ describe("App", () => {
expect(measureHeight).toHaveBeenCalledTimes(1);
expect(input).toHaveStyle({ height: "220px" });
+ // Appending at the maximum height cannot change the size.
+ measureHeight.mockClear();
+ fireEvent.input(input, { target: { value: "First line\nSecond line!" } });
+ expect(measureHeight).not.toHaveBeenCalled();
+ expect(input).toHaveStyle({ height: "220px" });
+
+ // Deleting text may shrink the box, so it is measured again.
+ measureHeight.mockImplementation(() => 100);
+ fireEvent.input(input, { target: { value: "First" } });
+ expect(measureHeight).toHaveBeenCalledTimes(1);
+ expect(input).toHaveStyle({ height: "100px" });
+
+ measureHeight.mockImplementation(() =>
+ (input as HTMLTextAreaElement).value ? 300 : 0,
+ );
measureHeight.mockClear();
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(input).toHaveValue(""));
@@ -4477,6 +4492,51 @@ describe("App", () => {
expect(screen.getByRole("button", { name: "到底部" })).toBeInTheDocument();
});
+ it("follows streaming output at the bottom once per frame", async () => {
+ render();
+ fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
+ target: { value: "生成流式回复" },
+ });
+ fireEvent.click(screen.getByLabelText("发送"));
+ await waitFor(() => expect(run).toHaveBeenCalledOnce());
+ const request = run.mock.calls[0]?.[0];
+ if (!request) {
+ throw new Error("Missing request");
+ }
+ await act(
+ () =>
+ new Promise((resolve) => requestAnimationFrame(() => resolve())),
+ );
+ const chat = document.querySelector(".chat");
+ if (!chat) {
+ throw new Error("Missing chat scroll container");
+ }
+ let scrollHeight = 1_200;
+ Object.defineProperties(chat, {
+ clientHeight: { configurable: true, value: 400 },
+ scrollHeight: { configurable: true, get: () => scrollHeight },
+ scrollTop: { configurable: true, writable: true, value: 800 },
+ });
+ const scrollTo = vi.fn();
+ chat.scrollTo = scrollTo;
+
+ for (const delta of ["第一段", "第二段", "第三段"]) {
+ scrollHeight += 100;
+ act(() => {
+ agentListener?.({ requestId: request.requestId, type: "text", delta });
+ });
+ }
+ expect(await screen.findByText(/第三段/u)).toBeInTheDocument();
+ await act(
+ () =>
+ new Promise((resolve) => requestAnimationFrame(() => resolve())),
+ );
+
+ expect(scrollTo).toHaveBeenCalled();
+ expect(scrollTo.mock.calls.length).toBeLessThanOrEqual(2);
+ expect(scrollTo).toHaveBeenLastCalledWith({ top: 1_500, behavior: "auto" });
+ });
+
it("restores the reader position after activity continues on another page", async () => {
const { container } = render();
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 6bfd5f54..2277fac0 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1354,15 +1354,51 @@ function formatAttachmentSize(size: number): string {
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).
function resizeComposerTextarea(textarea: HTMLTextAreaElement | null): void {
if (!textarea) {
return;
}
- textarea.style.height = "auto";
- textarea.style.height = `${Math.max(
- composerTextareaMinHeight,
- Math.min(textarea.scrollHeight, composerTextareaMaxHeight),
- )}px`;
+ 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 });
}
const imageDataUrlPattern = /^data:image\/(png|jpeg|webp);base64,/u;
diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx
index 11bfe327..882c0a7e 100644
--- a/src/renderer/src/ChatHistoryPane.tsx
+++ b/src/renderer/src/ChatHistoryPane.tsx
@@ -109,6 +109,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
const latestScrollSnapshotRef = useRef(scrollSnapshot);
const restorePendingRef = useRef(true);
const wasActiveRef = useRef(false);
+ const followFrameRef = useRef(undefined);
const prependScrollPositionRef = useRef<
| {
scrollHeight: number;
@@ -260,14 +261,42 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
return;
}
}
- if (pinnedToBottomRef.current) {
+ if (!pinnedToBottomRef.current) {
+ return;
+ }
+ if (activated) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "auto",
});
+ return;
}
+ // Streaming updates commit many times per frame. Reading scrollHeight here
+ // would force a synchronous layout per commit, so follow the bottom once
+ // per frame instead; rAF still runs before the frame is painted.
+ if (followFrameRef.current !== undefined) {
+ return;
+ }
+ followFrameRef.current = requestAnimationFrame(() => {
+ followFrameRef.current = undefined;
+ const container = scrollRef.current;
+ if (!container || !pinnedToBottomRef.current) {
+ return;
+ }
+ container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
+ });
}, [active, conversation.messages, scrollSnapshot, visibleMessageCount]);
+ useEffect(
+ () => () => {
+ if (followFrameRef.current !== undefined) {
+ cancelAnimationFrame(followFrameRef.current);
+ followFrameRef.current = undefined;
+ }
+ },
+ [],
+ );
+
useLayoutEffect(() => {
const previous = prependScrollPositionRef.current;
if (!previous) {
diff --git a/src/renderer/src/i18n/i18n.test.ts b/src/renderer/src/i18n/i18n.test.ts
index d24daf59..3386228b 100644
--- a/src/renderer/src/i18n/i18n.test.ts
+++ b/src/renderer/src/i18n/i18n.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import i18n, {
changeUiLocale,
i18nResources
@@ -41,4 +41,20 @@ describe('renderer i18n resources', () => {
await changeUiLocale('zh-CN')
expect(i18n.t('navigation.chat')).toBe('对话')
})
+
+ it('canonicalizes language codes once instead of on every translation', async () => {
+ const getCanonicalLocales = vi.spyOn(Intl, 'getCanonicalLocales')
+ try {
+ await changeUiLocale('en-US')
+ expect(i18n.t('navigation.chat')).toBe('Chat')
+ getCanonicalLocales.mockClear()
+ for (let index = 0; index < 50; index += 1) {
+ i18n.t('navigation.chat')
+ }
+ expect(getCanonicalLocales).not.toHaveBeenCalled()
+ expect(i18n.services.languageUtils.formatLanguageCode('zh-cn')).toBe('zh-CN')
+ } finally {
+ getCanonicalLocales.mockRestore()
+ }
+ })
})
diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts
index 608ffa1f..f48488a4 100644
--- a/src/renderer/src/i18n/index.ts
+++ b/src/renderer/src/i18n/index.ts
@@ -68,6 +68,37 @@ void i18n
returnNull: false
})
+memoizeLanguageCodeFormatting(i18n)
+
+/**
+ * i18next canonicalizes the current language with `Intl.getCanonicalLocales`
+ * on every `t()` call. The result depends only on the code and the static init
+ * options, so cache it; large lists call `t()` hundreds of times per render.
+ */
+export function memoizeLanguageCodeFormatting(instance: typeof i18n): void {
+ const languageUtils = instance.services?.languageUtils as
+ | { formatLanguageCode?: (code: string) => string }
+ | undefined
+ const format = languageUtils?.formatLanguageCode
+ if (!languageUtils || typeof format !== 'function') {
+ return
+ }
+ const cache = new Map()
+ languageUtils.formatLanguageCode = (code: string): string => {
+ if (typeof code !== 'string') {
+ return format.call(languageUtils, code)
+ }
+ let formatted = cache.get(code)
+ if (formatted === undefined) {
+ formatted = format.call(languageUtils, code)
+ if (cache.size < 64) {
+ cache.set(code, formatted)
+ }
+ }
+ return formatted
+ }
+}
+
export async function changeUiLocale(locale: UiLocale): Promise {
await i18n.changeLanguage(locale)
document.documentElement.lang = locale