Skip to content

Commit e016d06

Browse files
committed
feat(app): add mobile-friendly web UI
- Add PWA support with service worker, manifest, and iOS meta tags - Add virtual keyboard detection hook for proper layout adjustments - Add safe area inset CSS variables for notched devices - Improve touch target sizes for mobile accessibility (44px minimum) - Add mobile tab bar for switching between session and review views - Use stale-while-revalidate caching for unhashed static assets - Add explicit logo export for TypeScript resolution
1 parent 361a962 commit e016d06

File tree

13 files changed

+305
-57
lines changed

13 files changed

+305
-57
lines changed

packages/app/index.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
<html lang="en" style="background-color: var(--background-base)">
33
<head>
44
<meta charset="utf-8" />
5-
<meta name="viewport" content="width=device-width, initial-scale=1" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6+
<meta name="apple-mobile-web-app-capable" content="yes" />
7+
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
8+
<meta name="apple-mobile-web-app-title" content="OpenCode" />
9+
<meta name="mobile-web-app-capable" content="yes" />
610
<title>OpenCode</title>
711
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
812
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

packages/app/public/sw.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
const CACHE_NAME = "opencode-v1"
2+
const STATIC_ASSETS = [
3+
"/",
4+
"/favicon.svg",
5+
"/favicon-96x96.png",
6+
"/apple-touch-icon.png",
7+
"/web-app-manifest-192x192.png",
8+
"/web-app-manifest-512x512.png",
9+
]
10+
11+
self.addEventListener("install", (event) => {
12+
event.waitUntil(
13+
caches.open(CACHE_NAME).then((cache) => {
14+
return cache.addAll(STATIC_ASSETS).catch((err) => {
15+
console.warn("Failed to cache some assets:", err)
16+
})
17+
}),
18+
)
19+
self.skipWaiting()
20+
})
21+
22+
self.addEventListener("activate", (event) => {
23+
event.waitUntil(
24+
caches.keys().then((keys) => {
25+
return Promise.all(
26+
keys.filter((key) => key !== CACHE_NAME && key.startsWith("opencode-")).map((key) => caches.delete(key)),
27+
)
28+
}),
29+
)
30+
self.clients.claim()
31+
})
32+
33+
self.addEventListener("fetch", (event) => {
34+
const url = new URL(event.request.url)
35+
36+
// Skip non-GET requests
37+
if (event.request.method !== "GET") return
38+
39+
// Skip API requests and SSE connections
40+
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/event")) return
41+
42+
// Skip cross-origin requests
43+
if (url.origin !== self.location.origin) return
44+
45+
// Network-first for HTML (app shell)
46+
if (event.request.mode === "navigate" || event.request.headers.get("accept")?.includes("text/html")) {
47+
event.respondWith(
48+
fetch(event.request)
49+
.then((response) => {
50+
const clone = response.clone()
51+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
52+
return response
53+
})
54+
.catch(() => caches.match(event.request).then((cached) => cached || caches.match("/"))),
55+
)
56+
return
57+
}
58+
59+
// Cache-first for hashed assets (Vite adds content hashes to /assets/*)
60+
if (url.pathname.startsWith("/assets/")) {
61+
event.respondWith(
62+
caches.match(event.request).then((cached) => {
63+
if (cached) return cached
64+
return fetch(event.request).then((response) => {
65+
if (response.ok) {
66+
const clone = response.clone()
67+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
68+
}
69+
return response
70+
})
71+
}),
72+
)
73+
return
74+
}
75+
76+
// Stale-while-revalidate for unhashed static assets (favicon, icons, etc.)
77+
// Serves cached version immediately but updates cache in background
78+
if (url.pathname.match(/\.(js|css|png|jpg|jpeg|svg|gif|webp|woff|woff2|ttf|eot|ico|aac|mp3|wav)$/)) {
79+
event.respondWith(
80+
caches.match(event.request).then((cached) => {
81+
const fetchPromise = fetch(event.request).then((response) => {
82+
if (response.ok) {
83+
const clone = response.clone()
84+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
85+
}
86+
return response
87+
})
88+
return cached || fetchPromise
89+
}),
90+
)
91+
return
92+
}
93+
94+
// Network-first for everything else
95+
event.respondWith(
96+
fetch(event.request)
97+
.then((response) => {
98+
if (response.ok) {
99+
const clone = response.clone()
100+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
101+
}
102+
return response
103+
})
104+
.catch(() => caches.match(event.request)),
105+
)
106+
})

packages/app/src/app.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ const defaultServerUrl = iife(() => {
4646
if (import.meta.env.DEV)
4747
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
4848

49+
// For remote access (e.g., mobile via Tailscale), use same hostname on port 4096
50+
if (location.hostname !== "localhost" && location.hostname !== "127.0.0.1") {
51+
return `${location.protocol}//${location.hostname}:4096`
52+
}
53+
4954
return window.location.origin
5055
})
5156

