diff --git a/bun.lock b/bun.lock index 8e8dab33ed2..8d95708ba74 100644 --- a/bun.lock +++ b/bun.lock @@ -177,7 +177,6 @@ "version": "1.1.12", "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 e0fbe6913df..214874d1ba0 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -1,8 +1,12 @@ - + - + + + + + OpenCode diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index f8c71049619..f8f525536e1 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -1,11 +1,17 @@ ;(function () { - var themeId = localStorage.getItem("opencode-theme-id") - if (!themeId) return - var scheme = localStorage.getItem("opencode-color-scheme") || "system" var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches) var mode = isDark ? "dark" : "light" + // Set background color immediately to prevent flash + // These are the default theme background colors + var bgColor = isDark ? "#131010" : "#F8F7F7" + document.documentElement.style.backgroundColor = bgColor + document.body && (document.body.style.backgroundColor = bgColor) + + var themeId = localStorage.getItem("opencode-theme-id") + if (!themeId) return + document.documentElement.dataset.theme = themeId document.documentElement.dataset.colorScheme = mode diff --git a/packages/app/public/sw.js b/packages/app/public/sw.js new file mode 100644 index 00000000000..68b9c449ac1 --- /dev/null +++ b/packages/app/public/sw.js @@ -0,0 +1,116 @@ +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)), + ) +}) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index bf6da3b4d0b..4a5a2998e9b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -28,8 +28,16 @@ import { iife } from "@opencode-ai/util/iife" import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) -const Session = lazy(() => import("@/pages/session")) -const Loading = () =>
Loading...
+import Session from "@/pages/session" +const Loading = () => ( +
+
+ + + +
+
+) declare global { interface Window { @@ -44,6 +52,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 }) @@ -110,9 +123,7 @@ export function AppInterface() { - }> - - + diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 97f54da5ed5..94c08f7ea9b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -263,23 +263,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) => { @@ -1056,9 +1083,16 @@ export const PromptInput: Component = (props) => { const variant = local.model.variant.current() const clearInput = () => { + // Explicitly clear DOM in addition to resetting prompt state + // This ensures the input is cleared even if the reactive effect doesn't fire immediately + editorRef.innerHTML = "" 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 = () => { @@ -1531,8 +1565,8 @@ export const PromptInput: Component = (props) => { -
-
+
+
@@ -1547,7 +1581,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" /> @@ -1555,37 +1589,27 @@ export const PromptInput: Component = (props) => { when={providers.paid().length > 0} fallback={ - } > - - 0}> - - - - = (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}> + + + + - +
@@ -1659,7 +1720,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/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 680f327130e..22923f0aa53 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -1,4 +1,4 @@ -import { Match, Show, Switch, createMemo } from "solid-js" +import { Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js" import { Tooltip } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { Button } from "@opencode-ai/ui/button" @@ -12,10 +12,18 @@ interface SessionContextUsageProps { variant?: "button" | "indicator" } +// Detect touch/coarse pointer devices (mobile) +const isTouchDevice = () => typeof window !== "undefined" && window.matchMedia("(pointer: coarse)").matches + export function SessionContextUsage(props: SessionContextUsageProps) { const sync = useSync() const params = useParams() const layout = useLayout() + const [isTouch, setIsTouch] = createSignal(false) + + onMount(() => { + setIsTouch(isTouchDevice()) + }) const variant = createMemo(() => props.variant ?? "button") const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) @@ -49,9 +57,14 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const openContext = () => { if (!params.id) return - view().reviewPanel.open() + const v = view() + v.reviewPanel.open() tabs().open("context") tabs().setActive("context") + // On mobile, switch to review tab + if (isTouch()) { + v.mobileTab.set("review") + } } const circle = () => ( @@ -88,11 +101,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) { return ( - + {circle()} - diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 68ef0cc1f2b..9d855eeb529 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -45,8 +45,11 @@ export function NewSessionView(props: NewSessionViewProps) { return (
New session
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a0656c5fc60..405e7bd6667 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" @@ -23,7 +24,19 @@ import { Binary } from "@opencode-ai/util/binary" 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 { + batch, + createContext, + useContext, + onCleanup, + onMount, + createEffect, + 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" @@ -48,12 +61,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[] } @@ -62,8 +79,38 @@ type State = { } } +const CACHE_KEY = "opencode-global-state" + +type CachedState = { + project: Project[] + provider: ProviderListResponse + timestamp: number +} + +function loadCachedState(): CachedState | null { + try { + const cached = localStorage.getItem(CACHE_KEY) + if (!cached) return null + const parsed = JSON.parse(cached) as CachedState + // Cache expires after 24 hours + if (Date.now() - parsed.timestamp > 24 * 60 * 60 * 1000) return null + return parsed + } catch { + return null + } +} + +function saveCachedState(project: Project[], provider: ProviderListResponse) { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify({ project, provider, timestamp: Date.now() })) + } catch { + // Ignore storage errors (private browsing, quota exceeded, etc.) + } +} + function createGlobalSync() { const globalSDK = useGlobalSDK() + const cached = loadCachedState() const [globalStore, setGlobalStore] = createStore<{ ready: boolean error?: InitError @@ -72,10 +119,11 @@ function createGlobalSync() { provider: ProviderListResponse provider_auth: ProviderAuthResponse }>({ - ready: false, + // If we have cached data, mark as ready immediately for instant UI + ready: !!cached, path: { state: "", config: "", worktree: "", directory: "", home: "" }, - project: [], - provider: { all: [], connected: [], default: {} }, + project: cached?.project ?? [], + provider: cached?.provider ?? { all: [], connected: [], default: {} }, provider_auth: {}, }) @@ -96,10 +144,12 @@ function createGlobalSync() { session_diff: {}, todo: {}, permission: {}, + question: {}, mcp: {}, lsp: [], vcs: undefined, limit: 5, + totalSessions: 0, message: {}, part: {}, }) @@ -125,6 +175,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) => { @@ -205,6 +256,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") }) @@ -256,6 +339,8 @@ function createGlobalSync() { draft.splice(result.index, 1) }), ) + // Decrement totalSessions when session is archived + setStore("totalSessions", (n) => Math.max(0, n - 1)) } break } @@ -263,6 +348,14 @@ function createGlobalSync() { setStore("session", result.index, reconcile(event.properties.info)) break } + // Session not in local store - add it + // Only increment totalSessions if session was created recently (truly new), + // not if it's an old session we just didn't have loaded due to limit + const createdAt = new Date(event.properties.info.time?.created).getTime() + const isNewSession = Date.now() - createdAt < 30_000 + if (isNewSession) { + setStore("totalSessions", (n) => n + 1) + } setStore( "session", produce((draft) => { @@ -402,20 +495,64 @@ 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) async function bootstrap() { - const health = await globalSDK.client.global - .health() - .then((x) => x.data) - .catch(() => undefined) + const hasCachedData = globalStore.project.length > 0 + + // Quick health check with 3s timeout - don't block UI if server is slow + const healthPromise = globalSDK.client.global.health().then((x) => x.data) + const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(undefined), 3000)) + const health = await Promise.race([healthPromise, timeoutPromise]).catch(() => undefined) + if (!health?.healthy) { - setGlobalStore( - "error", - new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`), - ) + // If we have cached data, don't show error - just use cache silently + if (!hasCachedData) { + setGlobalStore( + "error", + new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`), + ) + } return } @@ -456,9 +593,21 @@ function createGlobalSync() { ), ]) .then(() => setGlobalStore("ready", true)) - .catch((e) => setGlobalStore("error", e)) + .catch((e) => { + // Only show error if we don't have cached data to fall back on + if (!hasCachedData) setGlobalStore("error", e) + }) } + // Save state to cache when projects or providers change + createEffect(() => { + const project = globalStore.project + const provider = globalStore.provider + if (project.length > 0 || provider.all.length > 0) { + saveCachedState(project, provider) + } + }) + onMount(() => { bootstrap() }) @@ -484,7 +633,17 @@ const GlobalSyncContext = createContext>() export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() return ( - + + +
+ + Connecting... +
+
+ } + > diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 385f564fa57..af652af9496 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -35,6 +35,7 @@ type SessionView = { reviewOpen?: string[] terminalOpened?: boolean reviewPanelOpened?: boolean + mobileTab?: "session" | "review" } export type LocalProject = Partial & { worktree: string; expanded: boolean } @@ -349,6 +350,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} }) const terminalOpened = createMemo(() => s().terminalOpened ?? false) const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true) + const mobileTab = createMemo(() => s().mobileTab ?? "session") function setTerminalOpened(next: boolean) { const current = store.sessionView[sessionKey] @@ -423,6 +425,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("sessionView", sessionKey, "reviewOpen", open) }, }, + mobileTab: { + value: mobileTab, + set(tab: "session" | "review") { + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { scroll: {}, mobileTab: tab }) + return + } + if (current.mobileTab === tab) return + setStore("sessionView", sessionKey, "mobileTab", tab) + }, + }, } }, tabs(sessionKey: string) { diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 8945cd37e9e..32cb0ce5c80 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -39,7 +39,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }), ) - const [active, setActiveRaw] = createSignal("") + // Initialize with default URL immediately to avoid blank screen on first render + const [active, setActiveRaw] = createSignal(normalizeServerUrl(props.defaultUrl) ?? "") function setActive(input: string) { const url = normalizeServerUrl(input) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 28741098c8e..4955805b34c 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -4,6 +4,20 @@ 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").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 +35,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..af2cb0e64d9 --- /dev/null +++ b/packages/app/src/hooks/use-virtual-keyboard.ts @@ -0,0 +1,70 @@ +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) + 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/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 39124637c26..d918b7a36c3 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -27,6 +27,11 @@ export default function Layout(props: ParentProps) { response: "once" | "always" | "reject" }) => sdk.client.permission.respond(input) + const respondToQuestion = (input: { requestID: string; answers: string[][] }) => + sdk.client.question.reply(input) + + const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input) + const navigateToSession = (sessionID: string) => { navigate(`/${params.dir}/session/${sessionID}`) } @@ -36,6 +41,8 @@ export default function Layout(props: ParentProps) { data={sync.data} directory={directory()} onPermissionRespond={respond} + onQuestionRespond={respondToQuestion} + onQuestionReject={rejectQuestion} onNavigateToSession={navigateToSession} > {props.children} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cffefd5634d..ad6b8b99b45 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 ( - <> - ) } @@ -944,7 +971,7 @@ export default function Layout(props: ParentProps) { .toSorted(sortSessions), ) const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) - const hasMoreSessions = createMemo(() => store.session.length >= store.limit) + const hasMoreSessions = createMemo(() => store.session.length < store.totalSessions) const loadMoreSessions = async () => { setProjectStore("limit", (limit) => limit + 5) await globalSync.project.loadSessions(props.project.worktree) @@ -965,107 +992,182 @@ export default function Layout(props: ParentProps) { else layout.projects.collapse(props.project.worktree) } } - return ( - // @ts-ignore -
- - - - - - + + + + + + + + + + ) + + // On mobile, don't apply sortable drag - use menu for reordering instead + if (props.mobile) { + return
{content}
+ } + + return ( + // @ts-ignore - sortable directive +
+ {content}
) } @@ -1285,10 +1387,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 69065a8fa7a..f4571e451ba 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -53,6 +53,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" @@ -212,6 +213,7 @@ export default function Page() { } const isDesktop = createMediaQuery("(min-width: 768px)") + const keyboard = useVirtualKeyboard() function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab @@ -266,7 +268,13 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const reviewCount = createMemo(() => info()?.summary?.files ?? 0) - const hasReview = createMemo(() => reviewCount() > 0) + // Show review tab when we have summary.files OR when diffs are loaded (handles race condition) + const hasReview = createMemo(() => { + if (reviewCount() > 0) return true + const id = params.id + if (!id) return false + return (sync.data.session_diff[id]?.length ?? 0) > 0 + }) const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messagesReady = createMemo(() => { @@ -311,7 +319,6 @@ export default function Page() { expanded: {} as Record, messageId: undefined as string | undefined, turnStart: 0, - mobileTab: "session" as "session" | "review", newSessionWorktree: "main", promptHeight: 0, }) @@ -728,7 +735,8 @@ export default function Page() { ) const reviewTab = createMemo(() => hasReview() || tabs().active() === "review") - const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review") + const mobileTab = createMemo(() => view().mobileTab.value()) + const mobileReview = createMemo(() => !isDesktop() && hasReview() && mobileTab() === "review") const showTabs = createMemo( () => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()), @@ -757,7 +765,7 @@ export default function Page() { if (!id) return if (!hasReview()) return - const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review" + const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : mobileTab() === "review" if (!wants) return if (diffsReady()) return @@ -1024,21 +1032,21 @@ export default function Page() {
{/* Mobile tab bar - only shown on mobile when there are diffs */} - + setStore("mobileTab", "session")} + classes={{ button: "w-full min-h-11" }} + onClick={() => view().mobileTab.set("session")} > Session setStore("mobileTab", "review")} + classes={{ button: "w-full min-h-11" }} + onClick={() => view().mobileTab.set("review")} > {reviewCount()} Files Changed @@ -1222,10 +1230,27 @@ export default function Page() {
+ {/* Scroll to bottom button */} + + + + {/* 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": "max(1rem, env(safe-area-inset-bottom, 0px))", + }} >
{ + 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, getDefaultServerUrl: async () => { const result = await invoke("get_default_server_url").catch(() => null) @@ -303,7 +307,29 @@ function ServerGate(props: ParentProps) { when={status.state !== "pending"} fallback={
- + + + + + + + + + + + + + + + + + +
Starting server...
} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index d2ff1d0b14c..eed7d13f129 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -138,28 +138,42 @@ export namespace File { if (process.platform === "darwin") ignore.add("Library") if (process.platform === "win32") ignore.add("AppData") - const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) + const ignoreNested = new Set([ + "node_modules", + "dist", + "build", + "target", + "vendor", + ".git", + "__pycache__", + ".venv", + "venv", + ]) const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) - const top = await fs.promises - .readdir(Instance.directory, { withFileTypes: true }) - .catch(() => [] as fs.Dirent[]) - - for (const entry of top) { - if (!entry.isDirectory()) continue - if (shouldIgnore(entry.name)) continue - dirs.add(entry.name + "/") - - const base = path.join(Instance.directory, entry.name) - const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) - for (const child of children) { - if (!child.isDirectory()) continue - if (shouldIgnoreNested(child.name)) continue - dirs.add(entry.name + "/" + child.name + "/") + // Recursively scan directories up to maxDepth levels + const maxDepth = 4 + const scanDir = async (relPath: string, depth: number) => { + if (depth >= maxDepth) return + const absPath = relPath ? path.join(Instance.directory, relPath) : Instance.directory + const entries = await fs.promises.readdir(absPath, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + const shouldSkip = depth === 0 ? shouldIgnore(entry.name) : shouldIgnoreNested(entry.name) + if (shouldSkip) continue + + const entryRelPath = relPath ? relPath + "/" + entry.name : entry.name + dirs.add(entryRelPath + "/") + + // Continue scanning subdirectories + await scanDir(entryRelPath, depth + 1) } } + await scanDir("", 0) + result.dirs = Array.from(dirs).toSorted() cache = result fetching = false diff --git a/packages/ui/src/assets/favicon/site.webmanifest b/packages/ui/src/assets/favicon/site.webmanifest index 41290e840c3..16e97406780 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" + "theme_color": "#131010", + "background_color": "#131010" } diff --git a/packages/ui/src/components/icon.css b/packages/ui/src/components/icon.css index 14a52bc83d2..110148663b8 100644 --- a/packages/ui/src/components/icon.css +++ b/packages/ui/src/components/icon.css @@ -5,7 +5,7 @@ flex-shrink: 0; /* resize: both; */ aspect-ratio: 1/1; - color: var(--icon-base); + /* color inherits from parent by default, allowing text-* classes to control icon color */ &[data-size="small"] { width: 16px; diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 3cdd93cb9fb..9d502b507de 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -494,3 +494,159 @@ justify-content: flex-end; } } + +[data-component="question-tool"] { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + + [data-slot="question-tabs"] { + display: flex; + gap: 4px; + flex-wrap: wrap; + } + + [data-slot="question-tab"] { + padding: 4px 12px; + border-radius: 4px; + border: none; + background-color: var(--surface-raised-base); + color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: + background-color 0.15s, + color 0.15s; + + &:hover { + background-color: var(--surface-raised-base-hover); + } + + &[data-active="true"] { + background-color: var(--surface-accent-weak); + color: var(--text-accent-strong); + } + + &[data-answered="true"]:not([data-active="true"]) { + color: var(--text-success-base); + } + } + + [data-slot="question-content"] { + display: flex; + flex-direction: column; + gap: 12px; + } + + [data-slot="question-text"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + color: var(--text-base); + } + + [data-slot="question-options"] { + display: flex; + flex-direction: column; + gap: 8px; + } + + [data-slot="question-option"] { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid var(--border-weak-base); + background-color: var(--surface-raised-base); + cursor: pointer; + transition: + border-color 0.15s, + background-color 0.15s; + text-align: left; + width: 100%; + + &:hover:not([data-disabled="true"]) { + border-color: var(--border-base); + background-color: var(--surface-raised-base-hover); + } + + &[data-selected="true"] { + border-color: var(--border-accent-base); + background-color: var(--surface-accent-weak); + } + + &[data-disabled="true"] { + cursor: default; + opacity: 0.7; + } + + [data-slot="icon-svg"] { + flex-shrink: 0; + color: var(--icon-accent-strong); + margin-left: auto; + } + } + + [data-slot="question-option-number"] { + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--text-weak); + flex-shrink: 0; + min-width: 20px; + } + + [data-slot="question-option-content"] { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; + } + + [data-slot="question-option-label"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + color: var(--text-strong); + } + + [data-slot="question-option-description"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + color: var(--text-weak); + line-height: var(--line-height-large); + } + + [data-slot="question-actions"] { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + padding-top: 8px; + border-top: 1px solid var(--border-weak-base); + } + + [data-slot="question-answered"] { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background-color: var(--surface-success-weak); + border-radius: 6px; + color: var(--text-success-strong); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + + [data-slot="icon-svg"] { + color: var(--icon-success-strong); + } + } +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 644690ed2f0..002397a1560 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -313,7 +313,20 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const handleCopy = async () => { const content = text() if (!content) return - await navigator.clipboard.writeText(content) + try { + await navigator.clipboard.writeText(content) + } catch { + // Fallback for iOS Safari + const textarea = document.createElement("textarea") + textarea.value = content + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + document.execCommand("copy") + document.body.removeChild(textarea) + } setCopied(true) setTimeout(() => setCopied(false), 2000) } @@ -1043,3 +1056,166 @@ ToolRegistry.register({ ) }, }) + +ToolRegistry.register({ + name: "question", + render(props) { + const data = useData() + const questions = createMemo(() => props.input.questions ?? []) + + const questionRequest = createMemo(() => { + const sessionID = props.metadata?.sessionID as string | undefined + if (!sessionID) return undefined + const requests = data.store.question?.[sessionID] ?? [] + return requests.find((r) => r.tool?.callID === props.metadata?.callID) + }) + + const [answers, setAnswers] = createSignal([]) + const [currentTab, setCurrentTab] = createSignal(0) + + const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) + const currentQuestion = createMemo(() => questions()[currentTab()]) + + const handleSelect = (questionIndex: number, optionLabel: string) => { + const q = questions()[questionIndex] + if (!q) return + + setAnswers((prev) => { + const next = [...prev] + if (q.multiple) { + const existing = next[questionIndex] ?? [] + const idx = existing.indexOf(optionLabel) + if (idx === -1) { + next[questionIndex] = [...existing, optionLabel] + } else { + next[questionIndex] = existing.filter((l) => l !== optionLabel) + } + } else { + next[questionIndex] = [optionLabel] + } + return next + }) + + if (!q.multiple && single()) { + const request = questionRequest() + if (request && data.respondToQuestion) { + data.respondToQuestion({ + requestID: request.id, + answers: [[optionLabel]], + }) + } + } else if (!q.multiple) { + setCurrentTab((t) => Math.min(t + 1, questions().length - 1)) + } + } + + const handleSubmit = () => { + const request = questionRequest() + if (!request || !data.respondToQuestion) return + data.respondToQuestion({ + requestID: request.id, + answers: answers(), + }) + } + + const handleReject = () => { + const request = questionRequest() + if (!request || !data.rejectQuestion) return + data.rejectQuestion({ requestID: request.id }) + } + + const isAnswered = createMemo(() => !questionRequest()) + + return ( + 1 ? `${questions().length} questions` : undefined, + }} + > + 0}> +
+ 1 && !isAnswered()}> +
+ + {(q, i) => ( + + )} + +
+
+ + + {(q) => ( +
+
+ {q().question} + {q().multiple ? " (select all that apply)" : ""} +
+
+ + {(opt, i) => { + const selected = () => answers()[currentTab()]?.includes(opt.label) ?? false + return ( + + ) + }} + +
+
+ )} +
+ + +
+ + +
+
+ + +
+ + Answered +
+
+
+
+
+ ) + }, +}) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 581935b3ed5..e6048919712 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -6,6 +6,7 @@ display: flex; align-items: flex-start; justify-content: flex-start; + overflow-x: hidden; [data-slot="session-turn-content"] { flex-grow: 1; @@ -13,6 +14,7 @@ height: 100%; min-width: 0; overflow-y: auto; + overflow-x: hidden; scrollbar-width: none; } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 075da218bb1..d29367a3ea1 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,6 +3,7 @@ import { Message as MessageType, Part as PartType, type PermissionRequest, + type QuestionRequest, TextPart, ToolPart, } from "@opencode-ai/sdk/v2/client" @@ -252,6 +253,31 @@ export function SessionTurn( return emptyPermissionParts }) + const emptyQuestions: QuestionRequest[] = [] + const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = [] + const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions) + const questionCount = createMemo(() => questions().length) + const nextQuestion = createMemo(() => questions()[0]) + + const questionParts = createMemo(() => { + if (props.stepsExpanded) return emptyQuestionParts + + const next = nextQuestion() + if (!next || !next.tool) return emptyQuestionParts + + const message = assistantMessages().findLast((m) => m.id === next.tool!.messageID) + if (!message) return emptyQuestionParts + + const parts = data.store.part[message.id] ?? emptyParts + for (const part of parts) { + if (part?.type !== "tool") continue + const tool = part as ToolPart + if (tool.callID === next.tool?.callID) return [{ part: tool, message }] + } + + return emptyQuestionParts + }) + const shellModePart = createMemo(() => { const p = parts() if (!p.every((part) => part?.type === "text" && part?.synthetic)) return @@ -334,7 +360,20 @@ export function SessionTurn( const handleCopyResponse = async () => { const content = response() if (!content) return - await navigator.clipboard.writeText(content) + try { + await navigator.clipboard.writeText(content) + } catch { + // Fallback for iOS Safari + const textarea = document.createElement("textarea") + textarea.value = content + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + document.execCommand("copy") + document.body.removeChild(textarea) + } setResponseCopied(true) setTimeout(() => setResponseCopied(false), 2000) } @@ -432,6 +471,14 @@ export function SessionTurn( }), ) + createEffect( + on(questionCount, (count, prev) => { + if (!count) return + if (prev !== undefined && count <= prev) return + autoScroll.forceScrollToBottom() + }), + ) + let lastStatusChange = Date.now() let statusTimeout: number | undefined createEffect(() => { @@ -564,6 +611,13 @@ export function SessionTurn(
+ 0}> +
+ + {({ part, message }) => } + +
+
{/* Response */}
diff --git a/packages/ui/src/components/text-field.tsx b/packages/ui/src/components/text-field.tsx index ed3d13fe355..0d1110343da 100644 --- a/packages/ui/src/components/text-field.tsx +++ b/packages/ui/src/components/text-field.tsx @@ -53,7 +53,20 @@ export function TextField(props: TextFieldProps) { async function handleCopy() { const value = local.value ?? local.defaultValue ?? "" - await navigator.clipboard.writeText(value) + try { + await navigator.clipboard.writeText(value) + } catch { + // Fallback for iOS Safari + const textarea = document.createElement("textarea") + textarea.value = value + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + document.execCommand("copy") + document.body.removeChild(textarea) + } setCopied(true) setTimeout(() => setCopied(false), 2000) } diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css index 1459bb18903..744c2bb39d8 100644 --- a/packages/ui/src/components/toast.css +++ b/packages/ui/src/components/toast.css @@ -1,15 +1,22 @@ [data-component="toast-region"] { position: fixed; bottom: 48px; - right: 32px; + left: 50%; + transform: translateX(-50%); z-index: 1000; display: flex; flex-direction: column; gap: 8px; - max-width: 400px; + max-width: min(400px, calc(100vw - 32px)); width: 100%; pointer-events: none; + @media (min-width: 768px) { + left: auto; + right: 32px; + transform: none; + } + [data-slot="toast-list"] { display: flex; flex-direction: column; diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index acab99fe8f6..08747c471d5 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,13 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2" +import type { + Message, + Session, + Part, + FileDiff, + SessionStatus, + PermissionRequest, + QuestionRequest, + QuestionAnswer, +} from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -16,6 +25,9 @@ type Data = { permission?: { [sessionID: string]: PermissionRequest[] } + question?: { + [sessionID: string]: QuestionRequest[] + } message: { [sessionID: string]: Message[] } @@ -30,6 +42,10 @@ export type PermissionRespondFn = (input: { response: "once" | "always" | "reject" }) => void +export type QuestionRespondFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void + +export type QuestionRejectFn = (input: { requestID: string }) => void + export type NavigateToSessionFn = (sessionID: string) => void export const { use: useData, provider: DataProvider } = createSimpleContext({ @@ -38,6 +54,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ data: Data directory: string onPermissionRespond?: PermissionRespondFn + onQuestionRespond?: QuestionRespondFn + onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn }) => { return { @@ -48,6 +66,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return props.directory }, respondToPermission: props.onPermissionRespond, + respondToQuestion: props.onQuestionRespond, + rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, } }, diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index b9eae54881d..6835e99e394 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -17,6 +17,7 @@ export function createAutoScroll(options: AutoScrollOptions) { const [store, setStore] = createStore({ contentRef: undefined as HTMLElement | undefined, userScrolled: false, + notAtBottom: false, }) const active = () => options.working() || settling @@ -83,10 +84,18 @@ export function createAutoScroll(options: AutoScrollOptions) { } const handleScroll = () => { - if (!active()) return if (!scroll) return - if (distanceFromBottom() < 10) { + // Always track if we're at the bottom for the scroll button + const distance = distanceFromBottom() + const atBottom = distance < 50 + if (store.notAtBottom !== !atBottom) { + setStore("notAtBottom", !atBottom) + } + + if (!active()) return + + if (distance < 10) { if (store.userScrolled) setStore("userScrolled", false) return } @@ -113,7 +122,11 @@ export function createAutoScroll(options: AutoScrollOptions) { if (settleTimer) clearTimeout(settleTimer) settleTimer = undefined - setStore("userScrolled", false) + // Only reset userScrolled if we're actually at the bottom + // This preserves the scroll button when returning to a scrolled conversation + if (distanceFromBottom() < 50) { + setStore("userScrolled", false) + } if (working) { scrollToBottom(true) @@ -149,6 +162,11 @@ export function createAutoScroll(options: AutoScrollOptions) { el.addEventListener("pointerdown", handlePointerDown) el.addEventListener("touchstart", handleTouchStart, { passive: true }) + // Check initial scroll position after layout is complete + requestAnimationFrame(() => { + handleScroll() + }) + cleanup = () => { el.removeEventListener("wheel", handleWheel) el.removeEventListener("pointerdown", handlePointerDown) @@ -163,5 +181,6 @@ export function createAutoScroll(options: AutoScrollOptions) { scrollToBottom: () => scrollToBottom(false), forceScrollToBottom: () => scrollToBottom(true), userScrolled: () => store.userScrolled, + notAtBottom: () => store.notAtBottom, } }