diff --git a/bun.lock b/bun.lock index 7768eab8832..fa6cfb1adc7 100644 --- a/bun.lock +++ b/bun.lock @@ -176,7 +176,6 @@ "version": "1.1.8", "dependencies": { "@opencode-ai/app": "workspace:*", - "@opencode-ai/ui": "workspace:*", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", diff --git a/packages/app/index.html b/packages/app/index.html index 44fa3b989dc..b467c087568 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..9da42affe21 --- /dev/null +++ b/packages/app/public/sw.js @@ -0,0 +1,106 @@ +const CACHE_NAME = "opencode-v1" +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 + + // Network-first for HTML (app shell) + if (event.request.mode === "navigate" || event.request.headers.get("accept")?.includes("text/html")) { + event.respondWith( + fetch(event.request) + .then((response) => { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)) + return response + }) + .catch(() => caches.match(event.request).then((cached) => cached || 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)), + ) +}) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index dc7b976fca2..f4c69dde398 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -43,6 +43,11 @@ const defaultServerUrl = iife(() => { if (import.meta.env.DEV) return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + // For remote access (e.g., mobile via Tailscale), use same hostname on port 4096 + if (location.hostname !== "localhost" && location.hostname !== "127.0.0.1") { + return `${location.protocol}//${location.hostname}:4096` + } + return window.location.origin }) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a5655902a47..dfadd2fe665 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1526,8 +1526,8 @@ export const PromptInput: Component = (props) => { -
-
+
+
@@ -1607,7 +1607,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
- @@ -1654,7 +1654,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/entry.tsx b/packages/app/src/entry.tsx index 28741098c8e..2514ca930fb 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -4,6 +4,11 @@ import { AppBaseProviders, AppInterface } from "@/app" import { Platform, PlatformProvider } from "@/context/platform" import pkg from "../package.json" +// Register service worker for PWA support +if ("serviceWorker" in navigator && import.meta.env.PROD) { + navigator.serviceWorker.register("/sw.js").catch(() => {}) +} + const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error( 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..40f2479d466 --- /dev/null +++ b/packages/app/src/hooks/use-virtual-keyboard.ts @@ -0,0 +1,68 @@ +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`) + } + + // 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) + window.addEventListener("orientationchange", handleOrientationChange) + + onCleanup(() => { + viewport.removeEventListener("resize", 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/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cffefd5634d..8ead4815d41 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -76,6 +76,11 @@ export default function Layout(props: ParentProps) { let scrollContainerRef: HTMLDivElement | undefined const xlQuery = window.matchMedia("(min-width: 1280px)") + const mdQuery = window.matchMedia("(min-width: 768px)") + const [isMobile, setIsMobile] = createSignal(!mdQuery.matches) + const handleMdChange = (e: MediaQueryListEvent) => setIsMobile(!e.matches) + mdQuery.addEventListener("change", handleMdChange) + onCleanup(() => mdQuery.removeEventListener("change", handleMdChange)) const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches) const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches) xlQuery.addEventListener("change", handleViewportChange) @@ -852,29 +857,50 @@ export default function Layout(props: ParentProps) { const status = sessionStore.session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) + return ( - <> - ) } @@ -1005,7 +1032,13 @@ export default function Layout(props: ParentProps) { - + layout.mobileSidebar.hide()} + />
@@ -1032,6 +1065,7 @@ export default function Layout(props: ParentProps) { layout.mobileSidebar.hide()} >
@@ -1285,10 +1319,15 @@ export default function Layout(props: ParentProps) { />
e.stopPropagation()} >
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ab6995d9214..64210e836ff 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -52,6 +52,7 @@ import { import { usePlatform } from "@/context/platform" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" +import { useVirtualKeyboard } from "@/hooks/use-virtual-keyboard" type DiffStyle = "unified" | "split" @@ -211,6 +212,7 @@ export default function Page() { } const isDesktop = createMediaQuery("(min-width: 768px)") + const keyboard = useVirtualKeyboard() function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab @@ -1014,12 +1016,12 @@ export default function Page() {
{/* Mobile tab bar - only shown on mobile when there are diffs */} - + setStore("mobileTab", "session")} > Session @@ -1027,7 +1029,7 @@ export default function Page() { setStore("mobileTab", "review")} > {reviewCount()} Files Changed @@ -1215,7 +1217,12 @@ export default function Page() { {/* Prompt input */}
(promptDock = el)} - class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" + class="absolute inset-x-0 bottom-0 pt-12 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" + style={{ + "padding-bottom": keyboard.visible() + ? `calc(var(--keyboard-height, 0px) + max(1rem, var(--safe-area-inset-bottom)))` + : "max(1rem, var(--safe-area-inset-bottom))", + }} >
{ + notify: async (title: string, description?: string, href?: string) => { const granted = await isPermissionGranted().catch(() => false) const permission = granted ? "granted" : await requestPermission().catch(() => "denied") if (permission !== "granted") return @@ -255,8 +260,7 @@ const platform: Platform = { .catch(() => undefined) }, - // @ts-expect-error - fetch: tauriFetch, + fetch: tauriFetch as typeof fetch, } createMenu() @@ -294,7 +298,29 @@ function ServerGate(props: ParentProps) { when={status.state !== "pending"} fallback={
- + + + + + + + + + + + + + + + + + +
Starting server...
} 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" }