From 6df85de73d92ae27c17aec31e54577d2ba8abd2f Mon Sep 17 00:00:00 2001 From: christian Date: Sun, 27 Sep 2026 23:19:39 +0800 Subject: [PATCH 1/2] fix(android): apply IME insets to root view --- .../cc/agentlabs/opencode/MainActivity.kt | 14 +++++++ app.json | 1 + package.json | 2 +- plugins/withKeyboardInsets.js | 38 +++++++++++++++++++ plugins/withKeyboardInsets.test.js | 10 +++++ 5 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 plugins/withKeyboardInsets.js create mode 100644 plugins/withKeyboardInsets.test.js diff --git a/android/app/src/main/java/cc/agentlabs/opencode/MainActivity.kt b/android/app/src/main/java/cc/agentlabs/opencode/MainActivity.kt index 66ff305d..61cfbe0c 100644 --- a/android/app/src/main/java/cc/agentlabs/opencode/MainActivity.kt +++ b/android/app/src/main/java/cc/agentlabs/opencode/MainActivity.kt @@ -2,6 +2,9 @@ package cc.agentlabs.opencode import android.os.Build import android.os.Bundle +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate @@ -17,6 +20,17 @@ class MainActivity : ReactActivity() { // This is required for expo-splash-screen. setTheme(R.style.AppTheme); super.onCreate(null) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + val root = findViewById(android.R.id.content) + val bottom = root.paddingBottom + // Android 15+ no longer resizes edge-to-edge windows for the IME. + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottom + insets.getInsets(WindowInsetsCompat.Type.ime()).bottom) + insets + } + ViewCompat.requestApplyInsets(root) + } } /** diff --git a/app.json b/app.json index f3ff1218..a6499fdc 100644 --- a/app.json +++ b/app.json @@ -48,6 +48,7 @@ }, "plugins": [ "expo-router", + "./plugins/withKeyboardInsets", "expo-secure-store", "expo-local-authentication", [ diff --git a/package.json b/package.json index b24a7338..710fbc37 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "android": "expo run:android", "typecheck": "tsc --noEmit", "check:versions": "node scripts/check-version-parity.mjs", - "test": "node --test 'src/**/*.test.ts' 'scripts/*.test.mjs'" + "test": "node --test 'src/**/*.test.ts' 'scripts/*.test.mjs' 'plugins/*.test.js'" }, "dependencies": { "@expo/vector-icons": "^15.0.3", diff --git a/plugins/withKeyboardInsets.js b/plugins/withKeyboardInsets.js new file mode 100644 index 00000000..295dc944 --- /dev/null +++ b/plugins/withKeyboardInsets.js @@ -0,0 +1,38 @@ +const { withMainActivity } = require("@expo/config-plugins") + +const imports = `import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +` + +const listener = ` + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + val root = findViewById(android.R.id.content) + val bottom = root.paddingBottom + // Android 15+ no longer resizes edge-to-edge windows for the IME. + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottom + insets.getInsets(WindowInsetsCompat.Type.ime()).bottom) + insets + } + ViewCompat.requestApplyInsets(root) + } +` + +function addKeyboardInsets(contents) { + if (contents.includes("WindowInsetsCompat.Type.ime()")) return contents + if (!contents.includes("import android.os.Bundle\n") || !contents.includes(" super.onCreate(null)\n")) { + throw new Error("Unsupported MainActivity.kt template") + } + return contents + .replace("import android.os.Bundle\n", `import android.os.Bundle\n${imports}`) + .replace(" super.onCreate(null)\n", ` super.onCreate(null)\n${listener}`) +} + +module.exports = (config) => + withMainActivity(config, (config) => { + if (config.modResults.language !== "kt") throw new Error("Keyboard inset fix requires MainActivity.kt") + config.modResults.contents = addKeyboardInsets(config.modResults.contents) + return config + }) + +module.exports.addKeyboardInsets = addKeyboardInsets diff --git a/plugins/withKeyboardInsets.test.js b/plugins/withKeyboardInsets.test.js new file mode 100644 index 00000000..79ab8ace --- /dev/null +++ b/plugins/withKeyboardInsets.test.js @@ -0,0 +1,10 @@ +const assert = require("node:assert/strict") +const test = require("node:test") +const { addKeyboardInsets } = require("./withKeyboardInsets") + +test("adds Android IME insets listener once", () => { + const source = "import android.os.Bundle\nclass MainActivity {\n super.onCreate(null)\n}\n" + const result = addKeyboardInsets(source) + assert.match(result, /WindowInsetsCompat\.Type\.ime\(\)/) + assert.equal(addKeyboardInsets(result), result) +}) From 6b153bee434eb235dbfe6228fd8e69918cfb4b1e Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Mon, 5 Oct 2026 08:16:35 -0400 Subject: [PATCH 2/2] fix: handle PermissionV2 events and keyboard avoidance on connection screens Two issues found while testing against OpenCode 1.18.34: 1. Permission prompts never appeared for tool calls. The server emits permission.v2.asked events with {id, sessionID, action, resources}, but the SSE handler only matched the legacy permission.asked format. Added a normalization layer (src/lib/permissions.ts) that converts both protocol generations into a single internal model, and updated the event handler to process v2 asked/replied events. Pending permissions are also reconciled on SSE reconnect and app foreground so a missed event doesn't leave the session stuck. 2. Keyboard covered input fields on the connection add/edit screens. The chat screen already used KeyboardAvoidingView with behavior="padding", but the connection screens used a bare ScrollView. On Android 15+ edge-to-edge, adjustResize is a no-op so the keyboard stays on top of the content. Wrapped both connection screens in the same KeyboardAvoidingView pattern. Includes PR #210 (IME insets) which was already open. --- app/connection/[id].tsx | 10 ++++- app/connection/add.tsx | 18 ++++++++- src/lib/permissions.test.ts | 27 ++++++++++++++ src/lib/permissions.ts | 74 +++++++++++++++++++++++++++++++++++++ src/stores/events.ts | 73 +++++++++++++++++++++++++++--------- 5 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 src/lib/permissions.test.ts create mode 100644 src/lib/permissions.ts diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx index 49701d21..b76a74d5 100644 --- a/app/connection/[id].tsx +++ b/app/connection/[id].tsx @@ -5,6 +5,8 @@ import { TextInput, TouchableOpacity, ScrollView, + KeyboardAvoidingView, + Platform, StyleSheet, useColorScheme, ActivityIndicator, @@ -181,8 +183,13 @@ export default function EditConnectionScreen() { } return ( - + @@ -316,6 +323,7 @@ export default function EditConnectionScreen() { + ) } diff --git a/app/connection/add.tsx b/app/connection/add.tsx index b8616086..0656da1b 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -5,6 +5,8 @@ import { TextInput, TouchableOpacity, ScrollView, + KeyboardAvoidingView, + Platform, StyleSheet, useColorScheme, ActivityIndicator, @@ -315,8 +317,13 @@ export default function AddConnectionScreen() { // Quick connect mode - simplified if (mode === "quick") { return ( - + @@ -516,13 +523,19 @@ export default function AddConnectionScreen() { + ) } // Advanced mode - full options return ( - + @@ -658,6 +671,7 @@ export default function AddConnectionScreen() { )} + ) } diff --git a/src/lib/permissions.test.ts b/src/lib/permissions.test.ts new file mode 100644 index 00000000..a265f979 --- /dev/null +++ b/src/lib/permissions.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mergePendingPermissions, normalizePermissionEvent } from "./permissions.ts" + +test("normalizes legacy permission asks", () => { + assert.deepEqual(normalizePermissionEvent({ type: "permission.asked", properties: { + id: "p1", sessionID: "s1", permission: "bash", patterns: ["printf hi"], metadata: {}, + } }), { id: "p1", sessionID: "s1", permission: "bash", patterns: ["printf hi"], metadata: {} }) +}) + +test("normalizes PermissionV2 action/resources/metadata/source", () => { + assert.deepEqual(normalizePermissionEvent({ type: "permission.v2.asked", properties: { + id: "p2", sessionID: "s1", action: "bash", resources: ["printf hi"], metadata: { command: "printf hi" }, source: { tool: "bash" }, + } }), { id: "p2", sessionID: "s1", permission: "bash", patterns: ["printf hi"], action: "bash", resources: ["printf hi"], metadata: { command: "printf hi" }, source: { tool: "bash" } }) +}) + +test("rejects malformed requests and ignores unrelated events", () => { + assert.equal(normalizePermissionEvent({ type: "permission.v2.asked", properties: { id: "p", sessionID: "s" } }), null) + assert.equal(normalizePermissionEvent({ type: "other", properties: {} }), null) +}) + +test("deduplicates replayed IDs and lets the latest request data win", () => { + const item = normalizePermissionEvent({ type: "permission.v2.asked", properties: { + id: "p", sessionID: "s", action: "bash", resources: ["safe"], + } })! + assert.deepEqual(mergePendingPermissions([item], [item]), [item]) +}) diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 00000000..490760ac --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,74 @@ +export type PendingPermission = { + id: string + sessionID: string + permission: string + patterns: string[] + metadata: Record + tool?: { messageID: string; callID: string } + action?: string + resources?: string[] + save?: string[] + source?: Record +} + +type PermissionEvent = { + type?: string + properties?: Record +} + +/** Normalize both generations of server permission request into the prompt model. */ +export function normalizePermissionEvent(event: PermissionEvent): PendingPermission | null { + if (event.type !== "permission.asked" && event.type !== "permission.v2.asked") return null + const properties = event.properties + if (!properties || typeof properties.id !== "string" || !properties.id || + typeof properties.sessionID !== "string" || !properties.sessionID) return null + + if (event.type === "permission.asked") { + if (typeof properties.permission !== "string" || !Array.isArray(properties.patterns) || + !properties.patterns.every((item) => typeof item === "string")) return null + return { + id: properties.id, + sessionID: properties.sessionID, + permission: properties.permission, + patterns: properties.patterns, + metadata: isRecord(properties.metadata) ? properties.metadata : {}, + ...(isRecord(properties.tool) ? { tool: properties.tool as PendingPermission["tool"] } : {}), + } + } + + if (typeof properties.action !== "string" || !Array.isArray(properties.resources) || + !properties.resources.every((item) => typeof item === "string")) return null + return { + id: properties.id, + sessionID: properties.sessionID, + permission: properties.action, + patterns: properties.resources, + metadata: isRecord(properties.metadata) ? properties.metadata : {}, + action: properties.action, + resources: properties.resources, + ...(Array.isArray(properties.save) && properties.save.every((item) => typeof item === "string") + ? { save: properties.save as string[] } : {}), + ...(isRecord(properties.source) ? { source: properties.source } : {}), + } +} + +/** Convert a pending-list API item (which may be from either protocol generation). */ +export function normalizePendingPermission(value: unknown): PendingPermission | null { + if (!isRecord(value)) return null + return normalizePermissionEvent({ + type: Array.isArray(value.resources) || typeof value.action === "string" ? "permission.v2.asked" : "permission.asked", + properties: value, + }) +} + +export function mergePendingPermissions( + current: PendingPermission[], incoming: PendingPermission[], +): PendingPermission[] { + const merged = new Map(current.map((item) => [item.id, item])) + for (const item of incoming) merged.set(item.id, { ...merged.get(item.id), ...item }) + return [...merged.values()] +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} diff --git a/src/stores/events.ts b/src/stores/events.ts index c53a8481..17b06f71 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -9,6 +9,8 @@ import { AnalyticsEvent, track } from "../lib/analytics" import { recordSuccessfulSession } from "../lib/store-review" import { isAuthError } from "../lib/api-error" import { isSessionActuallyIdle } from "../lib/session-status-reconcile" +import { AppState } from "react-native" +import { mergePendingPermissions, normalizePendingPermission, normalizePermissionEvent, type PendingPermission } from "../lib/permissions" import type { Client, Part, Session, Message } from "../lib/sdk" // Session status from the server @@ -28,17 +30,7 @@ interface EventsState { sessionStatus: Record statusText: Record // Permissions & questions (pending per session) - permissions: Record< - string, - Array<{ - id: string - sessionID: string - permission: string - patterns: string[] - metadata: Record - tool?: { messageID: string; callID: string } - }> - > + permissions: Record questions: Record< string, Array<{ @@ -78,10 +70,10 @@ const PROLONGED_DISCONNECT_MS = 30_000 export async function refreshPending(client: Client, sessionID: string) { try { const [perms, questions] = await Promise.all([client.permission.list(), client.question.list()]) - const sessionPerms = (perms || []).filter((p: Record) => p.sessionID === sessionID) + const sessionPerms = (perms || []).map(normalizePendingPermission).filter((p): p is PendingPermission => !!p && p.sessionID === sessionID) const sessionQuestions = (questions || []).filter((q: Record) => q.sessionID === sessionID) useEvents.setState((state) => ({ - permissions: { ...state.permissions, [sessionID]: sessionPerms as any }, + permissions: { ...state.permissions, [sessionID]: mergePendingPermissions([], sessionPerms) }, questions: { ...state.questions, [sessionID]: sessionQuestions as any }, })) } catch (err) { @@ -89,6 +81,34 @@ export async function refreshPending(client: Client, sessionID: string) { } } +async function refreshAllPending() { + const connectionState = useConnections.getState() + const clients = new Set() + if (connectionState.client) clients.add(connectionState.client) + for (const session of useSessions.getState().sessions) { + if (!session.directory) continue + const client = connectionState.clientForDirectory(session.directory) + if (client) clients.add(client) + } + await Promise.all([...clients].map(async (client) => { + try { + const pending = await client.permission.list() + const grouped = new Map() + for (const value of pending || []) { + const normalized = normalizePendingPermission(value) + if (normalized) grouped.set(normalized.sessionID, [...(grouped.get(normalized.sessionID) || []), normalized]) + } + useEvents.setState((state) => { + const permissions = { ...state.permissions } + for (const [sessionID, items] of grouped) permissions[sessionID] = mergePendingPermissions(permissions[sessionID] || [], items) + return { permissions } + }) + } catch (err) { + console.warn("[Events] Failed to reconcile pending permissions:", err) + } + })) +} + // Re-sync any session currently marked "busy" against the server after an // SSE reconnect. sessionStatus/sending are SSE-driven and there is normally // no other path to idle — if the server's busy -> idle `session.status` @@ -232,6 +252,7 @@ export const useEvents = create((set, get) => ({ if (isReconnect && !resyncedAfterReconnect) { resyncedAfterReconnect = true void resyncBusySessions() + void refreshAllPending() } const payload = (event as any).payload || event @@ -370,15 +391,16 @@ export const useEvents = create((set, get) => ({ break } - case "permission.asked": { - const req = props as any - if (!req.id || !req.sessionID) break + case "permission.asked": + case "permission.v2.asked": { + const req = normalizePermissionEvent({ type, properties: props }) + if (!req) break const existing = get().permissions[req.sessionID] || [] if (existing.some((item) => item.id === req.id)) break set((state) => ({ permissions: { ...state.permissions, - [req.sessionID]: [...(state.permissions[req.sessionID] || []), req], + [req.sessionID]: mergePendingPermissions(state.permissions[req.sessionID] || [], [req]), }, })) notify({ @@ -412,6 +434,19 @@ export const useEvents = create((set, get) => ({ break } + case "permission.v2.replied": { + const sessionID = props.sessionID as string + const requestID = props.requestID as string + if (!sessionID || !requestID) break + set((state) => ({ + permissions: { + ...state.permissions, + [sessionID]: (state.permissions[sessionID] || []).filter((p) => p.id !== requestID), + }, + })) + break + } + case "question.asked": { const req = props as any if (!req.id || !req.sessionID) break @@ -502,3 +537,7 @@ export const useEvents = create((set, get) => ({ }) }, })) + +AppState.addEventListener("change", (state) => { + if (state === "active") void refreshAllPending() +})