From d6a9e26b66efc7d5606c099f94089fbd5e691579 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Mon, 28 Sep 2026 14:07:28 +0200 Subject: [PATCH] feat(mobile): improve transcript hierarchy and approval cards Add descriptive activity summaries, inline code styling, and distinct command and reasoning treatments. Share compact permission cards across session and pending views, align labels with desktop, and move full resources and scope into Details. Keep blocked activity states consistent and center the inactive composer. Cover permission actions, full-detail inspection, semantic summaries, and inline code rendering. --- apps/mobile/src/screens/app-shell.tsx | 86 +------- .../screens/permission-request-card.test.tsx | 64 ++++++ .../src/screens/permission-request-card.tsx | 188 ++++++++++++++++++ apps/mobile/src/screens/session-composer.tsx | 6 +- .../screens/session-execution-panel.test.tsx | 2 +- .../src/screens/session-execution-panel.tsx | 119 ++--------- .../src/screens/session-transcript.test.tsx | 50 ++++- .../mobile/src/screens/session-transcript.tsx | 111 +++++++++-- .../workspace-screen.integration.test.tsx | 2 +- apps/mobile/src/screens/workspace-screen.tsx | 7 + apps/mobile/src/theme.ts | 2 + 11 files changed, 436 insertions(+), 201 deletions(-) create mode 100644 apps/mobile/src/screens/permission-request-card.test.tsx create mode 100644 apps/mobile/src/screens/permission-request-card.tsx diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx index 1526a85..596510c 100644 --- a/apps/mobile/src/screens/app-shell.tsx +++ b/apps/mobile/src/screens/app-shell.tsx @@ -37,8 +37,7 @@ import { useWorkspaceSelection } from "../state/workspace-selection-context"; import { palette, radius, space, typeRamp, usesLargeTextLayout } from "../theme"; import { AppUpdateCard } from "../updates/app-updates"; import { FormRequestList } from "./form-request-list"; -import { permissionActionExplanation } from "./permission-presentation"; -import { sanitizeTranscriptText } from "./session-transcript-model"; +import { PermissionRequestCard } from "./permission-request-card"; type Section = "Pending" | "Settings" | "Workspace"; type SessionBranch = { @@ -171,64 +170,13 @@ export function PendingInteractionsScreen({ navigation }: ScreenProps<"Pending"> {selection.permissions.map((request) => ( - - PERMISSION - {sanitizeTranscriptText(request.action, 256)} - {request.resources.map((resource) => ( - - {sanitizeTranscriptText(resource, 1_024)} - - ))} - {request.save?.map((pattern) => ( - - Save pattern: {sanitizeTranscriptText(pattern, 1_024)} - - ))} - {permissionActionExplanation(request.action) ? ( - - OPENCODE MOBILE EXPLANATION - - {permissionActionExplanation(request.action)} - - - ) : null} - - {request.save && request.save.length > 0 - ? `Always allow may save ${request.save.length} broader permission${request.save.length === 1 ? "" : "s"}. ` - : ""} - Reject may also reject other pending permission requests in this session. - - {selection.permissionReplyError ? ( - - The permission reply was not accepted. The request has been reloaded. - - ) : null} - - selection.replyPermission(request.id, request.sessionID, "once")} - /> - - selection.replyPermission(request.id, request.sessionID, "always") - } - secondary - /> - - selection.replyPermission(request.id, request.sessionID, "reject") - } - secondary - /> - - + ))} {selection.forms.length > 0 ? ( { + const reply = jest.fn(); + render( + , + ); + expect(screen.getByText("Run shell command")).toBeOnTheScreen(); + expect(screen.queryByText(command)).toBeNull(); + expect(screen.queryByText("python3 *")).toBeNull(); + fireEvent.press(screen.getByRole("button", { name: "Always allow" })); + expect(reply).toHaveBeenCalledWith("per_approval", "ses_owner", "always"); + fireEvent.press(screen.getByRole("button", { name: "Details" })); + expect(screen.getByText(command)).toBeOnTheScreen(); + expect(screen.getByText("python3 *")).toBeOnTheScreen(); + expect( + screen.getByText( + "Always allow saves the displayed patterns, which can cover more than this request.", + ), + ).toBeOnTheScreen(); +}); + +test("custom tool permissions use desktop wording and keep raw resources in Details", () => { + render( + , + ); + expect(screen.getByText("Permission required")).toBeOnTheScreen(); + expect(screen.getByText("Call tool mobile-approval-ui-test")).toBeOnTheScreen(); + expect(screen.queryByText(/python3/)).toBeNull(); + fireEvent.press(screen.getByRole("button", { name: "Details" })); + expect(screen.getByText(command)).toBeOnTheScreen(); +}); + +test("pending replies disable every choice and errors give a retry path", () => { + const view = render( + , + ); + for (const name of ["Allow once", "Always allow", "Reject"]) + expect(screen.getByRole("button", { name })).toBeDisabled(); + expect(screen.getByText("Sending permission reply…")).toBeOnTheScreen(); + view.rerender( + , + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "The server did not accept that reply. Review the refreshed request and try again.", + ); + expect(screen.getByRole("button", { name: "Allow once" })).toBeEnabled(); +}); diff --git a/apps/mobile/src/screens/permission-request-card.tsx b/apps/mobile/src/screens/permission-request-card.tsx new file mode 100644 index 0000000..29d7378 --- /dev/null +++ b/apps/mobile/src/screens/permission-request-card.tsx @@ -0,0 +1,188 @@ +import type { PermissionReply, PermissionRequest } from "@opencode2-mobile/opencode-adapter"; +import { useState } from "react"; +import { Platform, Pressable, StyleSheet, Text, useWindowDimensions, View } from "react-native"; +import { ModalSheet } from "../components/modal-sheet"; +import { markdownPalette, palette, radius, space, usesLargeTextLayout } from "../theme"; +import { permissionActionExplanation } from "./permission-presentation"; +import { sanitizeTranscriptText } from "./session-transcript-model"; + +export function PermissionRequestCard({ + request, + replying, + error, + onReply, +}: { + request: PermissionRequest; + replying: boolean; + error: boolean; + onReply: (requestID: string, sessionID: string, reply: PermissionReply) => void; +}) { + const [details, setDetails] = useState(false); + const { fontScale } = useWindowDimensions(); + const largeText = usesLargeTextLayout(fontScale); + const patterns = request.save ?? []; + const explanation = permissionActionExplanation(request.action); + const shell = request.action === "shell"; + const action = shell + ? "Run shell command" + : `Call tool ${sanitizeTranscriptText(request.action, 256)}`; + const actions = ( + + {( + [ + ["once", "Allow once"], + ["always", "Always allow"], + ["reject", "Reject"], + ] as const + ).map(([reply, label]) => ( + onReply(request.id, request.sessionID, reply)} + style={({ pressed }) => [ + styles.button, + reply === "once" && styles.primary, + replying && styles.disabled, + pressed && styles.disabled, + ]} + > + + {label} + + + ))} + + ); + const resources = request.resources.map((resource) => ( + + {sanitizeTranscriptText(resource, resource.length)} + + )); + const scope = + patterns.length > 0 ? ( + + Always allow saves these patterns for future requests + + {patterns.map((pattern) => ( + + {sanitizeTranscriptText(pattern, pattern.length)} + + ))} + + + ) : null; + return ( + + + Permission required + + {action} + {shell ? ( + + + {sanitizeTranscriptText(request.resources.join("\n"), 1024)} + + + ) : null} + setDetails(true)} + style={styles.detailsButton} + > + Details › + + {replying ? ( + + Sending permission reply… + + ) : null} + {error ? ( + + The server did not accept that reply. Review the refreshed request and try again. + + ) : null} + {actions} + setDetails(false)}> + {action} + {shell ? "Full command" : "Requested resources"} + {resources} + {scope} + {patterns.length > 0 ? ( + + Always allow saves the displayed patterns, which can cover more than this request. + + ) : null} + {explanation ? {explanation} : null} + + Reject may also reject other pending permission requests in this session. + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: palette.card, + borderColor: palette.border, + borderWidth: 1, + borderRadius: radius.lg, + padding: 12, + gap: 8, + }, + title: { color: palette.warm, fontSize: 16, fontWeight: "600" }, + action: { color: palette.ink, fontSize: 15, lineHeight: 22 }, + commandBox: { backgroundColor: palette.background, borderRadius: radius.sm }, + command: { + color: markdownPalette.code, + fontFamily: Platform.select({ ios: "Menlo", android: "monospace" }), + fontSize: 13, + lineHeight: 19, + padding: 10, + }, + scope: { gap: 4 }, + pattern: { + color: palette.ink, + fontFamily: Platform.select({ ios: "Menlo", android: "monospace" }), + fontSize: 13, + lineHeight: 19, + }, + caption: { color: palette.dim, fontSize: 12, lineHeight: 18 }, + copy: { color: palette.dim, fontSize: 15, lineHeight: 22, marginVertical: space.sm }, + actions: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + actionsLarge: { flexDirection: "column" }, + button: { + minHeight: 48, + flexGrow: 1, + justifyContent: "center", + alignItems: "center", + borderColor: palette.border, + borderWidth: 1, + borderRadius: radius.md, + padding: 10, + }, + primary: { backgroundColor: palette.signal }, + primaryLabel: { color: palette.background }, + buttonLabel: { color: palette.ink, fontSize: 14, fontWeight: "600" }, + reject: { color: palette.danger }, + disabled: { opacity: 0.5 }, + detailsButton: { minHeight: 44, justifyContent: "center" }, +}); diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx index b225b3a..1330140 100644 --- a/apps/mobile/src/screens/session-composer.tsx +++ b/apps/mobile/src/screens/session-composer.tsx @@ -240,7 +240,10 @@ export function SessionComposer({ styles.inputExpanded, { minHeight: minimumInputHeight, maxHeight: maximumInputHeight }, ] - : styles.inputCollapsed, + : [ + styles.inputCollapsed, + { paddingVertical: Math.max(0, (42 - 23 * fontScale) / 2) }, + ], ]} submitBehavior="newline" textAlignVertical={expanded ? "top" : "center"} @@ -829,6 +832,7 @@ const styles = StyleSheet.create({ }, surfaceCollapsed: { borderRadius: 999, + justifyContent: "center", minHeight: 60, paddingLeft: 12, paddingRight: 8, diff --git a/apps/mobile/src/screens/session-execution-panel.test.tsx b/apps/mobile/src/screens/session-execution-panel.test.tsx index cd5ed0a..63c53c4 100644 --- a/apps/mobile/src/screens/session-execution-panel.test.tsx +++ b/apps/mobile/src/screens/session-execution-panel.test.tsx @@ -160,7 +160,7 @@ test("shows and replies to a permission blocking the current session", () => { ); expect(screen.getByText("Waiting for permission")).toBeOnTheScreen(); - expect(screen.getByText("PERMISSION REQUIRED")).toBeOnTheScreen(); + expect(screen.getByText("Run shell command")).toBeOnTheScreen(); expect(screen.getByText("pnpm test")).toBeOnTheScreen(); fireEvent.press(screen.getByRole("button", { name: "Allow once" })); expect(callbacks.onReplyPermission).toHaveBeenCalledWith("per_test", "ses_test", "once"); diff --git a/apps/mobile/src/screens/session-execution-panel.tsx b/apps/mobile/src/screens/session-execution-panel.tsx index 3139875..74b1e98 100644 --- a/apps/mobile/src/screens/session-execution-panel.tsx +++ b/apps/mobile/src/screens/session-execution-panel.tsx @@ -4,11 +4,11 @@ import type { SessionInboxInfo, } from "@opencode2-mobile/opencode-adapter"; import type { ReactNode } from "react"; -import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View } from "react-native"; import { WorkingIndicator } from "../components/working-indicator"; import { palette, radius, space, typeRamp } from "../theme"; -import { permissionActionExplanation } from "./permission-presentation"; +import { PermissionRequestCard } from "./permission-request-card"; import { type PromptAdmission, promptAdmissionLabel, @@ -51,6 +51,7 @@ export function SessionExecutionPanel({ projectedMessageIds: Set; replyingPermissionId?: string | undefined; }) { + const { height } = useWindowDimensions(); const inboxIds = new Set(inbox.map((item) => item.id)); const localOverlays = admissions.filter( (admission) => @@ -72,7 +73,7 @@ export function SessionExecutionPanel({ accessibilityLabel="Session execution" contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled" - style={styles.shell} + style={[styles.shell, permissions.length > 0 && { maxHeight: height * 0.65 }]} > {active ? ( @@ -109,84 +110,15 @@ export function SessionExecutionPanel({ ) : null} - {permissions.map((request) => { - const replying = replyingPermissionId === request.id; - const explanation = permissionActionExplanation(request.action); - return ( - - - PERMISSION REQUIRED - - - {sanitizeTranscriptText(request.action, 256)} - - {request.resources.map((resource) => ( - - {sanitizeTranscriptText(resource, 1_024)} - - ))} - {request.save?.map((pattern) => ( - - Save pattern: {sanitizeTranscriptText(pattern, 1_024)} - - ))} - {explanation ? ( - - - OPENCODE MOBILE EXPLANATION - - - {explanation} - - - ) : null} - {request.save && request.save.length > 0 ? ( - - Always allow may save {request.save.length} broader permission - {request.save.length === 1 ? "" : "s"}. Reject may also reject other pending - permission requests in this session. - - ) : ( - - Reject may also reject other pending permission requests in this session. - - )} - {permissionReplyError ? ( - - The permission reply was not accepted. The request has been reloaded. - - ) : null} - - onReplyPermission(request.id, request.sessionID, "once")} - /> - onReplyPermission(request.id, request.sessionID, "always")} - /> - onReplyPermission(request.id, request.sessionID, "reject")} - /> - - - ); - })} + {permissions.map((request) => ( + + ))} {formRequests} @@ -347,31 +279,6 @@ const styles = StyleSheet.create({ inboxHeading: { alignItems: "center", flexDirection: "row", justifyContent: "space-between" }, inboxType: { color: palette.dim, fontSize: 11, fontWeight: "700" }, pressed: { opacity: 0.62 }, - permissionAction: { color: palette.ink, fontSize: 16, fontWeight: "800" }, - permissionCard: { - backgroundColor: palette.background, - borderColor: palette.warm, - borderRadius: radius.md, - borderWidth: 1, - gap: space.sm, - padding: space.md, - }, - permissionError: { color: palette.danger, fontSize: 13, lineHeight: 18 }, - permissionExplanation: { gap: 2 }, - permissionExplanationLabel: { - color: palette.dim, - fontSize: 10, - fontWeight: "800", - letterSpacing: 0.7, - }, - permissionResource: { - backgroundColor: palette.background, - borderRadius: radius.sm, - color: palette.ink, - fontSize: 13, - padding: space.sm, - }, - permissionWarning: { color: palette.warm, fontSize: 12, lineHeight: 17 }, promptPreview: { color: palette.ink, fontSize: 14, lineHeight: 20 }, content: { gap: space.sm, paddingHorizontal: space.md, paddingVertical: space.xs }, shell: { flexGrow: 0, flexShrink: 1, maxHeight: 280 }, diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx index 9710dd3..2f68999 100644 --- a/apps/mobile/src/screens/session-transcript.test.tsx +++ b/apps/mobile/src/screens/session-transcript.test.tsx @@ -5,10 +5,58 @@ import { Alert, Linking, View } from "react-native"; import { resetTranscriptPerformanceMetrics } from "../state/transcript-performance"; import { markdownPalette, palette } from "../theme"; -import { groupTranscriptMessages, SessionTranscriptRow } from "./session-transcript"; +import { + activitySummary, + groupTranscriptMessages, + SessionTranscriptRow, +} from "./session-transcript"; afterEach(resetTranscriptPerformanceMetrics); +test("inline code preserves literal markup and does not create links inside code", () => { + const message = messages.find((item) => item.type === "assistant"); + if (!message) throw new Error("fixture"); + render( + , + ); + expect(screen.getByText("pnpm check")).toHaveStyle({ + color: markdownPalette.code, + fontFamily: "monospace", + }); + expect(screen.getByText("**literal**")).toBeOnTheScreen(); + expect(screen.queryByRole("link", { name: "https://code.test" })).toBeNull(); + expect(screen.getByText("Important")).toHaveStyle({ fontWeight: "700" }); +}); + +test("activity summaries count operations rather than inventing file counts", () => { + const message = messages.find((item) => item.type === "assistant"); + if (!message) throw new Error("fixture"); + const tool = message.content.find((part) => part.type === "tool"); + if (!tool) throw new Error("fixture"); + expect( + activitySummary([ + { + ...message, + content: ["glob", "grep", "shell", "patch"].map((name, index) => ({ + ...tool, + name, + id: `tool_${index}`, + })), + }, + ]), + ).toBe("2 lookups · 1 command · 1 edit"); +}); + test("cross-message grouping respects replies, errors, reasoning visibility and detailed mode", () => { const original = messages.find((message) => message.type === "assistant"); if (!original) throw new Error("fixture"); diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx index 3bcd4ca..4538799 100644 --- a/apps/mobile/src/screens/session-transcript.tsx +++ b/apps/mobile/src/screens/session-transcript.tsx @@ -91,13 +91,40 @@ export function groupTranscriptMessages( return result; } +export function activitySummary(messages: SessionMessageInfo[]) { + const counts = new Map(); + 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"; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + } + return [...counts] + .map(([label, count]) => `${count} ${label}${count === 1 ? "" : "s"}`) + .join(" · "); +} + export function TranscriptActivityGroup({ + waitingFor, item, largeText, showReasoning, onOpenDiff, onOpenSubagent, }: { + waitingFor?: "permission" | "input" | undefined; item: Extract; largeText: boolean; showReasoning: boolean; @@ -106,16 +133,18 @@ export function TranscriptActivityGroup({ }) { const [expanded, setExpanded] = useState(false); return ( - + setExpanded((value) => !value)} style={styles.activityGroupHeader} > - - {item.running ? "Working" : "Activity"} · {item.count} tool calls + + {item.running ? (waitingFor ? `Waiting for ${waitingFor} · ` : "Working · ") : ""} + {activitySummary(item.messages)} {expanded ? "Hide" : "Show"} @@ -577,7 +606,7 @@ function ToolDisclosure({ )) : null} {expanded && presentation.command ? ( - + {`$ ${presentation.command}`} ) : null} @@ -692,7 +721,7 @@ function ShellDisclosure({ largeText, message }: { largeText: boolean; message: onPress={() => setExpanded((current) => !current)} /> {expanded ? ( - + {`$ ${message.command}`} ) : null} @@ -1007,13 +1036,19 @@ function InlineMarkdownText({ return ( {prefix ? {`${prefix} `} : null} - {splitBoldText(text).map((token) => ( - - ))} + {splitInlineText(text).map((token) => + token.code ? ( + + {token.text} + + ) : ( + + ), + )} ); } @@ -1136,6 +1171,28 @@ function splitBoldText(text: string) { return tokens; } +function splitInlineText(text: string) { + const tokens: { bold: boolean; code: boolean; key: string; text: string }[] = []; + const pattern = /(`+)([\s\S]*?)\1(?!`)/g; + let cursor = 0; + const prose = (value: string) => { + for (const token of splitBoldText(value)) + tokens.push({ ...token, code: false, key: `inline:${tokens.length}` }); + }; + for (const match of text.matchAll(pattern)) { + prose(text.slice(cursor, match.index)); + tokens.push({ + bold: false, + code: true, + key: `inline:${tokens.length}`, + text: (match[2] ?? "").replace(/\n/g, " "), + }); + cursor = match.index + match[0].length; + } + prose(text.slice(cursor)); + return tokens; +} + function splitCodeBlocks(text: string): MarkdownBlock[] { const blocks: MarkdownBlock[] = []; const prose: string[] = []; @@ -1456,6 +1513,26 @@ function keyDisclosureText(entries: string[]) { } const styles = StyleSheet.create({ + activityGroup: { + backgroundColor: palette.card, + borderRadius: radius.md, + marginHorizontal: space.md, + marginVertical: space.xs, + overflow: "hidden", + }, + inlineCode: { + backgroundColor: palette.raised, + color: markdownPalette.code, + fontFamily: "monospace", + }, + commandText: { + backgroundColor: palette.raised, + color: markdownPalette.code, + fontFamily: "monospace", + fontSize: 13, + lineHeight: 20, + padding: 12, + }, activityGroupHeader: { alignItems: "center", flexDirection: "row", @@ -1467,6 +1544,9 @@ const styles = StyleSheet.create({ paddingVertical: space.sm, }, activity: { + backgroundColor: palette.card, + borderRadius: radius.md, + paddingHorizontal: space.sm, borderBottomColor: palette.border, borderBottomWidth: StyleSheet.hairlineWidth, }, @@ -1600,7 +1680,12 @@ const styles = StyleSheet.create({ padding: 12, }, pressed: { opacity: 0.7 }, - reasoningLabel: { color: palette.dim, fontSize: 9, fontWeight: "900", letterSpacing: 0.8 }, + reasoningLabel: { + color: markdownPalette.reasoning, + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.4, + }, reasoningText: { color: palette.dim, fontSize: 13, lineHeight: 19 }, statusText: { color: palette.dim, fontSize: 12 }, subagent: { diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index 58a7ac9..3c375dc 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -323,7 +323,7 @@ test("shows a permission blocking the open session and can reply", async () => { ); try { - expect(await screen.findByText("PERMISSION REQUIRED")).toBeOnTheScreen(); + expect(await screen.findByText("Run shell command")).toBeOnTheScreen(); expect(screen.getByLabelText("Keyboard composer dock")).toBeOnTheScreen(); fireEvent.press(screen.getByRole("button", { name: "Allow once" })); expect(mockReplyPermission).toHaveBeenCalledWith("per_test", "ses_transcript", "once"); diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index 24c7877..db38941 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -987,6 +987,13 @@ export function SessionScreen({ navigation, route }: SessionProps) { renderItem={({ item }) => item.type === "activity-group" ? ( 0 + ? "permission" + : sessionForms.length > 0 + ? "input" + : undefined + } item={item} largeText={largeText} showReasoning={transcriptPreferences.reasoning} diff --git a/apps/mobile/src/theme.ts b/apps/mobile/src/theme.ts index cbc1e2a..fd607d2 100644 --- a/apps/mobile/src/theme.ts +++ b/apps/mobile/src/theme.ts @@ -27,6 +27,8 @@ export const diffPalette = { } as const; export const markdownPalette = { + code: "#a8d5ba", + reasoning: "#c4b5fd", link: "#8ab4f8", linkText: "#8ab4f8", strong: "#eeeeee",