diff --git a/docs/quality/recurring-defects.md b/docs/quality/recurring-defects.md index 8cb7f9f7..aa879dce 100644 --- a/docs/quality/recurring-defects.md +++ b/docs/quality/recurring-defects.md @@ -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 没有识别文字,但缺图警告让图片附件切成文字发送,或让仅图片结果被判为 diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index aa65fa09..8d679e96 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -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 文案 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index f722821c..e11cbcbe 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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(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(); + return { ...original, ChatHistoryPane: historyRenderProbes.instrument(original.ChatHistoryPane, historyRenderProbes.pane) }; +}); +vi.mock('./ConversationTaskStrip', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, ConversationTaskStrip: historyRenderProbes.instrument(original.ConversationTaskStrip, historyRenderProbes.task) }; +}); +vi.mock('./RuntimeChecklistStrip', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, RuntimeChecklistStrip: historyRenderProbes.instrument(original.RuntimeChecklistStrip, historyRenderProbes.checklist) }; +}); vi.mock("./WorkspacePrimitives", async (importOriginal) => { const original = await importOriginal(); @@ -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(); + 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(); + 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(); + vi.mocked(api.context.selectFiles).mockReturnValueOnce(selection.promise); render(); 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('.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('.composer')!).findByText('replacement.png')).toBeVisible(); expect(api.context.selectFiles).toHaveBeenCalledOnce(); expect(run).not.toHaveBeenCalled(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 517bb058..8fa235ba 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 >({}); const input = conversationDrafts[activeId] ?? ""; - const setInput = useCallback( - (update: SetStateAction): void => { + const setConversationInput = useCallback( + (conversationId: string, update: SetStateAction): 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): 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 => { if (selectingContextFilesRef.current) { @@ -8206,16 +8215,16 @@ function App(): React.JSX.Element { return schedule; }; - const runAssistantSchedule = async (scheduleId: string): Promise => { + const runAssistantSchedule = useCallback(async (scheduleId: string): Promise => { 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 => { @@ -8225,14 +8234,39 @@ function App(): React.JSX.Element { schedule.id === scheduleId ? { ...schedule, enabled } : schedule, ), ); - }; + }, []); - const removeAssistantSchedule = async (scheduleId: string): Promise => { + const removeAssistantSchedule = useCallback(async (scheduleId: string): Promise => { 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, ( +
+ {!conversation.remote && ( + 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} + /> + )} + +
+ )]; + })), [cachedConversations, tasksByConversation, activeId, effectiveWorkMode, projects, locale, + removeAssistantSchedule, runAssistantSchedule, setAssistantScheduleEnabled, assistantSchedules, selectedAssistantTaskId]); const clearLocalData = async (): Promise => { 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={ -
- {!conversation.remote ? ( - 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} - -
- } + taskStrip={conversationTaskStrips.get(conversation.id)} visibleMessageCount={ visibleMessageCounts[conversation.id] ?? messageRenderBatchSize diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx index c2b16701..aa6df976 100644 --- a/src/renderer/src/ChatHistoryPane.tsx +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -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({ )} ); -} +}); diff --git a/src/renderer/src/ConversationTaskStrip.tsx b/src/renderer/src/ConversationTaskStrip.tsx index 4238decf..c28d8afc 100644 --- a/src/renderer/src/ConversationTaskStrip.tsx +++ b/src/renderer/src/ConversationTaskStrip.tsx @@ -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({ )} ) -} +}) diff --git a/src/renderer/src/RuntimeChecklistStrip.tsx b/src/renderer/src/RuntimeChecklistStrip.tsx index ca828385..64d8aea4 100644 --- a/src/renderer/src/RuntimeChecklistStrip.tsx +++ b/src/renderer/src/RuntimeChecklistStrip.tsx @@ -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 -} +}) function Checklist({ message }: { message: Message }): React.JSX.Element { const { t } = useTranslation('app')