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: {
108108 <View className="gap-px">
109109 {rows.map((row) => {
110110 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;
112113 const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
113114 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
114115
......@@ -133,7 +134,7 @@ export function ThreadWorkLog(props: {
133134 props.onToggleRow(row.id);
134135 }
135136 }}
136 onLongPress={() => props.onCopyRow(row.id, row.copyText)}
137 onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
137138 style={({ pressed }) => ({
138139 backgroundColor: pressed ? pressedBackground : "transparent",
139140 })}
......@@ -204,7 +205,7 @@ export function ThreadWorkLog(props: {
204205 </View>
205206 </Pressable>
206207
207 {expanded && row.fullDetail ? (
208 {fullDetail ? (
208209 <View className="ml-7 border-l border-neutral-300/60 pb-1 pl-3 pt-0.5 dark:border-white/[0.12]">
209210 <ScrollView
210211 nestedScrollEnabled
......@@ -217,7 +218,7 @@ export function ThreadWorkLog(props: {
217218 selectable
218219 className="font-mono text-2xs leading-normal text-foreground-muted"
219220 >
220 {row.fullDetail}
221 {fullDetail}
221222 </Text>
222223 </ScrollView>
223224 </View>
apps/mobile/src/lib/threadActivity.test.ts+68-18
......@@ -161,20 +161,22 @@ describe("buildThreadFeed", () => {
161161 return;
162162 }
163163
164 expect(group.activities).toEqual([
165 {
166 id: "tool-completed",
167 createdAt: "2026-04-01T00:00:02.000Z",
168 turnId: "turn-1",
169 summary: "Run tests",
170 detail: "bun run test",
171 fullDetail: "/bin/zsh -lc 'bun run test'",
172 copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
173 icon: "command",
174 toolLike: true,
175 status: "success",
176 },
177 ]);
164 expect(group.activities).toHaveLength(1);
165 expect(group.activities[0]).toMatchObject({
166 id: "tool-completed",
167 createdAt: "2026-04-01T00:00:02.000Z",
168 turnId: "turn-1",
169 summary: "Run tests",
170 detail: "bun run test",
171 canExpand: true,
172 icon: "command",
173 toolLike: true,
174 status: "success",
175 });
176 expect(group.activities[0]?.getFullDetail()).toBe("/bin/zsh -lc 'bun run test'");
177 expect(group.activities[0]?.getCopyText()).toBe(
178 "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
179 );
178180 });
179181
180182 it("keeps MCP inputs available to expanded mobile work rows", () => {
......@@ -223,8 +225,55 @@ describe("buildThreadFeed", () => {
223225 }
224226
225227 expect(group.activities[0]?.icon).toBe("wrench");
226 expect(group.activities[0]?.fullDetail).toContain('"query": "work log"');
227 expect(group.activities[0]?.fullDetail).toContain("repository.search");
228 expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"');
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);
228277 });
229278
230279 it("folds settled turn work while leaving the terminal answer visible", () => {
......@@ -439,8 +488,9 @@ describe("buildThreadFeed", () => {
439488 turnId: null,
440489 summary: `Tool ${id}`,
441490 detail: null,
442 fullDetail: null,
443 copyText: id,
491 canExpand: false,
492 getFullDetail: () => null,
493 getCopyText: () => id,
444494 icon: "command",
445495 toolLike: true,
446496 status,
apps/mobile/src/lib/threadActivity.ts+35-9
......@@ -36,8 +36,9 @@ export interface ThreadFeedActivity {
3636 readonly turnId: TurnId | null;
3737 readonly summary: string;
3838 readonly detail: string | null;
39 readonly fullDetail: string | null;
40 readonly copyText: string;
39 readonly canExpand: boolean;
40 readonly getFullDetail: () => string | null;
41 readonly getCopyText: () => string;
4142 readonly icon:
4243 | "agent"
4344 | "alert"
......@@ -554,6 +555,27 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
554555 return blocks.length > 0 ? blocks.join("\n\n") : null;
555556}
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
557579function workEntryPreview(
558580 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,
559581): string | null {
......@@ -1353,7 +1375,14 @@ export function buildThreadFeed(
13531375 .map<RawThreadFeedEntry>((entry) => {
13541376 const summary = workEntryHeading(entry);
13551377 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 );
13571386 return {
13581387 type: "activity",
13591388 id: entry.id,
......@@ -1365,13 +1394,10 @@ export function buildThreadFeed(
13651394 turnId: entry.turnId,
13661395 summary,
13671396 detail,
1368 fullDetail,
1397 canExpand: workEntryHasExpandedBody(entry),
1398 getFullDetail,
1399 getCopyText,
13691400 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"),
13751401 toolLike: workLogEntryIsToolLike(entry),
13761402 status: workEntryStatus(entry),
13771403 },