authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-07-27 20:03:49+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 03:03:49-07:00
logb4680cbfd064d59fdd2386a5ac6fc75e26e8c96b
treec816f349694ebf0f74872850b4e24f2cc2764087
parentd60f6e971f8f982836f26507275d651e2e7af4a5
signature Signed by PGP key B5690EEEBB952194

perf(mobile): defer work-log detail serialization (#4607)


3 files changed, 108 insertions(+), 31 deletions(-)

apps/mobile/src/features/threads/thread-work-log.tsx+5-4
...@@ -108,7 +108,8 @@ export function ThreadWorkLog(props: {...@@ -108,7 +108,8 @@ export function ThreadWorkLog(props: {
108 <View className="gap-px">108 <View className="gap-px">
109 {rows.map((row) => {109 {rows.map((row) => {
110 const expanded = props.expandedRows[row.id] ?? false;110 const expanded = props.expandedRows[row.id] ?? false;
111 const canExpand = row.fullDetail !== null;111 const canExpand = row.canExpand;
112 const fullDetail = expanded ? row.getFullDetail() : null;
112 const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;113 const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
113 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";114 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
114115
...@@ -133,7 +134,7 @@ export function ThreadWorkLog(props: {...@@ -133,7 +134,7 @@ export function ThreadWorkLog(props: {
133 props.onToggleRow(row.id);134 props.onToggleRow(row.id);
134 }135 }
135 }}136 }}
136 onLongPress={() => props.onCopyRow(row.id, row.copyText)}137 onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
137 style={({ pressed }) => ({138 style={({ pressed }) => ({
138 backgroundColor: pressed ? pressedBackground : "transparent",139 backgroundColor: pressed ? pressedBackground : "transparent",
139 })}140 })}
...@@ -204,7 +205,7 @@ export function ThreadWorkLog(props: {...@@ -204,7 +205,7 @@ export function ThreadWorkLog(props: {
204 </View>205 </View>
205 </Pressable>206 </Pressable>
206207
207 {expanded && row.fullDetail ? (208 {fullDetail ? (
208 <View className="ml-7 border-l border-neutral-300/60 pb-1 pl-3 pt-0.5 dark:border-white/[0.12]">209 <View className="ml-7 border-l border-neutral-300/60 pb-1 pl-3 pt-0.5 dark:border-white/[0.12]">
209 <ScrollView210 <ScrollView
210 nestedScrollEnabled211 nestedScrollEnabled
...@@ -217,7 +218,7 @@ export function ThreadWorkLog(props: {...@@ -217,7 +218,7 @@ export function ThreadWorkLog(props: {
217 selectable218 selectable
218 className="font-mono text-2xs leading-normal text-foreground-muted"219 className="font-mono text-2xs leading-normal text-foreground-muted"
219 >220 >
220 {row.fullDetail}221 {fullDetail}
221 </Text>222 </Text>
222 </ScrollView>223 </ScrollView>
223 </View>224 </View>
apps/mobile/src/lib/threadActivity.test.ts+68-18
...@@ -161,20 +161,22 @@ describe("buildThreadFeed", () => {...@@ -161,20 +161,22 @@ describe("buildThreadFeed", () => {
161 return;161 return;
162 }162 }
163163
164 expect(group.activities).toEqual([164 expect(group.activities).toHaveLength(1);
165 {165 expect(group.activities[0]).toMatchObject({
166 id: "tool-completed",166 id: "tool-completed",
167 createdAt: "2026-04-01T00:00:02.000Z",167 createdAt: "2026-04-01T00:00:02.000Z",
168 turnId: "turn-1",168 turnId: "turn-1",
169 summary: "Run tests",169 summary: "Run tests",
170 detail: "bun run test",170 detail: "bun run test",
171 fullDetail: "/bin/zsh -lc 'bun run test'",171 canExpand: true,
172 copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",172 icon: "command",
173 icon: "command",173 toolLike: true,
174 toolLike: true,174 status: "success",
175 status: "success",175 });
176 },176 expect(group.activities[0]?.getFullDetail()).toBe("/bin/zsh -lc 'bun run test'");
177 ]);177 expect(group.activities[0]?.getCopyText()).toBe(
178 "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
179 );
178 });180 });
179181
180 it("keeps MCP inputs available to expanded mobile work rows", () => {182 it("keeps MCP inputs available to expanded mobile work rows", () => {
...@@ -223,8 +225,55 @@ describe("buildThreadFeed", () => {...@@ -223,8 +225,55 @@ describe("buildThreadFeed", () => {
223 }225 }
224226
225 expect(group.activities[0]?.icon).toBe("wrench");227 expect(group.activities[0]?.icon).toBe("wrench");
226 expect(group.activities[0]?.fullDetail).toContain('"query": "work log"');228 expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"');
227 expect(group.activities[0]?.fullDetail).toContain("repository.search");229 expect(group.activities[0]?.getFullDetail()).toContain("repository.search");
230 });
231
232 it("defers large tool output expansion until a work row is opened or copied", () => {
233 let serializedToolOutputs = 0;
234 const activities = Array.from({ length: 5_000 }, (_, index) =>
235 makeActivity({
236 id: EventId.make(`large-tool-${index}`),
237 kind: "tool.completed",
238 tone: "tool",
239 summary: `Tool ${index}`,
240 createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(),
241 payload: {
242 title: `Tool ${index}`,
243 itemType: "mcp_tool_call",
244 status: "completed",
245 data: {
246 item: {
247 toJSON: () => {
248 serializedToolOutputs += 1;
249 return { output: "x".repeat(32_768) };
250 },
251 },
252 },
253 },
254 }),
255 );
256 const thread = makeThread({
257 id: ThreadId.make("thread-large-tools"),
258 projectId: ProjectId.make("project-1"),
259 title: "Large tools",
260 activities,
261 });
262
263 const feed = buildThreadFeed(thread);
264 expect(serializedToolOutputs).toBe(0);
265
266 const group = feed[0];
267 expect(group).toMatchObject({ type: "activity-group" });
268 if (!group || group.type !== "activity-group") {
269 return;
270 }
271
272 expect(group.activities).toHaveLength(5_000);
273 expect(group.activities[0]?.getFullDetail()).toContain('"output"');
274 expect(serializedToolOutputs).toBe(1);
275 expect(group.activities[0]?.getCopyText()).toContain('"output"');
276 expect(serializedToolOutputs).toBe(1);
228 });277 });
229278
230 it("folds settled turn work while leaving the terminal answer visible", () => {279 it("folds settled turn work while leaving the terminal answer visible", () => {
...@@ -439,8 +488,9 @@ describe("buildThreadFeed", () => {...@@ -439,8 +488,9 @@ describe("buildThreadFeed", () => {
439 turnId: null,488 turnId: null,
440 summary: `Tool ${id}`,489 summary: `Tool ${id}`,
441 detail: null,490 detail: null,
442 fullDetail: null,491 canExpand: false,
443 copyText: id,492 getFullDetail: () => null,
493 getCopyText: () => id,
444 icon: "command",494 icon: "command",
445 toolLike: true,495 toolLike: true,
446 status,496 status,
apps/mobile/src/lib/threadActivity.ts+35-9
...@@ -36,8 +36,9 @@ export interface ThreadFeedActivity {...@@ -36,8 +36,9 @@ export interface ThreadFeedActivity {
36 readonly turnId: TurnId | null;36 readonly turnId: TurnId | null;
37 readonly summary: string;37 readonly summary: string;
38 readonly detail: string | null;38 readonly detail: string | null;
39 readonly fullDetail: string | null;39 readonly canExpand: boolean;
40 readonly copyText: string;40 readonly getFullDetail: () => string | null;
41 readonly getCopyText: () => string;
41 readonly icon:42 readonly icon:
42 | "agent"43 | "agent"
43 | "alert"44 | "alert"
...@@ -554,6 +555,27 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {...@@ -554,6 +555,27 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
554 return blocks.length > 0 ? blocks.join("\n\n") : null;555 return blocks.length > 0 ? blocks.join("\n\n") : null;
555}556}
556557
558function workEntryHasExpandedBody(entry: WorkLogEntry): boolean {
559 return (
560 (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) ||
561 Boolean((entry.rawCommand ?? entry.command)?.trim()) ||
562 Boolean(entry.detail?.trim()) ||
563 (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false)
564 );
565}
566
567function memoizeValue<T>(build: () => T): () => T {
568 let value: T;
569 let initialized = false;
570 return () => {
571 if (!initialized) {
572 value = build();
573 initialized = true;
574 }
575 return value;
576 };
577}
578
557function workEntryPreview(579function workEntryPreview(
558 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,580 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,
559): string | null {581): string | null {
...@@ -1353,7 +1375,14 @@ export function buildThreadFeed(...@@ -1353,7 +1375,14 @@ export function buildThreadFeed(
1353 .map<RawThreadFeedEntry>((entry) => {1375 .map<RawThreadFeedEntry>((entry) => {
1354 const summary = workEntryHeading(entry);1376 const summary = workEntryHeading(entry);
1355 const detail = workEntryPreview(entry);1377 const detail = workEntryPreview(entry);
1356 const fullDetail = buildWorkEntryExpandedBody(entry);1378 const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry));
1379 const getCopyText = memoizeValue(() =>
1380 [summary, detail, getFullDetail()]
1381 .filter((value, index, values): value is string => {
1382 return Boolean(value) && values.indexOf(value) === index;
1383 })
1384 .join("\n"),
1385 );
1357 return {1386 return {
1358 type: "activity",1387 type: "activity",
1359 id: entry.id,1388 id: entry.id,
...@@ -1365,13 +1394,10 @@ export function buildThreadFeed(...@@ -1365,13 +1394,10 @@ export function buildThreadFeed(
1365 turnId: entry.turnId,1394 turnId: entry.turnId,
1366 summary,1395 summary,
1367 detail,1396 detail,
1368 fullDetail,1397 canExpand: workEntryHasExpandedBody(entry),
1398 getFullDetail,
1399 getCopyText,
1369 icon: workEntryIcon(entry),1400 icon: workEntryIcon(entry),
1370 copyText: [summary, detail, fullDetail]
1371 .filter((value, index, values): value is string => {
1372 return Boolean(value) && values.indexOf(value) === index;
1373 })
1374 .join("\n"),
1375 toolLike: workLogEntryIsToolLike(entry),1401 toolLike: workLogEntryIsToolLike(entry),
1376 status: workEntryStatus(entry),1402 status: workEntryStatus(entry),
1377 },1403 },