perf(activity): save activity history incrementally through an activity store and sync queue

The renderer no longer sends the whole history on every change. Domain
actions on an external activity store update the list and queue the matching
contract changes; activity-sync.ts flushes them every 250 ms via
activityHistory.update (<= 20,000 per call), keeps failed changes queued in
order with backoff retry, and flushes on quit and unmount. replace remains
only for the legacy localStorage migration; clearing uses the new
activityHistory.clear IPC. App's activity state is gone; ActivityPanel reads
a selector. update is now required in the desktop API.
This commit is contained in:
mesalogo
2026-10-03 20:45:51 +08:00
parent 2adee13638
commit 6aab78b9fa
16 changed files with 987 additions and 263 deletions
+32
View File
@@ -0,0 +1,32 @@
import type {
ActivityHistoryChange,
ActivityRecord
} from './assistant-contracts'
/**
* Reference semantics of `activityHistory.update` on a plain newest-first
* list (see `activityHistoryChangeSchema`), for tests and test doubles.
*/
export function applyActivityChanges(
list: readonly ActivityRecord[],
changes: readonly ActivityHistoryChange[]
): ActivityRecord[] {
let next = [...list]
for (const change of changes) {
if (change.type === 'remove') {
next = next.filter((item) => item.id !== change.id)
continue
}
if (change.type === 'remove-duplicates') {
const seen = new Set<string>()
next = next.filter((item) => !seen.has(item.id) && Boolean(seen.add(item.id)))
continue
}
const index = next.findIndex((item) => item.id === change.record.id)
if (index < 0) next = [change.record, ...next]
else if (change.position === 'front') {
next = [change.record, ...next.filter((_, at) => at !== index)]
} else next = next.map((item, at) => (at === index ? change.record : item))
}
return next
}
+4 -1
View File
@@ -110,6 +110,8 @@ export const activityHistorySnapshotSchema = z
* - `upsert` + `position: 'in-place'` replaces the existing record where it
* is; a record that does not exist yet is inserted at the front.
* - `remove` deletes every record with the ID.
* - `remove-duplicates` deletes every record but the first of each ID; the
* renderer shows only the first (see `mergeActivityRecords`).
*/
export const activityHistoryChangeSchema = z.discriminatedUnion('type', [
z.object({
@@ -120,7 +122,8 @@ export const activityHistoryChangeSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('remove'),
id: z.string().min(1).max(256)
}).strict()
}).strict(),
z.object({ type: z.literal('remove-duplicates') }).strict()
])
export const activityHistoryUpdateSchema = z
+4 -3
View File
@@ -2162,10 +2162,11 @@ export type DesktopApi = {
) => Promise<void>
/**
* Incremental save (PERF-15): only the changed records cross IPC and are
* written. Optional until renderer mocks adopt it; the preload always
* provides it.
* written. `replace` remains for the legacy localStorage migration.
*/
update?: (update: ActivityHistoryUpdate) => Promise<void>
update: (update: ActivityHistoryUpdate) => Promise<void>
/** Deletes the whole history in one transaction. */
clear: () => Promise<void>
}
usage: {
getTokenSummary: () => Promise<TokenUsageSummary>
+1
View File
@@ -256,6 +256,7 @@ export const ipcChannels = {
activityHistoryGet: 'activity-history:get',
activityHistoryReplace: 'activity-history:replace',
activityHistoryUpdate: 'activity-history:update',
activityHistoryClear: 'activity-history:clear',
tokenUsageSummary: 'usage:token-summary',
tokenUsageChanged: 'usage:token-changed',
artifactsList: 'artifacts:list',