perf(activity): keep only loaded activity pages in the renderer, driven by Main's summary

The renderer no longer holds the whole history. Startup sends queued
changes, lets Main reconcile leftover pending/running records against the
stored tasks (same localized interruptedOnRestart line), then loads the
first page (200). The Activity page loads a full visible batch (500, as
before paging); "load more" shows the next batch and loads the next page
behind it; a filter switch refetches that filter's first page; leaving the
page trims to one batch. Counts, project/conversation groups, titles,
conversation status and the clear count come from activityHistory.summary
(refreshed on entry, by the page timer, the refresh button and after load
more / filter changes).

Records that are not loaded are still updated incrementally: the update
contract gains content-matched changes (upsert-call, update-request,
resolve-approval, settle-request, remove-request, remove-call) that Main
applies in SQL to shown rows; the store applies the same semantics to its
pages through the shared reference. update returns Main's stored call
records so a call whose earlier record was not loaded keeps one id. Task
refresh reconciles via Main SQL. Legacy localStorage records migrate as
incremental changes; the renderer no longer calls replace.

Tests: randomized sequences of every action, page loads, trims, flushes and
injected failures against the former full-list code on a real database;
first page <= 200 after startup; flush payload depends only on changed
records (including unloaded ones); ActivityPanel output with pages + summary
equals the full-list rendering for randomized datasets with duplicates;
restart interrupts leftover records with the same detail; clear empties the
database, pages and summary; SQL change semantics equal the reference.
This commit is contained in:
mesalogo
2026-10-04 00:03:33 +08:00
parent 0e9c9d4927
commit bf9850a6a9
16 changed files with 1294 additions and 493 deletions
+71 -14
View File
@@ -120,21 +120,78 @@ export function applyActivityChanges(
changes: readonly ActivityHistoryChange[]
): ActivityRecord[] {
let next = [...list]
/** Indexes of shown records (first occurrences) that match. */
const shown = (match: (record: ActivityRecord) => boolean): number[] => {
const seen = new Set<string>()
const indexes: number[] = []
next.forEach((record, index) => {
if (!seen.has(record.id) && match(record)) indexes.push(index)
seen.add(record.id)
})
return indexes
}
const updateAt = (indexes: readonly number[], update: (record: ActivityRecord) => ActivityRecord): void => {
const set = new Set(indexes)
next = next.map((record, index) => (set.has(index) ? update(record) : record))
}
const removeIds = (ids: ReadonlySet<string>): void => {
next = next.filter((record) => !ids.has(record.id))
}
for (const change of changes) {
if (change.type === 'remove') {
next = next.filter((item) => item.id !== change.id)
continue
switch (change.type) {
case 'remove':
removeIds(new Set([change.id]))
break
case 'remove-duplicates':
next = firstOccurrences(next)
break
case 'upsert': {
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))
break
}
case 'upsert-call': {
const incoming = change.record
const [index] = shown((record) => record.kind === incoming.kind &&
record.requestId === incoming.requestId && record.callId === incoming.callId)
if (index === undefined) {
next = [incoming, ...next]
break
}
const existing = next[index]!
const unchanged = existing.conversationId === incoming.conversationId && existing.title === incoming.title &&
existing.detail === incoming.detail && existing.status === incoming.status
if (unchanged && index === 0) break
next = [
unchanged ? existing : { ...incoming, id: existing.id, createdAt: existing.createdAt, scope: existing.scope },
...next.filter((_, at) => at !== index)
]
break
}
case 'update-request':
updateAt(shown((record) => record.requestId === change.requestId && record.kind === 'request'),
(record) => ({ ...record, status: change.status, detail: change.detail ?? record.detail }))
break
case 'resolve-approval':
updateAt(shown((record) => record.conversationId === change.conversationId &&
record.kind === 'approval' && record.status === 'pending').slice(0, 1),
(record) => ({ ...record, status: change.status, detail: `${record.detail}\n${change.detailLine}` }))
break
case 'settle-request':
updateAt(shown((record) => record.requestId === change.requestId && record.kind !== 'request' &&
isActive(record)),
(record) => ({ ...record, status: change.status, detail: `${record.detail}\n${change.detailLine}` }))
break
case 'remove-request':
removeIds(new Set(shown((record) => record.requestId === change.requestId).map((index) => next[index]!.id)))
break
case 'remove-call':
removeIds(new Set(shown((record) => record.requestId === change.requestId && record.kind === 'tool' &&
record.callId === change.callId).map((index) => next[index]!.id)))
break
}
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
}
}