| 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,7 +34,7 @@ import { AppText as Text } from "../../components/AppText"; |
| 34 | import { T3Wordmark } from "../../components/T3Wordmark"; | 34 | import { T3Wordmark } from "../../components/T3Wordmark"; |
| 35 | import { cn } from "../../lib/cn"; | 35 | import { cn } from "../../lib/cn"; |
| 36 | import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; | 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 | import { | 38 | import { |
| 39 | resolveThreadWorkGroupInitialScroll, | 39 | resolveThreadWorkGroupInitialScroll, |
| 40 | shouldFollowThreadWorkGroupAppend, | 40 | shouldFollowThreadWorkGroupAppend, |
| ... | @@ -307,21 +307,6 @@ export function ShimmeringWorkContent(props: { | ... | @@ -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 | function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { | 310 | function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { |
| 326 | switch (icon) { | 311 | switch (icon) { |
| 327 | case "agent": | 312 | case "agent": |
| ... | @@ -697,8 +682,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -697,8 +682,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 697 | const fullDetail = expanded ? row.getFullDetail() : null; | 682 | const fullDetail = expanded ? row.getFullDetail() : null; |
| 698 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); | 683 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); |
| 699 | const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); | 684 | const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); |
| 700 | const previewText = | 685 | const previewText = workEntryRowLabel(row.workEntry); |
| 701 | toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary; | ||
| 702 | const displayText = | 686 | const displayText = |
| 703 | !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; | 687 | !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; |
| 704 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; | 688 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
apps/mobile/src/lib/threadActivity.test.ts+90| ... | @@ -21,6 +21,7 @@ import { | ... | @@ -21,6 +21,7 @@ import { |
| 21 | isPendingUserInputOptionSelected, | 21 | isPendingUserInputOptionSelected, |
| 22 | setPendingUserInputCustomAnswer, | 22 | setPendingUserInputCustomAnswer, |
| 23 | togglePendingUserInputOptionSelection, | 23 | togglePendingUserInputOptionSelection, |
| 24 | workEntryRowLabel, | ||
| 24 | type ThreadFeedActivity, | 25 | type ThreadFeedActivity, |
| 25 | type ThreadFeedEntry, | 26 | type ThreadFeedEntry, |
| 26 | } from "./threadActivity"; | 27 | } from "./threadActivity"; |
| ... | @@ -702,6 +703,95 @@ describe("buildThreadFeed", () => { | ... | @@ -702,6 +703,95 @@ describe("buildThreadFeed", () => { |
| 702 | const [row] = group.activities; | 703 | const [row] = group.activities; |
| 703 | expect(row?.workEntry.detail).toBe(command); | 704 | expect(row?.workEntry.detail).toBe(command); |
| 704 | expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); | 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 | it("drops a truncated Claude echo of a long command", () => { | 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,13 +878,43 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { |
| 878 | return blocks.length > 0 ? blocks.join("\n\n") : null; | 878 | return blocks.length > 0 ? blocks.join("\n\n") : null; |
| 879 | } | 879 | } |
| 880 | 880 | ||
| 881 | function workEntryHasExpandedBody(entry: WorkLogEntry): boolean { | 881 | /** |
| 882 | return ( | 882 | * A row only opens when its body says more than its collapsed line. A row |
| 883 | (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) || | 883 | * whose only detail is the single-line text it already shows (a runtime |
| 884 | Boolean((entry.rawCommand ?? entry.command)?.trim()) || | 884 | * warning, a task summary, a short command) has nothing to reveal. |
| 885 | Boolean(entry.detail?.trim()) || | 885 | * Multi-line text still expands: the collapsed row truncates it to one line. |
| 886 | (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false) | 886 | * Cheap field checks come first so large tool payloads are not serialized |
| 887 | ); | 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 | function memoizeValue<T>(build: () => T): () => T { | 920 | function memoizeValue<T>(build: () => T): () => T { |
| ... | @@ -2098,7 +2128,7 @@ function toThreadFeedActivityEntry( | ... | @@ -2098,7 +2128,7 @@ function toThreadFeedActivityEntry( |
| 2098 | turnId: entry.turnId, | 2128 | turnId: entry.turnId, |
| 2099 | summary, | 2129 | summary, |
| 2100 | detail, | 2130 | detail, |
| 2101 | canExpand: workEntryHasExpandedBody(entry), | 2131 | canExpand: workEntryHasExpandedBody(entry, workEntryRowLabel(entry)), |
| 2102 | getFullDetail, | 2132 | getFullDetail, |
| 2103 | getCopyText, | 2133 | getCopyText, |
| 2104 | icon: workEntryIcon(entry), | 2134 | icon: workEntryIcon(entry), |