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
@@ -5,8 +5,9 @@ import { join, resolve } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { build } from 'esbuild'
import { afterEach, describe, expect, it } from 'vitest'
import type { ActivityRecord, AssistantTask } from '../../shared/assistant-contracts'
import type { ActivityHistoryChange, ActivityRecord, AssistantTask } from '../../shared/assistant-contracts'
import {
applyActivityChanges,
firstOccurrences,
referenceActivityPage,
referenceActivityReconcile,
@@ -89,6 +90,34 @@ describe('ActivityHistoryRepository', () => {
}
})
it('applies content-matched changes in SQL exactly like the reference, including legacy duplicates', () => {
for (const seed of [4, 8, 15]) {
const random = makeRandom(seed)
const pick = <T,>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!
const { database } = open()
let list = randomList(random, 150).map((item, index) =>
item.kind === 'tool' || item.kind === 'subagent' ? { ...item, callId: `call-${index % 4}` } : item)
database.replaceActivityHistory({ records: list, legacyHistoryMayBeIncomplete: false })
list = firstOccurrences(list)
for (let round = 0; round < 80; round++) {
const requestId = `request-${Math.floor(random() * 10)}`
const status = pick(statuses)
const changes: ActivityHistoryChange[] = [pick([
(): ActivityHistoryChange => ({ type: 'upsert-call', record: { ...randomList(random, 1)[0]!, id: `new-${round}`,
kind: pick(['tool', 'subagent'] as const), requestId, callId: `call-${Math.floor(random() * 4)}` } }),
(): ActivityHistoryChange => ({ type: 'update-request', requestId, status, ...(random() < 0.5 ? { detail: 'd' } : {}) }),
(): ActivityHistoryChange => ({ type: 'resolve-approval', conversationId: `conversation-${Math.floor(random() * 6)}`, status, detailLine: 'x' }),
(): ActivityHistoryChange => ({ type: 'settle-request', requestId, status, detailLine: 'y' }),
(): ActivityHistoryChange => ({ type: 'remove-request', requestId }),
(): ActivityHistoryChange => ({ type: 'remove-call', requestId, callId: `call-${Math.floor(random() * 4)}` })
])()]
database.updateActivityHistory({ changes })
list = applyActivityChanges(list, changes)
expect(firstOccurrences(database.getActivityHistory().records)).toEqual(list)
}
}
})
it('reconciles with the stored tasks like the former renderer code', () => {
const { database } = open()
const random = makeRandom(5)