diff --git a/packages/app/index.html b/packages/app/index.html index e0fbe6913df..b316e5d007e 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -2,7 +2,7 @@ - + OpenCode diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 13f2b00a375..d31833490dd 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -264,6 +264,14 @@ export const PromptInput: Component = (props) => { const [composing, setComposing] = createSignal(false) const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 + const isMobile = createMemo(() => { + if (typeof window === "undefined") return false + return ( + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || + ("ontouchstart" in window && window.innerWidth < 768) + ) + }) + const addImageAttachment = async (file: File) => { if (!ACCEPTED_FILE_TYPES.includes(file.type)) return @@ -948,6 +956,11 @@ export const PromptInput: Component = (props) => { return } if (event.key === "Enter" && !event.shiftKey) { + if (isMobile() && !local.settings.mobileSendOnEnter()) { + if (!event.ctrlKey && !event.metaKey) { + return + } + } handleSubmit(event) } if (event.key === "Escape") { @@ -1521,22 +1534,22 @@ export const PromptInput: Component = (props) => { onKeyDown={handleKeyDown} classList={{ "select-text": true, - "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "w-full px-3 md:px-5 py-3 pr-12 md:pr-16 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, "font-mono!": store.mode === "shell", }} /> -
+
{store.mode === "shell" ? "Enter shell command..." : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
-
-
+
+
@@ -1551,7 +1564,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 px-1 md:px-2" variant="ghost" /> @@ -1559,7 +1572,7 @@ export const PromptInput: Component = (props) => { when={providers.paid().length > 0} fallback={ - @@ -1568,7 +1581,7 @@ export const PromptInput: Component = (props) => { > - @@ -1583,7 +1596,7 @@ export const PromptInput: Component = (props) => { >
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -1649,6 +1662,9 @@ export const PromptInput: Component = (props) => { ESC
+ + Tap to send +
Send @@ -1658,13 +1674,14 @@ export const PromptInput: Component = (props) => { } > - + class="size-6 p-0" + > + +
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index dc8f937ff55..efe29491302 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -1,7 +1,7 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" -import { batch, onCleanup } from "solid-js" +import { batch, onCleanup, onMount } from "solid-js" import { usePlatform } from "./platform" import { useServer } from "./server" @@ -9,12 +9,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo name: "GlobalSDK", init: () => { const server = useServer() - const abort = new AbortController() + let abort = new AbortController() + let reconnectTimeout: ReturnType | undefined + let isConnected = false - const eventSdk = createOpencodeClient({ - baseUrl: server.url, - signal: abort.signal, - }) const emitter = createGlobalEmitter<{ [key: string]: Event }>() @@ -63,33 +61,67 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo flush() } - void (async () => { - const events = await eventSdk.global.event() - let yielded = Date.now() - for await (const event of events.stream) { - const directory = event.directory ?? "global" - const payload = event.payload - const k = key(directory, payload) - if (k) { - const i = coalesced.get(k) - if (i !== undefined) { - queue[i] = undefined + const startEventStream = async () => { + if (abort.signal.aborted) { + abort = new AbortController() + } + + const eventSdk = createOpencodeClient({ + baseUrl: server.url, + signal: abort.signal, + }) + + try { + isConnected = true + const events = await eventSdk.global.event() + let yielded = Date.now() + for await (const event of events.stream) { + const directory = event.directory ?? "global" + const payload = event.payload + const k = key(directory, payload) + if (k) { + const i = coalesced.get(k) + if (i !== undefined) { + queue[i] = undefined + } + coalesced.set(k, queue.length) } - coalesced.set(k, queue.length) + queue.push({ directory, payload }) + schedule() + + if (Date.now() - yielded < 8) continue + yielded = Date.now() + await new Promise((resolve) => setTimeout(resolve, 0)) } - queue.push({ directory, payload }) - schedule() + } catch { + isConnected = false + if (!abort.signal.aborted) { + reconnectTimeout = setTimeout(() => startEventStream(), 1000) + } + } finally { + stop() + } + } - if (Date.now() - yielded < 8) continue - yielded = Date.now() - await new Promise((resolve) => setTimeout(resolve, 0)) + const handleVisibilityChange = () => { + if (document.visibilityState === "visible" && !isConnected) { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + reconnectTimeout = undefined + } + startEventStream() } - })() - .finally(stop) - .catch(() => undefined) + } + + onMount(() => { + document.addEventListener("visibilitychange", handleVisibilityChange) + startEventStream() + }) onCleanup(() => { + document.removeEventListener("visibilitychange", handleVisibilityChange) abort.abort() + if (reconnectTimeout) clearTimeout(reconnectTimeout) stop() }) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a0656c5fc60..0d4b4b5b887 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -460,6 +460,15 @@ function createGlobalSync() { } onMount(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + bootstrap() + } + } + + document.addEventListener("visibilitychange", handleVisibilityChange) + onCleanup(() => document.removeEventListener("visibilitychange", handleVisibilityChange)) + bootstrap() }) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 385f564fa57..54344de2e5e 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -266,6 +266,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) }) + createEffect(() => { + const recentProjects = globalSync.data.project + .filter((p) => p.worktree && !p.worktree.includes("opencode-test")) + .toSorted((a, b) => { + const aTime = a.time?.updated ?? a.time?.created ?? 0 + const bTime = b.time?.updated ?? b.time?.created ?? 0 + return bTime - aTime + }) + .slice(0, 5) + + const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 + + for (const project of recentProjects) { + const existing = server.projects.list().find((x) => x.worktree === project.worktree) + if (!existing) { + const updated = project.time?.updated ?? project.time?.created ?? 0 + if (updated > fourHoursAgo) { + server.projects.open(project.worktree) + } + } + } + }) + return { ready, projects: { diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 2ed57234f29..799757c6304 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -552,11 +552,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } })() + const settings = (() => { + const [store, setStore, _, settingsReady] = persisted( + Persist.global("settings", ["settings.v1"]), + createStore({ + mobileSendOnEnter: false, + }), + ) + + return { + ready: settingsReady, + mobileSendOnEnter: createMemo(() => store.mobileSendOnEnter), + setMobileSendOnEnter(value: boolean) { + setStore("mobileSendOnEnter", value) + }, + } + })() + const result = { slug: createMemo(() => base64Encode(sdk.directory)), model, agent, file, + settings, } return result }, diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e40f0842b15..a60bfd9804b 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -5,3 +5,27 @@ cursor: default; } } + +/* Mobile viewport and overflow fixes */ +html, +body { + overflow-x: hidden; + max-width: 100vw; + overscroll-behavior-x: none; +} + +/* iOS Safari viewport height fix */ +@supports (-webkit-touch-callout: none) { + .h-dvh { + height: -webkit-fill-available; + } +} + +/* Safe area insets for notched devices */ +.safe-area-inset-top { + padding-top: env(safe-area-inset-top); +} + +.safe-area-inset-bottom { + padding-bottom: env(safe-area-inset-bottom); +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cffefd5634d..e2d646097cc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -14,7 +14,7 @@ import { type JSX, } from "solid-js" import { DateTime } from "luxon" -import { A, useNavigate, useParams } from "@solidjs/router" +import { A, useNavigate, useParams, useLocation } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { base64Decode, base64Encode } from "@opencode-ai/util/encode" @@ -698,6 +698,17 @@ export default function Layout(props: ParentProps) { } } + const location = useLocation() + let prevPathname: string | undefined + + createEffect(() => { + const currentPathname = location.pathname + if (prevPathname !== undefined && prevPathname !== currentPathname) { + layout.mobileSidebar.hide() + } + prevPathname = currentPathname + }) + createEffect(() => { if (!params.dir || !params.id) return const directory = base64Decode(params.dir) @@ -852,6 +863,34 @@ export default function Layout(props: ParentProps) { const status = sessionStore.session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) + + let touchStartY = 0 + let isTouchScrolling = false + const touchScrollThreshold = 10 + + const handleTouchStart = (e: TouchEvent) => { + touchStartY = e.touches[0].clientY + isTouchScrolling = false + } + + const handleTouchMove = (e: TouchEvent) => { + const deltaY = Math.abs(e.touches[0].clientY - touchStartY) + if (deltaY > touchScrollThreshold) { + isTouchScrolling = true + } + } + + const handleClick = (e: MouseEvent) => { + if (isTouchScrolling) { + e.preventDefault() + e.stopPropagation() + return + } + if (props.mobile) { + layout.mobileSidebar.hide() + } + } + return ( <>
prefetchSession(props.session, "high")} onFocus={() => prefetchSession(props.session, "high")} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onClick={handleClick} >
- + props.mobile && layout.mobileSidebar.hide()} />
@@ -1032,6 +1074,7 @@ export default function Layout(props: ParentProps) { props.mobile && layout.mobileSidebar.hide()} >
@@ -1242,7 +1285,7 @@ export default function Layout(props: ParentProps) { } return ( -
+
{ @@ -863,8 +866,10 @@ export default function Page() { createResizeObserver( () => promptDock, - ({ height }) => { - const next = Math.ceil(height) + () => { + // Use offsetHeight to get the full height including padding (pt-12, pb-4/pb-8) + // The callback's height is contentRect.height which excludes padding + const next = promptDock?.offsetHeight ?? 0 if (next === store.promptHeight) return @@ -947,9 +952,18 @@ export default function Page() { const ready = messagesReady() if (!sessionID || !ready) return - requestAnimationFrame(() => { + const attemptScroll = (retries = 0) => { const hash = window.location.hash.slice(1) if (!hash) { + if (retries < 30 && scroller) { + const notFullyRendered = scroller.scrollHeight <= scroller.clientHeight + const notAtBottom = scroller.scrollTop + scroller.clientHeight < scroller.scrollHeight - 10 + if (notFullyRendered || notAtBottom) { + autoScroll.forceScrollToBottom() + setTimeout(() => attemptScroll(retries + 1), 50) + return + } + } autoScroll.forceScrollToBottom() return } @@ -970,7 +984,9 @@ export default function Page() { } autoScroll.forceScrollToBottom() - }) + } + + requestAnimationFrame(() => attemptScroll()) }) createEffect(() => { @@ -1079,7 +1095,7 @@ export default function Page() { file.load(path) }} classes={{ - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + root: "pb-[calc(var(--prompt-height,10rem)+32px)]", header: "px-4", container: "px-4", }} @@ -1111,7 +1127,7 @@ export default function Page() { >