+
{
- const file = e.currentTarget.files?.[0]
- if (file) addImageAttachment(file)
+ multiple
+ class="absolute opacity-0 w-0 h-0 overflow-hidden"
+ style={{ "pointer-events": "none" }}
+ onInput={async (e) => {
+ const files = e.currentTarget.files
+ if (!files || files.length === 0) return
+ for (const file of Array.from(files)) {
+ await addImageAttachment(file)
+ }
e.currentTarget.value = ""
}}
/>
-
+
+ 0}>
+
+
+
+
-
+
@@ -1671,7 +1744,7 @@ export const PromptInput: Component
= (props) => {
disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
- class="h-6 w-4.5"
+ class="size-8 md:h-6 md:w-4.5"
/>
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index ddac1f2286e..e3e9332ca51 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -16,6 +16,7 @@ import {
type LspStatus,
type VcsInfo,
type PermissionRequest,
+ type QuestionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -24,6 +25,8 @@ import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error"
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Logo } from "@opencode-ai/ui/logo"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
@@ -49,12 +52,16 @@ type State = {
permission: {
[sessionID: string]: PermissionRequest[]
}
+ question: {
+ [sessionID: string]: QuestionRequest[]
+ }
mcp: {
[name: string]: McpStatus
}
lsp: LspStatus[]
vcs: VcsInfo | undefined
limit: number
+ totalSessions: number
message: {
[sessionID: string]: Message[]
}
@@ -98,10 +105,12 @@ function createGlobalSync() {
session_diff: {},
todo: {},
permission: {},
+ question: {},
mcp: {},
lsp: [],
vcs: undefined,
limit: 5,
+ totalSessions: 0,
message: {},
part: {},
})
@@ -127,6 +136,7 @@ function createGlobalSync() {
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
return updated > fourHoursAgo
})
+ setStore("totalSessions", nonArchived.length)
setStore("session", reconcile(sessions, { key: "id" }))
})
.catch((err) => {
@@ -208,6 +218,38 @@ function createGlobalSync() {
}
})
}),
+ sdk.question.list().then((x) => {
+ const grouped: Record
= {}
+ for (const q of x.data ?? []) {
+ if (!q?.id || !q.sessionID) continue
+ const existing = grouped[q.sessionID]
+ if (existing) {
+ existing.push(q)
+ continue
+ }
+ grouped[q.sessionID] = [q]
+ }
+
+ batch(() => {
+ for (const sessionID of Object.keys(store.question)) {
+ if (grouped[sessionID]) continue
+ setStore("question", sessionID, [])
+ }
+ for (const [sessionID, questions] of Object.entries(grouped)) {
+ setStore(
+ "question",
+ sessionID,
+ reconcile(
+ questions
+ .filter((q) => !!q?.id)
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
]).then(() => {
setStore("status", "complete")
})
@@ -406,6 +448,44 @@ function createGlobalSync() {
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
break
}
+ case "question.asked": {
+ const sessionID = event.properties.sessionID
+ const questions = store.question[sessionID]
+ if (!questions) {
+ setStore("question", sessionID, [event.properties])
+ break
+ }
+
+ const result = Binary.search(questions, event.properties.id, (q) => q.id)
+ if (result.found) {
+ setStore("question", sessionID, result.index, reconcile(event.properties))
+ break
+ }
+
+ setStore(
+ "question",
+ sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, event.properties)
+ }),
+ )
+ break
+ }
+ case "question.replied":
+ case "question.rejected": {
+ const questions = store.question[event.properties.sessionID]
+ if (!questions) break
+ const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
+ if (!result.found) break
+ setStore(
+ "question",
+ event.properties.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
}
})
onCleanup(unsub)
@@ -488,7 +568,17 @@ const GlobalSyncContext = createContext>()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return (
-
+
+
+
+
+ Connecting...
+
+
+ }
+ >
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index 28741098c8e..98677c6d94e 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -4,6 +4,49 @@ import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json"
+// Register service worker for PWA support (only in production)
+// In dev mode, unregister any existing service workers to avoid caching issues
+if ("serviceWorker" in navigator) {
+ if (import.meta.env.PROD) {
+ navigator.serviceWorker
+ .register("/sw.js")
+ .then(async (registration) => {
+ // Subscribe to push notifications if supported and permission granted
+ if (!("PushManager" in window)) return
+ if (Notification.permission !== "granted") return
+
+ try {
+ // Check if already subscribed
+ const existing = await registration.pushManager.getSubscription()
+ if (existing) return
+
+ // Subscribe to push (applicationServerKey would be the VAPID public key)
+ // For now, we use a null key which works for testing but not production
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ })
+
+ // Send subscription to server
+ const serverUrl = window.location.origin
+ await fetch(`${serverUrl}/push/subscribe`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(subscription.toJSON()),
+ })
+ } catch {
+ // Push subscription failed - not critical
+ }
+ })
+ .catch(() => {})
+ } else {
+ navigator.serviceWorker.getRegistrations().then((registrations) => {
+ for (const registration of registrations) {
+ registration.unregister()
+ }
+ })
+ }
+}
+
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
@@ -21,34 +64,39 @@ const platform: Platform = {
window.location.reload()
},
notify: async (title, description, href) => {
+ // On iOS Safari/PWA, native notifications aren't supported
+ // Check if Notification API is available and functional
if (!("Notification" in window)) return
- const permission =
- Notification.permission === "default"
- ? await Notification.requestPermission().catch(() => "denied")
- : Notification.permission
-
- if (permission !== "granted") return
-
+ // Skip notification if app is in view and has focus
const inView = document.visibilityState === "visible" && document.hasFocus()
if (inView) return
- await Promise.resolve()
- .then(() => {
- const notification = new Notification(title, {
- body: description ?? "",
- icon: "https://opencode.ai/favicon-96x96.png",
- })
- notification.onclick = () => {
- window.focus()
- if (href) {
- window.history.pushState(null, "", href)
- window.dispatchEvent(new PopStateEvent("popstate"))
- }
- notification.close()
- }
+ try {
+ const permission =
+ Notification.permission === "default"
+ ? await Notification.requestPermission().catch(() => "denied" as NotificationPermission)
+ : Notification.permission
+
+ if (permission !== "granted") return
+
+ const notification = new Notification(title, {
+ body: description ?? "",
+ icon: "https://opencode.ai/favicon-96x96.png",
+ tag: href ?? "opencode-notification",
})
- .catch(() => undefined)
+ notification.onclick = () => {
+ window.focus()
+ if (href) {
+ window.history.pushState(null, "", href)
+ window.dispatchEvent(new PopStateEvent("popstate"))
+ }
+ notification.close()
+ }
+ } catch {
+ // Notification API may throw on some mobile browsers
+ // The in-app notification system will still track these
+ }
},
}
diff --git a/packages/app/src/hooks/use-virtual-keyboard.ts b/packages/app/src/hooks/use-virtual-keyboard.ts
new file mode 100644
index 00000000000..5759c78e63a
--- /dev/null
+++ b/packages/app/src/hooks/use-virtual-keyboard.ts
@@ -0,0 +1,77 @@
+import { createSignal, onCleanup, onMount } from "solid-js"
+
+// Minimum height difference to consider keyboard visible (accounts for browser chrome changes)
+const KEYBOARD_VISIBILITY_THRESHOLD = 150
+
+export function useVirtualKeyboard() {
+ const [height, setHeight] = createSignal(0)
+ const [visible, setVisible] = createSignal(false)
+
+ onMount(() => {
+ // Initialize CSS property to prevent stale values from previous mounts
+ document.documentElement.style.setProperty("--keyboard-height", "0px")
+
+ // Use visualViewport API if available (iOS Safari 13+, Chrome, etc.)
+ const viewport = window.visualViewport
+ if (!viewport) return
+
+ // Track baseline height, reset on orientation change
+ let baselineHeight = viewport.height
+
+ const updateBaseline = () => {
+ // Only update baseline when keyboard is likely closed (viewport near window height)
+ // This handles orientation changes correctly
+ if (Math.abs(viewport.height - window.innerHeight) < 100) {
+ baselineHeight = viewport.height
+ }
+ }
+
+ const handleResize = () => {
+ const currentHeight = viewport.height
+ const keyboardHeight = Math.max(0, baselineHeight - currentHeight)
+
+ // Consider keyboard visible if it takes up more than threshold
+ const isVisible = keyboardHeight > KEYBOARD_VISIBILITY_THRESHOLD
+
+ // If keyboard just closed, update baseline for potential orientation change
+ if (!isVisible && visible()) {
+ baselineHeight = currentHeight
+ }
+
+ setHeight(keyboardHeight)
+ setVisible(isVisible)
+
+ // Update CSS custom property for use in styles
+ document.documentElement.style.setProperty("--keyboard-height", `${keyboardHeight}px`)
+
+ // On iOS Safari, scroll the viewport to compensate for browser auto-scrolling
+ // This keeps the input visible without the page jumping around
+ if (isVisible && viewport.offsetTop > 0) {
+ // The browser scrolled the page - we need to compensate
+ window.scrollTo(0, 0)
+ }
+ }
+
+ // Handle orientation changes - reset baseline after orientation settles
+ const handleOrientationChange = () => {
+ // Delay to let viewport settle after orientation change
+ setTimeout(updateBaseline, 300)
+ }
+
+ viewport.addEventListener("resize", handleResize)
+ viewport.addEventListener("scroll", handleResize)
+ window.addEventListener("orientationchange", handleOrientationChange)
+
+ onCleanup(() => {
+ viewport.removeEventListener("resize", handleResize)
+ viewport.removeEventListener("scroll", handleResize)
+ window.removeEventListener("orientationchange", handleOrientationChange)
+ document.documentElement.style.removeProperty("--keyboard-height")
+ })
+ })
+
+ return {
+ height,
+ visible,
+ }
+}
diff --git a/packages/app/src/index.css b/packages/app/src/index.css
index e40f0842b15..f72c8214634 100644
--- a/packages/app/src/index.css
+++ b/packages/app/src/index.css
@@ -1,6 +1,12 @@
@import "@opencode-ai/ui/styles/tailwind";
:root {
+ /* Safe area insets for notched devices (iPhone X+) */
+ --safe-area-inset-top: env(safe-area-inset-top, 0px);
+ --safe-area-inset-right: env(safe-area-inset-right, 0px);
+ --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-area-inset-left: env(safe-area-inset-left, 0px);
+
a {
cursor: default;
}
diff --git a/packages/opencode/src/server/push.ts b/packages/opencode/src/server/push.ts
new file mode 100644
index 00000000000..0ed2a16c8e4
--- /dev/null
+++ b/packages/opencode/src/server/push.ts
@@ -0,0 +1,83 @@
+import { Hono } from "hono"
+import { describeRoute, resolver, validator } from "hono-openapi"
+import z from "zod"
+import { Log } from "../util/log"
+
+const log = Log.create({ service: "push" })
+
+// Web Push subscription schema
+const PushSubscriptionSchema = z.object({
+ endpoint: z.string().url(),
+ keys: z.object({
+ p256dh: z.string(),
+ auth: z.string(),
+ }),
+})
+
+type PushSubscription = z.infer
+
+// In-memory subscription store (per server instance)
+const subscriptions = new Map()
+
+export function getSubscriptionCount() {
+ return subscriptions.size
+}
+
+export function getSubscriptions() {
+ return Array.from(subscriptions.values())
+}
+
+export const PushRoute = new Hono()
+ .post(
+ "/subscribe",
+ describeRoute({
+ summary: "Subscribe to push notifications",
+ description: "Register a push subscription for Web Push notifications",
+ operationId: "push.subscribe",
+ responses: {
+ 200: {
+ description: "Subscription registered",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ success: z.boolean() })),
+ },
+ },
+ },
+ },
+ }),
+ validator("json", PushSubscriptionSchema),
+ async (c) => {
+ const subscription = c.req.valid("json")
+ // Use endpoint hash as unique ID
+ const id = btoa(subscription.endpoint).slice(0, 32)
+ subscriptions.set(id, subscription)
+ log.info("push subscription added", { id, total: subscriptions.size })
+ return c.json({ success: true })
+ },
+ )
+ .post(
+ "/unsubscribe",
+ describeRoute({
+ summary: "Unsubscribe from push notifications",
+ description: "Remove a push subscription",
+ operationId: "push.unsubscribe",
+ responses: {
+ 200: {
+ description: "Subscription removed",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ success: z.boolean() })),
+ },
+ },
+ },
+ },
+ }),
+ validator("json", z.object({ endpoint: z.string().url() })),
+ async (c) => {
+ const { endpoint } = c.req.valid("json")
+ const id = btoa(endpoint).slice(0, 32)
+ const deleted = subscriptions.delete(id)
+ log.info("push subscription removed", { id, deleted, total: subscriptions.size })
+ return c.json({ success: true })
+ },
+ )
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 52457515b8e..ce1fbd41875 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -52,6 +52,7 @@ import { errors } from "./error"
import { Pty } from "@/pty"
import { PermissionNext } from "@/permission/next"
import { QuestionRoute } from "./question"
+import { PushRoute } from "./push"
import { Installation } from "@/installation"
import { MDNS } from "./mdns"
import { Worktree } from "../worktree"
@@ -1705,6 +1706,7 @@ export namespace Server {
},
)
.route("/question", QuestionRoute)
+ .route("/push", PushRoute)
.get(
"/command",
describeRoute({
diff --git a/packages/ui/src/assets/favicon/site.webmanifest b/packages/ui/src/assets/favicon/site.webmanifest
index 41290e840c3..b04bf025535 100644
--- a/packages/ui/src/assets/favicon/site.webmanifest
+++ b/packages/ui/src/assets/favicon/site.webmanifest
@@ -1,6 +1,11 @@
{
"name": "OpenCode",
"short_name": "OpenCode",
+ "description": "AI-powered coding assistant",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
@@ -13,9 +18,14 @@
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
+ },
+ {
+ "src": "/favicon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png",
+ "purpose": "any"
}
],
"theme_color": "#ffffff",
- "background_color": "#ffffff",
- "display": "standalone"
+ "background_color": "#ffffff"
}