| author | |
| committer | |
| log | 7eda989d38a30d5e35c9efbd946aae3d6f9c065a |
| tree | 022dd4f41bcea2f1a85a63ced551b4b94af59551 |
| parent | 1cb49c3df2e0fb4bc2c6e88b0f102c1f9eb8cee5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>3 files changed, 130 insertions(+), 26 deletions(-)
apps/mobile/src/features/threads/thread-work-log.tsx+2-18| ... | ... | @@ -34,7 +34,7 @@ import { AppText as Text } from "../../components/AppText"; |
| 34 | 34 | import { T3Wordmark } from "../../components/T3Wordmark"; |
| 35 | 35 | import { cn } from "../../lib/cn"; |
| 36 | 36 | import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; |
| 37 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; | |
| 37 | import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity"; | |
| 38 | 38 | import { |
| 39 | 39 | resolveThreadWorkGroupInitialScroll, |
| 40 | 40 | shouldFollowThreadWorkGroupAppend, |
| ... | ... | @@ -307,21 +307,6 @@ export function ShimmeringWorkContent(props: { |
| 307 | 307 | ); |
| 308 | 308 | } |
| 309 | 309 | |
| 310 | function stripShellWrapper(value: string): string { | |
| 311 | const trimmed = value.trim(); | |
| 312 | const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); | |
| 313 | return (match?.[1] ?? trimmed).trim(); | |
| 314 | } | |
| 315 | ||
| 316 | function compactActivityDetail(detail: string | null): string | null { | |
| 317 | if (!detail) { | |
| 318 | return null; | |
| 319 | } | |
| 320 | ||
| 321 | const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); | |
| 322 | return cleaned.length > 0 ? cleaned : null; | |
| 323 | } | |
| 324 | ||
| 325 | 310 | function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { |
| 326 | 311 | switch (icon) { |
| 327 | 312 | case "agent": |
| ... | ... | @@ -697,8 +682,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 697 | 682 | const fullDetail = expanded ? row.getFullDetail() : null; |
| 698 | 683 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); |
| 699 | 684 | const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); |
| 700 | const previewText = | |
| 701 | toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary; | |
| 685 | const previewText = workEntryRowLabel(row.workEntry); | |
| 702 | 686 | const displayText = |
| 703 | 687 | !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; |
| 704 | 688 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
apps/mobile/src/lib/threadActivity.test.ts+90| ... | ... | @@ -21,6 +21,7 @@ import { |
| 21 | 21 | isPendingUserInputOptionSelected, |
| 22 | 22 | setPendingUserInputCustomAnswer, |
| 23 | 23 | togglePendingUserInputOptionSelection, |
| 24 | workEntryRowLabel, | |
| 24 | 25 | type ThreadFeedActivity, |
| 25 | 26 | type ThreadFeedEntry, |
| 26 | 27 | } from "./threadActivity"; |
| ... | ... | @@ -702,6 +703,95 @@ describe("buildThreadFeed", () => { |
| 702 | 703 | const [row] = group.activities; |
| 703 | 704 | expect(row?.workEntry.detail).toBe(command); |
| 704 | 705 | expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); |
| 706 | // Opening it would only repeat the command the row already shows. | |
| 707 | expect(row?.canExpand).toBe(false); | |
| 708 | }); | |
| 709 | ||
| 710 | it.each([ | |
| 711 | { | |
| 712 | name: "a task summary that is its own detail", | |
| 713 | activity: { | |
| 714 | kind: "task.completed" as const, | |
| 715 | tone: "info" as const, | |
| 716 | summary: "Task completed", | |
| 717 | payload: { | |
| 718 | taskId: "bh2p996o4", | |
| 719 | status: "completed", | |
| 720 | title: "Check CI on the new head", | |
| 721 | summary: "Check CI on the new head", | |
| 722 | detail: "Check CI on the new head", | |
| 723 | agentKind: "background", | |
| 724 | taskType: "local_bash", | |
| 725 | }, | |
| 726 | }, | |
| 727 | label: "Check CI on the new head", | |
| 728 | canExpand: false, | |
| 729 | }, | |
| 730 | { | |
| 731 | name: "a runtime warning with only its message", | |
| 732 | activity: { | |
| 733 | kind: "runtime.warning" as const, | |
| 734 | tone: "info" as const, | |
| 735 | summary: "Bash is unusable in this environment", | |
| 736 | payload: { detail: "Bash is unusable in this environment" }, | |
| 737 | }, | |
| 738 | label: "Bash is unusable in this environment", | |
| 739 | canExpand: false, | |
| 740 | }, | |
| 741 | { | |
| 742 | name: "a multi-line task report", | |
| 743 | activity: { | |
| 744 | kind: "task.completed" as const, | |
| 745 | tone: "info" as const, | |
| 746 | summary: "Task completed", | |
| 747 | payload: { | |
| 748 | taskId: "ae3f85a", | |
| 749 | status: "completed", | |
| 750 | title: "Audit the PR", | |
| 751 | detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.", | |
| 752 | agentKind: "agent", | |
| 753 | taskType: "local_agent", | |
| 754 | }, | |
| 755 | }, | |
| 756 | label: "**Tooling note:** Bash is unusable. # Audit No blockers.", | |
| 757 | canExpand: true, | |
| 758 | }, | |
| 759 | { | |
| 760 | name: "a command whose output differs from the command", | |
| 761 | activity: { | |
| 762 | kind: "tool.completed" as const, | |
| 763 | tone: "tool" as const, | |
| 764 | summary: "Command run", | |
| 765 | payload: { | |
| 766 | itemType: "command_execution", | |
| 767 | title: "Command run", | |
| 768 | detail: "Bash: printf hello", | |
| 769 | data: { toolName: "Bash", command: "printf hello", rawOutput: { content: "hello" } }, | |
| 770 | }, | |
| 771 | }, | |
| 772 | label: "printf hello", | |
| 773 | canExpand: true, | |
| 774 | }, | |
| 775 | ])("only lets $name expand when the body adds something: $canExpand", (input) => { | |
| 776 | const thread = makeThread({ | |
| 777 | id: ThreadId.make("thread-expand-rule"), | |
| 778 | projectId: ProjectId.make("project-1"), | |
| 779 | title: "Expand rule", | |
| 780 | activities: [ | |
| 781 | makeActivity({ | |
| 782 | id: EventId.make("expand-rule"), | |
| 783 | createdAt: "2026-09-01T00:00:00.000Z", | |
| 784 | ...input.activity, | |
| 785 | }), | |
| 786 | ], | |
| 787 | }); | |
| 788 | ||
| 789 | const [group] = buildThreadFeed(thread); | |
| 790 | expect(group?.type).toBe("activity-group"); | |
| 791 | if (group?.type !== "activity-group") return; | |
| 792 | const [row] = group.activities; | |
| 793 | expect(workEntryRowLabel(row!.workEntry)).toBe(input.label); | |
| 794 | expect(row?.canExpand).toBe(input.canExpand); | |
| 705 | 795 | }); |
| 706 | 796 | |
| 707 | 797 | it("drops a truncated Claude echo of a long command", () => { |
apps/mobile/src/lib/threadActivity.ts+38-8| ... | ... | @@ -878,13 +878,43 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { |
| 878 | 878 | return blocks.length > 0 ? blocks.join("\n\n") : null; |
| 879 | 879 | } |
| 880 | 880 | |
| 881 | function workEntryHasExpandedBody(entry: WorkLogEntry): boolean { | |
| 882 | return ( | |
| 883 | (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) || | |
| 884 | Boolean((entry.rawCommand ?? entry.command)?.trim()) || | |
| 885 | Boolean(entry.detail?.trim()) || | |
| 886 | (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false) | |
| 887 | ); | |
| 881 | /** | |
| 882 | * A row only opens when its body says more than its collapsed line. A row | |
| 883 | * whose only detail is the single-line text it already shows (a runtime | |
| 884 | * warning, a task summary, a short command) has nothing to reveal. | |
| 885 | * Multi-line text still expands: the collapsed row truncates it to one line. | |
| 886 | * Cheap field checks come first so large tool payloads are not serialized | |
| 887 | * for every row (see the deferred-expansion test). | |
| 888 | */ | |
| 889 | function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean { | |
| 890 | if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; | |
| 891 | if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; | |
| 892 | const parts = [entry.rawCommand ?? entry.command, entry.detail] | |
| 893 | .map((value) => value?.trim()) | |
| 894 | .filter((value): value is string => Boolean(value)); | |
| 895 | if (parts.length === 0) return false; | |
| 896 | if (parts.length > 1 && new Set(parts).size > 1) return true; | |
| 897 | const only = parts[0]!; | |
| 898 | return only.includes("\n") || collapseWhitespace(only) !== collapseWhitespace(collapsedText); | |
| 899 | } | |
| 900 | ||
| 901 | function collapseWhitespace(value: string): string { | |
| 902 | return value.replace(/\s+/g, " ").trim(); | |
| 903 | } | |
| 904 | ||
| 905 | function stripShellWrapper(value: string): string { | |
| 906 | const trimmed = value.trim(); | |
| 907 | const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); | |
| 908 | return (match?.[1] ?? trimmed).trim(); | |
| 909 | } | |
| 910 | ||
| 911 | /** The one-line text a collapsed work row shows. */ | |
| 912 | export function workEntryRowLabel(entry: WorkLogEntry): string { | |
| 913 | const presentation = resolveWorkEntryToolPresentation(entry); | |
| 914 | if (presentation) return presentation.displayName; | |
| 915 | const preview = workEntryPreview(entry); | |
| 916 | const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview)); | |
| 917 | return compactPreview || workEntryHeading(entry); | |
| 888 | 918 | } |
| 889 | 919 | |
| 890 | 920 | function memoizeValue<T>(build: () => T): () => T { |
| ... | ... | @@ -2098,7 +2128,7 @@ function toThreadFeedActivityEntry( |
| 2098 | 2128 | turnId: entry.turnId, |
| 2099 | 2129 | summary, |
| 2100 | 2130 | detail, |
| 2101 | canExpand: workEntryHasExpandedBody(entry), | |
| 2131 | canExpand: workEntryHasExpandedBody(entry, workEntryRowLabel(entry)), | |
| 2102 | 2132 | getFullDetail, |
| 2103 | 2133 | getCopyText, |
| 2104 | 2134 | icon: workEntryIcon(entry), |