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 b8b165c..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: 20,
+ 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" ? (
) : (