packages/app/src/components/prompt-input.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,8 +1526,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
15261526
</div>
15271527
</Show>
15281528
</div>
1529-
<div class="relative p-3 flex items-center justify-between">
1530-
<div class="flex items-center justify-start gap-0.5">
1529+
<div class="relative p-3 flex items-center justify-between gap-2">
1530+
<div class="flex items-center justify-start gap-0.5 min-w-0 overflow-hidden">
15311531
<Switch>
15321532
<Match when={store.mode === "shell"}>
15331533
<div class="flex items-center gap-2 px-2 h-6">
@@ -1607,7 +1607,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
16071607
</Match>
16081608
</Switch>
16091609
</div>
1610-
<div class="flex items-center gap-3 absolute right-2 bottom-2">
1610+
<div class="flex items-center gap-2 md:gap-3 shrink-0">
16111611
<input
16121612
ref={fileInputRef}
16131613
type="file"
@@ -1619,12 +1619,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
16191619
e.currentTarget.value = ""
16201620
}}
16211621
/>
1622-
<div class="flex items-center gap-2">
1622+
<div class="flex items-center gap-1 md:gap-2">
16231623
<SessionContextUsage />
16241624
<Show when={store.mode === "normal"}>
16251625
<Tooltip placement="top" value="Attach file">
1626-
<Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
1627-
<Icon name="photo" class="size-4.5" />
1626+
<Button type="button" variant="ghost" class="size-8 md:size-6" onClick={() => fileInputRef.click()}>
1627+
<Icon name="photo" class="size-5 md:size-4.5" />
16281628
</Button>
16291629
</Tooltip>
16301630
</Show>
@@ -1654,7 +1654,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
16541654
disabled={!prompt.dirty() && !working()}
16551655
icon={working() ? "stop" : "arrow-up"}
16561656
variant="primary"
1657-
class="h-6 w-4.5"
1657+
class="size-8 md:h-6 md:w-4.5"
16581658
/>
16591659
</Tooltip>
16601660
</div>

packages/app/src/entry.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { AppBaseProviders, AppInterface } from "@/app"
44
import { Platform, PlatformProvider } from "@/context/platform"
55
import pkg from "../package.json"
66

7+
// Register service worker for PWA support
8+
if ("serviceWorker" in navigator && import.meta.env.PROD) {
9+
navigator.serviceWorker.register("/sw.js").catch(() => {})
10+
}
11+
712
const root = document.getElementById("root")
813
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
914
throw new Error(
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { createSignal, onCleanup, onMount } from "solid-js"
2+
3+
// Minimum height difference to consider keyboard visible (accounts for browser chrome changes)
4+
const KEYBOARD_VISIBILITY_THRESHOLD = 150
5+
6+
export function useVirtualKeyboard() {
7+
const [height, setHeight] = createSignal(0)
8+
const [visible, setVisible] = createSignal(false)
9+
10+
onMount(() => {
11+
// Initialize CSS property to prevent stale values from previous mounts
12+
document.documentElement.style.setProperty("--keyboard-height", "0px")
13+
14+
// Use visualViewport API if available (iOS Safari 13+, Chrome, etc.)
15+
const viewport = window.visualViewport
16+
if (!viewport) return
17+
18+
// Track baseline height, reset on orientation change
19+
let baselineHeight = viewport.height
20+
21+
const updateBaseline = () => {
22+
// Only update baseline when keyboard is likely closed (viewport near window height)
23+
// This handles orientation changes correctly
24+
if (Math.abs(viewport.height - window.innerHeight) < 100) {
25+
baselineHeight = viewport.height
26+
}
27+
}
28+
29+
const handleResize = () => {
30+
const currentHeight = viewport.height
31+
const keyboardHeight = Math.max(0, baselineHeight - currentHeight)
32+
33+
// Consider keyboard visible if it takes up more than threshold
34+
const isVisible = keyboardHeight > KEYBOARD_VISIBILITY_THRESHOLD
35+
36+
// If keyboard just closed, update baseline for potential orientation change
37+
if (!isVisible && visible()) {
38+
baselineHeight = currentHeight
39+
}
40+
41+
setHeight(keyboardHeight)
42+
setVisible(isVisible)
43+
44+
// Update CSS custom property for use in styles
45+
document.documentElement.style.setProperty("--keyboard-height", `${keyboardHeight}px`)
46+
}
47+
48+
// Handle orientation changes - reset baseline after orientation settles
49+
const handleOrientationChange = () => {
50+
// Delay to let viewport settle after orientation change
51+
setTimeout(updateBaseline, 300)
52+
}
53+
54+
viewport.addEventListener("resize", handleResize)
55+
window.addEventListener("orientationchange", handleOrientationChange)
56+
57+
onCleanup(() => {
58+
viewport.removeEventListener("resize", handleResize)
59+
window.removeEventListener("orientationchange", handleOrientationChange)
60+
document.documentElement.style.removeProperty("--keyboard-height")
61+
})
62+
})
63+
64+
return {
65+
height,
66+
visible,
67+
}
68+
}

packages/app/src/index.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
@import "@opencode-ai/ui/styles/tailwind";
22

33
:root {
4+
/* Safe area insets for notched devices (iPhone X+) */
5+
--safe-area-inset-top: env(safe-area-inset-top, 0px);
6+
--safe-area-inset-right: env(safe-area-inset-right, 0px);
7+
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
8+
--safe-area-inset-left: env(safe-area-inset-left, 0px);
9+
410
a {
511
cursor: default;
612
}

0 commit comments

Comments
 (0)