diff --git a/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-dark.png b/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-dark.png
new file mode 100644
index 0000000000..c7cc2dfb99
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-light.png b/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-light.png
new file mode 100644
index 0000000000..bfa61b7e1c
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-1-chats-dark.png b/.github/pr-screenshots/2333-ios-hig/after-1-chats-dark.png
new file mode 100644
index 0000000000..3a3599e1e4
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-1-chats-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-1-chats-light.png b/.github/pr-screenshots/2333-ios-hig/after-1-chats-light.png
new file mode 100644
index 0000000000..5c926426fc
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-1-chats-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-dark.png b/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-dark.png
new file mode 100644
index 0000000000..0d8504e044
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-light.png b/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-light.png
new file mode 100644
index 0000000000..a7692dac1b
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-2-search-dark.png b/.github/pr-screenshots/2333-ios-hig/after-2-search-dark.png
new file mode 100644
index 0000000000..2ff3f26ce4
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-2-search-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-2-search-light.png b/.github/pr-screenshots/2333-ios-hig/after-2-search-light.png
new file mode 100644
index 0000000000..eff41949e1
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-2-search-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-dark.png b/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-dark.png
new file mode 100644
index 0000000000..9ee57d9c36
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-light.png b/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-light.png
new file mode 100644
index 0000000000..6b5c87494a
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-dark.png b/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-dark.png
new file mode 100644
index 0000000000..73e9e01ba1
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-light.png b/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-light.png
new file mode 100644
index 0000000000..772e008fa4
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-5-unpair-dark.png b/.github/pr-screenshots/2333-ios-hig/after-5-unpair-dark.png
new file mode 100644
index 0000000000..86f56f705c
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-5-unpair-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-5-unpair-light.png b/.github/pr-screenshots/2333-ios-hig/after-5-unpair-light.png
new file mode 100644
index 0000000000..d12297b133
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-5-unpair-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-dark.png b/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-dark.png
new file mode 100644
index 0000000000..d06e5d1f6c
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-light.png b/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-light.png
new file mode 100644
index 0000000000..84d5533aa8
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-dark.png b/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-dark.png
new file mode 100644
index 0000000000..50a813c71f
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-light.png b/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-light.png
new file mode 100644
index 0000000000..b071f99a3a
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-dark.png b/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-dark.png
new file mode 100644
index 0000000000..07ebfd8d93
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-light.png b/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-light.png
new file mode 100644
index 0000000000..f02560367e
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-light.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-dark.png b/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-dark.png
new file mode 100644
index 0000000000..0ffa4f82f2
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-dark.png differ
diff --git a/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-light.png b/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-light.png
new file mode 100644
index 0000000000..1401086715
Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-light.png differ
diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md
index 6a68b98c3e..5b2b9dabcc 100644
--- a/apps/mobile/DESIGN.md
+++ b/apps/mobile/DESIGN.md
@@ -19,13 +19,12 @@ References:
On both platforms the system's own design and components are the standard: Apple's Human Interface Guidelines on iOS (`ios-hig` skill), Material 3 on Android (`android-material` skill). Where the platform has a component for the job, use it (native navigation, `@expo/ui` SwiftUI views on iOS, `@expo/ui` Jetpack Compose views on Android) and keep its default look and behaviour; do not draw a React Native look-alike or restyle it into a house style. A custom surface is allowed only where no native component fits, and its reason is written in this file first.
-Older rules below that prescribe a custom surface where a native component exists (for example the OCAT-inspired Android choice menus, or iOS menus shown as alerts) describe the current implementation, not the target. Change them to the native component when the screen is next touched, and update the rule in the same change.
+Older rules below that prescribe a custom surface where a native component exists describe the current implementation, not the target. Change them to the native component when the screen is next touched, and update the rule in the same change.
-## Android application dialogs
+## 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. On iOS `showAppDialog` shows the system alert and is for confirmations only; iOS menus follow [iOS menus and native controls](#ios-menus-and-native-controls). 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 separate filled tonal buttons for same-priority actions, with icons, visible containers and pressed/ripple feedback. Use the shared accent container and readable on-container label; do not leave key actions as unbounded text. Omit the redundant Cancel button in these menus: backdrop, Back and accessibility escape dismiss without choosing. Use colors rather than button outlines. Destructive menu choices use the shared danger fill and open a second confirmation describing the consequence, with a low-emphasis explicit Cancel and a destructive action. Keep the current drawer/page underneath. This adapts the [Android button hierarchy](https://developer.android.com/develop/ui/compose/quick-guides/content/create-button) to our OCAT-inspired app surfaces; a menu's equivalent actions are not all primary final confirmations.
+- Application-owned confirmations and choice menus use `showAppDialog` (or `confirmDestructive` / `showActionMenu`) and the root `AppDialogHost`. Do not call React Native `Alert.alert` directly from a screen or feature. Per Native first, iOS shows system alerts (menus follow [iOS menus and native controls](#ios-menus-and-native-controls)), and Android shows the Material 3 `AlertDialog` from `@expo/ui/jetpack-compose`, themed by `seedColor` = accent. Operating-system permission prompts, photo pickers and the share sheet remain system-owned.
+- A confirmation puts Cancel in the dialog's dismiss slot and the action in its confirm slot as text buttons; a destructive action's label uses `danger`. A menu with no anchor (a long press, `showActionMenu`) is never a dialog: on Android it is a Material 3 modal bottom sheet listing its title and one `ListItem` per command, a divider before the destructive command in `danger`, dismissed by Back, scrim or swipe. A menu from a ⋯ button is an anchored dropdown (`ActionMenuButton`), with the same divider before its destructive item. A confirmation always states the consequence in its message, not only a title. Destructive menu choices open a second confirmation describing the consequence. Back and a tap outside dismiss without choosing. The system dialog handles width, scrolling at large text sizes, focus and TalkBack.
- 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.
@@ -39,7 +38,7 @@ This applies [Native first](#native-first) to iOS menus, following the Apple HIG
- **Exceptions stay as recorded.** The chat bubble menu (reaction island and action card, see Chat) and app-owned tab presentation (ADR-0235) are deliberate custom surfaces. Any new custom surface where the system has a control needs the reason written here first.
- Native menus inherit Liquid Glass, Dynamic Type, VoiceOver and Reduce Transparency from the system; do not restyle them. Every action in a context menu is also reachable from a visible control or an accessibility action.
-Current iOS gaps: the Host drawer's ⋯ menu (`src/ui/shell/host-drawer.tsx`) and the group member ⋯ menu (`src/ui/chat/group-info.tsx`) still open a system alert listing their actions; both move to native pull-down menus.
+Build a ⋯ menu with `ActionMenuButton` (`src/ui/primitives/action-menu-button`): a SwiftUI pull-down `Menu` with SF Symbols on iOS and a Material 3 `DropdownMenu` on Android, both anchored to the button. The Host drawer and the group member ⋯ menus use it. Long press on a row still opens `showActionMenu` (an action sheet on iOS).
## Activity query controls
@@ -73,10 +72,11 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- Banner: the accent band (default), a solid colour (ten swatches or a typed hex), a two-stop gradient (six presets or typed stops), or a picture under a dark scrim.
- A custom colour goes through the accent band's steps, so dark mode mutes it the same way. The ink is white or near-black, whichever reads on every stop; a picture always takes white ink with a soft shadow.
- **Workspace drawer** switches between paired Computers and Cloud organization workspaces. It slides in from the left, covers about 85% of the width and dims the list behind it.
+ - On iOS 26, unless Reduce Transparency is on, it is a floating Liquid Glass sidebar: inset `sidebar.inset` from the screen's left, top and bottom edges with `sidebar.radius` corners concentric with the display. Its rows are clear; a pressed row and the current Computer or workspace show a `radius.lg` highlight inset from the sidebar's edges, as iOS sidebars do. The pairing card is frosted. A Computer's ⋯ opens a native pull-down menu anchored to it (Customize, Show this computer, Unpair); a long press on the row still opens the action sheet. Older iOS, Reduce Transparency and Android keep the opaque full-height drawer.
- Rounded **Computers / Cloud** tabs are the first row above one scrolling list, with no duplicate title or close button. Opening the drawer selects the current route's tab; switching tabs only changes the list, never the active computer or cloud session. Android uses opaque raised chips; iOS uses the existing glass material with Reduce Transparency, older OS and large-text fallbacks. Tabs expose their selected state to accessibility.
- One contextual plus action sits directly below the tabs, above the scrolling list: Computers opens **Pair a new computer** directly; Cloud opens **Join by link or QR** directly. There is no mixed action chooser or stacked icon group. Organization creation and service configuration live under **Manage cloud services** on the Cloud tab; Notifications stays at the bottom of a fixed footer on both tabs; Cloud service management sits above it. Only the directory scrolls.
- A cloud service is an instance/address; its organization is a cloud workspace; a channel belongs to that organization. An invitation already identifies its service and scope: workspace membership or access to only one channel. The existing preview and acceptance flow keeps this distinction explicit. No separate server-selection step or new authorization store is introduced.
- - Each Host row has its Computer avatar tile (the same tile as the Host chip; the current Host's tile has an `fg` ring at radius `lg`, concentric with the tile, as Slack marks the current workspace), its name, an online/offline/checking dot and label, then one **count chip** on the right (see Count chip below). It shows **Needs you** when anything waits for the Human, because that matters most; otherwise it shows **Unread**, the count of unread conversations. A ⋯ button at the end, or a long press on the row, opens the Host's menu: Customize (name, avatar and banner), Show this computer, and Unpair, which asks for confirmation.
+ - Each Host row has its Computer avatar tile (the same tile as the Host chip; the current Host's tile has an `fg` ring at radius `lg`, concentric with the tile, as Slack marks the current workspace), its name, an online/offline/checking dot and label, then one **count chip** on the right (see Count chip below). It shows **Needs you** when anything waits for the Human, because that matters most; otherwise it shows **Unread**, the count of unread conversations. A ⋯ button at the end, or a long press on the row, opens the Host's menu: Customize (name, avatar and banner), Show this computer, and Unpair, which asks for confirmation. The ⋯ menu is the system's own anchored menu: a SwiftUI pull-down on iOS and a Material 3 dropdown on Android, with Unpair in the danger colour.
- The Host chip opens it, and so does a swipe right from the left edge of a tab's root screen. The edge swipe is never attached inside a pushed chat, where it stays the system Back.
- Tapping a Host switches to it and closes the drawer. Tapping the backdrop, swiping left or using the accessibility escape gesture closes it. Opening focuses the selected tab.
- It takes screen-reader focus when it opens and returns focus to the Host chip when it closes. With Reduce Motion on it appears without sliding.
@@ -85,8 +85,8 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- Pairing accepts an optional local name using the existing computer appearance store; direct-link pairing can keep the original name, then rename in Customize computer. Cloud import accepts a display name; Manage cloud services can rename it later. These names affect only this phone and never change Host identity, organization names, trusted endpoints or sessions.
- **Tabs** remain **Chats** and **Activity**, using the same Expo Router JS tab navigator on all platforms, with the existing route stacks above it. Search arrives with its working N3 slice.
- A chat or creation form is pushed above the tabs, hiding the bar. Each list reserves the measured bar height plus safe-area gap; scroll hide/reveal stays on the UI thread.
- - The floating navigation island sits beside a separate circular **+**, equal-height at default text size. At larger text sizes, the island can grow and its labels wrap; the icon-only circle keeps its normal touch size and aligns to the bottom so it does not consume the labels' width. iOS uses real SwiftUI navigation and plus/close buttons. On iOS 26 the selected tab and circular action use native glass button styles over the shared glass island; earlier iOS uses bordered controls. System Reduce Transparency remains authoritative. Native text keeps semantic Dynamic Type. This is app-owned tab presentation, not `NativeTabs` or a second navigation stack (ADR-0235).
- - Android retains the opaque OCAT raised rounded rectangle, hairline edge, faint token shadow and selected icon tile. An inactive Activity destination replaces its icon with the capped pending count; the count owner and selected Activity behavior are unchanged. iOS shows the same scoped count beside its icon. Essential labels grow with system text.
+ - The floating navigation island sits beside a separate circular **+**, equal-height at default text size. At larger text sizes, the island can grow; on iOS each tab label stays on one line and shrinks to fit (`navigationBar.labelMinScale`) instead of breaking inside a word; the icon-only circle keeps its normal touch size and aligns to the bottom so it does not consume the labels' width. iOS uses real SwiftUI navigation and plus/close buttons. On iOS 26 the selected tab and circular action use native glass button styles over the shared glass island; earlier iOS uses bordered controls. System Reduce Transparency remains authoritative. Native text keeps semantic Dynamic Type. This is app-owned tab presentation, not `NativeTabs` or a second navigation stack (ADR-0235).
+ - Android retains the opaque OCAT raised rounded rectangle, hairline edge, faint token shadow and selected icon tile. The Activity destination shows the bell **or** the capped pending count, never both, on iOS and Android alike: when Activity is not selected and something is pending, the count replaces the bell; when nothing is pending, or Activity is selected (its feed already shows the items), the bell returns. Android cross-fades the swap. The accessible label always carries the count. Essential labels grow with system text.
- **+** opens a modal action panel in the island position, with close in the circular position. Back, accessibility escape and backdrop only dismiss it. The panel is always a scrollable vertical menu: each full-width row has a leading icon and left-aligned wrapping label. Target choices use the same row treatment. On iOS 26 the panel uses a native rounded-rectangle glass backdrop, including at larger text sizes; Reduce Transparency and older iOS keep an opaque raised surface. This panel opt-in does not change the existing multiline drawer-title fallback. Android retains its opaque OCAT surface. The panel still uses the existing modal fade, not a native glass morph transition.
- Computer: new Channel/new Bot. Owner Organization: new Channel/invite. All: new Channel/new Bot/join with link or QR, followed by explicit destination choice where needed. Member/guest workspaces do not gain owner controls. Pairing and Organization administration stay in the drawer.
- Creation binds the chosen Host ID or cloud account scope plus Organization ID. Cloud rechecks current identity and ownership before opening/submitting; stale scope refuses. Existing Host full-client and CloudClient idempotent mutations remain the business owners. Dismissal never starts a background operation.
@@ -95,7 +95,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- Conversation rows keep the title and timestamp on one horizontal line at every system text size. The title takes the remaining width and truncates with tail ellipsis; the timestamp keeps its own single-line slot. Preview/status and count stay on a second line. A conversation without a timestamp does not reserve an empty metadata line. Preserve native font scaling and the complete accessible title/preview.
- The header has a compact Host title on the left and a Settings gear on the right, on both iOS and Android. This row stays horizontal at every system text size: the Host chip shrinks, its name occupies one line with tail ellipsis, and the Settings target stays at the trailing edge. Keep native font scaling and the full accessible Host name; both targets retain the platform minimum touch size. Other Host-name presentations may wrap. The Host picker stays mounted between visits; its translation follows a critically damped UI-thread spring and its scrim is clamped. Hidden contents leave the accessibility tree.
- **+** sits at the right of the channel search field. It opens the native **New PersonaBot** / **New Channel** menu and uses the app's create flow, or the full client where the app has none yet.
- - A **search field** at the top of the list panel, inset so its left edge lines up with the row avatars (`AVATAR_EDGE`), with corners concentric with the panel's rounded top: the field uses `radius.md` and the panel `radius.md + AVATAR_EDGE`. It scrolls away with the list. There are no shortcut cards: Activity already gathers what needs the Human.
+ - A **search field** floats just above the bottom navigation island, within reach of the thumb, as iOS 26 places search. It is a capsule on the shared navigation glass (`DrawerMaterial`; opaque raised on older iOS, Reduce Transparency and Android) inset like the island, rides above the keyboard while typing, and hides and returns with the navigation on scroll. While it is focused or holds a query the navigation stays visible, and a circular close button beside it (the island's glass circle) clears the query and dismisses the keyboard. The field is a native UIKit text field with the system clear button, not a SwiftUI field, which has none. The list reserves the field's height under its last row. There are no shortcut cards: Activity already gathers what needs the Human.
- One list of PersonaBot DMs and Channels together, grouped by the Host's **Channel sections** as on the desktop sidebar:
- Sections come in the desktop's order. Ungrouped DMs and Channels sit between them in the desktop's top-level order.
- A section header shows its name and Channel count and collapses or expands on tap. A collapsed section still shows a "Needs you" mark when something inside needs the Human. The collapse state is kept on the phone, per Host.
@@ -115,7 +115,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- A row's title is `category · Bot`; a reply keeps its text on a second line, and a handled item collapses to one line: `category · Bot · summary`.
- Opening an item jumps to the card in its chat; an unread conversation opens its DM or group chat.
- A floating header follows the chat chrome: title on the left, sorting, filtering and Activity search on the right. Filters live in a sheet, with category, Bot, Channel, unread-message and clear controls; no permanent filter chips. A nonblank Activity search hides sections with no matches; an entirely empty result shows one no-results message. Clearing the query restores normal empty sections. Sections with another page remain reachable. Search uses safe Activity summaries and Channel/Bot labels on the current Host before pagination. Handled history stays excluded unless selected. Default ordering is oldest action first, mentions before other personal messages, then newest unread conversation; explicit newest/oldest overrides personal-message priority. Each section can load another bounded page. Hosts without query version 1 show an update error. Complete tab totals remain a subsequent slice of [#1954](https://github.com/BotHarness/DeepSeekBot/issues/1954); when another page exists, the screen identifies counts as loaded items.
-- **Settings** opens from the gear as an immediate-settings sheet: one handle, fixed header and scrolling body; swipe or activation of the handle dismisses while completed settings remain. Child pages use Back within the same sheet. Android retains its fixed accessible handle, large detent and 28dp top corners. On iOS, native modal presentation keeps nested content visible; the shared frame supplies one handle in the native vertical dismissal region, and the native header owns only the root title. The handle is also a Close button with a platform-sized touch target, explicit accessibility activation and escape; no extra X is shown. Child Back still pops only one page. Actual screen-reader focus and gestures require separate native acceptance. There is no Done/save step for immediate preferences. Settings reuses row and section components, in groups:
+- **Settings** opens from the gear as an immediate-settings sheet: one handle, fixed header and scrolling body; swipe or activation of the handle dismisses while completed settings remain. Child pages use Back within the same sheet. Android retains its fixed accessible handle, large detent and 28dp top corners. On iOS, native modal presentation keeps nested content visible; the shared frame supplies one handle in the native vertical dismissal region, and the native header owns only the root title. The handle is also a Close button with a platform-sized touch target, explicit accessibility activation and escape; no extra X is shown. Child Back still pops only one page. Actual screen-reader focus and gestures require separate native acceptance. There is no Done/save step for immediate preferences. Settings reuses row and section components, in groups. Each navigable or action row leads with a system icon (`RowIcon`: an SF Symbol on iOS, a Material Symbol on Android), monochrome `fg`, or `accent`/`danger` to match the row's tone, so a row's purpose reads before its label:
- **Models and presets** configures this Host's existing global default model and shared Model Preset library. Bots without an override follow that default. Presets can be created, renamed and edited for Orchestrator, Assignment default, allowed Assignment models and reasoning effort, through the existing Host methods. Editing a preset does not rewrite Bots that already applied a copy. Model selection pushes inside the same drawer, searches model/provider names and IDs, virtualizes the catalog, and keeps missing-key models visible but unavailable for selection. Saved settings and read errors are scoped to the Host and Bot/preset; cached content remains visible during refresh or failure, with retry.
- Android groups use `androidSettings` tokens: 28dp corners, 16dp row padding, 56dp minimum single-line rows and 72dp minimum supporting rows that expand with system text. Section headings use the accent role, supporting text uses the body ramp and muted role, and separators are inset 16dp. Selected choices use the layer's chosen fill plus accent text and the existing checkmark. Explicit navigation rows show a chevron; destructive and copy actions do not imply navigation. Android switches use SDK 58 Expo UI's native Material 3 control inside a bounded Host; state and persistence remain owned by existing preferences. iOS retains its existing rows and switch adapter.
- **Usage** comes first, as one card for the current Host, like a fitness app's week: "Usage", "Last 7 days" and a chevron, the 7-day token total in `display`, and a bar per day (`DayBars`). An idle Host shows a quiet week of stubs, and a failed read shows a danger line with Try again under the card. Tapping it pushes the **Usage** screen:
@@ -161,7 +161,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- The chip is a full-round pill on a frosted `raised` (the fill at `glass.tint` opacity) with a hairline `edge`, so messages show faintly through it as they pass under. In one row it holds the Bot's avatar (no state ring), its name, and a state dot: `success` when idle, `accent` while thinking or working, `warning` when it waits for the Human, `danger` when blocked, `muted` when paused. The state is read out in the chip's accessibility label.
- The chip's padding is concentric with its corners: it is `theme.touch` tall, so its corner radius is half that, and the avatar and the dot each sit that same distance from their end of the pill (padding = corner radius − the item's own radius).
- Back and the computer button are round buttons of `theme.touch`, on the same frosted `raised` with a hairline `edge`, on every platform. Both icons are `fg`, never the Bot's accent.
- - Messages run under the header and the status bar and fade there: a light static gradient from `base` to transparent (`edgeFade.stops`, at most 80%) covers the header and `edgeFade.top` below it, on every platform. At rest the list keeps the header's height free above the oldest message. UIKit's scroll edge effects stay off (`scrollEdgeEffects` all `hidden`). There is no live blur, because a blur view over a scrolling list re-renders every frame and needs a native module.
+ - Messages run under the header and the status bar and fade there: a light static gradient from `base` to transparent (`edgeFade.stops`, at most 80%) covers the header and `edgeFade.top` below it, on every platform. At rest the list keeps the header's height free above the oldest message. UIKit's scroll edge effects stay off (`scrollEdgeEffects` all `hidden`), because the header is our own. On iOS 26 with Reduce Transparency off, both fades also carry a soft scroll edge like HIG Scroll views: a `thin` system material (`EdgeMaterial`, drawn past the safe area) at full strength under the status bar, header and floating composer, fading out over `edgeFade.top` / `edgeFade.bottom` beyond them, so messages blur as well as fade and no sharp text shows through those bars. The bottom fade spans the composer's measured height plus `edgeFade.bottom`. The blur is composited by the system, so the list does not re-render. The header's back button, title capsule and trailing button sit on Liquid Glass there instead of the frosted `raised` fill. Older iOS, Reduce Transparency and Android keep the colour fade and frosted fill only.
- The list runs to the bottom of the screen, and the composer floats over it with no bar of its own; its pill keeps an opaque `surface` fill, so nothing shows through where the Human types; messages pass around and under it. The bottom of the screen fades out with the same light gradient over the composer and `edgeFade.bottom` above it. The list keeps that much space below the newest message, so at rest it sits just above the composer; scrolled up, messages pass behind the composer.
- Messages:
- Activity rows show Bot DMs, posts elsewhere, memory commits, Bot creation, departures and stopped turns as one left-aligned meta line with a kind icon and tail ellipsis. They use the same `bubbleRow(true)` inset and single `space.x4` (12pt) top gap as a new message group, with no bottom padding or vertically centred minimum-height box: bubble → activity, activity → activity and activity → new message group keep the same boundary gap. Icons and avatars match the meta line height. In groups, the actor avatar occupies the existing speaker gutter, followed by the icon at the bubble's left edge and the description. In a Human DM, omit the current Bot's avatar and redundant name; Bot-DM actions show the other Bot's avatar after the icon. Missing avatars use the person icon.
@@ -230,7 +230,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- The group's photo (or `#` on a rounded square), its name and member count.
- A **Group** settings group: **Name** opens a bottom sheet with the name field and Save; **Choose a photo** opens the photo picker, crops the photo square and sends it to the Host scaled to 256 × 256 px as a JPEG (the Host takes at most 96 KB); **Remove photo** appears once there is one.
- **Join requests** (only while one is pending): each requesting Bot with **Allow** (primary) and **Decline**.
-- **Members**: **Invite a PersonaBot** first, while any roster Bot is neither a member nor invited, opening a bottom sheet of those Bots; then each member Bot with its 32 pt rounded-square avatar and "Creator" under the Bot that made the group. Tapping a member opens its profile; its ⋯ menu offers Open profile and **Remove from group**, which asks for confirmation. Pending invitations follow, marked "Invited · waiting for an answer", with **Cancel**.
+- **Members**: **Invite a PersonaBot** first, while any roster Bot is neither a member nor invited, opening a bottom sheet of those Bots; then each member Bot with its 32 pt rounded-square avatar and "Creator" under the Bot that made the group. Tapping a member opens its profile; its ⋯ opens a native pull-down menu (Material dropdown on Android) with Open profile and **Remove from group**, which asks for confirmation. Pending invitations follow, marked "Invited · waiting for an answer", with **Cancel**.
- **Disband group** is a danger row at the bottom; it asks for confirmation and on success returns to Chats.
- A refused change shows a `danger` callout under the header and leaves the page as it was. Changes show on the phone and the desktop when the roster refreshes.
- Group wake policy, group connectors and the group's work list stay on the computer.
@@ -284,6 +284,8 @@ Each layer has `fill`, `pressed`, `selected` (neutral, such as the current tab),
- Profile token totals retain reported usage when other calls are unknown, using the global Settings count format (`674.7K+`, for example). Completely unreported usage remains unknown; an idle, fully reported window may show zero.
- `src/ui/design/` holds the tokens and theme (ADR-0201).
- `src/ui/primitives/` holds `Text` (variant `meta` / `body` / `title`, and a tone that is a colour role), `Button`, `Toggle`, `SettingsRow`, `SettingsSection` and `SettingsGroupItem`, `CountBadge`, `Segmented` (an iOS-style two- or three-way switch on the layer's `inset`), `DayBars` (one bar per day or week, scaled to the busiest, optionally stacked in tones from `design/chart.ts`; an empty day keeps a stub on the layer's `inset`), `DayHeat` (a weeks × weekdays heatmap), and later `Icon`, `IconButton`, `Button`, `Avatar`, `Card`, `SettingsRow` and `EmptyState`.
+- Forms use `TextField` from `primitives/text-field`: the `field` layer, a hairline `field` edge, `radius.md`, a touch-height minimum (two lines when multiline) and the system clear button on single-line fields. Give every field a placeholder or a visible label, and the keyboard and content type its value needs (`url`, `emailAddress`). `SearchField` leads with a magnifying glass and relies on the native iOS clear button; Android, which has none, adds its own.
+- Follow the HIG for alerts and action sheets. An action that can't be undone (unpair, remove access, revoke, sign out, reset) asks first through `confirmDestructive`: a title that names the action, Cancel first, then the destructive button. A short list of actions on one item uses `showActionMenu`, which on iOS is an action sheet with the destructive action last and Cancel set apart; never show such a list as a centered alert.
- `src/ui/chat/` will hold the conversation row, bubble, message group, time separator, action cards, composer, working status and scroll-to-bottom.
- `src/ui/shell/` holds the Host drawer, Settings gear, display preferences, Channel section headers and the activity item.
- `app/` routes, reads the Host client, and composes these components.
diff --git a/apps/mobile/app/(tabs)/(home)/index.tsx b/apps/mobile/app/(tabs)/(home)/index.tsx
index d4b5bad2cb..f26015d080 100644
--- a/apps/mobile/app/(tabs)/(home)/index.tsx
+++ b/apps/mobile/app/(tabs)/(home)/index.tsx
@@ -23,8 +23,9 @@ import { conversations, searchKey, type Conversation } from '../../../src/ui/cha
import { CloudConversationRow } from '../../../src/ui/chat/cloud-conversation-row';
import { useConversationFilter } from '../../../src/host/native-conversation-filter';
import { ConnectionBanner } from '../../../src/ui/connection-banner';
+import { useDesignTheme } from '../../../src/ui/design/theme';
import { space } from '../../../src/ui/design/tokens';
-import { SearchField } from '../../../src/ui/primitives/search-field';
+import { BottomSearch, bottomSearchHeight } from '../../../src/ui/shell/bottom-search';
import { EnvironmentPager } from '../../../src/ui/shell/environment-pager';
import {
conversationEnvironments,
@@ -98,13 +99,15 @@ const HomeContent = memo(function HomeContent({
};
}, [router, client]),
);
- const onScroll = useChromeScroll();
+ const [query, setQuery] = useState('');
+ const [searching, setSearching] = useState(false);
+ const onScroll = useChromeScroll(true, searching || query !== '');
const chrome = useScrollChrome();
- const bottom = useFloatingTabInset();
+ const theme = useDesignTheme();
+ const bottom = useFloatingTabInset() + bottomSearchHeight(theme.touch);
const look = useHostLook(saved?.hostId);
const cloud = useCloudSnapshot();
const directory = useCloudConversations();
- const [query, setQuery] = useState('');
const [filter, setFilter] = useConversationFilter();
const [refreshing, setRefreshing] = useState(false);
const [opening, setOpening] = useState(false);
@@ -300,12 +303,6 @@ const HomeContent = memo(function HomeContent({
onPairAgain={() => router.push('/pair')}
/>
)}
-
{(directory.errors.length > 0 || cloud.error || error) && (
{t.cloudListUnavailable}
@@ -347,6 +344,12 @@ const HomeContent = memo(function HomeContent({
)}
/>
+
>
);
diff --git a/apps/mobile/app/pair.tsx b/apps/mobile/app/pair.tsx
index 6c34472ce5..88dae5b9ac 100644
--- a/apps/mobile/app/pair.tsx
+++ b/apps/mobile/app/pair.tsx
@@ -1,9 +1,9 @@
-import { Text, TextInput } from '../src/ui/primitives/system-text';
+import { TextField } from '../src/ui/primitives/text-field';
import { CameraView, useCameraPermissions } from 'expo-camera';
import Constants from 'expo-constants';
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useRef, useState } from 'react';
-import { Platform, Pressable, View } from 'react-native';
+import { Platform, Pressable, View, useWindowDimensions } from 'react-native';
import { hostSession } from '../src/host/host-session';
import { hostLooks } from '../src/host/native-host-look';
@@ -16,19 +16,21 @@ import { mobileTelemetry } from '../src/telemetry/native';
import type { PairingMethod } from '../src/telemetry/schema';
import { Button } from '../src/ui/primitives/button';
import { DrawerHeader } from '../src/ui/primitives/drawer-header';
-import { useDesignTheme } from '../src/ui/design/theme';
import { KeyboardFormScroll } from '../src/ui/primitives/keyboard-form';
import { cloudAvailable } from '../src/cloud/available';
import { t } from '../src/ui/strings';
-import { useTheme } from '../src/ui/theme';
+import { useDesignTheme } from '../src/ui/design/theme';
+import { pairingScan, radius, space } from '../src/ui/design/tokens';
+import { Text } from '../src/ui/primitives/text';
function deviceName(): string {
return Constants.deviceName ?? (Platform.OS === 'ios' ? 'iPhone' : 'Android phone');
}
export default function PairScreen() {
- const theme = useTheme();
- const design = useDesignTheme();
+ const theme = useDesignTheme();
+ const { width } = useWindowDimensions();
+ const scan = Math.min(width * pairingScan.widthRatio, pairingScan.maxSize);
const router = useRouter();
const params = useLocalSearchParams<{ p?: string }>();
const [permission, requestPermission] = useCameraPermissions();
@@ -92,60 +94,60 @@ export default function PairScreen() {
router.back() } : {})}
/>
- {cloudAvailable && (
-
);
}
diff --git a/apps/mobile/app/settings/index.tsx b/apps/mobile/app/settings/index.tsx
index dfd103afd7..1ece14041f 100644
--- a/apps/mobile/app/settings/index.tsx
+++ b/apps/mobile/app/settings/index.tsx
@@ -11,6 +11,7 @@ import { StatisticsSettings } from '../../src/ui/shell/statistics-settings';
import { MessageDisplaySettings } from '../../src/ui/shell/message-display-settings';
import { ActivityCountLink } from '../../src/ui/shell/activity-count-settings';
import { SHEET_GROUP } from '../../src/ui/chat/sheet';
+import { RowIcon } from '../../src/ui/primitives/row-icon';
import { SettingsRow } from '../../src/ui/primitives/settings-row';
import { t } from '../../src/ui/strings';
import { SettingsView } from '../../src/ui/settings-view';
@@ -33,6 +34,7 @@ export default function SettingsScreen() {
}
disclosure
onPress={() => router.push('/settings/chat-background')}
/>
diff --git a/apps/mobile/src/ui/chat/chat-title.tsx b/apps/mobile/src/ui/chat/chat-title.tsx
index 925bd20f82..3f2d100de6 100644
--- a/apps/mobile/src/ui/chat/chat-title.tsx
+++ b/apps/mobile/src/ui/chat/chat-title.tsx
@@ -3,10 +3,12 @@ import { Pressable, View } from 'react-native';
import type { RosterBot } from '../../host/client';
import { BotAvatar } from '../bot-avatar';
-import { frosted, useDesignTheme } from '../design/theme';
-import { edge, radius, space } from '../design/tokens';
+import { useDesignTheme } from '../design/theme';
+import { radius, space } from '../design/tokens';
+import { DrawerMaterial, useLiquidGlass } from '../primitives/drawer-material';
import { t } from '../strings';
import { botDotTone } from './bot-tone';
+import { headerGlassFill } from './header-chrome';
const AVATAR = 28;
const DOT = space.x3;
@@ -21,6 +23,7 @@ export function ChatTitle({
onPress(): void;
}) {
const theme = useDesignTheme();
+ const glass = useLiquidGlass();
const status = bot === undefined ? undefined : bot.paused ? t.paused : t.state[bot.state];
const name = bot?.displayName ?? fallback;
return (
@@ -39,11 +42,10 @@ export function ChatTitle({
paddingLeft: bot === undefined ? theme.touch / 2 : (theme.touch - AVATAR) / 2,
paddingRight: bot === undefined ? theme.touch / 2 : (theme.touch - DOT) / 2,
borderRadius: radius.round,
- borderWidth: edge.hairline,
- borderColor: theme.layer.raised.edge,
- backgroundColor: pressed ? theme.layer.raised.pressed : frosted(theme.layer.raised.fill),
+ ...headerGlassFill(theme, glass, pressed),
})}
>
+ {glass ? : null}
{bot === undefined ? null : }
{bot === undefined ? null : (
diff --git a/apps/mobile/src/ui/chat/conversation-screen.tsx b/apps/mobile/src/ui/chat/conversation-screen.tsx
index ddf06effd6..859c0694ea 100644
--- a/apps/mobile/src/ui/chat/conversation-screen.tsx
+++ b/apps/mobile/src/ui/chat/conversation-screen.tsx
@@ -827,7 +827,11 @@ function ChatConversation({
/>
-
+
{latest.away ? (
-
+
}
center={
diff --git a/apps/mobile/src/ui/chat/group-info.tsx b/apps/mobile/src/ui/chat/group-info.tsx
index 0ae1569529..12f05de323 100644
--- a/apps/mobile/src/ui/chat/group-info.tsx
+++ b/apps/mobile/src/ui/chat/group-info.tsx
@@ -1,7 +1,8 @@
-import { TextInput } from '../primitives/system-text';
+import { TextField } from '../primitives/text-field';
import { useState, type ReactNode } from 'react';
-import { FlatList, Pressable, SectionList, View } from 'react-native';
-import { showAppDialog } from '../primitives/app-dialog';
+import { FlatList, SectionList, View } from 'react-native';
+import { ActionMenuButton, type MenuAction } from '../primitives/action-menu-button';
+import { confirmDestructive, showActionMenu, showAppDialog } from '../primitives/app-dialog';
import { KeyboardFormScroll } from '../primitives/keyboard-form';
import { errorText } from '../../host/bot-settings';
@@ -25,7 +26,7 @@ import {
SettingsSection,
} from '../primitives/settings-row';
import { Text } from '../primitives/text';
-import { ChevronRightIcon, MoreIcon, PlusIcon } from '../shell/icons';
+import { ChevronRightIcon, PlusIcon } from '../shell/icons';
import { t } from '../strings';
import { GroupMark } from './group-mark';
import { SHEET_GROUP, Sheet, SheetActions, SheetCallout, SheetNote } from './sheet';
@@ -111,27 +112,6 @@ function RowButtons({ children }: { children: ReactNode }) {
return {children};
}
-function MenuButton({ label, onPress }: { label: string; onPress(): void }) {
- const theme = useDesignTheme();
- return (
- ({
- width: theme.touch,
- height: theme.touch,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radius.round,
- backgroundColor: pressed ? theme.layer.raised.pressed : theme.layer.raised.fill,
- })}
- >
-
-
- );
-}
-
function RenameSheet({
visible,
name,
@@ -143,7 +123,6 @@ function RenameSheet({
onSave(name: string): Promise;
onClose(): void;
}) {
- const theme = useDesignTheme();
const [draft, setDraft] = useState(name);
const [busy, setBusy] = useState(false);
const next = draft.trim();
@@ -162,25 +141,18 @@ function RenameSheet({
avoidKeyboard
>
- {
if (next !== '' && next !== name) void save();
}}
- style={{
- minHeight: theme.touch,
- marginHorizontal: space.x5,
- paddingHorizontal: space.x4,
- borderRadius: radius.md,
- backgroundColor: theme.layer[SHEET_GROUP].fill,
- color: theme.color.fg,
- fontSize: theme.type.body.size,
- }}
+ style={{ marginHorizontal: space.x5 }}
/>
@@ -291,26 +263,30 @@ export function GroupInfoSheet({
if (avatar !== undefined) await run('avatar', () => actions.setAvatar(avatar));
};
- const memberMenu = (member: GroupMember) => {
+ const memberActions = (member: GroupMember): MenuAction[] => {
const name = memberName(member);
- showAppDialog(name, undefined, [
- { text: t.groupOpenProfile, onPress: () => onOpenBot(member.slug) },
+ return [
+ { label: t.groupOpenProfile, icon: 'person', run: () => onOpenBot(member.slug) },
{
- text: t.groupRemove,
- style: 'destructive',
- onPress: () =>
- showAppDialog(t.groupRemoveConfirm(name), undefined, [
- { text: t.cancel, style: 'cancel' },
- {
- text: t.groupRemove,
- style: 'destructive',
- onPress: () => run(`member:${member.slug}`, () => actions.removeMember(member.slug)),
- },
- ]),
+ label: t.groupRemove,
+ icon: 'remove',
+ destructive: true,
+ run: () =>
+ confirmDestructive(t.groupRemoveConfirm(name), undefined, t.groupRemove, () =>
+ run(`member:${member.slug}`, () => actions.removeMember(member.slug)),
+ ),
},
- { text: t.cancel, style: 'cancel' },
- ]);
+ ];
};
+ const memberMenu = (member: GroupMember) =>
+ showActionMenu(
+ memberName(member),
+ memberActions(member).map((action) => ({
+ text: action.label,
+ onPress: action.run,
+ ...(action.destructive === true ? { style: 'destructive' as const } : {}),
+ })),
+ );
const confirmDisband = () =>
showAppDialog(t.groupDisbandConfirm(channel.name), t.groupDisbandDetail, [
@@ -421,6 +397,7 @@ export function GroupInfoSheet({
onInvite={() => setSheet('invite')}
onOpenBot={onOpenBot}
onMenu={memberMenu}
+ memberActions={memberActions}
onCancelInvite={(pending) =>
void run(`invite:${pending.id}`, () => actions.cancelInvite(pending.id))
}
@@ -471,6 +448,7 @@ function GroupRow({
onInvite,
onOpenBot,
onMenu,
+ memberActions,
onCancelInvite,
onDecide,
}: {
@@ -479,6 +457,7 @@ function GroupRow({
onInvite(): void;
onOpenBot(slug: string): void;
onMenu(member: GroupMember): void;
+ memberActions(member: GroupMember): MenuAction[];
onCancelInvite(pending: GroupPending): void;
onDecide(pending: GroupPending, accept: boolean): void;
}) {
@@ -517,10 +496,11 @@ function GroupRow({
title={memberName(row.member)}
{...(row.member.owner ? { detail: t.groupOwner } : {})}
leading={}
- trailing={
- onMenu(row.member)}
+ actions={memberActions(row.member)}
+ onOpen={() => onMenu(row.member)}
/>
}
onPress={
diff --git a/apps/mobile/src/ui/chat/header-chrome.tsx b/apps/mobile/src/ui/chat/header-chrome.tsx
index 66e112ff78..96d7ed7132 100644
--- a/apps/mobile/src/ui/chat/header-chrome.tsx
+++ b/apps/mobile/src/ui/chat/header-chrome.tsx
@@ -3,8 +3,9 @@ import { Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
-import { frosted, useDesignTheme } from '../design/theme';
+import { frosted, pressTint, useDesignTheme, type DesignTheme } from '../design/theme';
import { edge, edgeFade, radius, space } from '../design/tokens';
+import { DrawerMaterial, EdgeMaterial, useLiquidGlass } from '../primitives/drawer-material';
import { ChevronLeftIcon } from '../shell/icons';
import { t } from '../strings';
@@ -67,6 +68,7 @@ export function HeaderCircleButton({
selected?: boolean;
}) {
const theme = useDesignTheme();
+ const glass = useLiquidGlass();
return (
+ {glass ? : null}
{children}
);
}
+export function headerGlassFill(theme: DesignTheme, glass: boolean, pressed: boolean) {
+ if (glass)
+ return {
+ borderWidth: 0,
+ backgroundColor: pressed ? pressTint(theme.color.fg) : 'transparent',
+ };
+ return {
+ borderWidth: edge.hairline,
+ borderColor: theme.layer.raised.edge,
+ backgroundColor: pressed ? theme.layer.raised.pressed : frosted(theme.layer.raised.fill),
+ };
+}
+
export function HeaderBackButton({ onPress }: { onPress(): void }) {
const theme = useDesignTheme();
return (
@@ -98,8 +112,17 @@ export function HeaderBackButton({ onPress }: { onPress(): void }) {
);
}
-export function EdgeFade({ edge: side, height }: { edge: 'top' | 'bottom'; height: number }) {
+export function EdgeFade({
+ edge: side,
+ height,
+ fade,
+}: {
+ edge: 'top' | 'bottom';
+ height: number;
+ fade: number;
+}) {
const theme = useDesignTheme();
+ const glass = useLiquidGlass();
const id = `fade-${useId().replace(/[^a-z0-9]/giu, '')}`;
const top = side === 'top';
return (
@@ -113,6 +136,9 @@ export function EdgeFade({ edge: side, height }: { edge: 'top' | 'bottom'; heigh
...(top ? { top: 0 } : { bottom: 0 }),
}}
>
+ {glass ? (
+ fade ? (height - fade) / height : 0} />
+ ) : null}
- {
setName(next);
@@ -397,15 +397,6 @@ function ModelsView({
}}
accessibilityLabel={t.modelSettings.name}
placeholder={t.modelSettings.name}
- placeholderTextColor={theme.color.subtle}
- style={{
- color: theme.color.fg,
- backgroundColor: theme.layer.raised.fill,
- minHeight: theme.touch,
- paddingHorizontal: space.x4,
- borderRadius: radius.md,
- fontSize: theme.type.body.size,
- }}
/>
) : (
- onChange(typeCustom(question, draft, text))}
- style={{
- minHeight: theme.touch,
- color: theme.color.fg,
- fontSize: theme.type.body.size,
- borderColor: theme.layer.field.edge,
- borderWidth: edge.hairline,
- borderRadius: radius.md,
- paddingHorizontal: space.x4,
- paddingVertical: space.x3,
- backgroundColor: theme.layer.field.fill,
- }}
/>
)}
diff --git a/apps/mobile/src/ui/chat/routine-sheet.tsx b/apps/mobile/src/ui/chat/routine-sheet.tsx
index f274ef69b3..60a3f0373a 100644
--- a/apps/mobile/src/ui/chat/routine-sheet.tsx
+++ b/apps/mobile/src/ui/chat/routine-sheet.tsx
@@ -1,4 +1,4 @@
-import { TextInput } from '../primitives/system-text';
+import { TextField } from '../primitives/text-field';
import { useRef, useState, type ComponentType, type ReactNode } from 'react';
import { ActivityIndicator, View } from 'react-native';
@@ -17,7 +17,7 @@ import {
} from '../../host/routines';
import type { BotScheduleFiring, BotScheduleSummary } from '../../host/wire';
import { useDesignTheme } from '../design/theme';
-import { edge, radius, space } from '../design/tokens';
+import { space } from '../design/tokens';
import { useMounted } from '../mounted';
import { Button } from '../primitives/button';
import { SettingsGroupItem, SettingsRow } from '../primitives/settings-row';
@@ -60,30 +60,16 @@ function Field({
keyboard?: 'default' | 'number-pad' | 'numbers-and-punctuation';
onChange(text: string): void;
}) {
- const theme = useDesignTheme();
return (
-
);
}
diff --git a/apps/mobile/src/ui/chat/workspace-sheet.tsx b/apps/mobile/src/ui/chat/workspace-sheet.tsx
index 82808a65ea..9895d34d81 100644
--- a/apps/mobile/src/ui/chat/workspace-sheet.tsx
+++ b/apps/mobile/src/ui/chat/workspace-sheet.tsx
@@ -35,6 +35,7 @@ import {
SheetNote,
SHEET_GROUP,
} from './sheet';
+import { confirmDestructive } from '../primitives/app-dialog';
type Mode = 'main' | 'browse' | 'confirm';
type Pending = { path: string; workspaceId?: string };
@@ -466,7 +467,12 @@ export function WorkspaceSheet({
) : null}
style={page !== undefined ? { flex: 1 } : { flexShrink: 1 }}
sections={sections}
@@ -666,7 +672,14 @@ export function WorkspaceSheet({
remove(item.grant)}
+ onPress={() =>
+ confirmDestructive(
+ t.confirmAction(t.removeFolder(item.grant.workspaceTitle)),
+ undefined,
+ t.remove,
+ () => remove(item.grant),
+ )
+ }
/>
)
}
diff --git a/apps/mobile/src/ui/cloud/agents-screen.tsx b/apps/mobile/src/ui/cloud/agents-screen.tsx
index 26bd8c2bfd..e0c5fea136 100644
--- a/apps/mobile/src/ui/cloud/agents-screen.tsx
+++ b/apps/mobile/src/ui/cloud/agents-screen.tsx
@@ -11,8 +11,9 @@ import { radius, space } from '../design/tokens';
import { Button } from '../primitives/button';
import { KeyboardFormScroll } from '../primitives/keyboard-form';
import { Text } from '../primitives/text';
-import { TextInput } from '../primitives/system-text';
+import { TextField } from '../primitives/text-field';
import { t } from '../strings';
+import { confirmDestructive } from '../primitives/app-dialog';
function AgentScroll(props: ScrollViewProps) {
const theme = useDesignTheme();
return (
@@ -144,11 +145,9 @@ function Agents({
{owner && (
{t.cloudAgents.hint}
-
- void run(async () => {
- await cloudClient.revokeAgent(
- instanceId,
- organizationId,
- channelId,
- item.actorId,
- );
- await reload();
- })
+ confirmDestructive(
+ t.confirmAction(t.cloudAgents.revoke),
+ t.cloudAgents.revokeDetail,
+ t.cloudAgents.revoke,
+ () =>
+ void run(async () => {
+ await cloudClient.revokeAgent(
+ instanceId,
+ organizationId,
+ channelId,
+ item.actorId,
+ );
+ await reload();
+ }),
+ )
}
/>
)}
diff --git a/apps/mobile/src/ui/cloud/channels-screen.tsx b/apps/mobile/src/ui/cloud/channels-screen.tsx
index 2b0b36d25c..583651edcb 100644
--- a/apps/mobile/src/ui/cloud/channels-screen.tsx
+++ b/apps/mobile/src/ui/cloud/channels-screen.tsx
@@ -18,7 +18,7 @@ import { radius, space } from '../design/tokens';
import { Button } from '../primitives/button';
import { KeyboardFormScroll } from '../primitives/keyboard-form';
import { Text } from '../primitives/text';
-import { TextInput } from '../primitives/system-text';
+import { TextField } from '../primitives/text-field';
import { t } from '../strings';
import { CloudChannelName } from './channel-name';
@@ -342,11 +342,9 @@ function ChannelWorkspace({
onPress={() => void run(() => history(selected))}
/>
-
{!guest && row.organization?.role === 'owner' && (
-
{t.cloudChannels.name}
-
{!!pendingHere && {t.cloudChannels.pending}}
diff --git a/apps/mobile/src/ui/cloud/invitations-screen.tsx b/apps/mobile/src/ui/cloud/invitations-screen.tsx
index 01a6535c60..3ac6169545 100644
--- a/apps/mobile/src/ui/cloud/invitations-screen.tsx
+++ b/apps/mobile/src/ui/cloud/invitations-screen.tsx
@@ -9,12 +9,22 @@ import { cloudViewScope } from '../../cloud/workspace-directory';
import type { Invitation } from '../../cloud/invitation-contract';
import { cloudClient, useCloudSnapshot } from '../../cloud/native';
import { useDesignTheme } from '../design/theme';
-import { edge, radius, space } from '../design/tokens';
+import { radius, space } from '../design/tokens';
import { Button } from '../primitives/button';
import { KeyboardFormScroll } from '../primitives/keyboard-form';
import { Text } from '../primitives/text';
-import { TextInput } from '../primitives/system-text';
+import { TextField } from '../primitives/text-field';
import { t } from '../strings';
+import { confirmDestructive } from '../primitives/app-dialog';
+function revokeLabel(guest: boolean, state: string): string {
+ return guest && state === 'accepted' ? t.cloudGuests.revoke : t.cloudInvitations.revoke;
+}
+function revokeDetail(guest: boolean, state: string): string {
+ return guest && state === 'accepted'
+ ? t.cloudGuests.revokeDetail
+ : t.cloudInvitations.revokeDetail;
+}
+
function InvitationScroll(props: ScrollViewProps) {
const theme = useDesignTheme();
return (
@@ -171,21 +181,11 @@ function InvitationWorkspace({
{!!organizationId && (
{guest ? t.cloudGuests.hint : t.cloudInvitations.hint}
-
- void run(async () => {
- await cloudClient.revokeInvitation(
- instanceId,
- organizationId,
- item.invitationId,
- channelId,
- );
- await reload();
- })
+ confirmDestructive(
+ t.confirmAction(revokeLabel(guest, item.state)),
+ revokeDetail(guest, item.state),
+ revokeLabel(guest, item.state),
+ () =>
+ void run(async () => {
+ await cloudClient.revokeInvitation(
+ instanceId,
+ organizationId,
+ item.invitationId,
+ channelId,
+ );
+ await reload();
+ }),
+ )
}
/>
)
diff --git a/apps/mobile/src/ui/cloud/join-screen.tsx b/apps/mobile/src/ui/cloud/join-screen.tsx
index 8afdf48c8d..e930f806f5 100644
--- a/apps/mobile/src/ui/cloud/join-screen.tsx
+++ b/apps/mobile/src/ui/cloud/join-screen.tsx
@@ -8,10 +8,10 @@ import type { Invitation } from '../../cloud/invitation-contract';
import { parseInvitationLink } from '../../cloud/invitation-link';
import { cloudClient, pendingCloudJoin, useCloudSnapshot } from '../../cloud/native';
import { useDesignTheme } from '../design/theme';
-import { edge, radius, space } from '../design/tokens';
+import { radius, space } from '../design/tokens';
import { Button } from '../primitives/button';
import { KeyboardFormScroll } from '../primitives/keyboard-form';
-import { TextInput } from '../primitives/system-text';
+import { TextField } from '../primitives/text-field';
import { Text } from '../primitives/text';
import { t } from '../strings';
import { CloudChannelName } from './channel-name';
@@ -99,10 +99,9 @@ export function CloudJoinScreen() {
>
{t.cloudJoin.neutral}
- {
setLink(value);
@@ -110,20 +109,11 @@ export function CloudJoinScreen() {
setInvitation(undefined);
setError(undefined);
}}
+ keyboardType="url"
autoCapitalize="none"
autoCorrect={false}
maxLength={2048}
editable={!busy}
- style={{
- minHeight: theme.touch,
- padding: space.x3,
- borderWidth: edge.hairline,
- borderColor: theme.layer.raised.edge,
- borderRadius: radius.md,
- color: theme.color.fg,
- backgroundColor: theme.layer.raised.fill,
- fontSize: theme.type.body.size,
- }}
/>
setScanning(true)} />
{scanning &&
diff --git a/apps/mobile/src/ui/cloud/organizations-screen.tsx b/apps/mobile/src/ui/cloud/organizations-screen.tsx
index bcd8e4bbad..35e6d496da 100644
--- a/apps/mobile/src/ui/cloud/organizations-screen.tsx
+++ b/apps/mobile/src/ui/cloud/organizations-screen.tsx
@@ -9,7 +9,7 @@ import { useDesignTheme } from '../design/theme';
import { radius, space } from '../design/tokens';
import { Button } from '../primitives/button';
import { Text } from '../primitives/text';
-import { TextInput } from '../primitives/system-text';
+import { TextField } from '../primitives/text-field';
import { t } from '../strings';
export function CloudOrganizationsScreen() {
@@ -107,7 +107,7 @@ function OrganizationWorkspace({
) : (
<>
-
)}
- ();
+ const [candidate, setCandidate] = useState<{
+ raw: string;
+ config: InstanceConfig;
+ }>();
const [error, setError] = useState();
const [notice, setNotice] = useState();
const [connectionName, setConnectionName] = useState('');
- const [editingName, setEditingName] = useState<{ id: string; value: string }>();
+ const [editingName, setEditingName] = useState<{
+ id: string;
+ value: string;
+ }>();
const old =
candidate && snapshot.rows.find((row) => row.config.instanceId === candidate.config.instanceId);
const replacement =
@@ -137,25 +144,16 @@ export function CloudServicesScreen() {
{pasting && (
{t.cloud.pasteHint}
-
- void run(async () => {
- const result = await cloudClient.logout(item.config.instanceId);
- setNotice(result.remoteRevoked ? t.cloud.loggedOut : t.cloud.localOnly);
- })
+ confirmDestructive(
+ t.confirmAction(t.cloud.logout),
+ t.cloud.logoutDetail,
+ t.cloud.logout,
+ () =>
+ run(async () => {
+ const result = await cloudClient.logout(item.config.instanceId);
+ setNotice(result.remoteRevoked ? t.cloud.loggedOut : t.cloud.localOnly);
+ }),
+ )
}
/>
>
diff --git a/apps/mobile/src/ui/design/tokens.ts b/apps/mobile/src/ui/design/tokens.ts
index 6dbdd3da89..f728d772e5 100644
--- a/apps/mobile/src/ui/design/tokens.ts
+++ b/apps/mobile/src/ui/design/tokens.ts
@@ -23,6 +23,8 @@ export const edge = {
focus: 2,
} as const;
+export const sidebar = { inset: 8, radius: 44 } as const;
+
export const sheetHandle = { width: 32, height: 4 } as const;
export const navigationBar = {
height: 64,
@@ -32,6 +34,7 @@ export const navigationBar = {
indicatorHeight: 32,
indicatorRadius: radius.md,
itemPadding: 6,
+ labelMinScale: 0.5,
shadow: {
light: '0 2px 8px rgba(15, 17, 21, 0.06)',
dark: '0 2px 8px rgba(0, 0, 0, 0.12)',
@@ -39,6 +42,7 @@ export const navigationBar = {
} as const;
export const environmentTab = { maxWidth: 220 } as const;
export const modalRail = { maxWidth: 360 } as const;
+export const pairingScan = { maxSize: 200, widthRatio: 0.5 } as const;
export const androidSettings = {
radius: radius.xl,
diff --git a/apps/mobile/src/ui/primitives/action-menu-button.android.tsx b/apps/mobile/src/ui/primitives/action-menu-button.android.tsx
new file mode 100644
index 0000000000..4902c45afd
--- /dev/null
+++ b/apps/mobile/src/ui/primitives/action-menu-button.android.tsx
@@ -0,0 +1,81 @@
+import {
+ Box,
+ DropdownMenu,
+ DropdownMenuItem,
+ HorizontalDivider,
+ Host,
+ Text,
+} from '@expo/ui/jetpack-compose';
+import { fillMaxSize } from '@expo/ui/jetpack-compose/modifiers';
+import { Fragment, useState } from 'react';
+import { Pressable, StyleSheet, View } from 'react-native';
+
+import { useDesignTheme } from '../design/theme';
+import { radius, space } from '../design/tokens';
+import type { ActionMenuButtonProps } from './action-menu-button';
+import { MoreIcon } from '../shell/icons';
+
+export type { MenuAction } from './action-menu-button';
+
+export function ActionMenuButton({ label, actions }: ActionMenuButtonProps) {
+ const theme = useDesignTheme();
+ const [open, setOpen] = useState(false);
+ return (
+
+ setOpen(true)}
+ accessibilityRole="button"
+ accessibilityLabel={label}
+ accessibilityState={{ expanded: open }}
+ hitSlop={space.x2}
+ android_ripple={{
+ borderless: true,
+ radius: theme.touch / 2,
+ color: theme.layer.sheet.pressed,
+ }}
+ style={{
+ width: theme.touch,
+ height: theme.touch,
+ borderRadius: radius.round,
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ >
+
+
+
+ setOpen(false)}>
+
+
+
+
+ {actions.map((action) => (
+
+ {action.destructive === true ? : null}
+ {
+ setOpen(false);
+ action.run();
+ }}
+ >
+
+ {action.label}
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/ui/primitives/action-menu-button.ios.tsx b/apps/mobile/src/ui/primitives/action-menu-button.ios.tsx
new file mode 100644
index 0000000000..b6c0df0666
--- /dev/null
+++ b/apps/mobile/src/ui/primitives/action-menu-button.ios.tsx
@@ -0,0 +1,50 @@
+import { Button, Host, Image, Menu } from '@expo/ui/swift-ui';
+import { accessibilityLabel, font, foregroundStyle, frame } from '@expo/ui/swift-ui/modifiers';
+
+import { useDesignTheme } from '../design/theme';
+import type { MenuAction, ActionMenuButtonProps } from './action-menu-button';
+
+export type { MenuAction } from './action-menu-button';
+
+const SYMBOL = {
+ settings: 'paintbrush',
+ computer: 'desktopcomputer',
+ close: 'xmark.circle',
+ person: 'person.crop.circle',
+ remove: 'person.badge.minus',
+} as const satisfies Record;
+
+export function ActionMenuButton({ label, actions }: ActionMenuButtonProps) {
+ const theme = useDesignTheme();
+ return (
+
+
+ }
+ modifiers={[accessibilityLabel(label)]}
+ >
+ {actions.map((action) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/mobile/src/ui/primitives/action-menu-button.tsx b/apps/mobile/src/ui/primitives/action-menu-button.tsx
new file mode 100644
index 0000000000..938540aa0d
--- /dev/null
+++ b/apps/mobile/src/ui/primitives/action-menu-button.tsx
@@ -0,0 +1,40 @@
+import { Pressable } from 'react-native';
+
+import { useDesignTheme } from '../design/theme';
+import { radius, space } from '../design/tokens';
+import { MoreIcon } from '../shell/icons';
+
+export type MenuAction = {
+ label: string;
+ icon: 'settings' | 'computer' | 'close' | 'person' | 'remove';
+ run(): void;
+ destructive?: boolean;
+};
+
+export type ActionMenuButtonProps = {
+ label: string;
+ actions: MenuAction[];
+ onOpen(): void;
+};
+
+export function ActionMenuButton({ label, onOpen }: ActionMenuButtonProps) {
+ const theme = useDesignTheme();
+ return (
+ ({
+ width: theme.touch,
+ height: theme.touch,
+ borderRadius: radius.round,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: pressed ? theme.layer.sheet.pressed : 'transparent',
+ })}
+ >
+
+
+ );
+}
diff --git a/apps/mobile/src/ui/primitives/app-dialog-host.tsx b/apps/mobile/src/ui/primitives/app-dialog-host.tsx
index 62b04350f7..2759439b46 100644
--- a/apps/mobile/src/ui/primitives/app-dialog-host.tsx
+++ b/apps/mobile/src/ui/primitives/app-dialog-host.tsx
@@ -1,32 +1,21 @@
-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';
+ AlertDialog,
+ Column,
+ HorizontalDivider,
+ Host,
+ ListItem,
+ ModalBottomSheet,
+ Text,
+ TextButton,
+ type ButtonColors,
+} from '@expo/ui/jetpack-compose';
+import { clickable } from '@expo/ui/jetpack-compose/modifiers';
+import { useSyncExternalStore } from 'react';
+import { Platform, StyleSheet } from 'react-native';
import { useDesignTheme } from '../design/theme';
-import { buttonColors } from '../design/controls';
-import { androidSettings, modalRail, space } from '../design/tokens';
import { t } from '../strings';
-import { Button } from './button';
-import { appDialogs } from './dialog-state';
-import { Text } from './text';
-import { ChatsIcon, CloseIcon, ComputerIcon, PersonIcon, SettingsIcon } from '../shell/icons';
-
-const ACTION_ICONS = {
- person: PersonIcon,
- chat: ChatsIcon,
- settings: SettingsIcon,
- close: CloseIcon,
- computer: ComputerIcon,
-};
+import { appDialogs, type DialogAction } from './dialog-state';
export function AppDialogHost() {
const shown = useSyncExternalStore(
@@ -35,150 +24,121 @@ export function AppDialogHost() {
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 stacked = theme.largeText || menu;
- const actionStyle = stacked ? undefined : { width: '50%' as const, flexShrink: 1, minWidth: 0 };
+ const idle = shown.busy === undefined;
const dismiss = () => appDialogs.dismiss();
- return (
- {
- if (heading.current) AccessibilityInfo.sendAccessibilityEvent?.(heading.current, 'focus');
+ const colors = (action: DialogAction): ButtonColors => ({
+ contentColor: action.style === 'destructive' ? theme.color.danger : theme.color.accent,
+ });
+ const choiceButton = ({ action, index }: { action: DialogAction; index: number }) => (
+ {
+ void appDialogs.choose(index, shown.id);
}}
>
- {shown.busy === index ? `${action.text}…` : action.text}
+
+ );
+ if (menu)
+ return (
+
-
-
-
-
-
- {shown.title}
-
-
- {shown.message === undefined ? null : {shown.message}}
- {shown.error === undefined ? null : (
-
- {t.dialogFailed} {shown.error}
-
- )}
- {menu ? (
-
- {choices.map(({ action, index }) => {
- const Icon = action.icon === undefined ? undefined : ACTION_ICONS[action.icon];
- const color = buttonColors(
- theme,
- action.style === 'destructive' ? 'danger' : 'tonal',
- 'sheet',
- ).label;
- return (
-
- )
- }
- disabled={shown.busy !== undefined}
- busy={shown.busy === index}
- onPress={() => {
- void appDialogs.choose(index, shown.id);
- }}
- />
- );
- })}
-
- ) : null}
- {menu ? null : (
-
- {cancel === undefined || menu ? null : (
-
-
-
- )}
- {menu
- ? null
- : choices.map(({ action, index }) => (
-
- {
+
+
+
+ {shown.title}
+
+
+ {choices.map(({ action, index }) => (
+
+ {action.style === 'destructive' ? : null}
+ {
void appDialogs.choose(index, shown.id);
- }}
- />
-
- ))}
-
+ }),
+ ],
+ }
+ : {})}
+ >
+
+
+ {shown.busy === index ? `${action.text}…` : action.text}
+
+
+
+
+ ))}
+ {shown.error === undefined ? null : (
+
+
+ {`${t.dialogFailed} ${shown.error}`}
+
+
)}
-
-
-
-
+
+
+
+ );
+ const single = choices[0];
+ return (
+
+
+
+ {shown.title}
+
+ {shown.message === undefined && shown.error === undefined ? null : (
+
+
+ {shown.message === undefined ? null : {shown.message}}
+ {shown.error === undefined ? null : (
+ {`${t.dialogFailed} ${shown.error}`}
+ )}
+
+
+ )}
+ {single === undefined ? null : (
+ {choiceButton(single)}
+ )}
+ {cancel === undefined ? null : (
+
+
+ {cancel.text}
+
+
+ )}
+
+
);
}
diff --git a/apps/mobile/src/ui/primitives/app-dialog.ts b/apps/mobile/src/ui/primitives/app-dialog.ts
index c58e591641..cb7c5ee1a1 100644
--- a/apps/mobile/src/ui/primitives/app-dialog.ts
+++ b/apps/mobile/src/ui/primitives/app-dialog.ts
@@ -1,6 +1,7 @@
-import { Alert, Keyboard, Platform } from 'react-native';
+import { ActionSheetIOS, Alert, Keyboard, Platform } from 'react-native';
import { appDialogs, type DialogAction } from './dialog-state';
+import { t } from '../strings';
export function showAppDialog(
title: string,
@@ -28,3 +29,39 @@ export function showAppDialog(
})),
);
}
+
+export function confirmDestructive(
+ title: string,
+ message: string | undefined,
+ action: string,
+ onConfirm: () => unknown,
+): void {
+ showAppDialog(title, message, [
+ { text: t.cancel, style: 'cancel' },
+ { text: action, style: 'destructive', onPress: onConfirm },
+ ]);
+}
+
+export function showActionMenu(title: string, actions: readonly DialogAction[]): void {
+ const ordered = [
+ ...actions.filter((action) => action.style !== 'destructive'),
+ ...actions.filter((action) => action.style === 'destructive'),
+ ];
+ if (Platform.OS === 'ios') {
+ const destructive = ordered.findIndex((action) => action.style === 'destructive');
+ ActionSheetIOS.showActionSheetWithOptions(
+ {
+ title,
+ options: [...ordered.map((action) => action.text), t.cancel],
+ ...(destructive === -1 ? {} : { destructiveButtonIndex: destructive }),
+ cancelButtonIndex: ordered.length,
+ },
+ (index) => {
+ const onPress = ordered[index]?.onPress;
+ if (onPress) void Promise.resolve(onPress()).catch(() => undefined);
+ },
+ );
+ return;
+ }
+ showAppDialog(title, undefined, [{ text: t.cancel, style: 'cancel' }, ...ordered]);
+}
diff --git a/apps/mobile/src/ui/primitives/connection-name-field.tsx b/apps/mobile/src/ui/primitives/connection-name-field.tsx
index 52fddef624..6f2afe0472 100644
--- a/apps/mobile/src/ui/primitives/connection-name-field.tsx
+++ b/apps/mobile/src/ui/primitives/connection-name-field.tsx
@@ -1,8 +1,7 @@
import { View } from 'react-native';
-import { useDesignTheme } from '../design/theme';
-import { edge, radius, space } from '../design/tokens';
+import { space } from '../design/tokens';
import { Text } from './text';
-import { TextInput } from './system-text';
+import { TextField } from './text-field';
import { t } from '../strings';
export function ConnectionNameField({
@@ -16,31 +15,19 @@ export function ConnectionNameField({
maxLength: number;
disabled?: boolean;
}) {
- const theme = useDesignTheme();
return (
{t.connectionName}
-
);
diff --git a/apps/mobile/src/ui/primitives/drawer-material.ios.tsx b/apps/mobile/src/ui/primitives/drawer-material.ios.tsx
index 324eabb94f..381dc49d06 100644
--- a/apps/mobile/src/ui/primitives/drawer-material.ios.tsx
+++ b/apps/mobile/src/ui/primitives/drawer-material.ios.tsx
@@ -1,9 +1,14 @@
-import { Capsule, Host, RoundedRectangle } from '@expo/ui/swift-ui';
-import { accessibilityHidden, foregroundStyle, glassEffect } from '@expo/ui/swift-ui/modifiers';
+import { Capsule, Host, Mask, Rectangle, RoundedRectangle } from '@expo/ui/swift-ui';
+import {
+ accessibilityHidden,
+ foregroundStyle,
+ glassEffect,
+ ignoreSafeArea,
+} from '@expo/ui/swift-ui/modifiers';
import { useSyncExternalStore } from 'react';
import { AccessibilityInfo, Platform, StyleSheet, View } from 'react-native';
-import { edge, radius } from '../design/tokens';
+import { edge, pointer, radius } from '../design/tokens';
import { useDesignTheme } from '../design/theme';
let reduced = true;
@@ -30,6 +35,65 @@ function subscribeTransparency(notify: () => void) {
};
}
+export function useLiquidGlass(): boolean {
+ const reduceTransparency = useSyncExternalStore(subscribeTransparency, readTransparency);
+ return Number.parseInt(String(Platform.Version), 10) >= 26 && !reduceTransparency;
+}
+
+export function SidebarMaterial({ cornerRadius }: { cornerRadius: number }) {
+ const theme = useDesignTheme();
+ return (
+
+
+
+ );
+}
+
+export function EdgeMaterial({ edge: side, solid }: { edge: 'top' | 'bottom'; solid: number }) {
+ const theme = useDesignTheme();
+ const top = side === 'top';
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
export function DrawerMaterial({
rounded = false,
glassPanel = false,
diff --git a/apps/mobile/src/ui/primitives/drawer-material.tsx b/apps/mobile/src/ui/primitives/drawer-material.tsx
index 26d696972c..44aa7941bf 100644
--- a/apps/mobile/src/ui/primitives/drawer-material.tsx
+++ b/apps/mobile/src/ui/primitives/drawer-material.tsx
@@ -21,3 +21,11 @@ export function DrawerMaterial({ rounded = false }: { rounded?: boolean; glassPa
/>
);
}
+
+export function useLiquidGlass(): boolean {
+ return false;
+}
+
+export const SidebarMaterial: (props: { cornerRadius: number }) => null = () => null;
+
+export const EdgeMaterial: (props: { edge: 'top' | 'bottom'; solid: number }) => null = () => null;
diff --git a/apps/mobile/src/ui/primitives/row-icon.tsx b/apps/mobile/src/ui/primitives/row-icon.tsx
new file mode 100644
index 0000000000..e93af5575b
--- /dev/null
+++ b/apps/mobile/src/ui/primitives/row-icon.tsx
@@ -0,0 +1,31 @@
+import { SymbolView } from 'expo-symbols';
+import type { ColorValue } from 'react-native';
+
+import { useDesignTheme } from '../design/theme';
+
+const ROW_ICONS = {
+ model: { ios: 'cpu', android: 'memory' },
+ computer: { ios: 'desktopcomputer', android: 'computer' },
+ notifications: { ios: 'bell', android: 'notifications' },
+ browser: { ios: 'safari', android: 'open_in_browser' },
+ unpair: { ios: 'xmark.circle', android: 'link_off' },
+ appIcon: { ios: 'app.badge', android: 'apps' },
+ activityCount: { ios: 'number.circle', android: 'tag' },
+ background: { ios: 'photo', android: 'wallpaper' },
+ version: { ios: 'info.circle', android: 'info' },
+} as const;
+
+export type RowIconName = keyof typeof ROW_ICONS;
+
+export function RowIcon({ name, color }: { name: RowIconName; color?: ColorValue }) {
+ const theme = useDesignTheme();
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/ui/primitives/search-field.tsx b/apps/mobile/src/ui/primitives/search-field.tsx
index a95dc0be42..534df4fc02 100644
--- a/apps/mobile/src/ui/primitives/search-field.tsx
+++ b/apps/mobile/src/ui/primitives/search-field.tsx
@@ -4,21 +4,27 @@ import { TextInput } from './system-text';
import { useDesignTheme } from '../design/theme';
import { radius, space } from '../design/tokens';
+import { ActivitySearchIcon } from '../shell/icons';
+import { DrawerMaterial } from './drawer-material';
export function SearchField({
trailing,
autoFocus,
+ onFocus,
onBlur,
value,
inset,
+ floating = false,
placeholder,
onChangeText,
}: {
trailing?: ReactNode;
autoFocus?: boolean;
+ onFocus?(): void;
onBlur?(): void;
value: string;
inset: number;
+ floating?: boolean;
placeholder: string;
onChangeText(text: string): void;
}) {
@@ -30,33 +36,47 @@ export function SearchField({
alignItems: 'center',
gap: space.x3,
marginHorizontal: inset,
- marginTop: space.x5,
- marginBottom: space.x3,
+ marginTop: floating ? 0 : space.x5,
+ marginBottom: floating ? 0 : space.x3,
}}
>
-
+ >
+ {floating ? : null}
+
+
+
{trailing}
);
diff --git a/apps/mobile/src/ui/primitives/settings-row.tsx b/apps/mobile/src/ui/primitives/settings-row.tsx
index 5acb35265e..09ba346f1d 100644
--- a/apps/mobile/src/ui/primitives/settings-row.tsx
+++ b/apps/mobile/src/ui/primitives/settings-row.tsx
@@ -35,6 +35,7 @@ export function SettingsRow({
strong = false,
leading,
trailing,
+ accessory,
selected,
expanded,
disclosure = false,
@@ -48,6 +49,7 @@ export function SettingsRow({
strong?: boolean;
leading?: ReactNode;
trailing?: ReactNode;
+ accessory?: ReactNode;
selected?: boolean;
expanded?: boolean;
disclosure?: boolean;
@@ -59,7 +61,7 @@ export function SettingsRow({
const layer = theme.layer[on];
const android = Platform.OS === 'android';
const colors = settingsColors(theme, on);
- return (
+ const row = (
({
+ flex: accessory === undefined ? undefined : 1,
minHeight: android
? detail === undefined
? androidSettings.rowHeight
@@ -111,6 +114,20 @@ export function SettingsRow({
) : null)}
);
+ if (accessory === undefined) return row;
+ return (
+
+ {row}
+ {accessory}
+
+ );
}
export function SettingsSection({
diff --git a/apps/mobile/src/ui/primitives/text-field.tsx b/apps/mobile/src/ui/primitives/text-field.tsx
new file mode 100644
index 0000000000..6ed6fed425
--- /dev/null
+++ b/apps/mobile/src/ui/primitives/text-field.tsx
@@ -0,0 +1,38 @@
+import type { ComponentPropsWithRef } from 'react';
+
+import { useDesignTheme, type DesignTheme } from '../design/theme';
+import { edge, radius, space } from '../design/tokens';
+import { TextInput } from './system-text';
+
+type FieldProps = ComponentPropsWithRef & {
+ invalid?: boolean;
+};
+
+export function fieldStyle(theme: DesignTheme, { multiline = false, invalid = false } = {}) {
+ return {
+ minHeight: multiline ? theme.touch * 2 : theme.touch,
+ paddingHorizontal: space.x4,
+ paddingVertical: space.x3,
+ borderRadius: radius.md,
+ borderWidth: edge.hairline,
+ borderColor: invalid ? theme.color.danger : theme.layer.field.edge,
+ backgroundColor: theme.layer.field.fill,
+ color: theme.color.fg,
+ fontSize: theme.type.body.size,
+ textAlignVertical: multiline ? ('top' as const) : ('center' as const),
+ };
+}
+
+export function TextField({ style, invalid = false, multiline, editable, ...props }: FieldProps) {
+ const theme = useDesignTheme();
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/ui/question-view.tsx b/apps/mobile/src/ui/question-view.tsx
index 55c85551bd..abdf634451 100644
--- a/apps/mobile/src/ui/question-view.tsx
+++ b/apps/mobile/src/ui/question-view.tsx
@@ -1,4 +1,5 @@
-import { Text, TextInput } from './primitives/system-text';
+import { Text } from './primitives/system-text';
+import { TextField } from './primitives/text-field';
import { useCallback, useRef, useState } from 'react';
import { Pressable, View, type ViewInstance } from 'react-native';
@@ -178,24 +179,13 @@ export function QuestionView({
);
})}
- update(question, typeCustom(question, draft, text))}
- style={{
- color: theme.ink,
- borderColor: theme.border,
- borderWidth: 1,
- borderRadius: 12,
- padding: 12,
- minHeight: 48,
- fontSize: 16,
- backgroundColor: theme.surface,
- }}
/>
);
diff --git a/apps/mobile/src/ui/settings-view.tsx b/apps/mobile/src/ui/settings-view.tsx
index 561eae67ce..3e0f0b6e3a 100644
--- a/apps/mobile/src/ui/settings-view.tsx
+++ b/apps/mobile/src/ui/settings-view.tsx
@@ -6,6 +6,8 @@ import type { OverviewUsage } from '../host/usage';
import { SHEET_GROUP } from './chat/sheet';
import { useDesignTheme } from './design/theme';
import { space } from './design/tokens';
+import { confirmDestructive } from './primitives/app-dialog';
+import { RowIcon } from './primitives/row-icon';
import { DrawerHeader } from './primitives/drawer-header';
import { SettingsRow, SettingsSection } from './primitives/settings-row';
import { errorText, Notice } from './request-parts';
@@ -72,6 +74,7 @@ export function SettingsView({
}
detail={t.modelSettings.global}
disclosure
onPress={actions.onModels}
@@ -82,6 +85,7 @@ export function SettingsView({
}
detail={t.computersHint}
disclosure
onPress={actions.onComputers}
@@ -89,23 +93,33 @@ export function SettingsView({
}
disclosure
onPress={actions.onNotifications}
/>
}
tone="accent"
onPress={actions.onOpenFullClient}
/>
}
tone="danger"
- onPress={() => {
- setUnpairError(undefined);
- actions.onUnpair().catch((error: unknown) => setUnpairError(errorText(error)));
- }}
+ onPress={() =>
+ confirmDestructive(
+ t.unpairHost(hostName ?? '—'),
+ t.unpairHostDetail,
+ t.unpair,
+ () => {
+ setUnpairError(undefined);
+ actions.onUnpair().catch((error: unknown) => setUnpairError(errorText(error)));
+ },
+ )
+ }
/>
{unpairError === undefined ? null : (
@@ -115,6 +129,7 @@ export function SettingsView({
}
detail={t.appIconHint}
disclosure
onPress={actions.onAppIcon}
@@ -127,6 +142,7 @@ export function SettingsView({
}
detail={
version === ''
? t.appVersionUnknown
diff --git a/apps/mobile/src/ui/shell/activity-count-settings.tsx b/apps/mobile/src/ui/shell/activity-count-settings.tsx
index 578b688e5e..88323fd648 100644
--- a/apps/mobile/src/ui/shell/activity-count-settings.tsx
+++ b/apps/mobile/src/ui/shell/activity-count-settings.tsx
@@ -9,6 +9,7 @@ import {
import { SHEET_GROUP } from '../chat/sheet';
import { useDesignTheme } from '../design/theme';
import { space } from '../design/tokens';
+import { RowIcon } from '../primitives/row-icon';
import { SettingsRow, SettingsSection } from '../primitives/settings-row';
import { Toggle } from '../primitives/toggle';
import { t } from '../strings';
@@ -20,6 +21,7 @@ export function ActivityCountLink() {
}
detail={
kinds.length
? kinds.map((kind) => t.activityCountSettings[kind]).join(' · ')
diff --git a/apps/mobile/src/ui/shell/activity-toolbar-base.tsx b/apps/mobile/src/ui/shell/activity-toolbar-base.tsx
index b477c3f93e..5a33232e81 100644
--- a/apps/mobile/src/ui/shell/activity-toolbar-base.tsx
+++ b/apps/mobile/src/ui/shell/activity-toolbar-base.tsx
@@ -1,6 +1,6 @@
import { useImperativeHandle, useState, type ReactNode, type Ref } from 'react';
import { Stack } from 'expo-router';
-import { Keyboard, Pressable, View } from 'react-native';
+import { Keyboard, Platform, Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDesignTheme } from '../design/theme';
@@ -105,7 +105,7 @@ export function ActivityToolbar({
{!unavailable && searching ? (
{
+ setFocused(next);
+ onFocusChange(next);
+ };
+ const insets = useSafeAreaInsets();
+ const reserved = useFloatingTabInset();
+ const lift = reserved - space.x5 + GAP;
+ const progress = useChromeProgress();
+ const hidden = useChromeHidden();
+ const style = useAnimatedStyle(() => ({
+ transform: [{ translateY: (lift + theme.touch) * progress.value }],
+ opacity: 1 - progress.value,
+ }));
+ return (
+
+
+
+ focus(true)}
+ onBlur={() => focus(false)}
+ trailing={
+ focused || value !== '' ? (
+ {
+ onChangeText('');
+ Keyboard.dismiss();
+ }}
+ />
+ ) : null
+ }
+ />
+
+
+
+ );
+}
diff --git a/apps/mobile/src/ui/shell/host-drawer.tsx b/apps/mobile/src/ui/shell/host-drawer.tsx
index 3c5bf89837..43cfdb0ba8 100644
--- a/apps/mobile/src/ui/shell/host-drawer.tsx
+++ b/apps/mobile/src/ui/shell/host-drawer.tsx
@@ -3,10 +3,8 @@ import { StatusBar } from 'expo-status-bar';
import { useMemo, useState, useSyncExternalStore, type ReactNode } from 'react';
import {
AccessibilityInfo,
- ActionSheetIOS,
BackHandler,
FlatList,
- Platform,
Pressable,
StyleSheet,
useWindowDimensions,
@@ -39,14 +37,16 @@ import { hostSession, useHostSession, useHostSnapshot, useInbox } from '../../ho
import { nativeCarrier } from '../../host/native-carrier';
import { useHostLook } from '../../host/native-host-look';
import { shownHostName, type HostLook } from '../design/host-look';
-import { glassShadow, useDesignTheme } from '../design/theme';
+import { glassShadow, pressTint, useDesignTheme, type DesignTheme } from '../design/theme';
import { useMounted } from '../mounted';
-import { drawerSpring, edge, motion, radius, scrim, space } from '../design/tokens';
+import { drawerSpring, edge, motion, radius, scrim, sidebar, space } from '../design/tokens';
+import { SidebarMaterial, useLiquidGlass } from '../primitives/drawer-material';
import { CountBadge } from '../primitives/count-badge';
import { Text } from '../primitives/text';
-import { showAppDialog } from '../primitives/app-dialog';
+import { showActionMenu, showAppDialog } from '../primitives/app-dialog';
import { t } from '../strings';
import { HostTile } from './host-tile';
+import { ActionMenuButton, type MenuAction } from '../primitives/action-menu-button';
import { WorkspaceTabs, type WorkspaceTab } from './workspace-tabs';
import { WorkspaceAddButton } from './workspace-add';
import {
@@ -58,7 +58,7 @@ import {
settleOpen,
type HostDrawerRow,
} from './host-drawer-model';
-import { ActivityIcon, CloudIcon, ComputerIcon, MoreIcon, PlusIcon } from './icons';
+import { ActivityIcon, CloudIcon, ComputerIcon, PlusIcon } from './icons';
export const hostDrawer = createHostDrawer({
switchTo: async (hostId) => {
@@ -171,11 +171,11 @@ function confirmUnpair(row: HostDrawerRow, name: string): void {
]);
}
-function openHostMenu(row: HostDrawerRow, name: string): void {
- const actions = [
+function hostMenuActions(row: HostDrawerRow, name: string): MenuAction[] {
+ return [
{
label: t.hostCustomize,
- icon: 'settings' as const,
+ icon: 'settings',
run: () =>
closeThen(() => router.push({ pathname: '/host-look', params: { hostId: row.hostId } })),
},
@@ -190,43 +190,44 @@ function openHostMenu(row: HostDrawerRow, name: string): void {
]),
{
label: t.unpair,
- icon: 'close' as const,
+ icon: 'close',
run: () => confirmUnpair(row, name),
destructive: true,
},
];
- if (Platform.OS === 'ios') {
- ActionSheetIOS.showActionSheetWithOptions(
- {
- title: name,
- options: [...actions.map((action) => action.label), t.cancel],
- destructiveButtonIndex: actions.findIndex((action) => action.destructive === true),
- cancelButtonIndex: actions.length,
- },
- (index) => actions[index]?.run(),
- );
- return;
- }
- showAppDialog(name, undefined, [
- { text: t.cancel, style: 'cancel' },
- ...actions.map((action) => ({
+}
+
+function openHostMenu(row: HostDrawerRow, name: string): void {
+ showActionMenu(
+ name,
+ hostMenuActions(row, name).map((action) => ({
text: action.label,
- icon: action.icon,
style: action.destructive === true ? ('destructive' as const) : ('default' as const),
onPress: action.run,
})),
- ]);
+ );
+}
+
+function rowStyle(theme: DesignTheme, glass: boolean, active: boolean) {
+ if (!glass)
+ return { backgroundColor: active ? theme.layer.sheet.pressed : theme.layer.sheet.fill };
+ return {
+ marginHorizontal: space.x3,
+ borderRadius: radius.lg,
+ backgroundColor: active ? pressTint(theme.color.fg) : 'transparent',
+ };
}
function DrawerTile({ look, name, current }: { look: HostLook; name: string; current: boolean }) {
const theme = useDesignTheme();
+ const glass = useLiquidGlass();
return (
+
closeThen(() => void hostDrawer.select(row.hostId))}
onLongPress={() => openHostMenu(row, name)}
@@ -276,9 +283,16 @@ function HostRow({ row, address }: { row: HostDrawerRow; address: string }) {
flexDirection: 'row',
alignItems: 'center',
gap: space.x4,
- paddingLeft: space.x4,
+ paddingLeft: glass ? space.x3 : space.x4,
paddingVertical: space.x3,
- backgroundColor: pressed ? theme.layer.sheet.pressed : theme.layer.sheet.fill,
+ borderRadius: glass ? radius.lg : 0,
+ backgroundColor: glass
+ ? pressed
+ ? pressTint(theme.color.fg)
+ : 'transparent'
+ : pressed
+ ? theme.layer.sheet.pressed
+ : theme.layer.sheet.fill,
})}
>
@@ -315,28 +329,18 @@ function HostRow({ row, address }: { row: HostDrawerRow; address: string }) {
/>
)}
- openHostMenu(row, name)}
- accessibilityRole="button"
- accessibilityLabel={t.hostMore(name)}
- hitSlop={space.x2}
- style={({ pressed }) => ({
- width: theme.touch,
- height: theme.touch,
- borderRadius: radius.round,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: pressed ? theme.layer.sheet.pressed : theme.layer.sheet.fill,
- })}
- >
-
-
+ openHostMenu(row, name)}
+ />
);
}
function FooterRow({ label, icon, onPress }: { label: string; icon: ReactNode; onPress(): void }) {
const theme = useDesignTheme();
+ const glass = useLiquidGlass();
return (
{icon}
@@ -376,6 +380,7 @@ function CloudServiceRow({
onPress(): void;
}) {
const theme = useDesignTheme();
+ const glass = useLiquidGlass();
return (
@@ -443,6 +448,7 @@ function WorkspaceRow({
onPress(): void;
}) {
const theme = useDesignTheme();
+ const glass = useLiquidGlass();
return (
({ opacity: clamp(progress.value) * fade }));
+ const travel = glass ? width + sidebar.inset : width;
const panelStyle = useAnimatedStyle(() => ({
- transform: [{ translateX: (progress.value - 1) * width }],
+ transform: [{ translateX: (progress.value - 1) * travel }],
}));
const swipe = Gesture.Pan()
@@ -628,25 +636,36 @@ export function HostDrawer() {
accessible={false}
onAccessibilityEscape={() => settle(false)}
style={[
- {
- position: 'absolute',
- top: 0,
- bottom: 0,
- left: 0,
- width,
- backgroundColor: theme.overlay.fill,
- borderTopRightRadius: radius.lg,
- borderBottomRightRadius: radius.lg,
- boxShadow: glassShadow(theme.scheme),
- overflow: 'hidden',
- },
+ glass
+ ? {
+ position: 'absolute',
+ top: sidebar.inset,
+ bottom: sidebar.inset,
+ left: sidebar.inset,
+ width,
+ borderRadius: sidebar.radius,
+ overflow: 'hidden',
+ }
+ : {
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ left: 0,
+ width,
+ backgroundColor: theme.overlay.fill,
+ borderTopRightRadius: radius.lg,
+ borderBottomRightRadius: radius.lg,
+ boxShadow: glassShadow(theme.scheme),
+ overflow: 'hidden',
+ },
panelStyle,
]}
>
+ {glass ? : null}
closeThen(() => {
@@ -760,7 +780,7 @@ export function HostDrawer() {
@@ -449,11 +440,13 @@ export function HostLookEditor({
on="canvas"
tone="danger"
label={t.hostLookReset}
- onPress={() => {
- setKind('accent');
- setNameDraft('');
- actions.onChange(DEFAULT_HOST_LOOK);
- }}
+ onPress={() =>
+ confirmDestructive(t.confirmAction(t.hostLookReset), undefined, t.hostLookReset, () => {
+ setKind('accent');
+ setNameDraft('');
+ actions.onChange(DEFAULT_HOST_LOOK);
+ })
+ }
/>
);
diff --git a/apps/mobile/src/ui/shell/navigation-destinations.ios.tsx b/apps/mobile/src/ui/shell/navigation-destinations.ios.tsx
index 3282ee19c1..4ecfab81b2 100644
--- a/apps/mobile/src/ui/shell/navigation-destinations.ios.tsx
+++ b/apps/mobile/src/ui/shell/navigation-destinations.ios.tsx
@@ -6,14 +6,16 @@ import {
buttonBorderShape,
buttonStyle,
font,
- fixedSize,
foregroundStyle,
frame,
+ lineLimit,
+ minimumScaleFactor,
+ monospacedDigit,
onLongPressGesture,
} from '@expo/ui/swift-ui/modifiers';
import { Platform } from 'react-native';
import { useDesignTheme } from '../design/theme';
-import { space } from '../design/tokens';
+import { navigationBar, space } from '../design/tokens';
import { badgeCount } from '../primitives/badge-count';
import { t } from '../strings';
import type { NavigationDestination } from './navigation-destination';
@@ -52,7 +54,16 @@ export function NavigationDestinations({ items }: { items: NavigationDestination
spacing={space.x1}
modifiers={[frame({ maxWidth: Infinity, minHeight: theme.touch })]}
>
-
+ {item.activity && !item.focused && item.count > 0 ? (
+
+ {badgeCount(item.count)}
+
+ ) : (
- {item.count > 0 && (
-
- {badgeCount(item.count)}
-
- )}
-
+ )}
{label}
diff --git a/apps/mobile/src/ui/shell/navigation-icon.tsx b/apps/mobile/src/ui/shell/navigation-icon.tsx
index f9dfa4962d..443e600966 100644
--- a/apps/mobile/src/ui/shell/navigation-icon.tsx
+++ b/apps/mobile/src/ui/shell/navigation-icon.tsx
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react';
import { View, type ColorValue } from 'react-native';
-import { space } from '../design/tokens';
import { Text } from '../primitives/text';
import { badgeCount } from '../primitives/badge-count';
@@ -8,6 +7,7 @@ export function NavigationIcon({
children,
color,
count = 0,
+ countVisible = false,
}: {
children: ReactNode;
focused: boolean;
@@ -22,14 +22,14 @@ export function NavigationIcon({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
- gap: space.x2,
}}
>
- {children}
- {count > 0 && (
-
+ {countVisible && count > 0 ? (
+
{badgeCount(count)}
+ ) : (
+ children
)}
);
diff --git a/apps/mobile/src/ui/shell/workspace-add.tsx b/apps/mobile/src/ui/shell/workspace-add.tsx
index 01f5843773..711d43731a 100644
--- a/apps/mobile/src/ui/shell/workspace-add.tsx
+++ b/apps/mobile/src/ui/shell/workspace-add.tsx
@@ -1,11 +1,19 @@
import { Pressable } from 'react-native';
import { edge, radius, space } from '../design/tokens';
-import { useDesignTheme } from '../design/theme';
+import { frosted, useDesignTheme } from '../design/theme';
import { Text } from '../primitives/text';
import { PlusIcon } from './icons';
-export function WorkspaceAddButton({ label, onPress }: { label: string; onPress(): void }) {
+export function WorkspaceAddButton({
+ label,
+ glass = false,
+ onPress,
+}: {
+ label: string;
+ glass?: boolean;
+ onPress(): void;
+}) {
const theme = useDesignTheme();
return (
diff --git a/apps/mobile/src/ui/strings.ts b/apps/mobile/src/ui/strings.ts
index 397a77c68f..9892641c9b 100644
--- a/apps/mobile/src/ui/strings.ts
+++ b/apps/mobile/src/ui/strings.ts
@@ -54,6 +54,7 @@ const en = {
verify: 'Check session',
refresh: 'Refresh session',
logout: 'Sign out',
+ logoutDetail: 'This phone stops receiving this service. You can sign in again later.',
signedIn: 'Signed in · Instance administrator',
signedInUser: 'Signed in',
onboardingIncomplete:
@@ -106,6 +107,7 @@ const en = {
reload: 'Reload agents',
empty: 'No agents in this channel.',
revoke: 'Revoke agent',
+ revokeDetail: 'The agent loses access to this organization at once.',
states: { active: 'Active', revoked: 'Revoked', expired: 'Expired' },
},
cloudJoin: {
@@ -142,6 +144,7 @@ const en = {
inbox: 'Channel invitations',
role: 'Channel guest',
revoke: 'Revoke channel access',
+ revokeDetail: 'The guest loses access to this channel at once.',
hint: 'This channel only, not its workspace. You see new messages after you accept.',
manage: 'Invite channel guest',
empty: 'No guest channels. Accept a channel invitation first.',
@@ -159,6 +162,7 @@ const en = {
empty: 'No invitations.',
accept: 'Accept invitation',
revoke: 'Revoke invitation',
+ revokeDetail: 'The invitation stops working and can no longer be accepted.',
expires: 'Expires:',
required:
'Verify your session. Managing invitations also requires the selected workspace owner.',
@@ -364,9 +368,11 @@ const en = {
'Could not save this choice. Statistics are paused on this launch. Please try again.',
pairTitle: 'Pair with your computer',
pairHint:
- 'On your computer, open DeepSeekBot settings → Devices → Pair a phone, then scan the code.',
+ 'On your computer, open DeepSeekBot Settings → Devices → Pair a phone, then scan the code it shows.',
allowCamera: 'Allow the camera to scan',
pasteLink: 'Or paste the pairing link',
+ pasteLinkHint:
+ 'When the camera can’t see the screen, choose Copy pairing link in the same window and paste it here. The code and the link work once and expire after 10 minutes.',
pair: 'Pair',
pairRefused: {
'link-invalid': 'This is not a DeepSeekBot pairing code.',
@@ -533,6 +539,8 @@ const en = {
reactions: 'Reactions',
copied: 'Copied',
cancel: 'Cancel',
+ remove: 'Remove',
+ confirmAction: (action: string) => `${action}?`,
copyHint: 'Long-press for Copy.',
messageActionsHint: 'Long-press to react, copy or share.',
messageActions: 'Message actions',
@@ -608,6 +616,7 @@ const en = {
unreadCount: (count: number) => (count === 1 ? '1 unread message' : `${count} unread messages`),
mutedUnread: (count: number) =>
count === 1 ? 'muted, 1 unread message' : `muted, ${count} unread messages`,
+ closeSearch: 'Close search',
searchPlaceholder: 'Search Bots and Channels',
searchNoMatch: (query: string) => `No Bot or Channel matches “${query}”.`,
pinnedSection: 'Pinned',
@@ -1046,6 +1055,7 @@ const zh: typeof en = {
verify: '核验会话',
refresh: '刷新会话',
logout: '退出登录',
+ logoutDetail: '这台手机将不再接收此服务的消息,之后可以重新登录。',
signedIn: '已登录 · 实例管理员',
signedInUser: '已登录',
onboardingIncomplete: '登录会话已保存,工作区尚未准备完成。请检查会话以重试。',
@@ -1092,6 +1102,7 @@ const zh: typeof en = {
reload: '刷新 Agent',
empty: '这个频道还没有 Agent。',
revoke: '撤销 Agent',
+ revokeDetail: '该 Agent 会立即失去对这个组织的访问权限。',
states: { active: '有效', revoked: '已撤销', expired: '已过期' },
},
cloudJoin: {
@@ -1126,6 +1137,7 @@ const zh: typeof en = {
inbox: '频道邀请',
role: '频道访客',
revoke: '撤销频道访问',
+ revokeDetail: '该访客会立即失去对这个频道的访问权限。',
hint: '只加入此频道,不加入工作区。接受后可看到新消息。',
manage: '邀请频道访客',
empty: '尚无访客频道,请先接受频道邀请。',
@@ -1143,6 +1155,7 @@ const zh: typeof en = {
empty: '暂无邀请。',
accept: '接受邀请',
revoke: '撤销邀请',
+ revokeDetail: '邀请会立即失效,无法再被接受。',
expires: '到期时间:',
required: '请核验登录状态,管理邀请还需要当前工作区所有者权限。',
states: { pending: '待接受', accepted: '已接受', revoked: '已撤销', expired: '已过期' },
@@ -1346,6 +1359,8 @@ const zh: typeof en = {
pairHint: '在电脑上打开 DeepSeekBot 设置 → 设备 → 配对手机,然后扫描二维码。',
allowCamera: '允许使用相机扫码',
pasteLink: '或粘贴配对链接',
+ pasteLinkHint:
+ '摄像头扫不到时,在同一窗口点「复制配对链接」,再粘贴到这里。二维码和链接都只能用一次,10 分钟后失效。',
pair: '配对',
pairRefused: {
'link-invalid': '这不是 DeepSeekBot 的配对二维码。',
@@ -1506,6 +1521,8 @@ const zh: typeof en = {
reactions: '回应',
copied: '已复制',
cancel: '取消',
+ remove: '移除',
+ confirmAction: (action: string) => `${action}?`,
copyHint: '长按可复制。',
messageActionsHint: '长按可回应、复制或分享。',
messageActions: '消息操作',
@@ -1580,6 +1597,7 @@ const zh: typeof en = {
needsYouCount: (count: number) => (count === 1 ? '等你处理' : `${count} 项等你处理`),
unreadCount: (count: number) => `${count} 条未读消息`,
mutedUnread: (count: number) => `已静音,${count} 条未读消息`,
+ closeSearch: '关闭搜索',
searchPlaceholder: '搜索 Bot 和频道',
searchNoMatch: (query: string) => `没有名称包含“${query}”的 Bot 或频道。`,
pinnedSection: '已置顶',
diff --git a/apps/mobile/test/action-menu-android.test.tsx b/apps/mobile/test/action-menu-android.test.tsx
new file mode 100644
index 0000000000..0f97f2d9bf
--- /dev/null
+++ b/apps/mobile/test/action-menu-android.test.tsx
@@ -0,0 +1,78 @@
+// @vitest-environment jsdom
+import { act, createElement, type ReactNode } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('react-native', async () => await import('./react-native-shim'));
+vi.mock('@expo/ui/jetpack-compose', () => {
+ const pass = ({ children }: { children?: ReactNode }) => children ?? null;
+ const DropdownMenu = ({ expanded, children }: { expanded: boolean; children: ReactNode }) =>
+ createElement('div', { 'data-expanded': String(expanded) }, children);
+ DropdownMenu.Trigger = pass;
+ DropdownMenu.Items = ({ children }: { children: ReactNode }) =>
+ createElement('ul', null, children);
+ const DropdownMenuItem = ({
+ children,
+ onClick,
+ elementColors,
+ }: {
+ children: ReactNode;
+ onClick(): void;
+ elementColors?: { textColor?: string };
+ }) => createElement('li', { onClick, 'data-color': elementColors?.textColor ?? '' }, children);
+ DropdownMenuItem.Text = pass;
+ return {
+ Box: () => null,
+ DropdownMenu,
+ DropdownMenuItem,
+ HorizontalDivider: () => createElement('hr'),
+ Host: pass,
+ Text: pass,
+ };
+});
+vi.mock('../src/ui/shell/icons', () => ({ MoreIcon: () => null }));
+vi.mock('@expo/ui/jetpack-compose/modifiers', () => ({ fillMaxSize: () => ({}) }));
+
+import { ActionMenuButton } from '../src/ui/primitives/action-menu-button.android';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+let root: Root | undefined;
+let host: HTMLElement | undefined;
+afterEach(() => {
+ act(() => root?.unmount());
+ host?.remove();
+});
+
+describe('Android action menu', () => {
+ it('opens a native dropdown from ⋯ and runs the chosen action', () => {
+ const customize = vi.fn();
+ const unpair = vi.fn();
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+ act(() =>
+ root?.render(
+ createElement(ActionMenuButton, {
+ label: 'More for Studio',
+ onOpen: () => undefined,
+ actions: [
+ { label: 'Customize', icon: 'settings', run: customize },
+ { label: 'Unpair', icon: 'close', run: unpair, destructive: true },
+ ],
+ }),
+ ),
+ );
+ const menu = () => host?.querySelector('[data-expanded]');
+ expect(menu()?.getAttribute('data-expanded')).toBe('false');
+ act(() => host?.querySelector('[aria-label="More for Studio"]')?.click());
+ expect(menu()?.getAttribute('data-expanded')).toBe('true');
+ const items = [...(host?.querySelectorAll('li') ?? [])];
+ expect(items.map((item) => item.textContent)).toEqual(['Customize', 'Unpair']);
+ expect(items[1]?.getAttribute('data-color')).not.toBe('');
+ expect(items[1]?.previousElementSibling?.tagName).toBe('HR');
+ act(() => items[1]?.click());
+ expect(unpair).toHaveBeenCalledOnce();
+ expect(customize).not.toHaveBeenCalled();
+ expect(menu()?.getAttribute('data-expanded')).toBe('false');
+ });
+});
diff --git a/apps/mobile/test/activity-feed.test.ts b/apps/mobile/test/activity-feed.test.ts
index bd53c2a79b..95c4d446d0 100644
--- a/apps/mobile/test/activity-feed.test.ts
+++ b/apps/mobile/test/activity-feed.test.ts
@@ -139,6 +139,17 @@ async function feed(
return container;
}
+async function clearSearch(view: HTMLElement): Promise {
+ expect(view.querySelector(`button[aria-label="${t.activityClearSearch}"]`)).toBeNull();
+ const field = view.querySelector(
+ `textarea[aria-label="${t.activitySearch}"]`,
+ )!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(field, '');
+ field.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+}
+
function button(view: HTMLElement, label: string): HTMLButtonElement {
const found = view.querySelector(`button[aria-label="${label}"]`);
if (found === null) throw new Error(`no button ${label}`);
@@ -358,7 +369,7 @@ describe('Activity feed', () => {
await press(button(view, t.cancel));
await press(button(view, t.activitySearch));
expect(view.querySelector('textarea')?.value).toBe('budget');
- await press(button(view, t.activityClearSearch));
+ await clearSearch(view);
expect(view.querySelector('textarea')?.value).toBe('');
expect(queries.at(-1)?.search).toBeUndefined();
expect(view.textContent).toContain(t.inboxSections.action);
@@ -383,7 +394,7 @@ describe('Activity feed', () => {
await settle();
expect(view.textContent).toContain(t.activityNoResults);
expect(view.querySelectorAll('[role=tab]')).toHaveLength(3);
- await press(button(view, t.activityClearSearch));
+ await clearSearch(view);
expect(view.textContent).not.toContain(t.activityNoResults);
expect(view.textContent).toContain(t.inboxSections.action);
});
diff --git a/apps/mobile/test/app-dialog-host-android.test.tsx b/apps/mobile/test/app-dialog-host-android.test.tsx
new file mode 100644
index 0000000000..c7994f230c
--- /dev/null
+++ b/apps/mobile/test/app-dialog-host-android.test.tsx
@@ -0,0 +1,167 @@
+// @vitest-environment jsdom
+import { act, createElement, type ReactNode } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('react-native', async () => ({
+ ...(await import('./react-native-shim')),
+ Platform: { OS: 'android' },
+ Keyboard: { dismiss() {} },
+}));
+vi.mock('@expo/ui/jetpack-compose', () => {
+ const pass = ({ children }: { children?: ReactNode }) => children ?? null;
+ const slot =
+ (name: string) =>
+ ({ children }: { children?: ReactNode }) =>
+ createElement('section', { 'data-slot': name }, children);
+ const AlertDialog = ({
+ children,
+ properties,
+ }: {
+ children: ReactNode;
+ properties: { dismissOnBackPress: boolean };
+ }) =>
+ createElement(
+ 'div',
+ { role: 'dialog', 'data-back': String(properties.dismissOnBackPress) },
+ children,
+ );
+ AlertDialog.Title = slot('title');
+ AlertDialog.Text = slot('text');
+ AlertDialog.ConfirmButton = slot('confirm');
+ AlertDialog.DismissButton = slot('dismiss');
+ const TextButton = ({
+ children,
+ onClick,
+ enabled,
+ colors,
+ }: {
+ children: ReactNode;
+ onClick(): void;
+ enabled?: boolean;
+ colors?: { contentColor?: string };
+ }) =>
+ createElement(
+ 'button',
+ { onClick, disabled: enabled === false, 'data-color': colors?.contentColor ?? '' },
+ children,
+ );
+ const ModalBottomSheet = ({ children }: { children: ReactNode }) =>
+ createElement('div', { role: 'menu' }, children);
+ const ListItem = ({
+ children,
+ modifiers,
+ }: {
+ children: ReactNode;
+ modifiers?: Array<{ onClick?: () => void }>;
+ }) =>
+ createElement(
+ 'li',
+ {
+ onClick: modifiers?.[0]?.onClick,
+ 'data-clickable': String(modifiers !== undefined),
+ },
+ children,
+ );
+ ListItem.HeadlineContent = pass;
+ return {
+ AlertDialog,
+ Column: pass,
+ HorizontalDivider: () => createElement('hr'),
+ Host: pass,
+ ListItem,
+ ModalBottomSheet,
+ Text: pass,
+ TextButton,
+ };
+});
+vi.mock('@expo/ui/jetpack-compose/modifiers', () => ({
+ clickable: (onClick: () => void) => ({ onClick }),
+}));
+
+import { AppDialogHost } from '../src/ui/primitives/app-dialog-host';
+import { appDialogs } from '../src/ui/primitives/dialog-state';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+let root: Root | undefined;
+let host: HTMLElement | undefined;
+afterEach(() => {
+ act(() => root?.unmount());
+ host?.remove();
+ appDialogs.dismiss();
+});
+
+function mount() {
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+ act(() => root?.render(createElement(AppDialogHost)));
+ return host;
+}
+const buttons = (view: HTMLElement, slot: string) => [
+ ...view.querySelectorAll(`[data-slot="${slot}"] button`),
+];
+
+describe('Android dialogs use the Material 3 AlertDialog', () => {
+ it('confirms with Cancel as the dismiss button and the destructive action as confirm', async () => {
+ const unpair = vi.fn(async () => undefined);
+ const view = mount();
+ act(() =>
+ appDialogs.open('Unpair Studio?', 'Phone only', [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Unpair', style: 'destructive', onPress: unpair },
+ ]),
+ );
+ expect(view.querySelector('[data-slot="title"]')?.textContent).toBe('Unpair Studio?');
+ expect(view.querySelector('[data-slot="text"]')?.textContent).toBe('Phone only');
+ expect(buttons(view, 'dismiss').map((button) => button.textContent)).toEqual(['Cancel']);
+ const [confirm] = buttons(view, 'confirm');
+ expect(confirm?.textContent).toBe('Unpair');
+ expect(confirm?.dataset.color).not.toBe('');
+ await act(async () => confirm?.click());
+ expect(unpair).toHaveBeenCalledOnce();
+ expect(view.querySelector('[role="dialog"]')).toBeNull();
+ });
+
+ it('shows a menu as a Material bottom sheet list with a divider before the destructive item', () => {
+ const open = vi.fn();
+ const remove = vi.fn();
+ const view = mount();
+ act(() =>
+ appDialogs.open('Ada', undefined, [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Remove', style: 'destructive', onPress: remove },
+ { text: 'Open profile', onPress: open },
+ ]),
+ );
+ expect(view.querySelector('[role="dialog"]')).toBeNull();
+ const items = [...view.querySelectorAll('[role="menu"] li')];
+ expect(items.map((item) => item.textContent)).toEqual(['Ada', 'Remove', 'Open profile']);
+ expect(view.querySelector('[role="menu"] hr')).not.toBeNull();
+ expect(items[0]?.getAttribute('data-clickable')).toBe('false');
+ act(() => (items[2] as HTMLElement).click());
+ expect(open).toHaveBeenCalledOnce();
+ expect(remove).not.toHaveBeenCalled();
+ });
+
+ it('blocks Back and both buttons while the action is pending', async () => {
+ let finish: () => void = () => undefined;
+ const view = mount();
+ act(() =>
+ appDialogs.open('Sign out?', undefined, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Sign out',
+ style: 'destructive',
+ onPress: () => new Promise((resolve) => (finish = resolve)),
+ },
+ ]),
+ );
+ act(() => buttons(view, 'confirm')[0]?.click());
+ expect(view.querySelector('[role="dialog"]')?.getAttribute('data-back')).toBe('false');
+ expect(buttons(view, 'confirm')[0]?.disabled).toBe(true);
+ expect(buttons(view, 'dismiss')[0]?.disabled).toBe(true);
+ await act(async () => finish());
+ expect(view.querySelector('[role="dialog"]')).toBeNull();
+ });
+});
diff --git a/apps/mobile/test/app-dialog.test.tsx b/apps/mobile/test/app-dialog.test.tsx
index c7e824043f..f372805ddb 100644
--- a/apps/mobile/test/app-dialog.test.tsx
+++ b/apps/mobile/test/app-dialog.test.tsx
@@ -1,25 +1,59 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-const { platform, nativeAlert } = vi.hoisted(() => ({
+const { platform, nativeAlert, actionSheet } = vi.hoisted(() => ({
platform: { OS: 'android' },
nativeAlert: vi.fn(),
+ actionSheet: vi.fn(),
}));
vi.mock('react-native', () => ({
Platform: platform,
Alert: { alert: nativeAlert },
+ ActionSheetIOS: { showActionSheetWithOptions: actionSheet },
Keyboard: { dismiss() {} },
}));
-import { showAppDialog } from '../src/ui/primitives/app-dialog';
+import { confirmDestructive, showActionMenu, showAppDialog } from '../src/ui/primitives/app-dialog';
import { appDialogs, createDialogState } from '../src/ui/primitives/dialog-state';
afterEach(() => {
appDialogs.dismiss();
nativeAlert.mockReset();
+ actionSheet.mockReset();
platform.OS = 'android';
});
describe('app dialogs', () => {
+ it('shows an iOS action sheet with the destructive action last and Cancel apart', () => {
+ platform.OS = 'ios';
+ const remove = vi.fn();
+ const open = vi.fn();
+ showActionMenu('Bo', [
+ { text: 'Remove', style: 'destructive', onPress: remove },
+ { text: 'Open profile', onPress: open },
+ ]);
+ const [options, pick] = actionSheet.mock.calls[0]!;
+ expect(options).toEqual({
+ title: 'Bo',
+ options: ['Open profile', 'Remove', 'Cancel'],
+ destructiveButtonIndex: 1,
+ cancelButtonIndex: 2,
+ });
+ pick(2);
+ expect(remove).not.toHaveBeenCalled();
+ pick(0);
+ expect(open).toHaveBeenCalled();
+ expect(nativeAlert).not.toHaveBeenCalled();
+ });
+
+ it('asks before a destructive action with Cancel first', () => {
+ platform.OS = 'ios';
+ confirmDestructive('Sign out?', undefined, 'Sign out', vi.fn());
+ expect(nativeAlert.mock.calls[0]![2]).toEqual([
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Sign out', style: 'destructive', onPress: expect.any(Function) },
+ ]);
+ });
+
it('routes Android through the app surface and leaves iOS native', () => {
showAppDialog('Unpair?', 'Phone only', [{ text: 'Cancel', style: 'cancel' }]);
expect(nativeAlert).not.toHaveBeenCalled();
diff --git a/apps/mobile/test/drawer-material.test.tsx b/apps/mobile/test/drawer-material.test.tsx
index 4b19924c93..a25e772e49 100644
--- a/apps/mobile/test/drawer-material.test.tsx
+++ b/apps/mobile/test/drawer-material.test.tsx
@@ -27,13 +27,14 @@ vi.mock('@expo/ui/swift-ui/modifiers', () => ({
foregroundStyle: () => ({}),
accessibilityHidden: () => ({}),
glassEffect: () => ({}),
+ ignoreSafeArea: () => ({}),
}));
vi.mock('@expo/ui/swift-ui', () => ({
Host: ({ children }: { children: ReactNode }) => createElement('div', null, children),
Capsule: () => createElement('div', { 'data-material': 'capsule' }),
RoundedRectangle: () => createElement('div', { 'data-material': 'panel' }),
}));
-import { DrawerMaterial } from '../src/ui/primitives/drawer-material.ios';
+import { DrawerMaterial, useLiquidGlass } from '../src/ui/primitives/drawer-material.ios';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
it('uses panel glass only when supported and keeps live transparency and drawer-title fallbacks', async () => {
@@ -64,3 +65,29 @@ it('uses panel glass only when supported and keeps live transparency and drawer-
expect(native.listeners.size).toBe(0);
}
});
+
+it('floats the sidebar on glass only on iOS 26 with transparency allowed', async () => {
+ const container = document.createElement('div');
+ const root = createRoot(container);
+ const Probe = () => createElement('span', null, useLiquidGlass() ? 'glass' : 'opaque');
+ try {
+ await act(async () => root.render());
+ expect(container.textContent).toBe('glass');
+ await act(async () => {
+ native.reduced = true;
+ native.listeners.forEach((notify) => notify(true));
+ });
+ expect(container.textContent).toBe('opaque');
+ await act(async () => {
+ native.reduced = false;
+ native.listeners.forEach((notify) => notify(false));
+ });
+ native.version = 18;
+ await act(async () => root.render());
+ expect(container.textContent).toBe('opaque');
+ } finally {
+ act(() => root.unmount());
+ native.version = 26;
+ native.reduced = false;
+ }
+});
diff --git a/apps/mobile/test/group-info.test.tsx b/apps/mobile/test/group-info.test.tsx
index 23fbd93c7d..766f63a26c 100644
--- a/apps/mobile/test/group-info.test.tsx
+++ b/apps/mobile/test/group-info.test.tsx
@@ -173,7 +173,10 @@ describe('Group info', () => {
it('removes a member only after the confirmation', async () => {
const props = sheet();
const view = await render();
- await act(async () => button(view, t.groupMemberMenu('Bo'))!.click());
+ const menu = button(view, t.groupMemberMenu('Bo'))!;
+ expect(menu.parentElement?.closest('button')).toBeNull();
+ await act(async () => menu.click());
+ expect(props.onOpenBot).not.toHaveBeenCalled();
await pressAlert(0, t.groupRemove);
expect(props.actions.removeMember).not.toHaveBeenCalled();
expect(alert.mock.calls[1]![0]).toBe(t.groupRemoveConfirm('Bo'));
diff --git a/apps/mobile/test/home-search.test.tsx b/apps/mobile/test/home-search.test.tsx
index 3f3200705c..b0c8123390 100644
--- a/apps/mobile/test/home-search.test.tsx
+++ b/apps/mobile/test/home-search.test.tsx
@@ -70,6 +70,9 @@ vi.mock('react-native', () => import('./react-native-shim'));
vi.mock('react-native-reanimated', () => import('./reanimated-shim'));
vi.mock('react-native-worklets', () => ({ scheduleOnRN: (callback: () => void) => callback() }));
vi.mock('react-native-svg', () => ({ default: () => null, Path: () => null }));
+vi.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
+}));
vi.mock('expo-router', () => {
const Stack = () => null;
Stack.Screen = () => null;
@@ -166,13 +169,13 @@ const rows = (view: HTMLElement) =>
.filter((node) => !node.closest('[hidden]') && !node.dataset.testid)
.filter(
(node) =>
- ![t.cloudJoin.newOrJoin, t.allEnvironments, 'studio'].includes(
+ ![t.cloudJoin.newOrJoin, t.allEnvironments, 'studio', t.closeSearch].includes(
node.getAttribute('aria-label') ?? '',
),
)
.map((node) => node.getAttribute('aria-label'));
-describe('Search at the top of Chats', () => {
+describe('Search above the bottom navigation', () => {
it('keeps the Computer usable when Cloud local state cannot load', async () => {
cloudState.ready = false;
cloudState.error = 'STORAGE_UNAVAILABLE';
@@ -323,4 +326,18 @@ describe('Search at the top of Chats', () => {
expect(rows(view)).toEqual([]);
expect(view.textContent).toContain(t.searchNoMatch('zed'));
});
+ it('closes search, clearing the query, from the button beside the field', async () => {
+ const view = await home();
+ expect(view.querySelector(`button[aria-label="${t.closeSearch}"]`)).toBeNull();
+ await type(view, 'ada');
+ expect(view.textContent).not.toContain('Launch team');
+ await act(async () =>
+ view.querySelector(`button[aria-label="${t.closeSearch}"]`)!.click(),
+ );
+ const field = view.querySelector(
+ `textarea[aria-label="${t.searchPlaceholder}"]`,
+ )!;
+ expect(field.value).toBe('');
+ expect(view.textContent).toContain('Launch team');
+ });
});
diff --git a/apps/mobile/test/host-look.test.tsx b/apps/mobile/test/host-look.test.tsx
index c84c6673b7..8dd43985cc 100644
--- a/apps/mobile/test/host-look.test.tsx
+++ b/apps/mobile/test/host-look.test.tsx
@@ -4,7 +4,11 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('react-native-keyboard-controller', async () => import('./keyboard-controller-shim'));
-vi.mock('react-native', () => import('./react-native-shim'));
+const alert = vi.fn();
+vi.mock('react-native', async () => ({
+ ...(await import('./react-native-shim')),
+ Alert: { alert: (...args: unknown[]) => alert(...args) },
+}));
vi.mock('react-native-svg', () => ({
default: () => null,
Path: () => null,
@@ -287,6 +291,13 @@ describe('Customize computer sheet', () => {
});
await act(async () => button(view, t.hostLookReset)!.click());
+ expect(alert.mock.calls.at(-1)![0]).toBe(t.confirmAction(t.hostLookReset));
+ const buttons = alert.mock.calls.at(-1)![2] as {
+ text: string;
+ onPress?: () => void;
+ }[];
+ expect(buttons.map((entry) => entry.text)).toEqual([t.cancel, t.hostLookReset]);
+ await act(async () => buttons[1]!.onPress!());
expect(handlers.onChange).toHaveBeenLastCalledWith(DEFAULT_HOST_LOOK);
await act(async () => button(view, t.done)!.click());
expect(handlers.onClose).toHaveBeenCalled();
diff --git a/apps/mobile/test/native-navigation-controls.test.tsx b/apps/mobile/test/native-navigation-controls.test.tsx
index 958c18ef93..5073e420de 100644
--- a/apps/mobile/test/native-navigation-controls.test.tsx
+++ b/apps/mobile/test/native-navigation-controls.test.tsx
@@ -14,9 +14,11 @@ vi.mock('@expo/ui/swift-ui/modifiers', () => ({
buttonStyle: () => ({}),
buttonBorderShape: () => ({}),
font: () => ({}),
- fixedSize: () => ({}),
+ lineLimit: () => ({}),
+ minimumScaleFactor: () => ({}),
foregroundStyle: () => ({}),
frame: () => ({}),
+ monospacedDigit: () => ({}),
}));
vi.mock('@expo/ui/swift-ui', () => {
const container = ({ children }: { children: ReactNode }) => createElement('div', null, children);
@@ -25,7 +27,7 @@ vi.mock('@expo/ui/swift-ui', () => {
HStack: container,
VStack: container,
Text: container,
- Image: () => null,
+ Image: ({ systemName }: { systemName: string }) => createElement('i', { title: systemName }),
Button: ({
onPress,
children,
@@ -93,6 +95,9 @@ it('native controls preserve selection, unread label and distinct navigation/cre
const buttons = [...host.querySelectorAll('button')];
expect(buttons[0]!.getAttribute('aria-pressed')).toBe('true');
expect(buttons[1]!.getAttribute('aria-label')).toContain('4');
+ expect(buttons[1]!.querySelector('i')).toBeNull();
+ expect(buttons[1]!.textContent).toContain('4');
+ expect(buttons[0]!.querySelector('i')?.title).toBe('bubble.left.and.bubble.right.fill');
await act(async () => {
buttons[0]!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }));
buttons[1]!.click();
diff --git a/apps/mobile/test/settings-sheet.test.tsx b/apps/mobile/test/settings-sheet.test.tsx
index d58602e576..4e28c15776 100644
--- a/apps/mobile/test/settings-sheet.test.tsx
+++ b/apps/mobile/test/settings-sheet.test.tsx
@@ -3,8 +3,13 @@ import { act, type ReactElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
+vi.mock('../src/ui/primitives/row-icon', () => ({ RowIcon: () => null }));
vi.mock('react-native-keyboard-controller', async () => import('./keyboard-controller-shim'));
-vi.mock('react-native', () => import('./react-native-shim'));
+const alert = vi.fn();
+vi.mock('react-native', async () => ({
+ ...(await import('./react-native-shim')),
+ Alert: { alert: (...args: unknown[]) => alert(...args) },
+}));
vi.mock('expo-router', () => ({ useRouter: () => ({ back() {} }) }));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
@@ -111,10 +116,24 @@ describe('Settings sheet', () => {
actions={actions({ onUnpair })}
/>,
);
+ const confirm = async (text: string) => {
+ const buttons = alert.mock.calls.at(-1)![2] as {
+ text: string;
+ onPress?: () => void;
+ }[];
+ await act(async () => buttons.find((entry) => entry.text === text)!.onPress?.());
+ };
+ await act(async () => button(view, t.unpair)!.click());
+ expect(alert.mock.calls.at(-1)![0]).toBe(t.unpairHost('studio'));
+ await confirm(t.cancel);
+ expect(onUnpair).not.toHaveBeenCalled();
+
await act(async () => button(view, t.unpair)!.click());
+ await confirm(t.unpair);
expect(dangerNotices(view)).toEqual([`${t.unpairFailed} cannot reach your computer.`]);
await act(async () => button(view, t.unpair)!.click());
+ await confirm(t.unpair);
expect(onUnpair).toHaveBeenCalledTimes(2);
expect(dangerNotices(view)).toEqual([]);
});
diff --git a/apps/mobile/test/style-guard.test.ts b/apps/mobile/test/style-guard.test.ts
index 675f0524f4..ee622d3b03 100644
--- a/apps/mobile/test/style-guard.test.ts
+++ b/apps/mobile/test/style-guard.test.ts
@@ -7,7 +7,6 @@ const root = fileURLToPath(new URL('..', import.meta.url));
const LEGACY = new Set([
'app/full-client.tsx',
- 'app/pair.tsx',
'app/inbox/[id].tsx',
'src/ui/approval-view.tsx',
'src/ui/connection-banner.tsx',
diff --git a/apps/mobile/test/usage-screen.test.tsx b/apps/mobile/test/usage-screen.test.tsx
index 228f8c484e..24c55848f5 100644
--- a/apps/mobile/test/usage-screen.test.tsx
+++ b/apps/mobile/test/usage-screen.test.tsx
@@ -3,6 +3,7 @@ import { act, type ReactElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
+vi.mock('../src/ui/primitives/row-icon', () => ({ RowIcon: () => null }));
vi.mock('react-native-keyboard-controller', async () => import('./keyboard-controller-shim'));
vi.mock('react-native', () => import('./react-native-shim'));
vi.mock('expo-image', () => ({ Image: () => null }));
diff --git a/changes/2333-ios-hig-glass.md b/changes/2333-ios-hig-glass.md
new file mode 100644
index 0000000000..70a894038e
--- /dev/null
+++ b/changes/2333-ios-hig-glass.md
@@ -0,0 +1,11 @@
+---
+section: Changed
+---
+
+## en
+
+- On iOS 26 the phone's Computers / Cloud sidebar floats on Liquid Glass with a native pull-down menu for each computer, and the Chats search field sits just above the bottom navigation where the thumb reaches it. Form fields share one look, item menus use iOS action sheets, and unpairing from Settings, removing folder access, resetting a computer's look, revoking cloud access and signing out now ask first. In chats, messages blur and fade softly under the header and the composer, and the header buttons and title use Liquid Glass. Settings rows show an icon beside each label, the pairing sheet fits on one screen with short guides under the QR code and the paste field, and the Activity tab shows either its bell or the pending count, never both, on iOS and Android alike. On Android, confirmations and menus are now Material 3 system dialogs and each computer's ⋯ opens a Material dropdown ([#2333](https://github.com/BotHarness/DeepSeekBot/pull/2333)).
+
+## zh
+
+- 在 iOS 26 上,手机端的「电脑 / 云端」侧边栏改为悬浮的液态玻璃面板,每台电脑的 ⋯ 打开原生下拉菜单;聊天搜索框移到底部导航上方,单手更容易点到。表单输入框统一样式,条目菜单改用 iOS 操作表;在设置中取消配对、移除文件夹授权、重置电脑外观、撤销云端访问和退出登录前都会先确认。聊天页里,消息在顶部标题栏和底部输入框下方柔和地模糊并渐隐,标题栏按钮和标题胶囊也改用液态玻璃。设置列表每行带图标;配对 sheet 一屏放下,二维码和粘贴框下方各有简短说明;Activity 标签在 iOS 和 Android 上一致,显示铃铛或待处理数字,二者不会同时出现。Android 上的确认框和选项菜单改用 Material 3 系统对话框,每台电脑的 ⋯ 打开 Material 下拉菜单([#2333](https://github.com/BotHarness/DeepSeekBot/pull/2333))。