authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-06 20:28:08-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 20:28:08-05:00
log8c9a49afb5a4c9b28c1ef3540248a1cfa87cdad5
tree295df90237205ce41d8b78db220ee91e448056f9
parent1e740e48a5f91b3edd35c5446d33b0d36fb5a6ac
signature Signed by PGP key B5690EEEBB952194

fix(mobile): expand single-line tool details in work logs (#10442)


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(
730730 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
731731 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
732732 const previewText = workEntryRowLabel(row.workEntry);
733 const displayText =
734 !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText;
733 const displayText = workEntryRowLabel(row.workEntry, expanded);
735734 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
736735 const failed = row.status === "failure";
737736 const toolIcon = row.workEntry.toolIcon ?? row.workEntry.toolSource?.icon;
......@@ -747,7 +746,9 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
747746 accessibilityRole={canExpand ? "button" : undefined}
748747 accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText}
749748 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."
751752 }
752753 accessibilityState={canExpand ? { expanded } : undefined}
753754 hitSlop={4}
......@@ -761,7 +762,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
761762 className="rounded-md px-0.5 py-0 active:bg-subtle"
762763 >
763764 <View className="min-h-8 flex-row items-center gap-1.5">
764 {row.live ? (
765 {row.live && !expanded ? (
765766 <ShimmeringWorkContent
766767 environmentId={props.environmentId}
767768 icon={icon}
......@@ -801,7 +802,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
801802 "min-w-0 flex-1 text-sm text-foreground-muted",
802803 iconIsDestructive && "font-t3-medium text-adaptive-rose-600-400",
803804 )}
804 numberOfLines={1}
805 numberOfLines={expanded ? undefined : 1}
805806 >
806807 {displayText}
807808 </Text>
......@@ -842,7 +843,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
842843 </View>
843844 </Pressable>
844845
845 {fullDetail ? (
846 {expanded && (fullDetail || viewedImagePath) ? (
846847 <Animated.View
847848 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
848849 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
apps/mobile/src/lib/threadActivity.test.ts+112-5
......@@ -561,8 +561,8 @@ describe("buildThreadFeed", () => {
561561 const [row] = group.activities;
562562 expect(row?.workEntry.detail).toBe(command);
563563 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");
566566 });
567567
568568 it.each([
......@@ -594,7 +594,7 @@ describe("buildThreadFeed", () => {
594594 payload: { detail: "Bash is unusable in this environment" },
595595 },
596596 label: "Bash is unusable in this environment",
597 canExpand: false,
597 canExpand: true,
598598 },
599599 {
600600 name: "a multi-line task report",
......@@ -630,7 +630,7 @@ describe("buildThreadFeed", () => {
630630 label: "printf hello",
631631 canExpand: true,
632632 },
633 ])("only lets $name expand when the body adds something: $canExpand", (input) => {
633 ])("sets expansion availability for $name: $canExpand", (input) => {
634634 const thread = makeThread({
635635 id: ThreadId.make("thread-expand-rule"),
636636 projectId: ProjectId.make("project-1"),
......@@ -652,6 +652,107 @@ describe("buildThreadFeed", () => {
652652 expect(row?.canExpand).toBe(input.canExpand);
653653 });
654654
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
655756 it("drops a truncated Claude echo of a long command", () => {
656757 const command = `git add -A && git commit -m "${"x".repeat(200)}"`;
657758 const thread = makeThread({
......@@ -1059,6 +1160,11 @@ describe("buildThreadFeed", () => {
10591160 },
10601161 ],
10611162 });
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)}...`);
10621168 });
10631169
10641170 it("keeps MCP inputs available to expanded mobile work rows", () => {
......@@ -1136,7 +1242,8 @@ describe("buildThreadFeed", () => {
11361242 },
11371243 });
11381244 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");
11401247 });
11411248
11421249 it.each([
apps/mobile/src/lib/threadActivity.ts+16-16
......@@ -525,6 +525,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
525525 if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) {
526526 entry.detail = payload.error;
527527 }
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 }
528532 if (viewedImagePath) {
529533 entry.viewedImagePath = viewedImagePath;
530534 }
......@@ -939,9 +943,12 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
939943function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
940944 if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn);
941945 const blocks: string[] = [];
946 const visibleLabel = workEntryRowLabel(entry, true).trim();
942947 const appendBlock = (value: string | null | undefined) => {
943948 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 }
945952 };
946953
947954 if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) {
......@@ -957,24 +964,15 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
957964}
958965
959966/**
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.
964968 * Cheap field checks come first so large tool payloads are not serialized
965969 * for every row (see the deferred-expansion test).
966970 */
967function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean {
971function workEntryCanExpand(entry: WorkLogEntry): boolean {
968972 if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0;
969973 if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true;
970974 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());
978976}
979977
980978function collapseWhitespace(value: string): string {
......@@ -987,12 +985,14 @@ function stripShellWrapper(value: string): string {
987985 return (match?.[1] ?? trimmed).trim();
988986}
989987
990/** The one-line text a collapsed work row shows. */
991export function workEntryRowLabel(entry: WorkLogEntry): string {
988/** Expanded rows retain detail formatting; commands stay in the separate body. */
989export function workEntryRowLabel(entry: WorkLogEntry, expanded = false): string {
992990 if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn);
993991 const presentation = resolveWorkEntryToolPresentation(entry);
994992 if (presentation) return presentation.displayName;
993 if (expanded && entry.command?.trim()) return "Command";
995994 const preview = workEntryPreview(entry);
995 if (expanded) return preview?.trim() || workEntryHeading(entry);
996996 const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview));
997997 return compactPreview || workEntryHeading(entry);
998998}
......@@ -2227,7 +2227,7 @@ function toThreadFeedActivityEntry(
22272227 turnId: entry.turnId,
22282228 summary,
22292229 detail,
2230 canExpand: workEntryHasExpandedBody(entry, workEntryRowLabel(entry)),
2230 canExpand: workEntryCanExpand(entry),
22312231 getFullDetail,
22322232 getCopyText,
22332233 icon: workEntryIcon(entry),