| author | |
| committer | |
| log | b4680cbfd064d59fdd2386a5ac6fc75e26e8c96b |
| tree | c816f349694ebf0f74872850b4e24f2cc2764087 |
| parent | d60f6e971f8f982836f26507275d651e2e7af4a5 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 114 | 115 | ||
| ... | @@ -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> |
| 206 | 207 | ||
| 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 | <ScrollView | 210 | <ScrollView |
| 210 | nestedScrollEnabled | 211 | nestedScrollEnabled |
| ... | @@ -217,7 +218,7 @@ export function ThreadWorkLog(props: { | ... | @@ -217,7 +218,7 @@ export function ThreadWorkLog(props: { |
| 217 | selectable | 218 | 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 | } |
| 163 | 163 | ||
| 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 | }); |
| 179 | 181 | ||
| 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 | } |
| 224 | 226 | ||
| 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 | }); |
| 229 | 278 | ||
| 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 | } |
| 556 | 557 | ||
| 558 | function 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 | |||
| 567 | function 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 | |||
| 557 | function workEntryPreview( | 579 | function 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 | }, |