Skip to content

Commit 45cc0d5

Browse files
feat(ios): add iOS WebView shell with notifications and fullscreen settings
Add iOS-specific features: - iOS WebView shell package (packages/ios) - Notification badge sync between app and iOS - Fullscreen Settings dialog on iOS - Platform-specific UI adjustments - Update dependencies for typecheck stability - Document rebase pitfall and correct sync flow This preserves five iOS-facing invariants documented in packages/ios/GIT_SYNC.md: 1. iOS session header reload button 2. iOS prompt agent label compaction 3. Narrow-layout settings tabs (horizontal + icon-only) 4. iOS notifications with badge sync 5. iOS Settings dialog fullscreen behavior Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent c2f7822 commit 45cc0d5

65 files changed

Lines changed: 4841 additions & 135 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,62 @@ const table = sqliteTable("session", {
126126
## Type Checking
127127

128128
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
129+
130+
# context-mode — MANDATORY routing rules
131+
132+
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
133+
134+
## BLOCKED commands — do NOT attempt these
135+
136+
### curl / wget — BLOCKED
137+
Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
138+
Instead use:
139+
- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
140+
- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
141+
142+
### Inline HTTP — BLOCKED
143+
Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
144+
Instead use:
145+
- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
146+
147+
### Direct web fetching — BLOCKED
148+
Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
149+
Instead use:
150+
- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
151+
152+
## REDIRECTED tools — use sandbox equivalents
153+
154+
### Shell (>20 lines output)
155+
Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
156+
For everything else, use:
157+
- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
158+
- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
159+
160+
### File reading (for analysis)
161+
If you are reading a file to **edit** it → reading is correct (edit needs content in context).
162+
If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
163+
164+
### grep / search (large results)
165+
Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
166+
167+
## Tool selection hierarchy
168+
169+
1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
170+
2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
171+
3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
172+
4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
173+
5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
174+
175+
## Output constraints
176+
177+
- Keep responses under 500 words.
178+
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
179+
- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
180+
181+
## ctx commands
182+
183+
| Command | Action |
184+
|---------|--------|
185+
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
186+
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
187+
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |

bun.lock

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/app/src/app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ declare global {
7878
api?: {
7979
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
8080
}
81+
/** iOS shell: sync window / webview chrome to theme (set from `entry-ios.tsx`) */
82+
__OPENCODE_SYNC_SHELL_BG__?: () => void
8183
}
8284
}
8385

packages/app/src/components/dialog-settings.tsx

Lines changed: 76 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { Component } from "solid-js"
1+
import { createMediaQuery } from "@solid-primitives/media"
2+
import { Component, Show } from "solid-js"
23
import { Dialog } from "@opencode-ai/ui/dialog"
34
import { Tabs } from "@opencode-ai/ui/tabs"
45
import { Icon } from "@opencode-ai/ui/icon"
6+
import { Button } from "@opencode-ai/ui/button"
7+
import { useDialog } from "@opencode-ai/ui/context/dialog"
58
import { useLanguage } from "@/context/language"
69
import { usePlatform } from "@/context/platform"
710
import { SettingsGeneral } from "./settings-general"
@@ -12,48 +15,86 @@ import { SettingsModels } from "./settings-models"
1215
export const DialogSettings: Component = () => {
1316
const language = useLanguage()
1417
const platform = usePlatform()
18+
const mobile = createMediaQuery("(max-width: 767px)")
19+
const isIOS = platform.platform === "ios"
20+
const dialog = useDialog()
1521

1622
return (
17-
<Dialog size="x-large" transition>
18-
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
23+
<Dialog size="x-large" transition class={isIOS ? "ios-fullscreen-dialog" : ""}>
24+
<Show when={isIOS}>
25+
<div class="absolute top-0 left-0 right-0 z-50 flex items-center justify-between px-4 py-3 bg-surface-base border-b border-border-base">
26+
<Button variant="ghost" size="small" onClick={() => dialog.close()} class="text-blue-500 hover:text-blue-600">
27+
{language.t("common.done")}
28+
</Button>
29+
<span class="text-16-medium text-text-strong">{language.t("settings.title")}</span>
30+
<div class="w-16" />
31+
</div>
32+
</Show>
33+
34+
<Tabs
35+
orientation={mobile() ? "horizontal" : "vertical"}
36+
variant="settings"
37+
defaultValue="general"
38+
class={isIOS ? "h-full settings-dialog pt-14" : "h-full settings-dialog"}
39+
>
1940
<Tabs.List>
20-
<div class="flex flex-col justify-between h-full w-full">
21-
<div class="flex flex-col gap-3 w-full pt-3">
22-
<div class="flex flex-col gap-3">
23-
<div class="flex flex-col gap-1.5">
24-
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
25-
<div class="flex flex-col gap-1.5 w-full">
26-
<Tabs.Trigger value="general">
27-
<Icon name="sliders" />
28-
{language.t("settings.tab.general")}
29-
</Tabs.Trigger>
30-
<Tabs.Trigger value="shortcuts">
31-
<Icon name="keyboard" />
32-
{language.t("settings.tab.shortcuts")}
33-
</Tabs.Trigger>
34-
</div>
35-
</div>
41+
<Show
42+
when={mobile()}
43+
fallback={
44+
<div class="flex flex-col justify-between h-full w-full">
45+
<div class="flex flex-col gap-3 w-full pt-3">
46+
<div class="flex flex-col gap-3">
47+
<div class="flex flex-col gap-1.5">
48+
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
49+
<div class="flex flex-col gap-1.5 w-full">
50+
<Tabs.Trigger value="general">
51+
<Icon name="sliders" />
52+
{language.t("settings.tab.general")}
53+
</Tabs.Trigger>
54+
<Tabs.Trigger value="shortcuts">
55+
<Icon name="keyboard" />
56+
{language.t("settings.tab.shortcuts")}
57+
</Tabs.Trigger>
58+
</div>
59+
</div>
3660

37-
<div class="flex flex-col gap-1.5">
38-
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
39-
<div class="flex flex-col gap-1.5 w-full">
40-
<Tabs.Trigger value="providers">
41-
<Icon name="providers" />
42-
{language.t("settings.providers.title")}
43-
</Tabs.Trigger>
44-
<Tabs.Trigger value="models">
45-
<Icon name="models" />
46-
{language.t("settings.models.title")}
47-
</Tabs.Trigger>
61+
<div class="flex flex-col gap-1.5">
62+
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
63+
<div class="flex flex-col gap-1.5 w-full">
64+
<Tabs.Trigger value="providers">
65+
<Icon name="providers" />
66+
{language.t("settings.providers.title")}
67+
</Tabs.Trigger>
68+
<Tabs.Trigger value="models">
69+
<Icon name="models" />
70+
{language.t("settings.models.title")}
71+
</Tabs.Trigger>
72+
</div>
73+
</div>
4874
</div>
4975
</div>
76+
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
77+
<span>{language.t("app.name.desktop")}</span>
78+
<span class="text-11-regular">v{platform.version}</span>
79+
</div>
5080
</div>
81+
}
82+
>
83+
<div class="flex items-stretch gap-2 w-full px-3 py-2">
84+
<Tabs.Trigger value="general" aria-label={language.t("settings.tab.general")}>
85+
<Icon name="sliders" />
86+
</Tabs.Trigger>
87+
<Tabs.Trigger value="shortcuts" aria-label={language.t("settings.tab.shortcuts")}>
88+
<Icon name="keyboard" />
89+
</Tabs.Trigger>
90+
<Tabs.Trigger value="providers" aria-label={language.t("settings.providers.title")}>
91+
<Icon name="providers" />
92+
</Tabs.Trigger>
93+
<Tabs.Trigger value="models" aria-label={language.t("settings.models.title")}>
94+
<Icon name="models" />
95+
</Tabs.Trigger>
5196
</div>
52-
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
53-
<span>{language.t("app.name.desktop")}</span>
54-
<span class="text-11-regular">v{platform.version}</span>
55-
</div>
56-
</div>
97+
</Show>
5798
</Tabs.List>
5899
<Tabs.Content value="general" class="no-scrollbar">
59100
<SettingsGeneral />

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

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const EXAMPLES = [
9898
] as const
9999

100100
const NON_EMPTY_TEXT = /[^\s\u200B]/
101+
const compact = (value: string) => value.replace(/\s*\([^()]*\)\s*/g, " ").replace(/\s{2,}/g, " ").trim()
101102

102103
export const PromptInput: Component<PromptInputProps> = (props) => {
103104
const sdk = useSDK()
@@ -353,9 +354,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
353354
selection ??
354355
(item.selection
355356
? ({
356-
start: item.selection.startLine,
357-
end: item.selection.endLine,
358-
} satisfies SelectedLineRange)
357+
start: item.selection.startLine,
358+
end: item.selection.endLine,
359+
} satisfies SelectedLineRange)
359360
: undefined)
360361
if (!nextSelection) return []
361362

@@ -427,8 +428,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
427428
}
428429
}
429430

