Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
<html lang="en" style="background-color: var(--background-base)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="OpenCode" />
<meta name="mobile-web-app-capable" content="yes" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
Expand Down
106 changes: 106 additions & 0 deletions packages/app/public/sw.js
Original file line number Diff line number Diff line change
@@ -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)),
)
})
5 changes: 5 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down
14 changes: 7 additions & 7 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1526,8 +1526,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Show>
</div>
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-0.5">
<div class="relative p-3 flex items-center justify-between gap-2">
<div class="flex items-center justify-start gap-0.5 min-w-0 overflow-hidden">
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
Expand Down Expand Up @@ -1607,7 +1607,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Match>
</Switch>
</div>
<div class="flex items-center gap-3 absolute right-2 bottom-2">
<div class="flex items-center gap-2 md:gap-3 shrink-0">
<input
ref={fileInputRef}
type="file"
Expand All @@ -1619,12 +1619,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1 md:gap-2">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value="Attach file">
<Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
<Icon name="photo" class="size-4.5" />
<Button type="button" variant="ghost" class="size-8 md:size-6" onClick={() => fileInputRef.click()}>
<Icon name="photo" class="size-5 md:size-4.5" />
</Button>
</Tooltip>
</Show>
Expand Down Expand Up @@ -1654,7 +1654,7 @@ export const PromptInput: Component<PromptInputProps> = (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"
/>
</Tooltip>
</div>
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
68 changes: 68 additions & 0 deletions packages/app/src/hooks/use-virtual-keyboard.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
6 changes: 6 additions & 0 deletions packages/app/src/index.css
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
Loading
Loading