From bbdc30a181fefda541680ed9d04258247ecc5dc6 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Sun, 18 Oct 2026 03:21:38 +0900 Subject: [PATCH 01/13] fix(mobile): align Android dialogs with Material settings Issue: #2106 Agent-Task: codex/local/01a145e7-8579-7ec3-b958-d3bef00fc347 --- .../features/phone-android-interactions.md | 2 + apps/mobile/DESIGN.md | 8 + apps/mobile/app/_layout.tsx | 2 + apps/mobile/src/ui/chat/group-info.tsx | 15 +- .../src/ui/primitives/app-dialog-host.tsx | 154 ++++++++++++++++++ apps/mobile/src/ui/primitives/app-dialog.ts | 29 ++++ apps/mobile/src/ui/primitives/dialog-state.ts | 66 ++++++++ apps/mobile/src/ui/shell/create-button.tsx | 5 +- apps/mobile/src/ui/shell/host-drawer.tsx | 8 +- apps/mobile/src/ui/strings.ts | 2 + apps/mobile/test/app-dialog.test.ts | 134 +++++++++++++++ apps/mobile/test/create-button.test.tsx | 15 +- apps/mobile/test/style-guard.test.ts | 7 + changes/2106-android-app-dialogs.md | 11 ++ scripts/cli-ui-inventory.mjs | 1 + scripts/test/cli-ui-inventory.test.mjs | 6 + 16 files changed, 447 insertions(+), 18 deletions(-) create mode 100644 apps/mobile/src/ui/primitives/app-dialog-host.tsx create mode 100644 apps/mobile/src/ui/primitives/app-dialog.ts create mode 100644 apps/mobile/src/ui/primitives/dialog-state.ts create mode 100644 apps/mobile/test/app-dialog.test.ts create mode 100644 changes/2106-android-app-dialogs.md diff --git a/.agents/skills/verify-deepseekbot/features/phone-android-interactions.md b/.agents/skills/verify-deepseekbot/features/phone-android-interactions.md index 5087029a07..e79844e52e 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-android-interactions.md +++ b/.agents/skills/verify-deepseekbot/features/phone-android-interactions.md @@ -4,6 +4,8 @@ The Android development app uses native form sheets for Bot and group profiles, ## Sub-features +- `app-dialogs` opens Chats → Host picker → the designated QA Host's More menu → Unpair. Both menu and confirmation use the rounded app surface with Settings tokens, not the native Android Alert. Cancel, Android Back and tapping outside each close only the prompt and leave the drawer and saved pairing unchanged. Repeat light/dark × font1/1.3/2; all consequence text and actions remain reachable, scrolling when needed. From Chats +, verify the same menu style and the existing creation target; from a prepared QA group, open member removal and disband confirmations without submitting a destructive action on shared data. A separate disposable pairing/group is required to prove destructive success. Async refusal/retry and double-submit isolation have focused logical tests; record whether they were exercised natively. The same adapter preserves existing iOS system presentation; iOS runtime and TalkBack require their own qualification. + - `form-keyboard` requires a real soft keyboard for every editable route: pairing, Inbox custom answers, Activity answer cards, model preset names and search, Host appearance, routine creation/editing, group rename, expanded chat answers/drafts, browser/computer text entry and Chats search. Open a low field and require its native bounds and the following action to stay above the IME. The shared Android script accepts `--mode form-keyboard --title --action ` after opening the designated form. It preserves existing drafts and autofocus modals, refuses floating/hidden keyboards and fails overlap beyond one physical pixel of frame rounding. Repeat focus changes, multiline/caret growth, failed-save draft retention, keyboard dismissal/reopening and font1/1.3/2 in light/dark. Preserve canonical QA state and explicitly queue iOS qualification. - `model-settings` opens Settings → Models and presets for the selected Host's global default and shared preset library, and profile → Model for one Bot's Orchestrator/Assignment overrides. Search the chooser by provider/name, check a no-results query and clear it, save an available model and confirm the canonical Host route. Check Assignment default/allowed choices and reasoning effort, create/edit/apply a designated QA preset, and restore the Bot's original model plan. Read failures retain cached content with retry; failed saves retain the input. Repeat light/dark and system font1/1.3/2. Global writes and preset edits must not rewrite independent Bot plans; changing the Orchestrator must retain Assignment choices/efforts/notes. Restore all QA model settings after the journey. diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 80a4cf7f82..ab6d0cf91f 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -11,6 +11,14 @@ References: | Meta Muse | A Bot profile whose identity and memory are visible and editable. | | Slack | Workspace switching, a row of shortcut cards on Chats, an Activity feed with filters, and agent steps with a Stop button. | +## Android application dialogs + +- Application-owned menus and confirmations use `showAppDialog` and the root `AppDialogHost`, matching the accepted OCAT-inspired Settings surfaces. Do not call React Native `Alert.alert` directly from a screen or feature. iOS retains its existing system alerts/action sheets; operating-system permission prompts, photo pickers and the share sheet remain system-owned. +- Use `theme.overlay.fill`, the same `androidSettings.radius` as Settings groups, the shared scrim, semantic text and existing Button/SettingsRow primitives. Keep the panel centered with safe-area gutters and a bounded width; do not stretch a short confirmation to fill the screen or add heavy shadows. Contents scroll at large system text sizes; confirmation actions stack when horizontal space is limited. Never cap font scaling or truncate the consequence text. +- Choice menus show tappable rows with a separate Cancel pill. Destructive menu choices open a second confirmation describing the consequence, with explicit Cancel and destructive actions. Back, accessibility escape and backdrop taps dismiss only; none chooses an action. Keep the current drawer/page underneath. +- While an asynchronous action is pending, disable repeat submission and dismissal; close after success. On failure, keep the prompt and retry available. A menu opening a confirmation must not dismiss the newer prompt when its old callback finishes. Dialog requests are transient UI state, never a second persistence or authorization owner. +- Native checks include cancel/button/Back/backdrop paths, nested confirmation, light/dark and default/intermediate/maximum text. Verify the original pairing/group remains unchanged after cancellation, keyboard dismissal from focused search, and the actual rendering above an open drawer or profile. Record iOS and TalkBack qualification separately. + ## Activity query controls - Activity has two header entries: **Filter and sort** and **Search Activity**. Sorting is part of the same options sheet as category, unread-only, Bot and conversation filters. diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 6f26d37b73..f13f420bcc 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -7,6 +7,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import { usePushRouting } from '../src/host/push-routing'; import { StatisticsNotice } from '../src/ui/shell/statistics-notice'; import { KeyboardRoot } from '../src/ui/primitives/keyboard-frame'; +import { AppDialogHost } from '../src/ui/primitives/app-dialog-host'; import { sheetScreenOptions } from '../src/ui/primitives/sheet-screen'; import { useDesignTheme } from '../src/ui/design/theme'; import { t } from '../src/ui/strings'; @@ -97,6 +98,7 @@ export default function RootLayout() { /> + diff --git a/apps/mobile/src/ui/chat/group-info.tsx b/apps/mobile/src/ui/chat/group-info.tsx index 5c0bf3bf77..f8fe23c5d3 100644 --- a/apps/mobile/src/ui/chat/group-info.tsx +++ b/apps/mobile/src/ui/chat/group-info.tsx @@ -1,6 +1,7 @@ import { TextInput } from '../primitives/system-text'; import { useState, type ReactNode } from 'react'; -import { Alert, FlatList, Pressable, SectionList, View } from 'react-native'; +import { FlatList, Pressable, SectionList, View } from 'react-native'; +import { showAppDialog } from '../primitives/app-dialog'; import { KeyboardFormScroll } from '../primitives/keyboard-form'; import { errorText } from '../../host/bot-settings'; @@ -291,19 +292,18 @@ export function GroupInfoSheet({ const memberMenu = (member: GroupMember) => { const name = memberName(member); - Alert.alert(name, undefined, [ + showAppDialog(name, undefined, [ { text: t.groupOpenProfile, onPress: () => onOpenBot(member.slug) }, { text: t.groupRemove, style: 'destructive', onPress: () => - Alert.alert(t.groupRemoveConfirm(name), undefined, [ + showAppDialog(t.groupRemoveConfirm(name), undefined, [ { text: t.cancel, style: 'cancel' }, { text: t.groupRemove, style: 'destructive', - onPress: () => - void run(`member:${member.slug}`, () => actions.removeMember(member.slug)), + onPress: () => run(`member:${member.slug}`, () => actions.removeMember(member.slug)), }, ]), }, @@ -312,14 +312,15 @@ export function GroupInfoSheet({ }; const confirmDisband = () => - Alert.alert(t.groupDisbandConfirm(channel.name), t.groupDisbandDetail, [ + showAppDialog(t.groupDisbandConfirm(channel.name), t.groupDisbandDetail, [ { text: t.cancel, style: 'cancel' }, { text: t.groupDisband, style: 'destructive', onPress: () => - void run('disband', actions.disband).then((ok) => { + run('disband', actions.disband).then((ok) => { if (ok) onDisbanded(); + return ok; }), }, ]); diff --git a/apps/mobile/src/ui/primitives/app-dialog-host.tsx b/apps/mobile/src/ui/primitives/app-dialog-host.tsx new file mode 100644 index 0000000000..df3e8985d2 --- /dev/null +++ b/apps/mobile/src/ui/primitives/app-dialog-host.tsx @@ -0,0 +1,154 @@ +import { useRef, useSyncExternalStore } from 'react'; +import { + AccessibilityInfo, + Modal, + Platform, + Pressable, + ScrollView, + View, + type ViewInstance, +} from 'react-native'; +import { useReducedMotion } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useDesignTheme } from '../design/theme'; +import { androidSettings, modalRail, space } from '../design/tokens'; +import { t } from '../strings'; +import { Button } from './button'; +import { appDialogs } from './dialog-state'; +import { SettingsRow } from './settings-row'; +import { Text } from './text'; + +export function AppDialogHost() { + const shown = useSyncExternalStore( + appDialogs.subscribe, + appDialogs.getSnapshot, + appDialogs.getSnapshot, + ); + const theme = useDesignTheme(); + const insets = useSafeAreaInsets(); + const reducedMotion = useReducedMotion(); + const heading = useRef(null); + if (Platform.OS !== 'android' || shown === undefined) return null; + const choices = shown.actions.flatMap((action, index) => + action.style === 'cancel' ? [] : [{ action, index }], + ); + const cancel = shown.actions.find((action) => action.style === 'cancel'); + const menu = choices.length > 1; + const dismiss = () => appDialogs.dismiss(); + return ( + { + if (heading.current) AccessibilityInfo.sendAccessibilityEvent?.(heading.current, 'focus'); + }} + > + + + + + + + {shown.title} + + + {shown.message === undefined ? null : {shown.message}} + {shown.error === undefined ? null : ( + + {t.dialogFailed} {shown.error} + + )} + {menu ? ( + + {choices.map(({ action, index }) => ( + { + void appDialogs.choose(index); + } + : undefined + } + /> + ))} + + ) : null} + + {cancel === undefined ? null : ( + +