fix: reduce chat typing and cached conversation switching overhead

Keep unchanged history panes and task strips from rerendering during typing, and stabilize message callbacks across cached conversation switches. Preserve streaming updates, scroll positions, drafts, and asynchronous attachment ownership.

Add App regressions for render isolation, warm switches, retry, and image recovery. Focused tests, typecheck, and lint passed; the full suite encountered unrelated settings failures.

Release note: 减少任务运行时输入和切换已缓存长会话的重复渲染,保留实时输出、滚动位置和会话草稿。
This commit is contained in:
mesalogo
2026-09-30 22:59:25 +08:00
parent bf64181c85
commit c23dca65e8
7 changed files with 223 additions and 78 deletions
+8
View File
@@ -293,6 +293,14 @@ Modal,兼顾 top layer 绘制、辅助技术的模态归属及焦点循环;
**注意**:React 允许在渲染期用守卫调用 `setState` 来响应 props 变化,这是官方推荐做法,不要
把它改写成 `useEffect`。仓库 lint 规则 `react-hooks/set-state-in-effect` 会拒绝后者。
2026-09-30 的 App 回归还确认两类关联开销:内联 JSX、回调和空数组使 memo 失效;
`useRef(hasConversationMigrationStorage())` 在每次输入时执行存储读取。初始化读取应延迟到
首次初始化,缓存面板的 props 应保持稳定。即时用户动作可在调用时读取当前会话;异步
草稿恢复、语音和附件操作须保留发起会话,不能统一改成完成时读取活动会话。
App 集成回归应计数 memo 边界内的真实渲染,覆盖输入、热切换和后续流式更新;仅测独立
消息组件无法发现父组件传入的不稳定 props。测量结果见
[PERF-10](../roadmap/product-performance-experience-improvement-plan.md#perf-10-keep-alive-轻量优化)。
## 解析警告被当作提取正文
**症状**:OCR 没有识别文字,但缺图警告让图片附件切成文字发送,或让仅图片结果被判为
@@ -372,14 +372,24 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
### PERF-10 Keep-alive 轻量优化
- **优先级 / 状态:** P2 / 待开始
- **实施条件:** `PERF-09` 确认当前缓存不达标。
- **优先级 / 状态:** P2 / 已完成输入与热切换渲染隔离,压力测试待完成
- **实施依据:** 2026-09-30 的真实 App 组件回归确认输入和热切换会重复渲染缓存内容;
`PERF-09` 的 Electron 内存与 GC 压力测试仍待执行。
- **范围:**
- 优先 memoize 隐藏 `ChatHistoryPane` 并稳定 props。
- `ChatHistoryPane`、任务条和 Runtime 清单使用 memo,稳定快捷输入、重试、图片恢复、
定时任务回调及任务条 JSX。异步草稿操作仍绑定发起时的会话。
- 仍不达标时才调整缓存数量或一小时有效期。
- 暂不引入完整虚拟化框架。
- **验收:** 内存和 commit 指标改善,同时保留会话切换位置和快速恢复体验。
`App.test.tsx` 修复前回归记录:运行请求期间连续输入三次,历史面板、任务条、清单各
渲染三次,会话迁移存储读取三次;两个已缓存会话往返一次,Markdown 渲染 324 次。
长会话已展开 160 条消息,另一会话含一条消息,计数包含展开的推理正文。修复后上述
计数均为零。最终用例还覆盖可见且展开的运行中任务条、实时文本与清单更新、停止、
会话草稿、图片素材选择期间切换会话,以及滚动位置和推理展开状态保留。
这些是 jsdom 中真实 App 的渲染次数,使用 mock preload;尚未测量 Electron 输入延迟、
绘制耗时或内存。流式事件未延后,缓存容量和有效期未改变。
## 8. 体验与可访问性
### UX-01 统一 Ask/Execute 文案
+137 -3
View File
@@ -55,6 +55,34 @@ const speechRecognitionMocks = vi.hoisted(() => ({
const messageRenderProbe = vi.hoisted(() => vi.fn());
const assistantTasksProbe = vi.hoisted(() => vi.fn());
const activityRenderProbe = vi.hoisted(() => vi.fn());
const historyRenderProbes = vi.hoisted(() => ({
pane: vi.fn(), task: vi.fn(), checklist: vi.fn(),
instrument<T>(component: T, probe: () => void): T {
const wrap = (render: (...args: never[]) => unknown) => new Proxy(render, {
apply(target, receiver, args) {
probe();
return Reflect.apply(target, receiver, args);
},
});
// Preserve the production memo boundary, and count calls inside it.
return (typeof component === 'function'
? wrap(component as (...args: never[]) => unknown)
: { ...component, type: wrap((component as { type: (...args: never[]) => unknown }).type) }) as T;
},
}));
vi.mock('./ChatHistoryPane', async (importOriginal) => {
const original = await importOriginal<typeof import('./ChatHistoryPane')>();
return { ...original, ChatHistoryPane: historyRenderProbes.instrument(original.ChatHistoryPane, historyRenderProbes.pane) };
});
vi.mock('./ConversationTaskStrip', async (importOriginal) => {
const original = await importOriginal<typeof import('./ConversationTaskStrip')>();
return { ...original, ConversationTaskStrip: historyRenderProbes.instrument(original.ConversationTaskStrip, historyRenderProbes.task) };
});
vi.mock('./RuntimeChecklistStrip', async (importOriginal) => {
const original = await importOriginal<typeof import('./RuntimeChecklistStrip')>();
return { ...original, RuntimeChecklistStrip: historyRenderProbes.instrument(original.RuntimeChecklistStrip, historyRenderProbes.checklist) };
});
vi.mock("./WorkspacePrimitives", async (importOriginal) => {
const original = await importOriginal<typeof import("./WorkspacePrimitives")>();
@@ -4498,7 +4526,88 @@ describe("App", () => {
expect(screen.getByRole("button", { name: "到底部" })).toBeInTheDocument();
});
it("keeps each conversation history window and reader position", async () => {
it('retries into the active draft after warm conversation switches', async () => {
const histories: ConversationSnapshot[] = ['First retry', 'Second retry'].map((title, index) => ({
id: crypto.randomUUID(), projectId, title, updatedAt: 2 - index,
messages: [
{ id: crypto.randomUUID(), role: 'user', content: `${title} prompt`, createdAt: 1, state: 'complete' },
{ id: crypto.randomUUID(), role: 'assistant', content: `${title} failed`, createdAt: 2, state: 'error' },
],
}));
vi.mocked(api.conversations.list).mockResolvedValueOnce(histories);
render(<App />);
await screen.findByText('First retry failed');
fireEvent.click(screen.getByText('Second retry').closest('button')!);
await screen.findByText('Second retry failed');
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
const composer = screen.getByLabelText('向 GoodBuddy 提问');
expect(composer).toHaveValue('Second retry prompt');
expect(composer).toHaveFocus();
fireEvent.click(screen.getByText('First retry').closest('button')!);
expect(composer).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
expect(composer).toHaveValue('First retry prompt');
fireEvent.click(screen.getByText('Second retry').closest('button')!);
expect(composer).toHaveValue('Second retry prompt');
expect(run).not.toHaveBeenCalled();
});
it("isolates history and task strips while typing during a running request", async () => {
const conversationId = crypto.randomUUID();
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
id: conversationId, projectId, title: 'Running render isolation', updatedAt: 2, messages: [],
}]);
vi.mocked(api.tasks.list).mockResolvedValue([{
id: crypto.randomUUID(), conversationId, projectId, title: 'Running scheduled task',
instructions: 'Keep task details live', origin: 'schedule', status: 'running',
createdAt: '2026-09-30T00:00:00.000Z',
}]);
render(<App />);
const taskStrip = await screen.findByRole('region', { name: '当前会话的任务' });
fireEvent.click(within(taskStrip).getByRole('button'));
expect(within(taskStrip).getByText('运行中')).toBeVisible();
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
fireEvent.change(composer, { target: { value: 'Running render isolation' } });
await waitFor(() => expect(screen.getByLabelText('加入待发送队列')).toBeEnabled());
fireEvent.click(screen.getByLabelText('加入待发送队列'));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
act(() => agentListener?.({ requestId, type: 'text', delta: 'Live response' }));
await screen.findByText('Live response');
act(() => agentListener?.({ requestId, type: 'checklist', checklist: {
source: 'opencode', items: [{ content: 'Working while typing', status: 'in_progress' }],
} }));
await screen.findByText('Working while typing');
expect(taskStrip).toBeVisible();
historyRenderProbes.pane.mockClear();
historyRenderProbes.task.mockClear();
historyRenderProbes.checklist.mockClear();
messageRenderProbe.mockClear();
const storageReads = vi.spyOn(Storage.prototype, 'getItem');
for (const value of ['a', 'ab', 'abc']) {
fireEvent.change(composer, { target: { value } });
}
expect.soft(historyRenderProbes.pane).toHaveBeenCalledTimes(0);
expect.soft(historyRenderProbes.task).toHaveBeenCalledTimes(0);
expect.soft(historyRenderProbes.checklist).toHaveBeenCalledTimes(0);
expect.soft(messageRenderProbe).toHaveBeenCalledTimes(0);
expect.soft(storageReads.mock.calls.filter(([key]) => key === 'goodbuddy.conversations.v1')).toHaveLength(0);
storageReads.mockRestore();
act(() => agentListener?.({ requestId, type: 'text', delta: ' continues' }));
await screen.findByText('Live response continues');
expect(historyRenderProbes.pane).toHaveBeenCalled();
expect(historyRenderProbes.checklist).toHaveBeenCalled();
expect(historyRenderProbes.task).toHaveBeenCalledTimes(0);
act(() => agentListener?.({ requestId, type: 'checklist', checklist: {
source: 'opencode', items: [{ content: 'Checklist updated live', status: 'in_progress' }],
} }));
await screen.findByText('Checklist updated live');
expect(composer).toHaveValue('abc');
fireEvent.click(screen.getByLabelText('停止生成'));
await waitFor(() => expect(api.agent.cancel).toHaveBeenCalledWith(requestId));
});
it("keeps each conversation history window and reader position on warm switches without rerendering messages", async () => {
const firstConversationId = "00000000-0000-4000-8000-000000000461";
const secondConversationId = "00000000-0000-4000-8000-000000000462";
const draftAttachment = {
@@ -4602,6 +4711,21 @@ describe("App", () => {
"第一段会话草稿",
);
expect(screen.getByText(draftAttachment.name)).toBeInTheDocument();
messageRenderProbe.mockClear();
historyRenderProbes.task.mockClear();
historyRenderProbes.checklist.mockClear();
fireEvent.click(screen.getByText("第二段会话").closest("button")!);
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("第二段会话草稿"));
fireEvent.click(screen.getByText("第一段长会话").closest("button")!);
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("第一段会话草稿"));
expect(messageRenderProbe).toHaveBeenCalledTimes(0);
expect(historyRenderProbes.task).toHaveBeenCalledTimes(0);
expect(historyRenderProbes.checklist).toHaveBeenCalledTimes(0);
expect(firstChat.scrollTop).toBe(225);
expect(reasoningDetails).toHaveAttribute('open');
historyRenderProbes.pane.mockClear();
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Warm cached draft' } });
expect(historyRenderProbes.pane).toHaveBeenCalledTimes(0);
});
it("requires an accessible confirmation before permanently deleting a conversation", async () => {
@@ -11651,6 +11775,7 @@ describe("App", () => {
it('recovers failed image editing through settings and composer attachments without resubmitting the operation', async () => {
const conversationId = crypto.randomUUID();
const otherId = crypto.randomUUID();
const operation: import('../../shared/image-generation-contracts').ImageOperation = {
id: crypto.randomUUID(), conversationId, messageId: crypto.randomUUID(), requestId: crypto.randomUUID(), callId: 'failed-edit',
modelProfileId: crypto.randomUUID(), modelName: 'original-image-model', modelProfileName: 'Original image connection',
@@ -11659,14 +11784,23 @@ describe("App", () => {
};
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: conversationId, projectId, title: 'Failed image editing', updatedAt: 2, workMode: 'execute', messages: [
{ id: operation.messageId, role: 'assistant', content: '', createdAt: 1, state: 'complete', imageOperations: [operation] },
] }]);
] }, { id: otherId, projectId, title: 'Other image draft', updatedAt: 1, messages: [] }]);
const source = { id: crypto.randomUUID(), name: 'replacement.png', size: 64, preview: 'Replacement image', kind: 'image' as const, contentUrl: 'data:image/png;base64,iVBORw0KGgo=' };
vi.mocked(api.context.selectFiles).mockResolvedValueOnce([source]);
const selection = deferred<ContextAttachment[]>();
vi.mocked(api.context.selectFiles).mockReturnValueOnce(selection.promise);
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: '重新选择素材' }));
const composer = screen.getByLabelText('向 GoodBuddy 提问');
expect(composer).toHaveValue('使用图片模型“Original image connection”:Change the background');
expect(composer).toHaveFocus();
await waitFor(() => expect(api.context.selectFiles).toHaveBeenCalledWith(conversationId));
fireEvent.click(screen.getByText('Other image draft').closest('button')!);
fireEvent.change(composer, { target: { value: 'Keep other image draft' } });
await act(async () => selection.resolve([source]));
expect(composer).toHaveValue('Keep other image draft');
expect(within(composer.closest<HTMLElement>('.composer')!).queryByText('replacement.png')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Failed image editing').closest('button')!);
expect(composer).toHaveValue('使用图片模型“Original image connection”:Change the background');
expect(await within(composer.closest<HTMLElement>('.composer')!).findByText('replacement.png')).toBeVisible();
expect(api.context.selectFiles).toHaveBeenCalledOnce();
expect(run).not.toHaveBeenCalled();
+56 -64
View File
@@ -575,6 +575,7 @@ const emptyTokenUsage: TokenUsageSummary = {
};
const storageKey = "goodbuddy.conversations.v1";
const emptyConversationTasks: AssistantTask[] = [];
const activeProjectStorageKey = "goodbuddy.active-project.v1";
@@ -1657,9 +1658,8 @@ function App(): React.JSX.Element {
tRef.current = t;
}, [t]);
const locale = i18n.resolvedLanguage === "en-US" ? "en-US" : "zh-CN";
const conversationMigrationStoragePresent = useRef(
hasConversationMigrationStorage(),
);
const [initialConversationMigrationStoragePresent] = useState(hasConversationMigrationStorage);
const conversationMigrationStoragePresent = useRef(initialConversationMigrationStoragePresent);
const [conversations, setConversations] = useState(() =>
loadConversations(
t("conversation.greeting"),
@@ -1773,10 +1773,10 @@ function App(): React.JSX.Element {
Record<string, string>
>({});
const input = conversationDrafts[activeId] ?? "";
const setInput = useCallback(
(update: SetStateAction<string>): void => {
const setConversationInput = useCallback(
(conversationId: string, update: SetStateAction<string>): void => {
setConversationDrafts((current) => {
const currentValue = current[activeId] ?? "";
const currentValue = current[conversationId] ?? "";
const nextValue =
typeof update === "function" ? update(currentValue) : update;
if (nextValue === currentValue) {
@@ -1784,13 +1784,18 @@ function App(): React.JSX.Element {
}
if (!nextValue) {
const next = { ...current };
delete next[activeId];
delete next[conversationId];
return next;
}
return { ...current, [activeId]: nextValue };
return { ...current, [conversationId]: nextValue };
});
},
[activeId],
[],
);
// Async composer operations retain the conversation that started them.
const setInput = useCallback(
(update: SetStateAction<string>): void => setConversationInput(activeId, update),
[activeId, setConversationInput],
);
const [voiceListening, setVoiceListening] = useState(false);
const [voiceRecording, setVoiceRecording] = useState(false);
@@ -2449,11 +2454,15 @@ function App(): React.JSX.Element {
>({});
const retryMessage = useCallback(
(content: string): void => {
setInput(content);
setConversationInput(activeConversationIdRef.current, content);
inputRef.current?.focus();
},
[setInput],
[setConversationInput],
);
const setQuickActionInput = useCallback((value: string): void => {
setConversationInput(activeConversationIdRef.current, value);
requestAnimationFrame(() => inputRef.current?.focus());
}, [setConversationInput]);
useEffect(
() =>
scheduleIdleRoutePreload(
@@ -7690,14 +7699,14 @@ function App(): React.JSX.Element {
}, [setView]);
const reselectImageSources = useCallback((operation: ImageOperation): void => {
if (operation.conversationId !== activeId) return;
setInput(current => current || t("chat.images.recoveryPrompt", {
if (operation.conversationId !== activeConversationIdRef.current) return;
setConversationInput(operation.conversationId, current => current || t("chat.images.recoveryPrompt", {
model: operation.modelProfileName ?? operation.modelName,
prompt: operation.input.prompt,
}));
inputRef.current?.focus();
attachmentButtonRef.current?.click();
}, [activeId, setInput, t]);
}, [setConversationInput, t]);
const selectContextFiles = async (paths?: string[]): Promise<void> => {
if (selectingContextFilesRef.current) {
@@ -8206,16 +8215,16 @@ function App(): React.JSX.Element {
return schedule;
};
const runAssistantSchedule = async (scheduleId: string): Promise<void> => {
const runAssistantSchedule = useCallback(async (scheduleId: string): Promise<void> => {
await window.goodbuddy.schedules.runNow(scheduleId);
setAssistantTasks(await window.goodbuddy.tasks.list());
notify({
tone: "success",
message: t("notices.scheduleStarted"),
});
};
}, [notify, t]);
const setAssistantScheduleEnabled = async (
const setAssistantScheduleEnabled = useCallback(async (
scheduleId: string,
enabled: boolean,
): Promise<void> => {
@@ -8225,14 +8234,39 @@ function App(): React.JSX.Element {
schedule.id === scheduleId ? { ...schedule, enabled } : schedule,
),
);
};
}, []);
const removeAssistantSchedule = async (scheduleId: string): Promise<void> => {
const removeAssistantSchedule = useCallback(async (scheduleId: string): Promise<void> => {
await window.goodbuddy.schedules.remove(scheduleId);
setAssistantSchedules((current) =>
current.filter((schedule) => schedule.id !== scheduleId),
);
};
}, []);
const conversationTaskStrips = useMemo(() => new Map(cachedConversations.map(conversation => {
const tasks = tasksByConversation.get(conversation.id) ?? emptyConversationTasks;
return [conversation.id, (
<div className="conversation-context-strips">
{!conversation.remote && (
<ConversationTaskStrip
conversationMode={conversation.id === activeId
? effectiveWorkMode
: normalizeInteractiveWorkMode(conversation.workMode ?? projects.find(project => project.id === conversation.projectId)?.defaultWorkMode)}
locale={locale}
onRemoveSchedule={removeAssistantSchedule}
onRunSchedule={runAssistantSchedule}
onSelectTask={setSelectedAssistantTaskId}
onSetScheduleEnabled={setAssistantScheduleEnabled}
schedules={assistantSchedules}
selectedTaskId={tasks.some(task => task.id === selectedAssistantTaskId) ? selectedAssistantTaskId : undefined}
tasks={tasks}
/>
)}
<RuntimeChecklistStrip messages={conversation.messages} activeMessageId={conversation.activeRequest?.messageId} />
</div>
)];
})), [cachedConversations, tasksByConversation, activeId, effectiveWorkMode, projects, locale,
removeAssistantSchedule, runAssistantSchedule, setAssistantScheduleEnabled, assistantSchedules, selectedAssistantTaskId]);
const clearLocalData = async (): Promise<void> => {
conversationPersistencePausedRef.current = true;
@@ -9504,55 +9538,13 @@ function App(): React.JSX.Element {
onRespondQuestion={respondToQuestion}
onRetry={retryMessage}
onScrollSnapshotChange={handleChatScrollSnapshotChange}
onSetInput={(value) => {
setInput(value);
requestAnimationFrame(() =>
inputRef.current?.focus(),
);
}}
onSetInput={setQuickActionInput}
onVisibleMessageCountChange={
handleVisibleMessageCountChange
}
quickActions={quickActions}
scrollSnapshot={chatScrollSnapshots[conversation.id]}
taskStrip={
<div className="conversation-context-strips">
{!conversation.remote ? (
<ConversationTaskStrip
conversationMode={
conversation.id === activeId
? effectiveWorkMode
: normalizeInteractiveWorkMode(
conversation.workMode ??
projects.find((project) => project.id === conversation.projectId)?.defaultWorkMode
)
}
locale={locale}
onRemoveSchedule={removeAssistantSchedule}
onRunSchedule={runAssistantSchedule}
onSelectTask={setSelectedAssistantTaskId}
onSetScheduleEnabled={setAssistantScheduleEnabled}
schedules={assistantSchedules}
selectedTaskId={
(
tasksByConversation.get(conversation.id) ?? []
).some(
(task) => task.id === selectedAssistantTaskId,
)
? selectedAssistantTaskId
: undefined
}
tasks={
tasksByConversation.get(conversation.id) ?? []
}
/>
) : undefined}
<RuntimeChecklistStrip
messages={conversation.messages}
activeMessageId={conversation.activeRequest?.messageId}
/>
</div>
}
taskStrip={conversationTaskStrips.get(conversation.id)}
visibleMessageCount={
visibleMessageCounts[conversation.id] ??
messageRenderBatchSize
+3 -2
View File
@@ -1,5 +1,6 @@
import { ArrowDown, FileText, Sparkles } from "lucide-react";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
@@ -34,7 +35,7 @@ export type ChatScrollSnapshot = {
scrollTop: number;
};
export function ChatHistoryPane({
export const ChatHistoryPane = memo(function ChatHistoryPane({
onAddToNote,
noteMessageNavigation,
onOpenImageModelSettings,
@@ -405,4 +406,4 @@ export function ChatHistoryPane({
)}
</div>
);
}
});
+3 -3
View File
@@ -4,7 +4,7 @@ import {
CircleAlert,
ClockFading
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
@@ -31,7 +31,7 @@ type ConversationTaskStripProps = {
tasks: AssistantTask[]
}
export function ConversationTaskStrip({
export const ConversationTaskStrip = memo(function ConversationTaskStrip({
conversationMode,
locale,
onRemoveSchedule,
@@ -213,4 +213,4 @@ export function ConversationTaskStrip({
)}
</section>
)
}
})
+3 -3
View File
@@ -1,5 +1,5 @@
import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } from 'lucide-react'
import { useId, useRef, useState } from 'react'
import { memo, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Message } from './ChatTimeline'
import { isCancelledMessage } from './message-terminal-status'
@@ -13,7 +13,7 @@ const statusIcons = {
cancelled: XCircle
}
export function RuntimeChecklistStrip({ messages, activeMessageId }: {
export const RuntimeChecklistStrip = memo(function RuntimeChecklistStrip({ messages, activeMessageId }: {
messages: readonly Message[]
activeMessageId?: string
}): React.JSX.Element | null {
@@ -26,7 +26,7 @@ export function RuntimeChecklistStrip({ messages, activeMessageId }: {
: messages.at(-1)
if (message?.role !== 'assistant' || !message.runtimeChecklist?.items.length) return null
return <Checklist key={message.id} message={message} />
}
})
function Checklist({ message }: { message: Message }): React.JSX.Element {
const { t } = useTranslation('app')