authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 15:57:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 15:57:43-07:00
log7eda989d38a30d5e35c9efbd946aae3d6f9c065a
tree022dd4f41bcea2f1a85a63ced551b4b94af59551
parent1cb49c3df2e0fb4bc2c6e88b0f102c1f9eb8cee5
signature Signed by PGP key B5690EEEBB952194

fix(mobile): only make work rows expandable when the body adds something (#10210)

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";
3434import { T3Wordmark } from "../../components/T3Wordmark";
3535import { cn } from "../../lib/cn";
3636import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
37import type { ThreadFeedActivity } from "../../lib/threadActivity";
37import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity";
3838import {
3939 resolveThreadWorkGroupInitialScroll,
4040 shouldFollowThreadWorkGroupAppend,
......@@ -307,21 +307,6 @@ export function ShimmeringWorkContent(props: {
307307 );
308308}
309309
310function 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
316function 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
325310function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
326311 switch (icon) {
327312 case "agent":
......@@ -697,8 +682,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
697682 const fullDetail = expanded ? row.getFullDetail() : null;
698683 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
699684 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
700 const previewText =
701 toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary;
685 const previewText = workEntryRowLabel(row.workEntry);
702686 const displayText =
703687 !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText;
704688 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
apps/mobile/src/lib/threadActivity.test.ts+90
......@@ -21,6 +21,7 @@ import {
2121 isPendingUserInputOptionSelected,
2222 setPendingUserInputCustomAnswer,
2323 togglePendingUserInputOptionSelection,
24 workEntryRowLabel,
2425 type ThreadFeedActivity,
2526 type ThreadFeedEntry,
2627} from "./threadActivity";
......@@ -702,6 +703,95 @@ describe("buildThreadFeed", () => {
702703 const [row] = group.activities;
703704 expect(row?.workEntry.detail).toBe(command);
704705 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);
705795 });
706796
707797 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 {
878878 return blocks.length > 0 ? blocks.join("\n\n") : null;
879879}
880880
881function 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 */
889function 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
901function collapseWhitespace(value: string): string {
902 return value.replace(/\s+/g, " ").trim();
903}
904
905function 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. */
912export 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);
888918}
889919
890920function memoizeValue<T>(build: () => T): () => T {
......@@ -2098,7 +2128,7 @@ function toThreadFeedActivityEntry(
20982128 turnId: entry.turnId,
20992129 summary,
21002130 detail,
2101 canExpand: workEntryHasExpandedBody(entry),
2131 canExpand: workEntryHasExpandedBody(entry, workEntryRowLabel(entry)),
21022132 getFullDetail,
21032133 getCopyText,
21042134 icon: workEntryIcon(entry),