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/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/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)
+})
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()
+})