From 56f4661794a93d871859463971d9b334a5825507 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Tue, 29 Sep 2026 01:53:08 +0200 Subject: [PATCH 1/2] Reduce user prompt bubble padding to 16 Co-Authored-By: opencode --- apps/mobile/src/screens/session-transcript.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx index b8b165c..594734f 100644 --- a/apps/mobile/src/screens/session-transcript.tsx +++ b/apps/mobile/src/screens/session-transcript.tsx @@ -1620,7 +1620,7 @@ const styles = StyleSheet.create({ backgroundColor: palette.prompt, borderRadius: 28, maxWidth: "80%", - padding: 20, + padding: 16, }, userBubbleLargeText: { maxWidth: "100%" }, // Supply intrinsic text width to Yoga; the native selection view supplies height. From fd18090c7e2a48f3a3c0212ee8d2f49f7eac1a0c Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Tue, 29 Sep 2026 02:27:06 +0200 Subject: [PATCH 2/2] Align transcript activity and shell output with OpenCode --- .../screens/session-execution-panel.test.tsx | 2 + .../src/screens/session-execution-panel.tsx | 16 ++ .../src/screens/session-transcript.test.tsx | 67 ++++- .../mobile/src/screens/session-transcript.tsx | 266 +++++++++++------- .../workspace-screen.integration.test.tsx | 2 + apps/mobile/src/screens/workspace-screen.tsx | 7 +- 6 files changed, 260 insertions(+), 100 deletions(-) diff --git a/apps/mobile/src/screens/session-execution-panel.test.tsx b/apps/mobile/src/screens/session-execution-panel.test.tsx index 63c53c4..c3fb205 100644 --- a/apps/mobile/src/screens/session-execution-panel.test.tsx +++ b/apps/mobile/src/screens/session-execution-panel.test.tsx @@ -41,6 +41,8 @@ test("renders active execution and mutable queued inbox work", () => { ); expect(screen.getByText("Working")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Move to background" })); + expect(callbacks.onBackground).toHaveBeenCalledTimes(1); fireEvent.press(screen.getByRole("button", { name: "Stop" })); expect(callbacks.onInterrupt).toHaveBeenCalledTimes(1); expect(screen.getByText("Queued prompt")).toBeOnTheScreen(); diff --git a/apps/mobile/src/screens/session-execution-panel.tsx b/apps/mobile/src/screens/session-execution-panel.tsx index c98db59..6eed3ab 100644 --- a/apps/mobile/src/screens/session-execution-panel.tsx +++ b/apps/mobile/src/screens/session-execution-panel.tsx @@ -26,6 +26,7 @@ export function SessionExecutionPanel({ onCancelInbox, onCheckAdmission, onInterrupt, + onBackground, onQueueInbox, onReplyPermission, onSteerInbox, @@ -43,6 +44,7 @@ export function SessionExecutionPanel({ onCancelInbox: (inboxID: string) => void; onCheckAdmission: (admissionID: string) => void; onInterrupt: () => void; + onBackground?: () => void; onQueueInbox: (inboxID: string) => void; onReplyPermission: (requestID: string, sessionID: string, reply: PermissionReply) => void; onSteerInbox: (inboxID: string) => void; @@ -91,6 +93,19 @@ export function SessionExecutionPanel({ : "Working"} + {onBackground && permissions.length === 0 && !formRequests ? ( + + + {busyAction === "background" ? "Moving to background" : "Move to background"} + + + ) : null} { + const output = `${"shell output\n".repeat(4000)}last output line`; + const original = messages.find((message) => message.type === "assistant"); + if (!original) throw new Error("fixture"); + const tool = original.content.find( + (part) => part.type === "tool" && part.state.status === "completed", + ); + if (tool?.type !== "tool" || tool.state.status !== "completed") throw new Error("fixture"); + const message: SessionMessageInfo = { + ...original, + content: [ + { + ...tool, + name: "shell", + state: { + ...tool.state, + input: { command: "echo nested" }, + content: [{ type: "text" as const, text: output }], + }, + }, + ], + }; + render( + , + ); + expect(screen.queryByText(output)).toBeNull(); + fireEvent.press(screen.getByRole("button", { name: "1 tool calls" })); + expect(screen.queryByText(output)).toBeNull(); + fireEvent.press(screen.getByRole("button", { name: /Shell.*Show/ })); + expect(screen.getByText(output)).toBeOnTheScreen(); + expect(screen.queryByRole("button", { name: /Show more/ })).toBeNull(); + expect(screen.getByLabelText("Shell output")).toHaveStyle({ maxHeight: 240 }); + expect(screen.getByLabelText("Shell output").props.nestedScrollEnabled).toBe(true); + expect(screen.getByRole("button", { name: "Copy shell output" })).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: /Shell.*Hide/ })); + expect(screen.queryByText(output)).toBeNull(); +}); + test("activity summaries count operations rather than inventing file counts", () => { const message = messages.find((item) => item.type === "assistant"); if (!message) throw new Error("fixture"); @@ -95,7 +146,7 @@ test("activity summaries count operations rather than inventing file counts", () })), }, ]), - ).toBe("2 lookups · 1 command · 1 edit"); + ).toBe("Used 4 Glob, Grep, Shell, Patch"); }); test("cross-message grouping respects replies, errors, reasoning visibility and detailed mode", () => { @@ -118,11 +169,21 @@ test("cross-message grouping respects replies, errors, reasoning visibility and expect(groupTranscriptMessages([clean, reasoning, second], false, false)).toMatchObject([ { type: "activity-group", count: 2 }, ]); - expect(groupTranscriptMessages([clean, reasoning, second], false, true)).toHaveLength(3); + expect(groupTranscriptMessages([clean, reasoning, second], false, true)).toMatchObject([ + { type: "activity-group", count: 2, messages: [clean, reasoning, second] }, + ]); expect(groupTranscriptMessages([clean, reply, second], false, false)).toHaveLength(3); expect(groupTranscriptMessages([clean, second], true, false)).toEqual([clean, second]); + const mixed = { ...clean, content: [{ type: "text" as const, text: "Progress" }, tool] }; + expect(groupTranscriptMessages([mixed, second], false, true)).toMatchObject([ + { type: "assistant", content: [{ type: "text", text: "Progress" }] }, + { type: "activity-group", count: 2 }, + ]); const failed = { ...second, error: { type: "ToolError", message: "Failed" } }; - expect(groupTranscriptMessages([clean, failed], false, false)).toEqual([clean, failed]); + expect(groupTranscriptMessages([clean, failed], false, false)).toMatchObject([ + { type: "activity-group", count: 1 }, + failed, + ]); }); test("reasoning can be hidden without hiding replies or tool failures", () => { diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx index 594734f..11a1f1c 100644 --- a/apps/mobile/src/screens/session-transcript.tsx +++ b/apps/mobile/src/screens/session-transcript.tsx @@ -1,6 +1,15 @@ import type { SessionMessageInfo } from "@opencode2-mobile/opencode-adapter"; import { memo, useEffect, useState } from "react"; -import { Alert, Linking, Platform, Pressable, StyleSheet, Text, View } from "react-native"; +import { + Alert, + Linking, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; import { applicationName } from "../application-name"; import { CopyTextButton } from "../components/copy-text-button"; @@ -45,7 +54,7 @@ export type TranscriptItem = export function groupTranscriptMessages( messages: SessionMessageInfo[], detailed: boolean, - showReasoning: boolean, + _showReasoning: boolean, ): TranscriptItem[] { if (detailed) return messages; const result: TranscriptItem[] = []; @@ -54,7 +63,7 @@ export function groupTranscriptMessages( let running = false; const flush = () => { const first = pending[0]; - if (first && count > 1) { + if (first && count > 0) { result.push({ type: "activity-group", id: `activity:${first.id}`, @@ -62,21 +71,37 @@ export function groupTranscriptMessages( count, running, }); - } else if (count > 0) result.push(...pending); + } else result.push(...pending); pending = []; count = 0; running = false; }; - for (const message of messages) { + // Split mixed assistant messages at prose boundaries so adjacent tool runs + // share one disclosure, even when the server batches prose and tools together. + const segments = messages.flatMap((message): SessionMessageInfo[] => { + if (message.type !== "assistant" || message.error || message.retry) return [message]; + const runs: AssistantMessage["content"][] = []; + for (const part of message.content) { + const previous = runs[runs.length - 1]; + if (previous && (previous[0]?.type === "text") === (part.type === "text")) + previous.push(part); + else runs.push([part]); + } + if (runs.length < 2) return [message]; + return runs.map((content, index) => ({ + ...message, + id: index === 0 ? message.id : `${message.id}:segment:${index}`, + content, + })); + }); + for (const message of segments) { if ( message.type === "assistant" && !message.error && !message.retry && message.content.length > 0 && message.content.every((part) => - part.type === "reasoning" - ? !showReasoning - : part.type === "tool" && part.state.status !== "error" && !getSubagentPresentation(part), + part.type === "reasoning" ? true : part.type === "tool" && !getSubagentPresentation(part), ) ) { pending.push(message); @@ -96,27 +121,18 @@ export function groupTranscriptMessages( export function activitySummary(messages: SessionMessageInfo[]) { const counts = new Map(); + let failures = 0; for (const message of messages) { if (message.type !== "assistant") continue; for (const part of message.content) { if (part.type !== "tool") continue; - const category = toolCategory(part); - const label = - category === "exploration" - ? "lookup" - : category === "shell" - ? "command" - : category === "edit" - ? "edit" - : category === "skill" - ? "skill call" - : "tool call"; + if (part.state.status === "error") failures += 1; + const label = capitalize(part.name); counts.set(label, (counts.get(label) ?? 0) + 1); } } - return [...counts] - .map(([label, count]) => `${count} ${label}${count === 1 ? "" : "s"}`) - .join(" · "); + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + return `Used ${total} ${[...counts.keys()].join(", ")}${failures ? ` · ${failures} failed` : ""}`; } export function TranscriptActivityGroup({ @@ -146,16 +162,18 @@ export function TranscriptActivityGroup({ style={styles.activityGroupHeader} > - {item.running ? (waitingFor ? `Waiting for ${waitingFor} · ` : "Working · ") : ""} {activitySummary(item.messages)} - {expanded ? "Hide" : "Show"} + + {expanded ? "⌄" : "›"} + {expanded ? item.messages.map((message) => ( + {(detailed ? visibleContent.map( (part, index): AssistantPresentationItem => ({ @@ -286,6 +308,7 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ return ( No projected content ) : null} - {hasNarrativeContent(message) ? ( - - ) : null} - {responseText ? ( - - ) : null} + + {!compactActivity && !hideFooter && hasNarrativeContent(message) ? ( + + ) : null} + {responseText && !hideFooter ? ( + + ) : null} + ); } @@ -617,56 +642,73 @@ function ToolDisclosure({ > setExpanded((current) => !current)} /> - {expanded - ? presentation.files.map((file) => ( - - {file} - - )) - : null} - {expanded && presentation.command ? ( - - {`$ ${presentation.command}`} - - ) : null} - {expanded - ? keyToolContent(visibleContent).map(({ item, key }) => - item.type === "text" ? ( - - ) : ( + + {expanded && toolInputRecord(tool) ? ( + + ) : null} + {expanded + ? presentation.files.map((file) => ( - {sanitizeTranscriptText( - item.name?.trim() ? basename(item.name.trim()) : "File result", - 256, - )} + {file} - ), - ) - : null} - {expanded && content.length > visibleContent.length ? ( - Additional tool output omitted on this device. - ) : null} - {error ? : null} - {!nested && category === "edit" && onOpenDiff ? : null} + )) + : null} + {expanded && presentation.command ? ( + + {`$ ${presentation.command}`} + + ) : null} + {expanded + ? keyToolContent(visibleContent).map(({ item, key }) => + item.type === "text" ? ( + category === "shell" ? ( + + ) : ( + + ) + ) : ( + + {sanitizeTranscriptText( + item.name?.trim() ? basename(item.name.trim()) : "File result", + 256, + )} + + ), + ) + : null} + {expanded && content.length > visibleContent.length ? ( + Additional tool output omitted on this device. + ) : null} + {error && (!nested || expanded) ? ( + + ) : null} + {(!nested || expanded) && category === "edit" && onOpenDiff ? ( + + ) : null} + ); } @@ -687,6 +729,7 @@ function DiffAction({ onPress }: { onPress: () => void }) { function ActivityHeader({ canExpand, + error = false, detail, expanded, label, @@ -694,6 +737,7 @@ function ActivityHeader({ onPress, }: { canExpand: boolean; + error?: boolean; detail?: string | undefined; expanded: boolean; label: string; @@ -703,6 +747,7 @@ function ActivityHeader({ return ( {sanitizeTranscriptText(detail, 512)} @@ -731,7 +776,7 @@ function ActivityHeader({ dynamicTypeRamp={typeRamp.control} style={[styles.activityAction, largeText && styles.activityActionLargeText]} > - {expanded ? "Hide" : "Show"} + {expanded ? "⌄" : "›"} ) : null} @@ -759,14 +804,35 @@ function ShellDisclosure({ largeText, message }: { largeText: boolean; message: {`$ ${message.command}`} ) : null} - {expanded && message.output?.output ? ( - - ) : null} + {expanded && message.output?.output ? : null} ); } +function ShellOutput({ text }: { text: string }) { + const safeText = sanitizeTranscriptText(text, text.length); + return ( + + + + + + + {safeText} + + + + ); +} + function AssistantFooter({ message, modelName, @@ -1269,6 +1335,7 @@ function toolPresentation(tool: AssistantTool) { } function canExpandTool(tool: AssistantTool) { + if (toolInputRecord(tool)) return true; if (tool.state.status === "error") return true; if (tool.state.status !== "completed") return false; return Boolean(tool.state.content?.length || toolPresentation(tool).files.length); @@ -1424,10 +1491,18 @@ function keyDisclosureText(entries: string[]) { } const styles = StyleSheet.create({ + shellOutputBox: { + backgroundColor: palette.raised, + borderRadius: radius.sm, + overflow: "hidden", + marginVertical: space.xs, + }, + shellOutputToolbar: { alignItems: "flex-end", paddingHorizontal: space.xs }, + shellOutputScroll: { maxHeight: 240 }, + shellOutputText: { ...typography.code, color: palette.dim }, + shellOutputContent: { paddingHorizontal: space.sm, paddingBottom: space.sm }, activityGroup: { - backgroundColor: palette.card, - borderRadius: radius.md, - marginHorizontal: space.md, + marginHorizontal: space.lg, marginVertical: space.xs, overflow: "hidden", }, @@ -1444,15 +1519,11 @@ const styles = StyleSheet.create({ gap: space.sm, justifyContent: "space-between", minHeight: 48, - paddingHorizontal: space.lg, + paddingHorizontal: 0, paddingVertical: space.sm, }, activity: { - backgroundColor: palette.card, - borderRadius: radius.md, - paddingHorizontal: space.sm, - borderBottomColor: palette.border, - borderBottomWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 0, }, activityAction: { color: palette.signal, fontSize: 11, fontWeight: "700" }, activityError: { @@ -1467,7 +1538,7 @@ const styles = StyleSheet.create({ alignItems: "baseline", flex: 1, flexDirection: "row", - flexWrap: "wrap", + flexWrap: "nowrap", gap: space.xs, minWidth: 0, }, @@ -1491,7 +1562,10 @@ const styles = StyleSheet.create({ }, activityHeaderLargeText: { alignItems: "flex-start", flexDirection: "column" }, activityLabel: { ...typography.control, color: palette.ink }, - activityNested: { marginLeft: 12 }, + activityNested: { marginLeft: 0 }, + compactActivity: { paddingLeft: space.sm, gap: 2 }, + toolDetails: { paddingLeft: space.md }, + responseFooter: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: space.sm }, activityStandalone: { marginHorizontal: space.lg, paddingVertical: space.xs }, assistantFooter: { color: palette.dim, fontSize: 15, lineHeight: 22, marginTop: space.sm }, assistantRow: { @@ -1618,9 +1692,9 @@ const styles = StyleSheet.create({ textActionLabel: { color: palette.signal, fontSize: 12, fontWeight: "700" }, userBubble: { backgroundColor: palette.prompt, - borderRadius: 28, + borderRadius: 16, maxWidth: "80%", - padding: 16, + padding: 14, }, userBubbleLargeText: { maxWidth: "100%" }, // Supply intrinsic text width to Yoga; the native selection view supplies height. diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index d94c485..a403e2f 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -869,6 +869,8 @@ test("waits for and adopts a moved session's authoritative location", async () = ), ).toBeDefined(); + fireEvent.press(await screen.findByRole("button", { name: "1 tool calls" })); + fireEvent.press(screen.getByRole("button", { name: /Patch.*Show/ })); fireEvent.press(await screen.findByRole("button", { name: "Review current changes" })); expect(push).toHaveBeenCalledWith("Diff", { connectionId: "connection-1", diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index 568c6d0..911f85a 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -894,6 +894,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { onCancelInbox={execution.cancelInbox} onCheckAdmission={execution.reconcileAdmission} onInterrupt={execution.interrupt} + onBackground={execution.background} onQueueInbox={execution.queueInbox} onReplyPermission={workspaceSelection.replyPermission} onSteerInbox={execution.steerInbox} @@ -1032,7 +1033,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { onScrollBeginDrag={handleScrollBeginDrag} onScrollEndDrag={handleScrollEndDrag} ref={transcriptListRef} - renderItem={({ item }) => + renderItem={({ item, index }) => item.type === "activity-group" ? ( ) : (