430-
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
431-
432431
const pick = () => fileInputRef?.click()
433432

434433
const setMode = (mode: "normal" | "shell") => {
@@ -1155,13 +1154,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
11551154
event.stopPropagation()
11561155
return
11571156
}
1158-
1159-
if (escBlur()) {
1160-
editorRef.blur()
1161-
event.preventDefault()
1162-
event.stopPropagation()
1163-
return
1164-
}
11651157
}
11661158

11671159
if (store.mode === "shell") {
@@ -1469,6 +1461,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
14691461
options={agentNames()}
14701462
current={local.agent.current()?.name ?? ""}
14711463
onSelect={local.agent.set}
1464+
label={(value) => (platform.platform === "ios" ? compact(value) : value)}
14721465
class="capitalize max-w-[160px] text-text-base"
14731466
valueClass="truncate text-13-regular text-text-base"
14741467
triggerStyle={control()}

packages/app/src/components/session/session-header.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const OPEN_APPS = [
4343
] as const
4444

4545
type OpenApp = (typeof OPEN_APPS)[number]
46-
type OS = "macos" | "windows" | "linux" | "unknown"
46+
type OS = "macos" | "windows" | "linux" | "ios" | "unknown"
4747

4848
const MAC_APPS = [
4949
{
@@ -111,7 +111,11 @@ const LINUX_APPS = [
111111
] as const
112112

113113
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
114-
if (platform.platform === "desktop" && platform.os) return platform.os
114+
if (platform.platform === "ios") return "ios"
115+
if (platform.platform === "desktop" && platform.os) {
116+
const o = platform.os
117+
if (o === "macos" || o === "windows" || o === "linux") return o
118+
}
115119
if (typeof navigator !== "object") return "unknown"
116120
const value = navigator.platform || navigator.userAgent
117121
if (/Mac/i.test(value)) return "macos"
@@ -207,6 +211,10 @@ export function SessionHeader() {
207211
focusTerminalById(id)
208212
}
209213

214+
const reload = () => {
215+
void platform.restart().catch((err: unknown) => showRequestError(language, err))
216+
}
217+
210218
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
211219
const [menu, setMenu] = createStore({ open: false })
212220
const [openRequest, setOpenRequest] = createStore({
@@ -415,6 +423,18 @@ export function SessionHeader() {
415423
</div>
416424
</Show>
417425
<div class="flex items-center gap-1">
426+
<Show when={platform.platform === "ios"}>
427+
<Tooltip placement="bottom" value={language.t("session.header.reload")}>
428+
<Button
429+
variant="ghost"
430+
class="titlebar-icon w-8 h-6 p-0 box-border shrink-0"
431+
onClick={reload}
432+
aria-label={language.t("session.header.reload")}
433+
>
434+
<Icon size="small" name="reset" />
435+
</Button>
436+
</Tooltip>
437+
</Show>
418438
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
419439
<StatusPopover />
420440
</Tooltip>

0 commit comments

Comments
 (0)