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
@@ -8,6 +8,7 @@ import {
type ActivityHistoryPage,
type ActivityHistorySnapshot,
type ActivityHistorySummary,
type ActivityHistoryUpdateResult,
type ActivityRecord
} from '../../shared/assistant-contracts'
import {
@@ -275,9 +276,15 @@ export class ActivityHistoryRepository {
})
}
/** Applies incremental changes in one transaction, without reading the history. */
update(input: unknown): void {
/**
* Applies incremental changes in one transaction, without reading the
* history. Returns the stored record of every upsert-call, which keeps
* the id and createdAt of an earlier record of the call the renderer may
* not have loaded.
*/
update(input: unknown): ActivityHistoryUpdateResult {
const update = activityHistoryUpdateSchema.parse(input)
const calls: ActivityRecord[] = []
this.write(() => {
const database = this.database
const nextSeq = database.prepare(
@@ -294,25 +301,95 @@ export class ActivityHistoryRepository {
'UPDATE activity_history_records SET record_json = ? WHERE record_key = ? AND record_json != ?'
)
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_id = ?')
const seq = (): number => (nextSeq.get() as { seq: number }).seq
// Shown rows (first occurrences) a content-matched change applies to, newest first.
type Row = { record_key: string; order_seq: number; record: ActivityRecord }
const select = (where: string, limit: number, ...args: SqlValue[]): Row[] =>
(database.prepare(
`SELECT record_key, order_seq, record_json FROM activity_history_records
WHERE occurrence = 0 AND ${where} ORDER BY order_seq DESC LIMIT ${limit}`
).all(...args) as Array<{ record_key: string; order_seq: number; record_json: string }>)
.map((row) => ({ record_key: row.record_key, order_seq: row.order_seq, record: parseRow(row.record_json) }))
const frontSeq = database.prepare(
'SELECT MAX(order_seq) AS seq FROM activity_history_records WHERE occurrence = 0'
)
const rewrite = (key: string, record: ActivityRecord): void => {
const json = JSON.stringify(activityRecordSchema.parse(record))
replaceInPlace.run(json, key, json)
}
for (const change of update.changes) {
if (change.type === 'remove-duplicates') {
database.exec('DELETE FROM activity_history_records WHERE occurrence != 0')
} else if (change.type === 'remove') {
remove.run(change.id)
} else {
// The first occurrence stays first when moved to the front, so its key is stable.
const key = activityRecordKey(change.record.id, 0)
const json = JSON.stringify(change.record)
const seq = (): number => (nextSeq.get() as { seq: number }).seq
if (!exists.get(key)) insert.run(key, json, seq())
else if (change.position === 'front') moveToFront.run(json, seq(), key)
else replaceInPlace.run(json, key, json)
switch (change.type) {
case 'remove-duplicates':
database.exec('DELETE FROM activity_history_records WHERE occurrence != 0')
break
case 'remove':
remove.run(change.id)
break
case 'upsert': {
// The first occurrence stays first when moved to the front, so its key is stable.
const key = activityRecordKey(change.record.id, 0)
const json = JSON.stringify(change.record)
if (!exists.get(key)) insert.run(key, json, seq())
else if (change.position === 'front') moveToFront.run(json, seq(), key)
else replaceInPlace.run(json, key, json)
break
}
case 'upsert-call': {
const incoming = change.record
const [existing] = select(
"request_id = ? AND kind = ? AND json_extract(record_json, '$.callId') = ?", 1,
incoming.requestId, incoming.kind, incoming.callId!
)
if (!existing) {
insert.run(activityRecordKey(incoming.id, 0), JSON.stringify(incoming), seq())
calls.push(incoming)
break
}
const before = existing.record
const unchanged = before.conversationId === incoming.conversationId && before.title === incoming.title &&
before.detail === incoming.detail && before.status === incoming.status
const front = existing.order_seq === (frontSeq.get() as { seq: number }).seq
const next = unchanged ? before : { ...incoming, id: before.id, createdAt: before.createdAt, scope: before.scope }
calls.push(next)
if (unchanged && front) break
moveToFront.run(JSON.stringify(activityRecordSchema.parse(next)), seq(), existing.record_key)
break
}
case 'update-request':
for (const row of select("request_id = ? AND kind = 'request'", -1, change.requestId)) {
rewrite(row.record_key, { ...row.record, status: change.status, detail: change.detail ?? row.record.detail })
}
break
case 'resolve-approval': {
const [row] = select("conversation_id = ? AND kind = 'approval' AND status = 'pending'", 1,
change.conversationId)
if (row) {
rewrite(row.record_key, { ...row.record, status: change.status, detail: `${row.record.detail}\n${change.detailLine}` })
}
break
}
case 'settle-request':
for (const row of select("request_id = ? AND kind != 'request' AND status_group = 'active'", -1,
change.requestId)) {
rewrite(row.record_key, { ...row.record, status: change.status, detail: `${row.record.detail}\n${change.detailLine}` })
}
break
case 'remove-request':
for (const row of select('request_id = ?', -1, change.requestId)) remove.run(row.record.id)
break
case 'remove-call':
for (const row of select(
"request_id = ? AND kind = 'tool' AND json_extract(record_json, '$.callId') = ?", -1,
change.requestId, change.callId
)) remove.run(row.record.id)
break
}
}
if (update.legacyHistoryMayBeIncomplete !== undefined) {
this.writeIncomplete(update.legacyHistoryMayBeIncomplete)
}
})
return { calls }
}
/**