Skip to content

Commit fcdf01c

Browse files
feat(ios): preserve notifications and settings dialog
Keep the iOS fork behavior stable while upstream sync continues. This preserves native notification badge clearing, fullscreen Settings dialog behavior, and records the five iOS invariants that must survive future rebases. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent e5762af commit fcdf01c

15 files changed

Lines changed: 448 additions & 187 deletions

File tree

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Component, Show } from "solid-js"
33
import { Dialog } from "@opencode-ai/ui/dialog"
44
import { Tabs } from "@opencode-ai/ui/tabs"
55
import { Icon } from "@opencode-ai/ui/icon"
6+
import { Button } from "@opencode-ai/ui/button"
7+
import { useDialog } from "@opencode-ai/ui/context/dialog"
68
import { useLanguage } from "@/context/language"
79
import { usePlatform } from "@/context/platform"
810
import { SettingsGeneral } from "./settings-general"
@@ -14,14 +16,26 @@ export const DialogSettings: Component = () => {
1416
const language = useLanguage()
1517
const platform = usePlatform()
1618
const mobile = createMediaQuery("(max-width: 767px)")
19+
const isIOS = platform.platform === "ios"
20+
const dialog = useDialog()
1721

1822
return (
19-
<Dialog size="x-large" transition>
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+
2034
<Tabs
2135
orientation={mobile() ? "horizontal" : "vertical"}
2236
variant="settings"
2337
defaultValue="general"
24-
class="h-full settings-dialog"
38+
class={isIOS ? "h-full settings-dialog pt-14" : "h-full settings-dialog"}
2539
>
2640
<Tabs.List>
2741
<Show

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

Lines changed: 121 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -370,49 +370,128 @@ export const SettingsGeneral: Component = () => {
370370
</div>
371371
)
372372

373-
const NotificationsSection = () => (
374-
<div class="flex flex-col gap-1">
375-
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
376-
377-
<SettingsList>
378-
<SettingsRow
379-
title={language.t("settings.general.notifications.agent.title")}
380-
description={language.t("settings.general.notifications.agent.description")}
381-
>
382-
<div data-action="settings-notifications-agent">
383-
<Switch
384-
checked={settings.notifications.agent()}
385-
onChange={(checked) => settings.notifications.setAgent(checked)}
386-
/>
387-
</div>
388-
</SettingsRow>
389-
390-
<SettingsRow
391-
title={language.t("settings.general.notifications.permissions.title")}
392-
description={language.t("settings.general.notifications.permissions.description")}
393-
>
394-
<div data-action="settings-notifications-permissions">
395-
<Switch
396-
checked={settings.notifications.permissions()}
397-
onChange={(checked) => settings.notifications.setPermissions(checked)}
398-
/>
399-
</div>
400-
</SettingsRow>
373+
const NotificationsSection = () => {
374+
const platform = usePlatform()
375+
376+
const [pushState, { refetch }] = createResource(
377+
() => platform.platform === "ios",
378+
async (isIOS) => {
379+
if (!isIOS) return null
380+
try {
381+
return await platform.getPushState?.()
382+
} catch {
383+
return null
384+
}
385+
},
386+
)
387+
388+
const permissionStatus = createMemo(() => {
389+
const state = pushState()
390+
if (!state) return null
391+
const permission = state.permission
392+
const allowed = state.allowed ?? false
393+
394+
if (permission === "authorized")
395+
return { text: language.t("settings.general.notifications.status.authorized"), color: "text-green-600" }
396+
if (permission === "denied")
397+
return { text: language.t("settings.general.notifications.status.denied"), color: "text-red-600" }
398+
if (permission === "not-determined")
399+
return { text: language.t("settings.general.notifications.status.notDetermined"), color: "text-yellow-600" }
400+
return allowed
401+
? { text: language.t("settings.general.notifications.status.authorized"), color: "text-green-600" }
402+
: { text: language.t("settings.general.notifications.status.notAuthorized"), color: "text-text-weak" }
403+
})
401404

402-
<SettingsRow
403-
title={language.t("settings.general.notifications.errors.title")}
404-
description={language.t("settings.general.notifications.errors.description")}
405-
>
406-
<div data-action="settings-notifications-errors">
407-
<Switch
408-
checked={settings.notifications.errors()}
409-
onChange={(checked) => settings.notifications.setErrors(checked)}
410-
/>
411-
</div>
412-
</SettingsRow>
413-
</SettingsList>
414-
</div>
415-
)
405+
const handleRequestPermission = async () => {
406+
try {
407+
await platform.requestPushPermission?.()
408+
refetch()
409+
showToast({
410+
variant: "success",
411+
title: language.t("settings.general.notifications.permissionRequested"),
412+
})
413+
} catch (err) {
414+
showToast({
415+
variant: "error",
416+
title: language.t("common.requestFailed"),
417+
description: String(err),
418+
})
419+
}
420+
}
421+
422+
const handleOpenSettings = () => {
423+
platform.openSystemSettings?.()
424+
}
425+
426+
return (
427+
<div class="flex flex-col gap-1">
428+
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
429+
430+
<SettingsList>
431+
<Show when={platform.platform === "ios"}>
432+
<SettingsRow
433+
title={language.t("settings.general.notifications.systemPermission.title")}
434+
description={language.t("settings.general.notifications.systemPermission.description")}
435+
>
436+
<div class="flex items-center gap-2">
437+
<Show when={permissionStatus()}>
438+
{(status) => <span class={`text-12-medium ${status().color}`}>{status().text}</span>}
439+
</Show>
440+
<Show
441+
when={permissionStatus()?.text === language.t("settings.general.notifications.status.notDetermined")}
442+
>
443+
<Button size="small" onClick={handleRequestPermission}>
444+
{language.t("settings.general.notifications.requestPermission")}
445+
</Button>
446+
</Show>
447+
<Show when={permissionStatus()?.text === language.t("settings.general.notifications.status.denied")}>
448+
<Button size="small" onClick={handleOpenSettings}>
449+
{language.t("settings.general.notifications.openSettings")}
450+
</Button>
451+
</Show>
452+
</div>
453+
</SettingsRow>
454+
</Show>
455+
456+
<SettingsRow
457+
title={language.t("settings.general.notifications.agent.title")}
458+
description={language.t("settings.general.notifications.agent.description")}
459+
>
460+
<div data-action="settings-notifications-agent">
461+
<Switch
462+
checked={settings.notifications.agent()}
463+
onChange={(checked) => settings.notifications.setAgent(checked)}
464+
/>
465+
</div>
466+
</SettingsRow>
467+
468+
<SettingsRow
469+
title={language.t("settings.general.notifications.permissions.title")}
470+
description={language.t("settings.general.notifications.permissions.description")}
471+
>
472+
<div data-action="settings-notifications-permissions">
473+
<Switch
474+
checked={settings.notifications.permissions()}
475+
onChange={(checked) => settings.notifications.setPermissions(checked)}
476+
/>
477+
</div>
478+
</SettingsRow>
479+
480+
<SettingsRow
481+
title={language.t("settings.general.notifications.errors.title")}
482+
description={language.t("settings.general.notifications.errors.description")}
483+
>
484+
<div data-action="settings-notifications-errors">
485+
<Switch
486+
checked={settings.notifications.errors()}
487+
onChange={(checked) => settings.notifications.setErrors(checked)}
488+
/>
489+
</div>
490+
</SettingsRow>
491+
</SettingsList>
492+
</div>
493+
)
494+
}
416495

417496
const SoundsSection = () => (
418497
<div class="flex flex-col gap-1">

packages/app/src/context/notification.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
129129
list: [] as Notification[],
130130
}),
131131
)
132+
const unseen = createMemo(() => store.list.reduce((sum, item) => sum + (item.viewed ? 0 : 1), 0))
132133
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
133134

