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",