| author | |
| committer | |
| log | 8c9a49afb5a4c9b28c1ef3540248a1cfa87cdad5 |
| tree | 295df90237205ce41d8b78db220ee91e448056f9 |
| parent | 1e740e48a5f91b3edd35c5446d33b0d36fb5a6ac |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 135 insertions(+), 27 deletions(-)
apps/mobile/src/features/threads/thread-work-log.tsx+7-6| ... | ... | @@ -730,8 +730,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 730 | 730 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); |
| 731 | 731 | const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); |
| 732 | 732 | const previewText = workEntryRowLabel(row.workEntry); |
| 733 | const displayText = | |
| 734 | !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; | |
| 733 | const displayText = workEntryRowLabel(row.workEntry, expanded); | |
| 735 | 734 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
| 736 | 735 | const failed = row.status === "failure"; |
| 737 | 736 | const toolIcon = row.workEntry.toolIcon ?? row.workEntry.toolSource?.icon; |
| ... | ... | @@ -747,7 +746,9 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 747 | 746 | accessibilityRole={canExpand ? "button" : undefined} |
| 748 | 747 | accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText} |
| 749 | 748 | accessibilityHint={ |
| 750 | canExpand ? "Double tap to show full details. Long press to copy." : "Long press to copy." | |
| 749 | canExpand | |
| 750 | ? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.` | |
| 751 | : "Long press to copy." | |
| 751 | 752 | } |
| 752 | 753 | accessibilityState={canExpand ? { expanded } : undefined} |
| 753 | 754 | hitSlop={4} |
| ... | ... | @@ -761,7 +762,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 761 | 762 | className="rounded-md px-0.5 py-0 active:bg-subtle" |
| 762 | 763 | > |
| 763 | 764 | <View className="min-h-8 flex-row items-center gap-1.5"> |
| 764 | {row.live ? ( | |
| 765 | {row.live && !expanded ? ( | |
| 765 | 766 | <ShimmeringWorkContent |
| 766 | 767 | environmentId={props.environmentId} |
| 767 | 768 | icon={icon} |
| ... | ... | @@ -801,7 +802,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 801 | 802 | "min-w-0 flex-1 text-sm text-foreground-muted", |
| 802 | 803 | iconIsDestructive && "font-t3-medium text-adaptive-rose-600-400", |
| 803 | 804 | )} |
| 804 | numberOfLines={1} | |
| 805 | numberOfLines={expanded ? undefined : 1} | |
| 805 | 806 | > |
| 806 | 807 | {displayText} |
| 807 | 808 | </Text> |
| ... | ... | @@ -842,7 +843,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 842 | 843 | </View> |
| 843 | 844 | </Pressable> |
| 844 | 845 | |
| 845 | {fullDetail ? ( | |
| 846 | {expanded && (fullDetail || viewedImagePath) ? ( | |
| 846 | 847 | <Animated.View |
| 847 | 848 | entering={WORK_LOG_DETAIL_ENTER_TRANSITION} |
| 848 | 849 | exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} |
apps/mobile/src/lib/threadActivity.test.ts+112-5| ... | ... | @@ -561,8 +561,8 @@ describe("buildThreadFeed", () => { |
| 561 | 561 | const [row] = group.activities; |
| 562 | 562 | expect(row?.workEntry.detail).toBe(command); |
| 563 | 563 | expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); |
| 564 | // Opening it would only repeat the command the row already shows. | |
| 565 | expect(row?.canExpand).toBe(false); | |
| 564 | expect(row?.canExpand).toBe(true); | |
| 565 | expect(workEntryRowLabel(row!.workEntry, true)).toBe("Command"); | |
| 566 | 566 | }); |
| 567 | 567 | |
| 568 | 568 | it.each([ |
| ... | ... | @@ -594,7 +594,7 @@ describe("buildThreadFeed", () => { |
| 594 | 594 | payload: { detail: "Bash is unusable in this environment" }, |
| 595 | 595 | }, |
| 596 | 596 | label: "Bash is unusable in this environment", |
| 597 | canExpand: false, | |
| 597 | canExpand: true, | |
| 598 | 598 | }, |
| 599 | 599 | { |
| 600 | 600 | name: "a multi-line task report", |
| ... | ... | @@ -630,7 +630,7 @@ describe("buildThreadFeed", () => { |
| 630 | 630 | label: "printf hello", |
| 631 | 631 | canExpand: true, |
| 632 | 632 | }, |
| 633 | ])("only lets $name expand when the body adds something: $canExpand", (input) => { | |
| 633 | ])("sets expansion availability for $name: $canExpand", (input) => { | |
| 634 | 634 | const thread = makeThread({ |
| 635 | 635 | id: ThreadId.make("thread-expand-rule"), |
| 636 | 636 | projectId: ProjectId.make("project-1"), |
| ... | ... | @@ -652,6 +652,107 @@ describe("buildThreadFeed", () => { |
| 652 | 652 | expect(row?.canExpand).toBe(input.canExpand); |
| 653 | 653 | }); |
| 654 | 654 | |
| 655 | it.each(["runtime.error", "runtime.warning"] as const)( | |
| 656 | "shows and copies the message of %s without a duplicate expanded body", | |
| 657 | (kind) => { | |
| 658 | const message = | |
| 659 | "You've hit your usage limit for GPT-5.3-Codex-Spark. Switch to another model now, or try again at 5:21 AM."; | |
| 660 | const thread = makeThread({ | |
| 661 | id: ThreadId.make("runtime-message"), | |
| 662 | projectId: ProjectId.make("project-1"), | |
| 663 | title: "Runtime message", | |
| 664 | activities: [ | |
| 665 | makeActivity({ | |
| 666 | id: EventId.make("runtime-message"), | |
| 667 | createdAt: "2026-09-01T00:00:00.000Z", | |
| 668 | kind, | |
| 669 | tone: "error", | |
| 670 | summary: "Runtime error", | |
| 671 | payload: { message }, | |
| 672 | }), | |
| 673 | ], | |
| 674 | }); | |
| 675 | const [group] = buildThreadFeed(thread); | |
| 676 | expect(group?.type).toBe("activity-group"); | |
| 677 | if (group?.type !== "activity-group") return; | |
| 678 | const row = group.activities[0]!; | |
| 679 | expect(row.canExpand).toBe(true); | |
| 680 | expect(workEntryRowLabel(row.workEntry, true)).toBe(message); | |
| 681 | expect(row.getFullDetail()).toBeNull(); | |
| 682 | expect(row.getCopyText()).toBe(`Runtime error\n${message}`); | |
| 683 | }, | |
| 684 | ); | |
| 685 | ||
| 686 | it.each([ | |
| 687 | { | |
| 688 | message: "fallback message", | |
| 689 | detail: "More specific detail", | |
| 690 | expected: "More specific detail", | |
| 691 | }, | |
| 692 | { message: " ", expected: undefined }, | |
| 693 | { message: { error: "not a string" }, expected: undefined }, | |
| 694 | ])("preserves existing runtime details and ignores invalid messages: $message", (input) => { | |
| 695 | const thread = makeThread({ | |
| 696 | id: ThreadId.make("runtime-detail"), | |
| 697 | projectId: ProjectId.make("project-1"), | |
| 698 | title: "Runtime detail", | |
| 699 | activities: [ | |
| 700 | makeActivity({ | |
| 701 | id: EventId.make("runtime-detail"), | |
| 702 | createdAt: "2026-09-01T00:00:00.000Z", | |
| 703 | kind: "runtime.error", | |
| 704 | tone: "error", | |
| 705 | summary: "Runtime error", | |
| 706 | payload: { message: input.message, detail: input.detail }, | |
| 707 | }), | |
| 708 | ], | |
| 709 | }); | |
| 710 | const [group] = buildThreadFeed(thread); | |
| 711 | expect(group?.type).toBe("activity-group"); | |
| 712 | if (group?.type !== "activity-group") return; | |
| 713 | expect(group.activities[0]?.workEntry.detail).toBe(input.expected); | |
| 714 | expect(group.activities[0]?.canExpand).toBe(Boolean(input.expected)); | |
| 715 | }); | |
| 716 | ||
| 717 | it.each([ | |
| 718 | { | |
| 719 | summary: "Runtime error", | |
| 720 | kind: "runtime.error" as const, | |
| 721 | detail: | |
| 722 | "Request failed.\nThe service returned an unexpected response.\nRetry after checking the connection.", | |
| 723 | }, | |
| 724 | { | |
| 725 | summary: "Web search", | |
| 726 | kind: "tool.completed" as const, | |
| 727 | detail: "https://itanium-cxx-abi.github.io/cxx-abi/abi.html", | |
| 728 | itemType: "web_search", | |
| 729 | }, | |
| 730 | ])("expands the full $summary label once, retaining its formatting and copy text", (input) => { | |
| 731 | const thread = makeThread({ | |
| 732 | id: ThreadId.make("expanded-label"), | |
| 733 | projectId: ProjectId.make("project-1"), | |
| 734 | title: "Expanded label", | |
| 735 | activities: [ | |
| 736 | makeActivity({ | |
| 737 | id: EventId.make("expanded-label"), | |
| 738 | createdAt: "2026-09-01T00:00:00.000Z", | |
| 739 | kind: input.kind, | |
| 740 | summary: input.summary, | |
| 741 | payload: { detail: input.detail, itemType: input.itemType }, | |
| 742 | }), | |
| 743 | ], | |
| 744 | }); | |
| 745 | const [group] = buildThreadFeed(thread); | |
| 746 | expect(group?.type).toBe("activity-group"); | |
| 747 | if (group?.type !== "activity-group") return; | |
| 748 | const row = group.activities[0]!; | |
| 749 | expect(row.canExpand).toBe(true); | |
| 750 | expect(workEntryRowLabel(row.workEntry)).toBe(input.detail.replace(/\s+/g, " ")); | |
| 751 | expect(workEntryRowLabel(row.workEntry, true)).toBe(input.detail); | |
| 752 | expect(row.getFullDetail()).toBeNull(); | |
| 753 | expect(row.getCopyText()).toBe(`${input.summary}\n${input.detail}`); | |
| 754 | }); | |
| 755 | ||
| 655 | 756 | it("drops a truncated Claude echo of a long command", () => { |
| 656 | 757 | const command = `git add -A && git commit -m "${"x".repeat(200)}"`; |
| 657 | 758 | const thread = makeThread({ |
| ... | ... | @@ -1059,6 +1160,11 @@ describe("buildThreadFeed", () => { |
| 1059 | 1160 | }, |
| 1060 | 1161 | ], |
| 1061 | 1162 | }); |
| 1163 | if (group?.type !== "activity-group") return; | |
| 1164 | const row = group.activities[0]!; | |
| 1165 | expect(row.canExpand).toBe(true); | |
| 1166 | expect(row.getFullDetail()).toBeNull(); | |
| 1167 | expect(workEntryRowLabel(row.workEntry, true)).toBe(`${imagePath.slice(0, 177)}...`); | |
| 1062 | 1168 | }); |
| 1063 | 1169 | |
| 1064 | 1170 | it("keeps MCP inputs available to expanded mobile work rows", () => { |
| ... | ... | @@ -1136,7 +1242,8 @@ describe("buildThreadFeed", () => { |
| 1136 | 1242 | }, |
| 1137 | 1243 | }); |
| 1138 | 1244 | expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"'); |
| 1139 | expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); | |
| 1245 | expect(workEntryRowLabel(group.activities[0]!.workEntry, true)).toBe("repository.search"); | |
| 1246 | expect(group.activities[0]?.getFullDetail()).not.toContain("repository.search"); | |
| 1140 | 1247 | }); |
| 1141 | 1248 | |
| 1142 | 1249 | it.each([ |
apps/mobile/src/lib/threadActivity.ts+16-16| ... | ... | @@ -525,6 +525,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 525 | 525 | if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) { |
| 526 | 526 | entry.detail = payload.error; |
| 527 | 527 | } |
| 528 | if (!entry.detail && (activity.kind === "runtime.error" || activity.kind === "runtime.warning")) { | |
| 529 | const message = asTrimmedString(payload?.message); | |
| 530 | if (message) entry.detail = message; | |
| 531 | } | |
| 528 | 532 | if (viewedImagePath) { |
| 529 | 533 | entry.viewedImagePath = viewedImagePath; |
| 530 | 534 | } |
| ... | ... | @@ -939,9 +943,12 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { |
| 939 | 943 | function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { |
| 940 | 944 | if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn); |
| 941 | 945 | const blocks: string[] = []; |
| 946 | const visibleLabel = workEntryRowLabel(entry, true).trim(); | |
| 942 | 947 | const appendBlock = (value: string | null | undefined) => { |
| 943 | 948 | const trimmed = value?.trim(); |
| 944 | if (trimmed && (entry.command || !blocks.includes(trimmed))) blocks.push(trimmed); | |
| 949 | if (trimmed && (entry.command || (trimmed !== visibleLabel && !blocks.includes(trimmed)))) { | |
| 950 | blocks.push(trimmed); | |
| 951 | } | |
| 945 | 952 | }; |
| 946 | 953 | |
| 947 | 954 | if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) { |
| ... | ... | @@ -957,24 +964,15 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { |
| 957 | 964 | } |
| 958 | 965 | |
| 959 | 966 | /** |
| 960 | * A row only opens when its body says more than its collapsed line. A row | |
| 961 | * whose only detail is the single-line text it already shows (a runtime | |
| 962 | * warning, a task summary, a short command) has nothing to reveal. | |
| 963 | * Multi-line text still expands: the collapsed row truncates it to one line. | |
| 967 | * Even single-line details can be truncated by the available screen width. | |
| 964 | 968 | * Cheap field checks come first so large tool payloads are not serialized |
| 965 | 969 | * for every row (see the deferred-expansion test). |
| 966 | 970 | */ |
| 967 | function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean { | |
| 971 | function workEntryCanExpand(entry: WorkLogEntry): boolean { | |
| 968 | 972 | if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0; |
| 969 | 973 | if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; |
| 970 | 974 | if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; |
| 971 | const parts = [entry.rawCommand ?? entry.command, entry.detail] | |
| 972 | .map((value) => value?.trim()) | |
| 973 | .filter((value): value is string => Boolean(value)); | |
| 974 | if (parts.length === 0) return false; | |
| 975 | if (parts.length > 1 && new Set(parts).size > 1) return true; | |
| 976 | const only = parts[0]!; | |
| 977 | return only.includes("\n") || collapseWhitespace(only) !== collapseWhitespace(collapsedText); | |
| 975 | return Boolean((entry.rawCommand ?? entry.command)?.trim() || entry.detail?.trim()); | |
| 978 | 976 | } |
| 979 | 977 | |
| 980 | 978 | function collapseWhitespace(value: string): string { |
| ... | ... | @@ -987,12 +985,14 @@ function stripShellWrapper(value: string): string { |
| 987 | 985 | return (match?.[1] ?? trimmed).trim(); |
| 988 | 986 | } |
| 989 | 987 | |
| 990 | /** The one-line text a collapsed work row shows. */ | |
| 991 | export function workEntryRowLabel(entry: WorkLogEntry): string { | |
| 988 | /** Expanded rows retain detail formatting; commands stay in the separate body. */ | |
| 989 | export function workEntryRowLabel(entry: WorkLogEntry, expanded = false): string { | |
| 992 | 990 | if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn); |
| 993 | 991 | const presentation = resolveWorkEntryToolPresentation(entry); |
| 994 | 992 | if (presentation) return presentation.displayName; |
| 993 | if (expanded && entry.command?.trim()) return "Command"; | |
| 995 | 994 | const preview = workEntryPreview(entry); |
| 995 | if (expanded) return preview?.trim() || workEntryHeading(entry); | |
| 996 | 996 | const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview)); |
| 997 | 997 | return compactPreview || workEntryHeading(entry); |
| 998 | 998 | } |
| ... | ... | @@ -2227,7 +2227,7 @@ function toThreadFeedActivityEntry( |
| 2227 | 2227 | turnId: entry.turnId, |
| 2228 | 2228 | summary, |
| 2229 | 2229 | detail, |
| 2230 | canExpand: workEntryHasExpandedBody(entry, workEntryRowLabel(entry)), | |
| 2230 | canExpand: workEntryCanExpand(entry), | |
| 2231 | 2231 | getFullDetail, |
| 2232 | 2232 | getCopyText, |
| 2233 | 2233 | icon: workEntryIcon(entry), |