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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -110,9 +110,25 @@ 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`).
|
||||
* The other changes match shown records (first occurrences) by content, so
|
||||
* the renderer does not need to have them loaded:
|
||||
* - `upsert-call`: a tool/subagent record with a callId. The first record
|
||||
* of the same kind, request and call is moved to the front with the new
|
||||
* content but its own id, createdAt and scope (nothing happens when it is
|
||||
* unchanged and already first); without one the record is inserted at the
|
||||
* front (renderer `upsertActivityRecord`).
|
||||
* - `update-request`: every request record of the request gets the status
|
||||
* (and the detail, when given), in place.
|
||||
* - `resolve-approval`: the first pending approval of the conversation gets
|
||||
* the status and `detailLine` appended on a new line, in place.
|
||||
* - `settle-request`: every pending/running non-request record of the
|
||||
* request gets the status and `detailLine` appended, in place.
|
||||
* - `remove-request`: deletes every record of the request.
|
||||
* - `remove-call`: deletes the tool records of the request with the callId.
|
||||
*/
|
||||
const activityIdSchema = z.string().min(1).max(256)
|
||||
const activityStatusSchema = activityRecordSchema.shape.status
|
||||
const activityDetailLineSchema = z.string().max(1_000_000)
|
||||
export const activityHistoryChangeSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('upsert'),
|
||||
@@ -121,9 +137,43 @@ export const activityHistoryChangeSchema = z.discriminatedUnion('type', [
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('remove'),
|
||||
id: z.string().min(1).max(256)
|
||||
id: activityIdSchema
|
||||
}).strict(),
|
||||
z.object({ type: z.literal('remove-duplicates') }).strict()
|
||||
z.object({ type: z.literal('remove-duplicates') }).strict(),
|
||||
z.object({
|
||||
type: z.literal('upsert-call'),
|
||||
record: activityRecordSchema.refine(
|
||||
(record) => (record.kind === 'tool' || record.kind === 'subagent') && record.callId !== undefined,
|
||||
'upsert-call needs a tool or subagent record with a callId'
|
||||
)
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('update-request'),
|
||||
requestId: activityIdSchema,
|
||||
status: activityStatusSchema,
|
||||
detail: z.string().optional()
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('resolve-approval'),
|
||||
conversationId: activityIdSchema,
|
||||
status: activityStatusSchema,
|
||||
detailLine: activityDetailLineSchema
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('settle-request'),
|
||||
requestId: activityIdSchema,
|
||||
status: activityStatusSchema,
|
||||
detailLine: activityDetailLineSchema
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('remove-request'),
|
||||
requestId: activityIdSchema
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('remove-call'),
|
||||
requestId: activityIdSchema,
|
||||
callId: activityIdSchema
|
||||
}).strict()
|
||||
])
|
||||
|
||||
export const activityHistoryUpdateSchema = z
|
||||
@@ -189,6 +239,8 @@ export type ActivityHistorySnapshot = z.infer<
|
||||
>
|
||||
export type ActivityHistoryChange = z.infer<typeof activityHistoryChangeSchema>
|
||||
export type ActivityHistoryUpdate = z.infer<typeof activityHistoryUpdateSchema>
|
||||
/** The stored record of each upsert-call change, in order. */
|
||||
export type ActivityHistoryUpdateResult = { calls: ActivityRecord[] }
|
||||
|
||||
export const projectExecutionSpaceSchema = z.discriminatedUnion(
|
||||
'kind',
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type ActivityHistorySummary,
|
||||
type ActivityHistorySummaryRequest,
|
||||
type ActivityHistoryUpdate,
|
||||
type ActivityHistoryUpdateResult,
|
||||
type ActivityRecord,
|
||||
type TokenUsageSummary,
|
||||
type ConversationSnapshot,
|
||||
@@ -2169,7 +2170,7 @@ export type DesktopApi = {
|
||||
* Incremental save (PERF-15): only the changed records cross IPC and are
|
||||
* written. `replace` remains for the legacy localStorage migration.
|
||||
*/
|
||||
update: (update: ActivityHistoryUpdate) => Promise<void>
|
||||
update: (update: ActivityHistoryUpdate) => Promise<ActivityHistoryUpdateResult | void>
|
||||
/** Deletes the whole history in one transaction. */
|
||||
clear: () => Promise<void>
|
||||
/** One page of shown records, newest first (see activityHistoryPageRequestSchema). */
|
||||
|
||||
Reference in New Issue
Block a user