| 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 | 108 | <View className="gap-px"> |
| 109 | 109 | {rows.map((row) => { |
| 110 | 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 | 113 | const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; |
| 113 | 114 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
| 114 | 115 | |
| ... | ... | @@ -133,7 +134,7 @@ export function ThreadWorkLog(props: { |
| 133 | 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 | 138 | style={({ pressed }) => ({ |
| 138 | 139 | backgroundColor: pressed ? pressedBackground : "transparent", |
| 139 | 140 | })} |
| ... | ... | @@ -204,7 +205,7 @@ export function ThreadWorkLog(props: { |
| 204 | 205 | </View> |
| 205 | 206 | </Pressable> |
| 206 | 207 | |
| 207 | {expanded && row.fullDetail ? ( | |
| 208 | {fullDetail ? ( | |
| 208 | 209 | <View className="ml-7 border-l border-neutral-300/60 pb-1 pl-3 pt-0.5 dark:border-white/[0.12]"> |
| 209 | 210 | <ScrollView |
| 210 | 211 | nestedScrollEnabled |
| ... | ... | @@ -217,7 +218,7 @@ export function ThreadWorkLog(props: { |
| 217 | 218 | selectable |
| 218 | 219 | className="font-mono text-2xs leading-normal text-foreground-muted" |
| 219 | 220 | > |
| 220 | {row.fullDetail} | |
| 221 | {fullDetail} | |
| 221 | 222 | </Text> |
| 222 | 223 | </ScrollView> |
| 223 | 224 | </View> |
apps/mobile/src/lib/threadActivity.test.ts+68-18| ... | ... | @@ -161,20 +161,22 @@ describe("buildThreadFeed", () => { |
| 161 | 161 | return; |
| 162 | 162 | } |
| 163 | 163 | |
| 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 | ); | |
| 178 | 180 | }); |
| 179 | 181 | |
| 180 | 182 | it("keeps MCP inputs available to expanded mobile work rows", () => { |
| ... | ... | @@ -223,8 +225,55 @@ describe("buildThreadFeed", () => { |
| 223 | 225 | } |
| 224 | 226 | |
| 225 | 227 | 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); | |
| 228 | 277 | }); |
| 229 | 278 | |
| 230 | 279 | it("folds settled turn work while leaving the terminal answer visible", () => { |
| ... | ... | @@ -439,8 +488,9 @@ describe("buildThreadFeed", () => { |
| 439 | 488 | turnId: null, |
| 440 | 489 | summary: `Tool ${id}`, |
| 441 | 490 | detail: null, |
| 442 | fullDetail: null, | |
| 443 | copyText: id, | |
| 491 | canExpand: false, | |
| 492 | getFullDetail: () => null, | |
| 493 | getCopyText: () => id, | |
| 444 | 494 | icon: "command", |
| 445 | 495 | toolLike: true, |
| 446 | 496 | status, |
apps/mobile/src/lib/threadActivity.ts+35-9| ... | ... | @@ -36,8 +36,9 @@ export interface ThreadFeedActivity { |
| 36 | 36 | readonly turnId: TurnId | null; |
| 37 | 37 | readonly summary: string; |
| 38 | 38 | 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; | |
| 41 | 42 | readonly icon: |
| 42 | 43 | | "agent" |
| 43 | 44 | | "alert" |
| ... | ... | @@ -554,6 +555,27 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { |
| 554 | 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 | 579 | function workEntryPreview( |
| 558 | 580 | workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">, |
| 559 | 581 | ): string | null { |
| ... | ... | @@ -1353,7 +1375,14 @@ export function buildThreadFeed( |
| 1353 | 1375 | .map<RawThreadFeedEntry>((entry) => { |
| 1354 | 1376 | const summary = workEntryHeading(entry); |
| 1355 | 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 | 1386 | return { |
| 1358 | 1387 | type: "activity", |
| 1359 | 1388 | id: entry.id, |
| ... | ... | @@ -1365,13 +1394,10 @@ export function buildThreadFeed( |
| 1365 | 1394 | turnId: entry.turnId, |
| 1366 | 1395 | summary, |
| 1367 | 1396 | detail, |
| 1368 | fullDetail, | |
| 1397 | canExpand: workEntryHasExpandedBody(entry), | |
| 1398 | getFullDetail, | |
| 1399 | getCopyText, | |
| 1369 | 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 | 1401 | toolLike: workLogEntryIsToolLike(entry), |
| 1376 | 1402 | status: workEntryStatus(entry), |
| 1377 | 1403 | }, |