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
2 changes: 1 addition & 1 deletion packages/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<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, maximum-scale=1, user-scalable=no" />
<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
45 changes: 31 additions & 14 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,14 @@ export const PromptInput: Component<PromptInputProps> = (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

Expand Down Expand Up @@ -948,6 +956,11 @@ export const PromptInput: Component<PromptInputProps> = (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") {
Expand Down Expand Up @@ -1521,22 +1534,22 @@ export const PromptInput: Component<PromptInputProps> = (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",
}}
/>
<Show when={!prompt.dirty()}>
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
<div class="absolute top-0 inset-x-0 px-3 md:px-5 py-3 pr-12 md:pr-16 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell"
? "Enter shell command..."
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
</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 px-1.5 py-1.5 md:p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-0 md:gap-0.5 min-w-0 overflow-hidden shrink">
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
Expand All @@ -1551,15 +1564,15 @@ export const PromptInput: Component<PromptInputProps> = (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"
/>
</TooltipKeybind>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
<Button as="div" variant="ghost" class="px-1 md:px-2" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
Expand All @@ -1568,7 +1581,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<ModelSelectorPopover>
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost">
<Button as="div" variant="ghost" class="px-1 md:px-2">
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
Expand All @@ -1583,7 +1596,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Button
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block"
class="hidden md:inline-flex text-text-base _hidden group-hover/prompt-input:inline-block"
onClick={() => local.model.variant.cycle()}
>
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
Expand Down Expand Up @@ -1616,7 +1629,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-1 md:gap-3 shrink-0">
<input
ref={fileInputRef}
type="file"
Expand All @@ -1628,7 +1641,7 @@ 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">
Expand All @@ -1649,6 +1662,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<span class="text-icon-base text-12-medium text-[10px]!">ESC</span>
</div>
</Match>
<Match when={isMobile() && !local.settings.mobileSendOnEnter()}>
<span>Tap to send</span>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>Send</span>
Expand All @@ -1658,13 +1674,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Switch>
}
>
<IconButton
<Button
type="submit"
disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-4.5"
/>
class="size-6 p-0"
>
<Icon name={working() ? "stop" : "arrow-up"} size="small" />
</Button>
</Tooltip>
</div>
</div>
Expand Down
84 changes: 58 additions & 26 deletions packages/app/src/context/global-sdk.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
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"

export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: () => {
const server = useServer()
const abort = new AbortController()
let abort = new AbortController()
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
let isConnected = false

const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
Expand Down Expand Up @@ -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<void>((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<void>((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()
})

Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,15 @@ function createGlobalSync() {
}

onMount(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
bootstrap()
}
}

document.addEventListener("visibilitychange", handleVisibilityChange)
onCleanup(() => document.removeEventListener("visibilitychange", handleVisibilityChange))

bootstrap()
})

Expand Down
23 changes: 23 additions & 0 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
24 changes: 24 additions & 0 deletions packages/app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading