diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5d256f7c6206..de32f83d1cf6 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -291,6 +291,100 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps completed tool rows stable when another activity arrives", () => { + const turnId = TurnId.make("tool-history"); + const started = makeActivity({ + id: EventId.make("tool-start"), + kind: "tool.updated", + summary: "Run checks", + createdAt: "2026-04-01T00:00:01.000Z", + turnId, + payload: { + toolCallId: "checks", + itemType: "command_execution", + data: { command: "vp test" }, + status: "inProgress", + }, + }); + const completed = makeActivity({ + ...started, + id: EventId.make("tool-complete"), + kind: "tool.completed", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { toolCallId: "checks", status: "completed", detail: "All checks passed" }, + }); + const thread = { messages: [], activities: [started, completed] }; + const rows = (activities: ReadonlyArray) => + buildThreadFeed({ ...thread, activities }).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + const previous = rows(thread.activities)[0]!; + const appended = makeActivity({ + id: EventId.make("next-tool"), + kind: "tool.completed", + summary: "Read file", + createdAt: "2026-04-01T00:00:03.000Z", + turnId, + payload: { toolCallId: "read", itemType: "file_read", status: "completed" }, + }); + const next = rows([...thread.activities, appended]); + expect(next).toHaveLength(2); + expect(next[0]).toBe(previous); + expect(next[0]?.getCopyText()).toContain("All checks passed"); + expect(next[0]?.status).toBe("success"); + + const failed = { + ...completed, + payload: { toolCallId: "checks", status: "failed", detail: "Checks failed" }, + }; + const updated = rows([started, failed, appended]); + expect(updated[0]).not.toBe(previous); + expect(updated[0]?.status).toBe("failure"); + expect(updated[0]?.getCopyText()).toContain("Checks failed"); + expect(updated[1]).toBe(next[1]); + }); + + it("recomputes a tool merge when pagination changes its earlier activity", () => { + const completed = makeActivity({ + id: EventId.make("completion"), + kind: "tool.completed", + summary: "Command completed", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { toolCallId: "call", status: "completed", detail: "Done" }, + }); + const started = makeActivity({ + ...completed, + id: EventId.make("start"), + kind: "tool.updated", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { + toolCallId: "call", + data: { command: "original command" }, + status: "inProgress", + }, + }); + const row = (activities: ReadonlyArray) => + buildThreadFeed({ messages: [], activities }).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + )[0]!; + const original = row([started, completed]); + expect(original.workEntry.command).toBe("original command"); + const replaced = row([ + { + ...started, + payload: { + toolCallId: "call", + data: { command: "replacement command" }, + status: "inProgress", + }, + }, + completed, + ]); + expect(replaced.workEntry.command).toBe("replacement command"); + expect(row([completed]).workEntry.command).toBeUndefined(); + expect(row([started, completed]).workEntry.command).toBe("original command"); + }); + it("reuses unchanged feed and presentation rows during an assistant text update", () => { const completedTurnId = TurnId.make("completed-turn"); const activeTurnId = TurnId.make("active-turn"); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8f000b1b55fc..5972ce954829 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -238,6 +238,17 @@ const activityEntriesCache = new WeakMap< ReadonlyArray, ReadonlyArray> >(); +// Activity arrays change on every tool update; their unchanged rows must not +// repeatedly parse old output or invalidate the rendered work log. +const derivedActivityCache = new WeakMap(); +const mergedWorkEntriesCache = new WeakMap< + DerivedWorkLogEntry, + WeakMap +>(); +const activityEntryCache = new WeakMap< + DerivedWorkLogEntry, + Extract +>(); const messageEntriesCache = new WeakMap< OrchestrationThread["messages"][number], Extract @@ -476,6 +487,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload); function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { + const cached = derivedActivityCache.get(activity); + if (cached) return cached; const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) @@ -630,6 +643,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (collapseKey) { entry.collapseKey = collapseKey; } + derivedActivityCache.set(activity, entry); return entry; } @@ -867,6 +881,9 @@ function mergeDerivedWorkLogEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): DerivedWorkLogEntry { + let byPrevious = mergedWorkEntriesCache.get(next); + const cached = byPrevious?.get(previous); + if (cached) return cached; const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); const detail = next.detail ?? previous.detail; const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath; @@ -882,7 +899,7 @@ function mergeDerivedWorkLogEntries( const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolCallId = next.toolCallId ?? previous.toolCallId; const toolData = next.toolData ?? previous.toolData; - return { + const merged = { ...previous, ...next, id: previous.id, @@ -903,6 +920,12 @@ function mergeDerivedWorkLogEntries( ...(toolCallId ? { toolCallId } : {}), ...(toolData !== undefined ? { toolData } : {}), }; + if (byPrevious === undefined) { + byPrevious = new WeakMap(); + mergedWorkEntriesCache.set(next, byPrevious); + } + byPrevious.set(previous, merged); + return merged; } function mergeChangedFiles( @@ -2459,6 +2482,8 @@ function getThreadFeedActivityEntries(activities: ReadonlyArray { + const cached = activityEntryCache.get(entry); + if (cached) return cached; const summary = workEntryHeading(entry); const detail = workEntryPreview(entry); const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); @@ -2478,7 +2503,7 @@ function toThreadFeedActivityEntry( }) .join("\n"); }); - return { + const result: Extract = { type: "activity", id: entry.id, createdAt: entry.createdAt, @@ -2499,4 +2524,6 @@ function toThreadFeedActivityEntry( workEntry: entry, }, }; + activityEntryCache.set(entry, result); + return result; }