diff --git a/packages/app/index.html b/packages/app/index.html index e0fbe6913df..450ae931c4e 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -2,7 +2,11 @@ - + + + + + OpenCode diff --git a/packages/app/public/sw.js b/packages/app/public/sw.js new file mode 100644 index 00000000000..6db52d51459 --- /dev/null +++ b/packages/app/public/sw.js @@ -0,0 +1,162 @@ +const CACHE_NAME = "opencode-v3" +const STATIC_ASSETS = [ + "/", + "/favicon.svg", + "/favicon-96x96.png", + "/apple-touch-icon.png", + "/web-app-manifest-192x192.png", + "/web-app-manifest-512x512.png", +] + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(STATIC_ASSETS).catch((err) => { + console.warn("Failed to cache some assets:", err) + }) + }), + ) + self.skipWaiting() +}) + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => { + return Promise.all( + keys.filter((key) => key !== CACHE_NAME && key.startsWith("opencode-")).map((key) => caches.delete(key)), + ) + }), + ) + self.clients.claim() +}) + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url) + + // Skip non-GET requests + if (event.request.method !== "GET") return + + // Skip API requests and SSE connections + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/event")) return + + // Skip cross-origin requests + if (url.origin !== self.location.origin) return + + // Stale-while-revalidate for HTML (app shell) + // This prevents the app from refreshing when returning from background on mobile + // The cached version is served immediately while updating in the background + if (event.request.mode === "navigate" || event.request.headers.get("accept")?.includes("text/html")) { + event.respondWith( + caches.match(event.request).then((cached) => { + const fetchPromise = fetch(event.request) + .then((response) => { + if (response.ok) { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)) + } + return response + }) + .catch(() => null) + + // Return cached immediately if available, otherwise wait for network + // This prevents blank screen when offline and fast return when online + return cached || fetchPromise || caches.match("/") + }), + ) + return + } + + // Cache-first for hashed assets (Vite adds content hashes to /assets/*) + if (url.pathname.startsWith("/assets/")) { + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached + return fetch(event.request).then((response) => { + if (response.ok) { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)) + } + return response + }) + }), + ) + return + } + + // Stale-while-revalidate for unhashed static assets (favicon, icons, etc.) + // Serves cached version immediately but updates cache in background + if (url.pathname.match(/\.(js|css|png|jpg|jpeg|svg|gif|webp|woff|woff2|ttf|eot|ico|aac|mp3|wav)$/)) { + event.respondWith( + caches.match(event.request).then((cached) => { + const fetchPromise = fetch(event.request).then((response) => { + if (response.ok) { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)) + } + return response + }) + return cached || fetchPromise + }), + ) + return + } + + // Network-first for everything else + event.respondWith( + fetch(event.request) + .then((response) => { + if (response.ok) { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)) + } + return response + }) + .catch(() => caches.match(event.request)), + ) +}) + +// Web Push notification handler +self.addEventListener("push", (event) => { + if (!event.data) return + + const data = event.data.json() + const title = data.title || "OpenCode" + const options = { + body: data.body || "", + icon: "/favicon-96x96.png", + badge: "/favicon-96x96.png", + tag: data.tag || "opencode-push", + data: { href: data.href }, + // Vibrate on mobile: short-long-short pattern + vibrate: [100, 50, 100], + // Keep notification until user interacts + requireInteraction: data.requireInteraction ?? false, + } + + event.waitUntil(self.registration.showNotification(title, options)) +}) + +// Handle notification click - focus or open the app +self.addEventListener("notificationclick", (event) => { + event.notification.close() + + const href = event.notification.data?.href || "/" + + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { + // Try to focus an existing window + for (const client of clients) { + if (client.url.includes(self.location.origin) && "focus" in client) { + client.focus() + // Navigate to the href if provided + if (href && href !== "/") { + client.navigate(href) + } + return + } + } + // No existing window, open a new one + return self.clients.openWindow(href) + }), + ) +}) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d0678dc5369..179ebfd41d6 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -24,7 +24,7 @@ import { Logo } from "@opencode-ai/ui/logo" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" -import { iife } from "@opencode-ai/util/iife" + import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) @@ -33,10 +33,32 @@ const Loading = () =>
@@ -66,17 +88,8 @@ function ServerKey(props: ParentProps) { } export function AppInterface(props: { defaultUrl?: string }) { - const defaultServerUrl = () => { - if (props.defaultUrl) return props.defaultUrl - if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - - return window.location.origin - } - return ( - + diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f1ca3ee888b..0a896cf859b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -266,23 +266,50 @@ export const PromptInput: Component = (props) => { const [composing, setComposing] = createSignal(false) const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 - const addImageAttachment = async (file: File) => { - if (!ACCEPTED_FILE_TYPES.includes(file.type)) return - - const reader = new FileReader() - reader.onload = () => { - const dataUrl = reader.result as string - const attachment: ImageAttachmentPart = { - type: "image", - id: crypto.randomUUID(), - filename: file.name, - mime: file.type, - dataUrl, + const addImageAttachment = (file: File) => { + // On iOS Safari, file.type may be empty - infer from filename extension + let mimeType = file.type + if (!mimeType) { + const ext = file.name.split(".").pop()?.toLowerCase() + const mimeMap: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + heic: "image/heic", + heif: "image/heif", + pdf: "application/pdf", } - const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef) - prompt.set([...prompt.current(), attachment], cursorPosition) + mimeType = ext ? (mimeMap[ext] ?? "") : "" } - reader.readAsDataURL(file) + + if (!mimeType || !ACCEPTED_FILE_TYPES.includes(mimeType)) return Promise.resolve() + + return new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => { + try { + const dataUrl = reader.result as string + const id = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` + const attachment: ImageAttachmentPart = { + type: "image", + id, + filename: file.name, + mime: mimeType, + dataUrl, + } + const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef) + prompt.set([...prompt.current(), attachment], cursorPosition) + resolve() + } catch (err) { + showToast({ title: "Error", description: String(err) }) + resolve() + } + } + reader.onerror = () => resolve() + reader.readAsDataURL(file) + }) } const removeImageAttachment = (id: string) => { @@ -1062,6 +1089,10 @@ export const PromptInput: Component = (props) => { prompt.reset() setStore("mode", "normal") setStore("popover", null) + // Blur input on mobile to dismiss keyboard after sending + if (window.innerWidth < 768) { + editorRef.blur() + } } const restoreInput = () => { @@ -1537,8 +1568,8 @@ export const PromptInput: Component = (props) => {
-
-
+
+
@@ -1553,7 +1584,7 @@ export const PromptInput: Component = (props) => { options={local.agent.list().map((agent) => agent.name)} current={local.agent.current()?.name ?? ""} onSelect={local.agent.set} - class="capitalize" + class="capitalize shrink-0" variant="ghost" /> @@ -1561,24 +1592,29 @@ export const PromptInput: Component = (props) => { when={providers.paid().length > 0} fallback={ - } > - @@ -1624,25 +1660,62 @@ export const PromptInput: Component = (props) => {
-
+
{ - 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" }