134135
const meta = { pruned: false, disposed: false }
@@ -193,14 +194,21 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
193194
})
194195
})
195196

197+
createEffect(() => {
198+
if (!ready()) return
199+
void platform.setNotificationBadge?.(unseen())
200+
})
201+
196202
const append = (notification: Notification) => {
197203
const list = pruneNotifications([...store.list, notification])
198204
const keep = new Set(list)
199205
const removed = store.list.filter((n) => !keep.has(n))
200206

201207
batch(() => {
202208
if (keep.has(notification)) appendToIndex(notification)
203-
removed.forEach((n) => removeFromIndex(n))
209+
removed.forEach((n) => {
210+
removeFromIndex(n)
211+
})
204212
setStore("list", list)
205213
})
206214
}

packages/app/src/context/platform.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
88
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
99
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
1010
type UpdateInfo = { updateAvailable: boolean; version?: string }
11+
type PushState = { permission?: string; allowed?: boolean } | null
1112

1213
export type Platform = {
1314
/** Platform discriminator */
@@ -93,6 +94,17 @@ export type Platform = {
9394

9495
/** Share content (mobile only) */
9596
share?(data: { text?: string; url?: string }): Promise<boolean>
97+
98+
/** Get push notification state (iOS only) */
99+
getPushState?(): Promise<PushState>
100+
101+
/** Request push notification permission (iOS only) */
102+
requestPushPermission?(): Promise<PushState>
103+
104+
/** Open system settings (iOS only) */
105+
openSystemSettings?(): void
106+
107+
setNotificationBadge?(count: number): Promise<void>
96108
}
97109

98110
export type DisplayBackend = "auto" | "wayland"

packages/app/src/context/settings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ const defaultSettings: Settings = {
108108
notifications: {
109109
agent: true,
110110
permissions: true,
111-
errors: false,
111+
errors: true,
112112
},
113113
sounds: {
114114
agentEnabled: true,

packages/app/src/i18n/en.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ export const dict = {
225225
"common.disconnect": "Disconnect",
226226
"common.continue": "Continue",
227227
"common.submit": "Submit",
228+
"common.done": "Done",
228229
"common.save": "Save",
229230
"common.saving": "Saving...",
230231
"common.default": "Default",
@@ -709,6 +710,7 @@ export const dict = {
709710

710711
"settings.section.desktop": "Desktop",
711712
"settings.section.server": "Server",
713+
"settings.title": "Settings",
712714
"settings.tab.general": "General",
713715
"settings.tab.shortcuts": "Shortcuts",
714716
"settings.desktop.section.wsl": "WSL",
@@ -815,6 +817,30 @@ export const dict = {
815817
"Show system notification when the agent is complete or needs attention",
816818
"settings.general.notifications.permissions.title": "Permissions",
817819
"settings.general.notifications.permissions.description": "Show system notification when a permission is required",
820+
"settings.general.notifications.systemPermission.title": "System Notification Permission",
821+
"settings.general.notifications.systemPermission.description": "Allow the app to send system notifications",
822+
"settings.general.notifications.status.authorized": "Authorized",
823+
"settings.general.notifications.status.denied": "Denied",
824+
"settings.general.notifications.status.notDetermined": "Not Set",
825+
"settings.general.notifications.status.notAuthorized": "Not Authorized",
826+
"settings.general.notifications.requestPermission": "Request Permission",
827+
"settings.general.notifications.openSettings": "Open Settings",
828+
"settings.general.notifications.permissionRequested": "Notification permission requested",
829+
"settings.general.notifications.test.title": "Test Notifications",
830+
"settings.general.notifications.test.description": "Send test notifications to verify functionality",
831+
"settings.general.notifications.test.sent": "Test notification sent",
832+
"settings.general.notifications.test.complete.button": "Test Session Complete",
833+
"settings.general.notifications.test.complete.title": "Session Complete",
834+
"settings.general.notifications.test.complete.description": "Your session has completed, tap to view results",
835+
"settings.general.notifications.test.error.button": "Test Error Notification",
836+
"settings.general.notifications.test.error.title": "Session Error",
837+
"settings.general.notifications.test.error.description": "An error occurred during session execution",
838+
"settings.general.notifications.test.permission.button": "Test Permission Request",
839+
"settings.general.notifications.test.permission.title": "Permission Required",
840+
"settings.general.notifications.test.permission.description": "Session needs your permission to continue",
841+
"settings.general.notifications.test.question.button": "Test Question Notification",
842+
"settings.general.notifications.test.question.title": "Answer Needed",
843+
"settings.general.notifications.test.question.description": "Claude has a question that needs your answer",
818844
"settings.general.notifications.errors.title": "Errors",
819845
"settings.general.notifications.errors.description": "Show system notification when an error occurs",
820846

packages/app/src/i18n/zh.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export const dict = {
244244
"common.disconnect": "断开连接",
245245
"common.continue": "提交",
246246
"common.submit": "提交",
247+
"common.done": "完成",
247248
"common.save": "保存",
248249
"common.saving": "保存中...",
249250
"common.default": "默认",
@@ -609,6 +610,7 @@ export const dict = {
609610

610611
"settings.section.desktop": "桌面",
611612
"settings.section.server": "服务器",
613+
"settings.title": "设置",
612614

613615
"settings.tab.general": "通用",
614616
"settings.tab.shortcuts": "快捷键",
@@ -711,6 +713,30 @@ export const dict = {
711713
"settings.general.notifications.agent.description": "当智能体完成或需要注意时显示系统通知",
712714
"settings.general.notifications.permissions.title": "权限",
713715
"settings.general.notifications.permissions.description": "当需要权限时显示系统通知",
716+
"settings.general.notifications.systemPermission.title": "系统通知权限",
717+
"settings.general.notifications.systemPermission.description": "允许应用发送系统通知",
718+
"settings.general.notifications.status.authorized": "已授权",
719+
"settings.general.notifications.status.denied": "已拒绝",
720+
"settings.general.notifications.status.notDetermined": "未设置",
721+
"settings.general.notifications.status.notAuthorized": "未授权",
722+
"settings.general.notifications.requestPermission": "请求权限",
723+
"settings.general.notifications.openSettings": "打开设置",
724+
"settings.general.notifications.permissionRequested": "已请求通知权限",
725+
"settings.general.notifications.test.title": "测试通知",
726+
"settings.general.notifications.test.description": "发送测试通知以验证功能",
727+
"settings.general.notifications.test.sent": "测试通知已发送",
728+
"settings.general.notifications.test.complete.button": "测试 Session 完成",
729+
"settings.general.notifications.test.complete.title": "Session 完成",
730+
"settings.general.notifications.test.complete.description": "您的 Session 已完成,点击查看结果",
731+
"settings.general.notifications.test.error.button": "测试错误通知",
732+
"settings.general.notifications.test.error.title": "Session 出错",
733+
"settings.general.notifications.test.error.description": "Session 执行时发生错误,需要您的注意",
734+
"settings.general.notifications.test.permission.button": "测试权限请求",
735+
"settings.general.notifications.test.permission.title": "需要权限",
736+
"settings.general.notifications.test.permission.description": "Session 需要您授予权限才能继续",
737+
"settings.general.notifications.test.question.button": "测试提问通知",
738+
"settings.general.notifications.test.question.title": "需要回答",
739+
"settings.general.notifications.test.question.description": "Claude 有问题需要您回答",
714740
"settings.general.notifications.errors.title": "错误",
715741
"settings.general.notifications.errors.description": "发生错误时显示系统通知",
716742
"settings.general.sounds.agent.title": "智能体",

packages/app/src/index.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,28 @@
2626
width: auto;
2727
}
2828
}
29+
30+
/* iOS 全屏 Settings 对话框 */
31+
[data-component="dialog"].ios-fullscreen-dialog {
32+
align-items: stretch;
33+
justify-content: flex-start;
34+
}
35+
36+
[data-component="dialog"].ios-fullscreen-dialog [data-slot="dialog-container"] {
37+
width: 100vw;
38+
height: 100dvh;
39+
max-width: 100vw;
40+
max-height: 100dvh;
41+
}
42+
43+
[data-component="dialog"].ios-fullscreen-dialog [data-slot="dialog-content"] {
44+
width: 100vw;
45+
min-height: 100dvh;
46+
height: 100dvh;
47+
max-width: 100vw;
48+
max-height: 100dvh;
49+
border-radius: 0;
50+
margin: 0;
51+
box-shadow: none;
52+
}
2953
}

0 commit comments

Comments
 (0)