Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 8 additions & 78 deletions apps/mobile/src/screens/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -171,64 +170,13 @@ export function PendingInteractionsScreen({ navigation }: ScreenProps<"Pending">
</Text>
</View>
{selection.permissions.map((request) => (
<View key={`permission:${request.id}`} style={styles.actionCard}>
<Text style={styles.cardLabel}>PERMISSION</Text>
<Text style={styles.cardTitle}>{sanitizeTranscriptText(request.action, 256)}</Text>
{request.resources.map((resource) => (
<Text key={resource} selectable style={styles.permissionResource}>
{sanitizeTranscriptText(resource, 1_024)}
</Text>
))}
{request.save?.map((pattern) => (
<Text key={pattern} selectable style={styles.permissionResource}>
Save pattern: {sanitizeTranscriptText(pattern, 1_024)}
</Text>
))}
{permissionActionExplanation(request.action) ? (
<View style={styles.clientExplanation}>
<Text style={styles.clientExplanationLabel}>OPENCODE MOBILE EXPLANATION</Text>
<Text style={styles.cardCopy}>
{permissionActionExplanation(request.action)}
</Text>
</View>
) : null}
<Text style={styles.permissionWarning}>
{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.
</Text>
{selection.permissionReplyError ? (
<Text accessibilityRole="alert" style={styles.permissionError}>
The permission reply was not accepted. The request has been reloaded.
</Text>
) : null}
<View style={styles.permissionActions}>
<ActionButton
disabled={selection.replyingPermissionId === request.id}
label={
selection.replyingPermissionId === request.id ? "Replying" : "Allow once"
}
onPress={() => selection.replyPermission(request.id, request.sessionID, "once")}
/>
<ActionButton
disabled={selection.replyingPermissionId === request.id}
label="Always allow"
onPress={() =>
selection.replyPermission(request.id, request.sessionID, "always")
}
secondary
/>
<ActionButton
disabled={selection.replyingPermissionId === request.id}
label="Reject"
onPress={() =>
selection.replyPermission(request.id, request.sessionID, "reject")
}
secondary
/>
</View>
</View>
<PermissionRequestCard
key={`permission:${request.id}`}
request={request}
replying={selection.replyingPermissionId === request.id}
error={selection.permissionReplyError}
onReply={selection.replyPermission}
/>
))}
{selection.forms.length > 0 ? (
<FormRequestList
Expand Down Expand Up @@ -980,13 +928,6 @@ const styles = StyleSheet.create({
lineHeight: 23,
marginTop: space.xs,
},
clientExplanation: { marginTop: space.sm },
clientExplanationLabel: {
color: palette.dim,
fontSize: 10,
fontWeight: "800",
letterSpacing: 0.7,
},
centeredState: {
alignItems: "flex-start",
backgroundColor: palette.background,
Expand Down Expand Up @@ -1088,17 +1029,6 @@ const styles = StyleSheet.create({
textAlign: "center",
},
navigationLabelActive: { color: palette.signal },
permissionActions: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
permissionError: { color: palette.danger, fontSize: 13, lineHeight: 18, marginTop: space.sm },
permissionResource: {
backgroundColor: palette.background,
borderRadius: radius.sm,
color: palette.ink,
fontSize: 13,
marginTop: space.sm,
padding: space.sm,
},
permissionWarning: { color: palette.warm, fontSize: 12, lineHeight: 17, marginTop: space.sm },
rail: {
backgroundColor: palette.card,
borderRightColor: palette.border,
Expand Down
64 changes: 64 additions & 0 deletions apps/mobile/src/screens/permission-request-card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect, jest, test } from "@jest/globals";
import { fireEvent, render, screen } from "@testing-library/react-native";
import { PermissionRequestCard } from "./permission-request-card";

const command = `python3 - <<'PY'\n${"print('review me')\n".repeat(100)}PY`;
const request = {
action: "shell",
id: "per_approval",
sessionID: "ses_owner",
resources: [command],
save: ["python3 *"],
};

test("shell requests show a short preview and preserve full commands and scope in Details", () => {
const reply = jest.fn();
render(
<PermissionRequestCard request={request} replying={false} error={false} onReply={reply} />,
);
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(
<PermissionRequestCard
request={{ ...request, action: "mobile-approval-ui-test" }}
replying={false}
error={false}
onReply={jest.fn()}
/>,
);
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(
<PermissionRequestCard request={request} replying error={false} onReply={jest.fn()} />,
);
for (const name of ["Allow once", "Always allow", "Reject"])
expect(screen.getByRole("button", { name })).toBeDisabled();
expect(screen.getByText("Sending permission reply…")).toBeOnTheScreen();
view.rerender(
<PermissionRequestCard request={request} replying={false} error onReply={jest.fn()} />,
);
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();
});
188 changes: 188 additions & 0 deletions apps/mobile/src/screens/permission-request-card.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<View style={[styles.actions, largeText && styles.actionsLarge]}>
{(
[
["once", "Allow once"],
["always", "Always allow"],
["reject", "Reject"],
] as const
).map(([reply, label]) => (
<Pressable
key={reply}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={
reply === "once"
? "Allow this request one time"
: reply === "always"
? "Save permission for matching requests. Review the scope in Details"
: "Reject this request; other pending requests in this session may also be rejected"
}
accessibilityState={{ disabled: replying, busy: replying }}
disabled={replying}
onPress={() => onReply(request.id, request.sessionID, reply)}
style={({ pressed }) => [
styles.button,
reply === "once" && styles.primary,
replying && styles.disabled,
pressed && styles.disabled,
]}
>
<Text
style={[
styles.buttonLabel,
reply === "once" && styles.primaryLabel,
reply === "reject" && styles.reject,
]}
>
{label}
</Text>
</Pressable>
))}
</View>
);
const resources = request.resources.map((resource) => (
<Text key={resource} selectable style={styles.command}>
{sanitizeTranscriptText(resource, resource.length)}
</Text>
));
const scope =
patterns.length > 0 ? (
<View style={styles.scope}>
<Text style={styles.caption}>Always allow saves these patterns for future requests</Text>
<View accessibilityLabel="Saved permission patterns">
{patterns.map((pattern) => (
<Text key={pattern} selectable style={styles.pattern}>
{sanitizeTranscriptText(pattern, pattern.length)}
</Text>
))}
</View>
</View>
) : null;
return (
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.title}>
Permission required
</Text>
<Text style={styles.action}>{action}</Text>
{shell ? (
<View style={styles.commandBox}>
<Text numberOfLines={3} style={styles.command}>
{sanitizeTranscriptText(request.resources.join("\n"), 1024)}
</Text>
</View>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="Details"
accessibilityHint="Opens the full request, saved permission patterns, and explanation"
onPress={() => setDetails(true)}
style={styles.detailsButton}
>
<Text style={styles.caption}>Details ›</Text>
</Pressable>
{replying ? (
<Text accessibilityLiveRegion="polite" style={styles.caption}>
Sending permission reply…
</Text>
) : null}
{error ? (
<Text accessibilityRole="alert" style={styles.reject}>
The server did not accept that reply. Review the refreshed request and try again.
</Text>
) : null}
{actions}
<ModalSheet title="Permission details" visible={details} onClose={() => setDetails(false)}>
<Text style={styles.title}>{action}</Text>
<Text style={styles.caption}>{shell ? "Full command" : "Requested resources"}</Text>
{resources}
{scope}
{patterns.length > 0 ? (
<Text style={styles.copy}>
Always allow saves the displayed patterns, which can cover more than this request.
</Text>
) : null}
{explanation ? <Text style={styles.copy}>{explanation}</Text> : null}
<Text style={styles.copy}>
Reject may also reject other pending permission requests in this session.
</Text>
</ModalSheet>
</View>
);
}

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" },
});
6 changes: 5 additions & 1 deletion apps/mobile/src/screens/session-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -829,6 +832,7 @@ const styles = StyleSheet.create({
},
surfaceCollapsed: {
borderRadius: 999,
justifyContent: "center",
minHeight: 60,
paddingLeft: 12,
paddingRight: 8,
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/screens/session-execution-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading