fix: reduce redundant work during chat and note startup

Avoid hidden activity renders, no-op message saves and attachment scans when pinning conversations. Reuse note body inspection and skip unchanged attachment reference writes while preserving recovery and cleanup behavior.

Skip unnecessary Markdown normalization and memoize subagent lookups without delaying live rich text. Add storage operation counts and real Electron rendering equivalence benchmarks; full-render speedup remains unproven.

Validation: 5397 tests passed, 84 skipped; typecheck passed. Lint passed excluding two existing untracked sidebar probe files.

Release note: Reduce repeated rendering and disk work during chat, conversation pinning and note startup while preserving existing content and interactions.
This commit is contained in:
mesalogo
2026-09-28 20:53:43 +08:00
parent 594222a2b5
commit 13ff182858
23 changed files with 1047 additions and 54 deletions
+43
View File
@@ -15,6 +15,18 @@ import { ActivityPanel } from './ActivityPanel'
import { upsertActivityRecord, type ActivityRecord } from './activity-store'
import i18n from './i18n'
const activityRenderProbe = vi.hoisted(() => vi.fn())
vi.mock('./WorkspacePrimitives', async (importOriginal) => {
const original = await importOriginal<typeof import('./WorkspacePrimitives')>()
return {
...original,
PageHeader: (props: Parameters<typeof original.PageHeader>[0]) => {
if (props.headingId === 'activity-panel-title') activityRenderProbe()
return <original.PageHeader {...props} />
}
}
})
function makeRecord(
index: number,
status: ActivityRecord['status'] = 'completed'
@@ -77,6 +89,37 @@ function makeTokenUsage(): TokenUsageSummary {
}
describe('ActivityPanel', () => {
it('skips unchanged hidden renders while retaining selection and accepting real updates', () => {
const record = makeRecord(1, 'running')
const props = { records: [record], tokenUsage: makeTokenUsage(), onClear: vi.fn(), onOpenConversation: vi.fn() }
const { rerender } = render(<div hidden={false}><ActivityPanel {...props} /></div>)
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
const node = screen.getByRole('button', { name: /活动 1,工具/u })
fireEvent.click(node)
const detail = screen.getByLabelText('选中的活动节点详情')
activityRenderProbe.mockClear()
for (let index = 0; index < 3; index++) {
rerender(<div hidden data-update={index}><ActivityPanel {...props} /></div>)
}
expect(activityRenderProbe).toHaveBeenCalledTimes(0)
const updated = { ...props, records: [{ ...record, detail: 'Real update', status: 'failed' as const }], onOpenConversation: vi.fn(), onClear: vi.fn() }
rerender(<div hidden><ActivityPanel {...updated} /></div>)
expect(activityRenderProbe).toHaveBeenCalledTimes(1)
rerender(<div hidden={false}><ActivityPanel {...updated} /></div>)
expect(activityRenderProbe).toHaveBeenCalledTimes(1)
expect(screen.getByLabelText('选中的活动节点详情')).toBe(detail)
expect(node).toHaveAttribute('aria-pressed', 'true')
expect(node).toHaveClass('activity-track__node--failed')
expect(within(detail).getByText('Real update')).toBeInTheDocument()
fireEvent.click(within(detail).getByRole('button', { name: '打开所属对话' }))
expect(updated.onOpenConversation).toHaveBeenCalledWith(record.conversationId)
expect(props.onOpenConversation).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '清空记录' }))
fireEvent.click(screen.getByRole('button', { name: '确认清空 1 条活动记录' }))
expect(updated.onClear).toHaveBeenCalledOnce()
expect(props.onClear).not.toHaveBeenCalled()
})
it('opens timeline guidance separately from tab navigation', () => {
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[makeRecord(1)]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
+3 -3
View File
@@ -1,5 +1,5 @@
import { Activity, ChevronRight, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
@@ -239,7 +239,7 @@ function groupActivityRecordsByProject(
})
}
export function ActivityPanel({
export const ActivityPanel = memo(function ActivityPanel({
projects = [],
records,
tokenUsage,
@@ -1165,4 +1165,4 @@ export function ActivityPanel({
)}
</section>
)
}
})
+74
View File
@@ -49,6 +49,18 @@ const speechRecognitionMocks = vi.hoisted(() => ({
const messageRenderProbe = vi.hoisted(() => vi.fn());
const assistantTasksProbe = vi.hoisted(() => vi.fn());
const activityRenderProbe = vi.hoisted(() => vi.fn());
vi.mock("./WorkspacePrimitives", async (importOriginal) => {
const original = await importOriginal<typeof import("./WorkspacePrimitives")>();
return {
...original,
PageHeader: (props: Parameters<typeof original.PageHeader>[0]) => {
if (props.headingId === "activity-panel-title") activityRenderProbe();
return <original.PageHeader {...props} />;
},
};
});
vi.mock("./use-unviewed-completions", async (importOriginal) => {
const original = await importOriginal<typeof import("./use-unviewed-completions")>();
@@ -2982,6 +2994,46 @@ describe("App", () => {
expect(route).not.toHaveAttribute("hidden");
});
it("isolates hidden Activity renders from composer and streaming updates", async () => {
render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Activity render isolation" } });
fireEvent.click(await screen.findByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
const heading = await screen.findByRole("heading", { name: "运行记录" });
fireEvent.click(screen.getByRole("tab", { name: "活动时间线" }));
const node = document.querySelector<HTMLButtonElement>(".activity-track__node")!;
fireEvent.click(node);
const detail = screen.getByLabelText("选中的活动节点详情");
fireEvent.click(screen.getByRole("button", { name: "对话" }));
const route = heading.closest('[data-route="activity"]')!;
expect(route).toHaveAttribute("hidden");
activityRenderProbe.mockClear();
for (const value of ["a", "ab", "abc"]) {
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value } });
}
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
activityRenderProbe.mockClear();
for (const delta of ["Stream", " response", " updated"]) {
act(() => agentListener?.({ requestId, type: "text", delta }));
await screen.findByText(new RegExp(delta.trim(), "u"));
}
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
act(() => agentListener?.({ requestId, type: "done" }));
await waitFor(() => expect(activityRenderProbe.mock.calls.length).toBeGreaterThan(0));
expect(route).toHaveAttribute("hidden");
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
expect(screen.getByRole("heading", { name: "运行记录" })).toBe(heading);
expect(screen.getByRole("tab", { name: "活动时间线" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByLabelText("选中的活动节点详情")).toBe(detail);
expect(node).toHaveAttribute("aria-pressed", "true");
expect(document.querySelectorAll(".activity-track__node")).toHaveLength(2);
fireEvent.click(within(detail).getByRole("button", { name: "打开所属对话" }));
expect(route).toHaveAttribute("hidden");
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("abc");
});
it("keeps Settings outside the workspace KeepAlive cache", async () => {
let now = 2_000_000;
vi.spyOn(Date, "now").mockImplementation(() => ++now);
@@ -12238,6 +12290,28 @@ describe("App", () => {
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
});
it("does not save a duplicate question-resolved event after the first resolution is persisted", async () => {
render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Persist question resolution" } });
fireEvent.click(await screen.findByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
act(() => agentListener?.({ requestId, type: "question", questionId: "persisted-question",
questions: [{ header: "Input", question: "Resolve me?", options: [], multiple: false, custom: true }] }));
expect(screen.getByText("Resolve me?")).toBeInTheDocument();
await act(async () => { await beforeQuitListener!(); });
vi.mocked(api.conversations.saveLocal).mockClear();
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "persisted-question" }));
expect(screen.queryByText("Resolve me?")).not.toBeInTheDocument();
await act(async () => { await beforeQuitListener!(); });
expect(api.conversations.saveLocal).toHaveBeenCalledTimes(1);
expect(vi.mocked(api.conversations.saveLocal).mock.calls[0]![0].flatMap(item => item.messages)).toHaveLength(1);
vi.mocked(api.conversations.saveLocal).mockClear();
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "persisted-question" }));
await act(async () => { await beforeQuitListener!(); });
expect(api.conversations.saveLocal).toHaveBeenCalledTimes(0);
});
it("renders, saves and reloads OpenCode answers across question rounds", async () => {
const view = render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
+24 -20
View File
@@ -3876,19 +3876,21 @@ function App(): React.JSX.Element {
messageId: string,
update: (message: Message) => Message,
): void => {
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
updatedAt: Date.now(),
messages: conversation.messages.map((message) =>
message.id === messageId ? update(message) : message,
),
}
: conversation,
),
);
setConversations((current) => {
const conversationIndex = current.findIndex(item => item.id === conversationId);
const conversation = current[conversationIndex];
if (!conversation) return current;
const messageIndex = conversation.messages.findIndex(message => message.id === messageId);
const message = conversation.messages[messageIndex];
if (!message) return current;
const updated = update(message);
if (updated === message) return current;
const messages = [...conversation.messages];
messages[messageIndex] = updated;
const next = [...current];
next[conversationIndex] = { ...conversation, updatedAt: Date.now(), messages };
return next;
});
},
[],
);
@@ -8159,7 +8161,13 @@ function App(): React.JSX.Element {
return 'opened';
};
const openActivityConversation = (conversationId: string): void => {
const clearActivity = useCallback((): void => {
legacyActivityHistoryMayBeIncompleteRef.current = false;
setLegacyActivityHistoryMayBeIncomplete(false);
setActivityRecords([]);
}, []);
const openActivityConversation = useCallback((conversationId: string): void => {
const open = async (): Promise<void> => {
let conversation = conversationsRef.current.find(
(candidate) => candidate.id === conversationId,
@@ -8195,7 +8203,7 @@ function App(): React.JSX.Element {
if (narrowWindow) closeNarrowSidebar();
};
requestWorkspaceLeave("chat", () => { void open(); });
};
}, [closeNarrowSidebar, commitView, narrowWindow, requestWorkspaceLeave, setActiveId, t]);
const openAssistantTask = (task: AssistantTask): void => {
if (!task.conversationId) {
@@ -11361,11 +11369,7 @@ function App(): React.JSX.Element {
}
>
<ActivityPanel
onClear={() => {
legacyActivityHistoryMayBeIncompleteRef.current = false;
setLegacyActivityHistoryMayBeIncomplete(false);
setActivityRecords([]);
}}
onClear={clearActivity}
onOpenConversation={openActivityConversation}
projects={projects}
records={activityRecords}
+91
View File
@@ -861,6 +861,97 @@ describe('ChatTimeline', () => {
}
})
it('reuses subagent derivations during streaming and invalidates only changed dependencies', () => {
const props = {
...callbacks, artifactById: new Map(), conversationId: 'subagent-stream',
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
messageStartIndex: 0, totalMessageCount: 1
}
const subagents: NonNullable<Message['subagents']> = ['a', 'b', 'c', 'd'].map(id => ({
childTaskId: id, expertId: id, expertName: `Expert ${id}`,
routingMode: 'native', state: 'running'
}))
const makeBlocks = (content: string): NonNullable<Message['blocks']> => [
{ id: 'b', type: 'subagent', childTaskId: 'b' },
{ id: 'missing', type: 'subagent', childTaskId: 'missing' },
{ id: 'text', type: 'text', content },
{ id: 'a', type: 'subagent', childTaskId: 'a' }
]
let message: Message = {
id: 'stream', role: 'assistant', content: 'Initial', createdAt: 0,
state: 'streaming', subagents, blocks: makeBlocks('Initial')
}
const map = vi.spyOn(subagents, 'map')
const filter = vi.spyOn(subagents, 'filter')
const blockFilters = [vi.spyOn(message.blocks!, 'filter')]
// Question IDs also filter blocks; count only passes returning subagent markers.
const counts = () => ({
map: map.mock.calls.length,
ordered: blockFilters.reduce((count, probe) => count + probe.mock.results.filter(
result => result.type === 'return' && result.value[0]?.type === 'subagent'
).length, 0),
unordered: filter.mock.calls.length
})
try {
const view = render(<ChatTimeline {...props} messages={[message]} />)
const cardNames = () => Array.from(view.container.querySelectorAll(
'.subagent-status-card__heading strong'
), element => element.textContent)
expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d'])
expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 })
for (let index = 0; index < 3; index += 1) {
message = { ...message, status: `Streaming ${index}` }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(screen.getByRole('status', { name: `Streaming ${index}` })).toBeVisible()
}
expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 })
for (let index = 0; index < 3; index += 1) {
const content = `Streamed block ${index}`
message = { ...message, content, blocks: makeBlocks(content) }
blockFilters.push(vi.spyOn(message.blocks!, 'filter'))
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(screen.getByText(content)).toBeVisible()
expect(markdownRenderProbe).toHaveBeenLastCalledWith(content)
expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d'])
}
expect(counts()).toEqual({ map: 1, ordered: 4, unordered: 4 })
// Both anchored lookup and unanchored fallback must receive new child data.
message = { ...message, subagents: subagents.map(child => ({
...child, expertName: `Updated ${child.childTaskId}`, state: 'completed',
output: `Result ${child.childTaskId}`
})) }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual(['Updated b', 'Updated a', 'Updated c', 'Updated d'])
for (const card of view.container.querySelectorAll<HTMLDetailsElement>('.subagent-status-card')) {
expect(card).toHaveClass('subagent-status-card--completed')
fireEvent.click(card.querySelector('summary')!)
fireEvent(card, new Event('toggle'))
const id = card.querySelector('strong')!.textContent!.slice(-1)
expect(within(card).getByText(`Result ${id}`)).toBeVisible()
}
// Move a fallback child into the ordered blocks without duplicating it.
message = { ...message, blocks: [{ id: 'd', type: 'subagent', childTaskId: 'd' }] }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual(['Updated d', 'Updated a', 'Updated b', 'Updated c'])
for (const blocks of [undefined, []]) {
message = { ...message, blocks }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual(['Updated a', 'Updated b', 'Updated c', 'Updated d'])
}
message = { ...message, subagents: undefined }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual([])
} finally {
map.mockRestore()
filter.mockRestore()
blockFilters.forEach(probe => probe.mockRestore())
}
})
it('shows every parallel expert output in its own expandable card', () => {
const messages: Message[] = [
{
+6 -6
View File
@@ -608,20 +608,20 @@ function ChatMessageRowView({
const compressionMarkers =
message.contextCompressions ??
(message.contextCompression ? [message.contextCompression] : [])
const subagentsById = new Map(
const subagentsById = useMemo(() => new Map(
(message.subagents ?? []).map((subagent) => [
subagent.childTaskId,
subagent
])
)
const orderedSubagentIds = new Set(
), [message.subagents])
const orderedSubagentIds = useMemo(() => new Set(
message.blocks
?.filter((block) => block.type === 'subagent')
.map((block) => block.childTaskId)
)
const unorderedSubagents = message.subagents?.filter(
), [message.blocks])
const unorderedSubagents = useMemo(() => message.subagents?.filter(
(subagent) => !orderedSubagentIds.has(subagent.childTaskId)
)
), [message.subagents, orderedSubagentIds])
const compressionLabel = (
compression: ConversationContextCompressionMarker
): string =>
+22 -1
View File
@@ -17,7 +17,8 @@ import {
import { changeUiLocale } from './i18n'
import {
InlineMarkdown,
MarkdownRenderer
MarkdownRenderer,
normalizeLatexDelimiters
} from './MarkdownRenderer'
const mermaidMock = vi.hoisted(() => ({
@@ -31,6 +32,26 @@ vi.mock('mermaid', () => ({
}))
describe('MarkdownRenderer', () => {
it.each([
['# Title\n\n**bold** `code`\n', '# Title\n\n**bold** `code`\n'],
['plain \\', 'plain \\'],
['partial \\(x', 'partial $x'],
['partial x\\)', 'partial x$'],
['\\(x\\)', '$$\nx\n$$'],
['\\[x\\]', '$$\nx\n$$'],
['$x$', '$$\nx\n$$'],
['`\\(code\\)` then \\(x\\)', '`\\(code\\)` then $x$'],
['~~~tex\n$x$\n\\(x\\)\n~~~', '~~~tex\n$x$\n\\(x\\)\n~~~']
])('preserves normalization across fast paths: %j', (content, expected) => {
expect(normalizeLatexDelimiters(content)).toBe(expected)
})
it('preserves CRLF conversion inside code and standalone HTML without math', () => {
expect(normalizeLatexDelimiters('```txt\r\na\r\n```')).toBe('```txt\na\n```')
expect(normalizeLatexDelimiters('<section>\r\na\r\n</section>')).toBe('<section>\na\n</section>')
expect(normalizeLatexDelimiters('a\rb\r')).toBe('a\rb\r')
})
beforeEach(() => {
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
app: { onPreviewEscape: vi.fn((listener: () => void) => {
+8 -1
View File
@@ -134,6 +134,9 @@ const wholeMarkdownFence =
/^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu
function replaceLatexDelimiters(line: string): string {
if (!line.includes('\\(') && !line.includes('\\)')) {
return line
}
let output = ''
let index = 0
let codeDelimiterLength = 0
@@ -171,7 +174,11 @@ function replaceLatexDelimiters(line: string): string {
return output
}
function normalizeLatexDelimiters(content: string): string {
export function normalizeLatexDelimiters(content: string): string {
// Without math markers or CRLF, every line would be returned unchanged.
if (!/[$\\\r]/u.test(content)) {
return content
}
let fence:
| {
character: '`' | '~'