diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index a3490ddbbc..4a25591cf7 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -68,8 +68,18 @@
"expo-image",
"expo-audio",
"expo-web-browser",
- "expo-font",
- "expo-splash-screen",
+ [
+ "expo-splash-screen",
+ {
+ "image": "./assets/splash-icon.png",
+ "imageWidth": 200,
+ "backgroundColor": "#ffffff",
+ "dark": {
+ "image": "./assets/splash-icon-dark.png",
+ "backgroundColor": "#000000"
+ }
+ }
+ ],
[
"expo-notifications",
{
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index dd8c24f70d..da9229d154 100644
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -17,18 +17,21 @@ import {
} from "@/app-shell";
import { RootNavigator, RouteErrorBoundary } from "@/screens";
import { ThemeProvider } from "@/theme";
-import { useAppFonts } from "@/theme/useAppFonts";
import { SheetProvider, Toaster } from "@/ui";
+// Keep the native splash up until boot finishes; `RootLayout` hides it once
+// `useAppBoot` is ready. Module scope so it runs before the first render
+// (fonts are the platform system faces, so nothing else is awaited).
+void SplashScreen.preventAutoHideAsync().catch(() => undefined);
+
// Deep links into a pushed screen still get home underneath.
export const unstable_settings = { anchor: "index" };
export { RouteErrorBoundary as ErrorBoundary };
export default function RootLayout() {
- const fonts = useAppFonts();
const boot = useAppBoot();
- const ready = fonts.ready && boot.ready;
+ const ready = boot.ready;
useEffect(() => {
if (ready) void SplashScreen.hideAsync().catch(() => undefined);
diff --git a/apps/mobile/app/dev/ui.tsx b/apps/mobile/app/dev/ui.tsx
index 286d9f04ac..29b44668fe 100644
--- a/apps/mobile/app/dev/ui.tsx
+++ b/apps/mobile/app/dev/ui.tsx
@@ -3,7 +3,7 @@
import { BUILTIN_THEME_IDS } from "@bb/domain";
import { Redirect } from "expo-router";
import { useMemo, useState, type ReactNode } from "react";
-import { ScrollView, View } from "react-native";
+import { Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { e2eModeEnabled } from "@/app-shell";
import { VoiceBar, type VoiceBarController } from "@/composer";
@@ -13,12 +13,17 @@ import {
ActionSheet,
Badge,
Button,
+ confirmDestructive,
EmptyState,
EmptyStatePanel,
+ GroupedRow,
+ GroupedSection,
ICON_NAMES,
Icon,
+ IconBadge,
Input,
ListRow,
+ NativeMenu,
Pill,
Separator,
Sheet,
@@ -29,6 +34,8 @@ import {
TextArea,
toast,
useSheet,
+ type ActionSheetAction,
+ type NativeMenuAction,
} from "@/ui";
function Section({ title, children }: { title: string; children: ReactNode }) {
@@ -58,9 +65,11 @@ function syntheticVoiceLevel(): number {
function UiGalleryScreen() {
const insets = useSafeAreaInsets();
const theme = useTheme();
+ const { tokens } = theme;
const [checked, setChecked] = useState(true);
const [text, setText] = useState("");
const [pressed, setPressed] = useState(false);
+ const [sort, setSort] = useState<"recent" | "name">("recent");
const [voiceState, setVoiceState] = useState<"recording" | "transcribing">(
"recording",
);
@@ -76,10 +85,68 @@ function UiGalleryScreen() {
const sheet = useSheet();
const scrollSheet = useSheet();
const menu = useSheet();
+ const sortSheet = useSheet();
+
+ const menuActions: NativeMenuAction[] = [
+ {
+ key: "open",
+ label: "Open",
+ icon: "ArrowUpRight",
+ onPress: () => toast.message("Open"),
+ },
+ {
+ key: "pin",
+ label: "Pin",
+ icon: "Pin",
+ onPress: () => toast.message("Pin"),
+ },
+ {
+ key: "rename",
+ label: "Rename",
+ icon: "Edit",
+ onPress: () => toast.message("Rename"),
+ },
+ {
+ key: "archive",
+ label: "Archive",
+ icon: "Archive",
+ onPress: () => toast.message("Archive"),
+ },
+ {
+ key: "delete",
+ label: "Delete",
+ icon: "Trash2",
+ destructive: true,
+ onPress: () =>
+ confirmDestructive({
+ title: "Delete thread?",
+ message: "This cannot be undone.",
+ actionLabel: "Delete",
+ onConfirm: () => toast.error("Deleted"),
+ }),
+ },
+ ];
+ const sortActions: ActionSheetAction[] = [
+ {
+ key: "recent",
+ label: "Recent",
+ icon: "Clock",
+ checked: sort === "recent",
+ onPress: () => setSort("recent"),
+ },
+ {
+ key: "name",
+ label: "Name",
+ icon: "Sort",
+ checked: sort === "name",
+ onPress: () => setSort("name"),
+ },
+ ];
return (
(
{key}
@@ -134,17 +203,30 @@ function UiGalleryScreen() {
-
- Title — Inter SemiBold 18
- Heading — 16 semibold
- Label — 15 medium
+
+ Large title — 34 bold
+ Title — 22 bold
+ Heading — 17 semibold
+ Headline — 17 semibold
+
+ Body large — 17 regular. The quick brown fox jumps over the lazy dog.
+
- Body — 15 regular. The quick brown fox jumps over the lazy dog.
+ Body — 15 regular (subheadline). The quick brown fox jumps over the
+ lazy dog.
- Body large — 16 regular.
- Caption — 14 muted
- CHROME — 11 muted
+ Label — 15 medium
+ Caption — 13 muted (footnote)
+ Footnote — 13 foreground
+ Section label — grouped header
+ Chrome — 11 muted (caption2)
mono — const x = fn(a) => 0x1F;
+
+
+ 1111 / 0000 (tabular)
+
+ 1111 / 0000 (proportional)
+
className-driven: font-semibold text-destructive-text
@@ -162,7 +244,7 @@ function UiGalleryScreen() {
- Small switch
+ Small switch (Android only)
+
+
+
+
+ {/* The one shape a native menu may wrap: an icon-only trigger
+ named by `accessibilityLabel` (the host is the single element
+ VoiceOver / Maestro see; it hides whatever it wraps). */}
+
+
+
+
+
+ {/* Text-bearing triggers present a sheet on both platforms. */}
+
+ {sort === "recent" ? "Recent" : "Name"}
+
+
+ confirmDestructive({
+ title: "Delete everything?",
+ actionLabel: "Delete",
+ onConfirm: () => toast.error("Gone"),
+ })
+ }
+ >
+ Confirm
+
+
+
+ Long-press me for the action sheet
+
+
+ A native menu (iOS: UIMenu; Android: the ActionSheet fallback) wraps
+ only an icon-only trigger — the iOS host drops what it wraps from the
+ accessibility tree. Anything that shows text is a Pressable that
+ presents an ActionSheet / OptionSheet on both platforms.
+
+
+
-
+
toast.message("Row pressed")}
onLongPress={menu.present}
/>
-
+
}
+ onPress={() => undefined}
+ />
+
+ undefined}
/>
-
+
-
+
Nothing here yet.
-
+
Sheet
@@ -343,6 +562,41 @@ function UiGalleryScreen() {
ActionSheet
+
+ toast.success("Saved")}
+ >
+ success
+
+ toast.error("Failed")}
+ >
+ error
+
+ toast.warning("Careful")}
+ >
+ warning
+
+
+ toast.info("Heads up", {
+ description: "With a description and an action.",
+ action: { label: "Undo", onClick: () => undefined },
+ })
+ }
+ >
+ info
+
+
@@ -383,39 +637,12 @@ function UiGalleryScreen() {
controller={menu}
title="Thread"
message="bb · main"
- actions={[
- {
- key: "open",
- label: "Open",
- icon: "ArrowUpRight",
- onPress: () => toast.message("Open"),
- },
- {
- key: "pin",
- label: "Pin",
- icon: "Pin",
- onPress: () => toast.message("Pin"),
- },
- {
- key: "rename",
- label: "Rename",
- icon: "Edit",
- onPress: () => toast.message("Rename"),
- },
- {
- key: "archive",
- label: "Archive",
- icon: "Archive",
- onPress: () => toast.message("Archive"),
- },
- {
- key: "delete",
- label: "Delete",
- icon: "Trash2",
- destructive: true,
- onPress: () => toast.error("Deleted"),
- },
- ]}
+ actions={menuActions}
+ />
+
);
diff --git a/apps/mobile/assets/splash-icon-dark.png b/apps/mobile/assets/splash-icon-dark.png
new file mode 100644
index 0000000000..4274058bd8
Binary files /dev/null and b/apps/mobile/assets/splash-icon-dark.png differ
diff --git a/apps/mobile/e2e/flows/phase1-shell.yaml b/apps/mobile/e2e/flows/phase1-shell.yaml
index 5a132b2a41..3c8da2eaee 100644
--- a/apps/mobile/e2e/flows/phase1-shell.yaml
+++ b/apps/mobile/e2e/flows/phase1-shell.yaml
@@ -32,26 +32,23 @@ env:
visible:
id: "home-thread-list"
timeout: 30000
+# The server label is the large title.
- assertVisible: "E2E backend"
- assertNotVisible:
id: "connection-banner"
- takeScreenshot: phase1-home
-# Workspace menu (the header's server avatar): the active server with its
-# realtime label, the server rows, Add server, Settings.
-- tapOn:
- id: "home-workspace-menu"
+# Workspace menu (the native "Workspace" bar item): the menu title carries
+# the active server and its realtime label, then the server rows, Add
+# server, Archived threads, Settings. Native menu items have no test ids;
+# they are matched by text.
+- tapOn: "Workspace"
- extendedWaitUntil:
- visible:
- id: "workspace-settings"
+ visible: "Settings"
timeout: 10000
-- assertVisible:
- id: "workspace-profile-label"
-- assertVisible: "Connected"
-- assertVisible:
- id: "workspace-add-server"
+- assertVisible: ".*E2E backend.*Connected.*"
+- assertVisible: "Add server.*"
- takeScreenshot: phase1-workspace-menu
-- tapOn:
- id: "workspace-settings"
+- tapOn: "Settings"
- extendedWaitUntil:
visible:
id: "settings-server-status"
diff --git a/apps/mobile/e2e/flows/phase3-compose.yaml b/apps/mobile/e2e/flows/phase3-compose.yaml
index 785ecb1ed3..aa2c481c44 100644
--- a/apps/mobile/e2e/flows/phase3-compose.yaml
+++ b/apps/mobile/e2e/flows/phase3-compose.yaml
@@ -1,6 +1,7 @@
# Phase 3 compose: first run → add the harness server → deep link to
-# bb://compose (home opens its dock) → pick the seeded project → open the environment picker (screenshot)
-# → type a prompt → Create → lands on the thread placeholder with the title →
+# bb://compose (home opens its dock) → pick the seeded project → open the
+# environment picker sheet (screenshot) → type a prompt → Create → lands on
+# the thread placeholder with the title →
# deep link to /projects/new → the machine picker lists the harness host and
# the remote path browser lists that machine's folders.
#
@@ -72,7 +73,9 @@ env:
id: "project-picker"
text: "(?i).*mobile e2e project.*"
timeout: 15000
-# Environment picker opens as a sheet listing the modes; screenshot it open.
+# Environment picker: the pill opens a sheet listing the modes as check-mark
+# rows (both platforms; the pill shows text, so it is never a native menu).
+# Screenshot it open.
- extendedWaitUntil:
visible:
id: "environment-picker"
diff --git a/apps/mobile/e2e/flows/phase3-threads.yaml b/apps/mobile/e2e/flows/phase3-threads.yaml
index 086ad3090a..e8363f3957 100644
--- a/apps/mobile/e2e/flows/phase3-threads.yaml
+++ b/apps/mobile/e2e/flows/phase3-threads.yaml
@@ -1,6 +1,11 @@
# Phase 3 threads: first run → add the harness server → home lists the seeded
-# project and threads → long-press menu (rename, pin, archive) → Settings →
-# Archived (unarchive) → search.
+# project and threads → long-press action sheet (rename, pin, archive) →
+# Settings → Archived (unarchive) → search from the header search bar.
+#
+# Thread rows open the action sheet on long-press on both platforms (a native
+# context menu would hide the row from the accessibility tree); its rows carry
+# `sidebar-action-` ids. Rename is the system prompt on iOS: the focused
+# text field takes `inputText`, and its "Rename" button commits.
#
# Requires Metro started with EXPO_PUBLIC_BB_E2E=1 (profiles/preferences are
# wiped on launch) and the harness backend (seeds "Mobile E2E Project" with
@@ -39,7 +44,7 @@ env:
- assertVisible: "Completed thread"
- assertVisible: "Idle thread"
- takeScreenshot: phase3-home
-# Long-press → Rename.
+# Long-press → action sheet → Rename → system prompt.
- longPressOn: "Idle thread"
- extendedWaitUntil:
visible:
@@ -48,15 +53,17 @@ env:
- tapOn:
id: "sidebar-action-rename"
- extendedWaitUntil:
- visible:
- id: "rename-input"
+ visible: "Rename thread"
+ timeout: 10000
+# The sheet's Rename row is still animating out as the prompt appears; wait
+# for it to leave so "Rename" matches only the prompt's button.
+- extendedWaitUntil:
+ notVisible:
+ id: "sidebar-action-rename"
timeout: 10000
-- tapOn:
- id: "rename-input"
- eraseText: 40
- inputText: "Renamed thread"
-- tapOn:
- id: "rename-submit"
+- tapOn: "Rename"
- extendedWaitUntil:
visible: "Renamed thread"
timeout: 15000
@@ -109,8 +116,9 @@ env:
visible: "Renamed thread"
timeout: 15000
- takeScreenshot: phase3-archived
-# Unarchive from the long-press menu (the trailing button sits under the
-# dev-client's floating gear on some simulators); the row leaves the list.
+# Unarchive from the long-press action sheet (the trailing swipe action sits
+# under the dev-client's floating gear on some simulators); the row leaves
+# the list.
- longPressOn: "Renamed thread"
- extendedWaitUntil:
visible:
@@ -137,20 +145,15 @@ env:
- extendedWaitUntil:
visible: "Renamed thread"
timeout: 15000
-# Search from the home header.
+# Search from the home header's search bar (matched by its placeholder):
+# recent threads while empty, then results in place of the list.
- extendedWaitUntil:
- visible:
- id: "home-search"
+ visible: "Search threads"
timeout: 10000
-- tapOn:
- id: "home-search"
+- tapOn: "Search threads"
- extendedWaitUntil:
- visible:
- id: "thread-search-input"
+ visible: "Recent"
timeout: 10000
-- assertVisible: "Recent"
-- tapOn:
- id: "thread-search-input"
- inputText: "Compl"
- extendedWaitUntil:
visible: "Completed thread"
diff --git a/apps/mobile/e2e/flows/phase4b-actions.yaml b/apps/mobile/e2e/flows/phase4b-actions.yaml
index 00fea1f52e..34c868a0c9 100644
--- a/apps/mobile/e2e/flows/phase4b-actions.yaml
+++ b/apps/mobile/e2e/flows/phase4b-actions.yaml
@@ -1,8 +1,14 @@
# Phase 4b actions on the thread screen: open the thread named by
-# THREAD_TITLE → the header shows the environment line (project · host ·
-# workspace) → "…" → Rename through the sheet → the title updates →
-# long-press the user message → Copy text toasts "Copied" → long-press again
-# → Add to chat quotes it into the follow-up composer ("> …").
+# THREAD_TITLE → the header "…" (a native menu titled with the environment
+# line: project · host · workspace) → Rename through the system prompt → the
+# title updates → long-press the user message → the action sheet's Copy
+# text toasts "Copied" → long-press again → Add to chat quotes it into the
+# follow-up composer ("> …").
+#
+# iOS: native bar items and menu items have no testID, so the flow taps them
+# by accessibility label / text ("Thread actions", "Rename"). Message
+# actions stay a bottom sheet on both platforms (its rows keep
+# `action-sheet-` ids).
#
# Create an idle thread titled "P4b actions" first (see phase4b-send.yaml):
# maestro test e2e/flows/phase4b-actions.yaml
@@ -28,34 +34,29 @@ env:
id: "thread-composer-input"
timeout: 30000
- takeScreenshot: phase4b-actions-thread
-# Rename through the "…" sheet; its header carries the environment line
+# Rename through the "…" menu; its title carries the environment line
# (project · host · workspace).
-- tapOn:
- id: "thread-actions-button"
+- tapOn: "Thread actions"
- extendedWaitUntil:
- visible:
- id: "thread-action-rename"
+ visible: "Rename"
timeout: 10000
- assertVisible: "Mobile E2E Project.*"
-- tapOn:
- id: "thread-action-rename"
+- tapOn: "Rename"
+# The system prompt: the field is pre-filled with the current title and
+# focused, the confirming button reads "Rename".
- extendedWaitUntil:
- visible:
- id: "thread-rename-input"
+ visible: "Rename thread"
timeout: 10000
-- tapOn:
- id: "thread-rename-input"
- eraseText: 60
- inputText: "${THREAD_TITLE} renamed"
-- tapOn:
- id: "thread-rename-submit"
+- tapOn: "Rename"
- extendedWaitUntil:
visible:
id: "thread-detail-title"
text: "${THREAD_TITLE} renamed"
timeout: 15000
- takeScreenshot: phase4b-actions-renamed
-# Long-press the user message → Copy text.
+# Long-press the user message → the action sheet → Copy text.
- longPressOn:
id: "conversation-user-bubble"
index: 0
diff --git a/apps/mobile/e2e/flows/phase4b-composer.yaml b/apps/mobile/e2e/flows/phase4b-composer.yaml
index cf28c2af5f..249784ec75 100644
--- a/apps/mobile/e2e/flows/phase4b-composer.yaml
+++ b/apps/mobile/e2e/flows/phase4b-composer.yaml
@@ -2,9 +2,9 @@
# Developer) against the harness backend. Types an `@` query → the typeahead
# opens above the input listing the seeded threads; picking one inserts a
# pill and the serialized PromptInput carries `@thread:`; backspace at the
-# pill end removes it whole; `/` lists provider commands; the "+" menu opens
-# with the attachment + prompt actions; then the compose screen creates a
-# thread through the composer.
+# pill end removes it whole; `/` lists provider commands; the "+" menu (a
+# native pull-down menu on iOS) opens with the attachment + prompt actions;
+# then the compose screen creates a thread through the composer.
#
# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999)
appId: app.getbb.mobile
@@ -72,13 +72,20 @@ env:
id: "dev-composer-typeahead-row-0"
- assertVisible: '(?s).*"kind": "command".*'
- eraseText: 30
-# "+" menu: attachment + prompt actions.
+# "+" menu (a native pull-down menu on iOS): attachment + prompt actions.
+# A native menu has no Cancel row; a tap outside it dismisses it.
- tapOn:
id: "dev-composer-actions"
-- assertVisible: "Photo library"
+- extendedWaitUntil:
+ visible: "Photo library"
+ timeout: 10000
- assertVisible: "Attach file"
- takeScreenshot: phase4b-composer-actions
-- tapOn: "Cancel"
+- tapOn:
+ point: "50%,15%"
+- extendedWaitUntil:
+ notVisible: "Photo library"
+ timeout: 10000
# Submit in the ready mode reports the kind.
- tapOn:
id: "dev-composer-input"
diff --git a/apps/mobile/e2e/flows/phase4b-thread-actions.yaml b/apps/mobile/e2e/flows/phase4b-thread-actions.yaml
index f3e488652c..2e0ffb6397 100644
--- a/apps/mobile/e2e/flows/phase4b-thread-actions.yaml
+++ b/apps/mobile/e2e/flows/phase4b-thread-actions.yaml
@@ -1,8 +1,14 @@
# Phase 4b thread actions + context banner: add the harness server → open
# the thread named by THREAD_TITLE (create it first; see README) → header
-# title tap opens the rename sheet → rename → "…" menu lists the thread
-# actions (Copy link toasts) → the git button opens the git sheet (when the
-# worktree is dirty) → the context banner's changed-files row expands.
+# title tap opens the rename prompt → rename → the "…" native menu lists
+# the thread actions (Copy link toasts) → its git row opens the git sheet
+# (when the worktree is dirty) → the context banner's changed-files row
+# expands → long-pressing an assistant message opens its action sheet.
+#
+# iOS: native bar items and menu items have no testID, so the flow taps them
+# by accessibility label / text ("Thread actions", "Commit", "Copy link").
+# Message actions stay a bottom sheet on both platforms (its rows keep
+# `action-sheet-` ids).
#
# Setup (the flow asserts the banner's git row, which needs a dirty
# worktree): create a managed-worktree thread through the API, write a file
@@ -49,15 +55,12 @@ env:
notVisible:
id: "thread-chip-changes-sheet"
timeout: 10000
-# Git sheet from the "…" menu (the header has no second row).
-- tapOn:
- id: "thread-actions-button"
+# Git sheet from the "…" menu's first row (the dirty worktree offers Commit).
+- tapOn: "Thread actions"
- extendedWaitUntil:
- visible:
- id: "thread-git-button"
+ visible: "Commit"
timeout: 10000
-- tapOn:
- id: "thread-git-button"
+- tapOn: "Commit"
- extendedWaitUntil:
visible:
id: "thread-git-sheet"
@@ -71,40 +74,30 @@ env:
notVisible:
id: "thread-git-sheet"
timeout: 10000
-# Rename through the title.
+# Rename through the title (the system prompt, pre-filled and focused).
- tapOn:
id: "thread-detail-title"
- extendedWaitUntil:
- visible:
- id: "thread-rename-input"
+ visible: "Rename thread"
timeout: 10000
-- tapOn:
- id: "thread-rename-input"
- eraseText: 60
- inputText: "${THREAD_TITLE} renamed"
-- tapOn:
- id: "thread-rename-submit"
+- tapOn: "Rename"
- extendedWaitUntil:
visible:
id: "thread-detail-title"
text: "${THREAD_TITLE} renamed"
timeout: 15000
# Actions menu: copy link.
-- tapOn:
- id: "thread-actions-button"
+- tapOn: "Thread actions"
- extendedWaitUntil:
- visible:
- id: "thread-action-copy-link"
+ visible: "Copy link"
timeout: 10000
-- assertVisible:
- id: "thread-action-handoff"
-- assertVisible:
- id: "thread-action-pin"
-- assertVisible:
- id: "thread-action-delete"
+- assertVisible: "Handoff to new thread"
+- assertVisible: "Pin"
+- assertVisible: "Delete"
- takeScreenshot: phase4b-thread-menu
-- tapOn:
- id: "thread-action-copy-link"
+- tapOn: "Copy link"
- extendedWaitUntil:
visible: "Link copied"
timeout: 10000
diff --git a/apps/mobile/e2e/flows/phase6-diff.yaml b/apps/mobile/e2e/flows/phase6-diff.yaml
index 296545c034..1ca738da3d 100644
--- a/apps/mobile/e2e/flows/phase6-diff.yaml
+++ b/apps/mobile/e2e/flows/phase6-diff.yaml
@@ -1,7 +1,7 @@
# Phase 6 Diff tab: add the harness server → open the thread named by
# THREAD_TITLE (its worktree is dirty) → the context banner's changed-files
# row → "Open diff" → the Diff sheet lists the modified / deleted / added
-# cards with the modified file's hunk → the target picker offers the
+# cards with the modified file's hunk → the target picker sheet offers the
# uncommitted target → "Add to chat" quotes the patch into the composer
# ("> diff --git …") → a file row in the banner opens the diff focused on it
# → an API commit + refresh offers the committed target.
@@ -62,8 +62,10 @@ env:
- assertVisible:
id: "diff-tab-removed"
- takeScreenshot: phase6-diff
-# Target picker: the working tree is the only target on a fresh worktree
-# with no commits above the default branch; pick it and the list holds.
+# Target picker: the capsule opens the picker sheet (both platforms; its rows
+# carry `diff-target-` ids). The working tree is the only target on a
+# fresh worktree with no commits above the default branch; pick it and the
+# list holds.
- tapOn:
id: "diff-tab-target"
- extendedWaitUntil:
@@ -118,8 +120,9 @@ env:
timeout: 30000
- takeScreenshot: phase6-diff-focused
# Commit through the API (the fake provider cannot), refresh: the picker now
-# offers "Committed changes" and the commit itself; the committed target
-# lists the same files.
+# offers "Committed changes" and the commit itself (a row with the short sha
+# as its mono prefix and the subject); the committed target lists the same
+# files.
- runScript:
file: ../scripts/phase6-commit.js
- tapOn:
diff --git a/apps/mobile/e2e/flows/phase6-files.yaml b/apps/mobile/e2e/flows/phase6-files.yaml
index 9ccda190fc..0e20134bfd 100644
--- a/apps/mobile/e2e/flows/phase6-files.yaml
+++ b/apps/mobile/e2e/flows/phase6-files.yaml
@@ -25,9 +25,9 @@ env:
visible:
id: "thread-detail-header"
timeout: 30000
-# Workspace panel → Files launcher.
-- tapOn:
- id: "thread-panel-button"
+# Workspace panel (the header's toolbar item, tapped by its accessibility
+# label) → Files launcher.
+- tapOn: "Workspace panel"
- extendedWaitUntil:
visible:
id: "panel-tab-files"
@@ -66,9 +66,9 @@ env:
timeout: 30000
- assertVisible: "Mobile E2E Project"
- takeScreenshot: phase6-files-readme-preview
-# Source view + jump to line 60.
-- tapOn:
- id: "file-preview-mode-source"
+# Source view (the Preview / Source segmented control's second segment) +
+# jump to line 60 through the panel's sheet field.
+- tapOn: "Source"
- extendedWaitUntil:
visible:
id: "file-preview-lines"
@@ -118,7 +118,8 @@ env:
- assertVisible: "Build the files tab"
- takeScreenshot: phase6-files-storage-preview
# Close the panel (tap the backdrop), then the full-screen preview route by
-# deep link: highlighted line 12 of src/app.ts.
+# deep link: highlighted line 12 of src/app.ts. The file name is the native
+# navigation title there.
- tapOn:
point: "50%,8%"
- extendedWaitUntil:
@@ -127,9 +128,7 @@ env:
timeout: 10000
- openLink: "bb://threads/${THREAD_ID}/files?kind=workspace&path=src%2Fapp.ts&line=12"
- extendedWaitUntil:
- visible:
- id: "file-preview-name"
- text: "app.ts"
+ visible: "app.ts"
timeout: 30000
- extendedWaitUntil:
visible:
diff --git a/apps/mobile/e2e/flows/phase6-panel.yaml b/apps/mobile/e2e/flows/phase6-panel.yaml
index af0fc30f28..8140452772 100644
--- a/apps/mobile/e2e/flows/phase6-panel.yaml
+++ b/apps/mobile/e2e/flows/phase6-panel.yaml
@@ -25,13 +25,13 @@ env:
id: "thread-detail-title"
text: "${THREAD_TITLE}"
timeout: 30000
-# The header's panel button presents the sheet on the Info tab.
+# The header's panel button presents the sheet on the Info tab. It is a
+# native toolbar item on iOS (no test id), so the flow taps its
+# accessibility label; the Android Pressable carries the same label.
- extendedWaitUntil:
- visible:
- id: "thread-panel-button"
+ visible: "Workspace panel"
timeout: 10000
-- tapOn:
- id: "thread-panel-button"
+- tapOn: "Workspace panel"
- extendedWaitUntil:
visible:
id: "workspace-panel-tab-strip"
diff --git a/apps/mobile/e2e/flows/phase6-terminal-resume.yaml b/apps/mobile/e2e/flows/phase6-terminal-resume.yaml
index 4dd065deca..4bebb867e5 100644
--- a/apps/mobile/e2e/flows/phase6-terminal-resume.yaml
+++ b/apps/mobile/e2e/flows/phase6-terminal-resume.yaml
@@ -17,8 +17,8 @@ env:
visible: "${THREAD_TITLE}"
timeout: 30000
- tapOn: "${THREAD_TITLE}"
-- tapOn:
- id: "thread-panel-button"
+# The header's panel button is a native toolbar item: tap its label.
+- tapOn: "Workspace panel"
- extendedWaitUntil:
visible:
id: "panel-tab-terminal"
diff --git a/apps/mobile/e2e/flows/phase6-terminal.yaml b/apps/mobile/e2e/flows/phase6-terminal.yaml
index 5ce41e76eb..68431138ab 100644
--- a/apps/mobile/e2e/flows/phase6-terminal.yaml
+++ b/apps/mobile/e2e/flows/phase6-terminal.yaml
@@ -25,9 +25,9 @@ env:
visible:
id: "thread-detail-screen"
timeout: 30000
-# Workspace panel → Terminal.
-- tapOn:
- id: "thread-panel-button"
+# Workspace panel (the header's toolbar item, tapped by its accessibility
+# label) → Terminal.
+- tapOn: "Workspace panel"
- extendedWaitUntil:
visible:
id: "panel-tab-terminal"
@@ -92,33 +92,27 @@ env:
id: "terminal-key-ctrl"
- inputText: "u"
- takeScreenshot: phase6-terminal-keys
-# Terminal menu: rename the session. (The accessory bar's "…" mirrors the
-# header's, which the dev client's floating gear covers on large simulators.)
+# Terminal menu: rename the session. The accessory bar's "…" anchors the
+# same native menu as the header's (which the dev client's floating gear
+# covers on large simulators); its items have no test ids, so they are
+# tapped by title.
- tapOn:
id: "terminal-key-menu"
- extendedWaitUntil:
- visible:
- id: "terminal-action-rename"
+ visible: "Rename"
timeout: 10000
-- assertVisible:
- id: "terminal-action-restart"
-- assertVisible:
- id: "terminal-action-new"
-- assertVisible:
- id: "terminal-action-close"
+- assertVisible: "Restart terminal"
+- assertVisible: "New terminal"
+- assertVisible: "Close terminal"
- takeScreenshot: phase6-terminal-menu
-- tapOn:
- id: "terminal-action-rename"
+- tapOn: "Rename"
+# The system prompt opens focused on the current title; replace it.
- extendedWaitUntil:
- visible:
- id: "terminal-rename-input"
+ visible: "Rename terminal"
timeout: 10000
-- tapOn:
- id: "terminal-rename-input"
- eraseText: 60
- inputText: "P6 shell"
-- tapOn:
- id: "terminal-rename-submit"
+- tapOn: "Rename"
- extendedWaitUntil:
visible: "P6 shell"
timeout: 15000
diff --git a/apps/mobile/e2e/flows/phase7-plugins.yaml b/apps/mobile/e2e/flows/phase7-plugins.yaml
index c362ba125a..3f77eccb44 100644
--- a/apps/mobile/e2e/flows/phase7-plugins.yaml
+++ b/apps/mobile/e2e/flows/phase7-plugins.yaml
@@ -50,6 +50,22 @@ env:
timeout: 20000
- assertVisible: ".*BB Community.*"
- takeScreenshot: p7-plugins-marketplaces
+# Tapping the row opens its action sheet (refresh / remove) on both
+# platforms; the rows carry `action-sheet-` ids. Cancel leaves the
+# catalog alone.
+- tapOn:
+ id: "marketplace-row-bb-community"
+- extendedWaitUntil:
+ visible:
+ id: "action-sheet-refresh"
+ timeout: 10000
+- takeScreenshot: p7-plugins-marketplace-menu
+- tapOn:
+ id: "action-sheet-cancel"
+- extendedWaitUntil:
+ notVisible:
+ id: "action-sheet-refresh"
+ timeout: 10000
# The add-marketplace sheet (also behind the header "+", which the dev
# client's floating gear can cover on larger simulators).
- tapOn:
@@ -108,20 +124,20 @@ env:
id: "skill-row-.*"
timeout: 20000
# The in-process daemon discovers this Mac's real skill folders too, so the
-# library can be long: filter down to the built-in bb-cli skill when the
-# filter field is shown (more than a handful of skills).
+# library can be long: filter down to the built-in bb-cli skill through the
+# native header search bar (iOS; Android keeps the in-body skills-filter
+# field), then scroll to the row in case the list is still long.
- runFlow:
when:
- visible:
- id: "skills-filter"
+ visible: "Search skills"
commands:
- - tapOn:
- id: "skills-filter"
+ - tapOn: "Search skills"
- inputText: "bb-cli"
- tapOn: ".*My skills.*"
-- extendedWaitUntil:
- visible:
+- scrollUntilVisible:
+ element:
id: "skill-row-bb-cli"
+ direction: DOWN
timeout: 20000
- takeScreenshot: p7-plugins-skills
- tapOn:
diff --git a/apps/mobile/e2e/flows/phase7-settings.yaml b/apps/mobile/e2e/flows/phase7-settings.yaml
index 6051e1a5bf..dc0405a896 100644
--- a/apps/mobile/e2e/flows/phase7-settings.yaml
+++ b/apps/mobile/e2e/flows/phase7-settings.yaml
@@ -1,5 +1,6 @@
-# Phase 7 settings: Settings home → Experiments (toggle "New onboarding",
-# persisted server-side: re-open shows it on, the API agrees) → Appearance
+# Phase 7 settings: Settings home → Experiments (toggle "Edit messages" off —
+# it defaults on — persisted server-side: re-open shows it off, the API
+# agrees; toggle it back on) → Appearance
# (palette → Nord: the row shows "Nord", the API agrees, the UI re-tints) →
# Machines (the harness host row → detail → rename → the list shows the new
# name) → restore. Resets the server settings it touches at start and end, so
@@ -23,22 +24,24 @@ env:
id: "settings-experiments"
timeout: 10000
- takeScreenshot: p7-settings-home
-# Experiments: toggle "New onboarding" on (PUT /settings/experiments).
+# Experiments: toggle "Edit messages" off (PUT /settings/experiments). It is
+# the one experiment the reset leaves on, so the flow drives it on → off → on.
- tapOn:
id: "settings-experiments"
- extendedWaitUntil:
visible:
- id: "experiment-newOnboarding"
+ id: "experiment-editMessages"
+ checked: true
timeout: 15000
- tapOn:
- id: "experiment-newOnboarding"
+ id: "experiment-editMessages"
- extendedWaitUntil:
visible:
- id: "experiment-newOnboarding"
- checked: true
+ id: "experiment-editMessages"
+ checked: false
timeout: 10000
- takeScreenshot: p7-settings-experiments
-# Persisted: leave, come back, still on; and the server says so.
+# Persisted: leave, come back, still off; and the server says so.
- tapOn:
id: "BackButton"
- extendedWaitUntil:
@@ -49,21 +52,21 @@ env:
id: "settings-experiments"
- extendedWaitUntil:
visible:
- id: "experiment-newOnboarding"
- checked: true
+ id: "experiment-editMessages"
+ checked: false
timeout: 15000
- runScript:
file: ../scripts/phase7-settings-assert.js
env:
- EXPECT_NEW_ONBOARDING: "true"
+ EXPECT_EDIT_MESSAGES: "false"
EXPECT_THEME_ID: "default"
-# Toggle it back off through the UI (the reset at the end covers failures).
+# Toggle it back on through the UI (the reset at the end covers failures).
- tapOn:
- id: "experiment-newOnboarding"
+ id: "experiment-editMessages"
- extendedWaitUntil:
visible:
- id: "experiment-newOnboarding"
- checked: false
+ id: "experiment-editMessages"
+ checked: true
timeout: 10000
- tapOn:
id: "BackButton"
@@ -79,8 +82,10 @@ env:
visible:
id: "appearance-palette"
timeout: 15000
+# The mode control is a native segmented control on iOS (one id for the
+# whole control); Android keeps per-option appearance-mode- buttons.
- assertVisible:
- id: "appearance-mode-system"
+ id: "appearance-mode"
- tapOn:
id: "appearance-palette"
- extendedWaitUntil:
@@ -97,7 +102,7 @@ env:
- runScript:
file: ../scripts/phase7-settings-assert.js
env:
- EXPECT_NEW_ONBOARDING: "false"
+ EXPECT_EDIT_MESSAGES: "true"
EXPECT_THEME_ID: "nord"
- takeScreenshot: p7-settings-appearance-nord
- tapOn:
@@ -139,16 +144,15 @@ env:
- takeScreenshot: p7-settings-machine-detail
- tapOn:
id: "machine-rename-row"
+# iOS renames through the system prompt (Alert.prompt: Cancel / Save, the
+# field pre-filled and focused); Android presents the rename sheet
+# (machine-rename-input / machine-rename-save).
- extendedWaitUntil:
- visible:
- id: "machine-rename-input"
+ visible: "Save"
timeout: 10000
-- tapOn:
- id: "machine-rename-input"
- eraseText: 80
- inputText: "P7 renamed machine"
-- tapOn:
- id: "machine-rename-save"
+- tapOn: "Save"
- extendedWaitUntil:
visible:
id: "machine-detail-name"
diff --git a/apps/mobile/e2e/scripts/phase7-settings-assert.js b/apps/mobile/e2e/scripts/phase7-settings-assert.js
index e222fa4e1a..5193e84e36 100644
--- a/apps/mobile/e2e/scripts/phase7-settings-assert.js
+++ b/apps/mobile/e2e/scripts/phase7-settings-assert.js
@@ -1,11 +1,11 @@
// Maestro runScript: read the server-persisted settings the Phase 7 flow
// changed through the UI and fail unless they landed. Env: SERVER_URL,
-// EXPECT_NEW_ONBOARDING ("true" | "false"), EXPECT_THEME_ID.
+// EXPECT_EDIT_MESSAGES ("true" | "false"), EXPECT_THEME_ID.
const config = json(http.get(`${SERVER_URL}/api/v1/system/config`).body);
-const newOnboarding = String(config.experiments.newOnboarding);
-if (newOnboarding !== EXPECT_NEW_ONBOARDING) {
+const editMessages = String(config.experiments.editMessages);
+if (editMessages !== EXPECT_EDIT_MESSAGES) {
throw new Error(
- `experiments.newOnboarding is ${newOnboarding}, expected ${EXPECT_NEW_ONBOARDING}`,
+ `experiments.editMessages is ${editMessages}, expected ${EXPECT_EDIT_MESSAGES}`,
);
}
if (config.appearance.themeId !== EXPECT_THEME_ID) {
diff --git a/apps/mobile/e2e/scripts/phase7-settings-reset.js b/apps/mobile/e2e/scripts/phase7-settings-reset.js
index 9e3f1a67f4..6879cf642b 100644
--- a/apps/mobile/e2e/scripts/phase7-settings-reset.js
+++ b/apps/mobile/e2e/scripts/phase7-settings-reset.js
@@ -1,14 +1,17 @@
// Maestro runScript: put the harness server's settings back to the defaults
// the Phase 7 settings flow toggles (experiments, appearance) so the flow
// starts and ends from a known state on a shared backend. Env: SERVER_URL.
+// The experiments body must name every key of @bb/domain `experimentKeys`
+// (the schema is exhaustive); the values are `defaultExperiments`.
const headers = { "Content-Type": "application/json" };
const experiments = http.put(`${SERVER_URL}/api/v1/settings/experiments`, {
headers,
body: JSON.stringify({
+ changelogPreview: false,
editMessages: true,
mobileApp: false,
- newOnboarding: false,
providerSessionReaping: false,
+ timelineWindowing: false,
}),
});
if (!experiments.ok) {
diff --git a/apps/mobile/e2e/subflows/open-settings.yaml b/apps/mobile/e2e/subflows/open-settings.yaml
index 0eb301b8bd..a6b0858eae 100644
--- a/apps/mobile/e2e/subflows/open-settings.yaml
+++ b/apps/mobile/e2e/subflows/open-settings.yaml
@@ -1,11 +1,9 @@
-# Home → workspace menu (the header's server avatar) → Settings.
+# Home → workspace menu (the native "Workspace" bar item on the header's
+# left; its items are matched by text) → Settings.
appId: ${APP_ID}
---
-- tapOn:
- id: "home-workspace-menu"
+- tapOn: "Workspace"
- extendedWaitUntil:
- visible:
- id: "workspace-settings"
+ visible: "Settings"
timeout: 10000
-- tapOn:
- id: "workspace-settings"
+- tapOn: "Settings"
diff --git a/apps/mobile/global.css b/apps/mobile/global.css
index b700e22a47..1a863987ae 100644
--- a/apps/mobile/global.css
+++ b/apps/mobile/global.css
@@ -84,48 +84,64 @@
--color-sidebar-search-match: var(--sidebar-search-match);
--color-sidebar-search-match-border: var(--sidebar-search-match-border);
--color-shadow-color: var(--shadow-color);
+ /* iOS inset-grouped lists (src/theme/mobile-overrides.css): the page behind
+ the cards and the cards themselves. */
+ --color-surface-grouped: var(--surface-grouped);
+ --color-surface-grouped-cell: var(--surface-grouped-cell);
/*
- * Fonts. Expo Google Fonts register one family name per weight, so the
- * `font-sans-*` / `font-mono-*` utilities select the weight-specific family.
- * `` (src/ui/Text.tsx) picks the right family for you and
- * also derives it from web-style `font-medium|semibold|bold` classes.
+ * Fonts. The app renders in the platform faces (SF Pro / Menlo on iOS,
+ * sans-serif / monospace on Android); CSS cannot pick a family per
+ * platform, so these only keep the `font-sans` / `font-mono` class names
+ * resolvable. `System` is React Native's iOS alias for the system font and
+ * falls through to the default sans-serif typeface on Android; `monospace`
+ * is Android's mono family (iOS gets Menlo from ``, below).
+ * `` (src/ui/Text.tsx) re-resolves the real family + numeric weight
+ * through `resolveFont` (src/theme/fonts.ts, font-platform*.ts) as an
+ * inline style, and that is what renders — outside `` use
+ * `theme.fonts.mono.regular` rather than a `font-mono` class.
*/
- --font-sans: "Inter_400Regular";
- --font-sans-medium: "Inter_500Medium";
- --font-sans-semibold: "Inter_600SemiBold";
- --font-sans-bold: "Inter_700Bold";
- --font-mono: "FiraCode_400Regular";
- --font-mono-medium: "FiraCode_500Medium";
- --font-mono-semibold: "FiraCode_600SemiBold";
- --font-mono-bold: "FiraCode_700Bold";
+ --font-sans: "System";
+ --font-mono: monospace;
- /* Radii mirror `--radius: 0.5rem` and its steps (nativeRadii). */
+ /* Radii mirror `--radius: 0.5rem` and its steps (nativeRadii); 2xl/full are
+ Tailwind's defaults pinned as numbers (nativeRadii.xl2 / .full). */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
+ --radius-2xl: 16px;
+ --radius-full: 9999px;
}
/*
- * Typography scale: the touch (coarse-pointer) values from theme.css, which
- * the mobile PWA renders on a phone. Sizes are px so react-native-css does
+ * Typography scale: the Apple text-style ramp from
+ * src/theme/mobile-overrides.css (caption2, footnote, subheadline, body,
+ * title3, title2, title1, largeTitle). Sizes are px so react-native-css does
* not depend on the rem multiplier. Line heights are unitless ratios
* (`calc(line-height / font-size)`): Tailwind emits them as the fallback of
* `var(--tw-leading, …)`, and react-native-css drops the unit inside that
* fallback and treats the number as an em multiplier at runtime (`22px`
* became `22 × 15 = 330`). A ratio survives that path and yields the px value.
- * Mirrors nativeTypography in theme.native.ts.
+ * Mirrors nativeTypography in theme.native.ts (theme-vars.test.ts).
*/
@theme {
--text-2xs: 11px;
- --text-2xs--line-height: calc(15 / 11);
- --text-xs: 14px;
- --text-xs--line-height: calc(20 / 14);
+ --text-2xs--line-height: calc(13 / 11);
+ --text-xs: 13px;
+ --text-xs--line-height: calc(18 / 13);
--text-sm: 15px;
- --text-sm--line-height: calc(22 / 15);
- --text-base: 16px;
- --text-base--line-height: calc(24 / 16);
+ --text-sm--line-height: calc(20 / 15);
+ --text-base: 17px;
+ --text-base--line-height: calc(22 / 17);
+ --text-lg: 20px;
+ --text-lg--line-height: calc(25 / 20);
+ --text-xl: 22px;
+ --text-xl--line-height: calc(28 / 22);
+ --text-2xl: 28px;
+ --text-2xl--line-height: calc(34 / 28);
+ --text-3xl: 34px;
+ --text-3xl--line-height: calc(41 / 34);
}
/*
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 8c5eb45664..138ebc2c87 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -26,8 +26,7 @@
"@bb/sdk": "workspace:*",
"@bb/server-contract": "workspace:*",
"@bb/thread-view": "workspace:*",
- "@expo-google-fonts/fira-code": "^0.4.1",
- "@expo-google-fonts/inter": "^0.4.2",
+ "@expo/ui": "57.0.11",
"@gorhom/bottom-sheet": "^5.2.14",
"@hugeicons/core-free-icons": "^4.1.3",
"@hugeicons/react-native": "^1.0.15",
@@ -48,7 +47,7 @@
"expo-dev-client": "~57.0.13",
"expo-document-picker": "~57.0.1",
"expo-file-system": "~57.0.4",
- "expo-font": "~57.0.1",
+ "expo-glass-effect": "57.0.1",
"expo-haptics": "~57.0.1",
"expo-image": "~57.0.3",
"expo-image-picker": "~57.0.11",
@@ -105,6 +104,7 @@
"lightningcss": "1.30.1",
"mdast-util-directive": "^3.1.0",
"postcss": "^8.5.26",
+ "sf-symbols-typescript": "^2.2.0",
"tailwindcss": "^4.3.0",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vitest": "^4.1.1"
diff --git a/apps/mobile/scripts/generate-native-theme.ts b/apps/mobile/scripts/generate-native-theme.ts
index 4db23693cb..3e935ad4f1 100644
--- a/apps/mobile/scripts/generate-native-theme.ts
+++ b/apps/mobile/scripts/generate-native-theme.ts
@@ -3,11 +3,19 @@
* Generates `src/theme/theme.native.ts` from the web app's CSS theme tokens.
*
* React Native has no `var()`, `color-mix()`, or `oklch()`, so this script
- * replays the web cascade (theme.css light/dark blocks, then a built-in
+ * replays the web cascade (theme.css light/dark blocks, then the mobile-only
+ * override layer in `src/theme/mobile-overrides.css`, then a built-in
* palette's overrides) for every palette × mode and resolves each token to a
* plain color string. The result is committed; a vitest drift test regenerates
* it in memory and fails when it no longer matches.
*
+ * Cascade order is the whole contract: the mobile layer re-tunes the DEFAULT
+ * palette to the iOS system look (pure black/white anchors, systemBlue tint,
+ * system status colors, grouped-list surfaces) while palettes that set their
+ * own anchors and literals (Nord, Dracula, …) still win because they cascade
+ * last. Every mobile value derives from `--canvas`/`--ink` so those palettes
+ * keep tinting the surfaces the layer touches.
+ *
* Run: `pnpm --filter @bb/mobile theme:generate`
* (`node --conditions=source --import tsx scripts/generate-native-theme.ts`).
*/
@@ -21,6 +29,17 @@ const MOBILE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const APP_ROOT = join(MOBILE_ROOT, "..", "app");
const THEME_CSS_PATH = join(APP_ROOT, "src", "components", "ui", "theme.css");
const PALETTES_DIR = join(APP_ROOT, "src", "lib", "themes");
+/**
+ * The mobile-only override layer. Same `:root, .light` / `.dark` custom
+ * property shape as theme.css plus a `@theme` block for the native type ramp
+ * and the extra radii; the web app never loads it.
+ */
+export const MOBILE_OVERRIDES_CSS_PATH = join(
+ MOBILE_ROOT,
+ "src",
+ "theme",
+ "mobile-overrides.css",
+);
export const NATIVE_THEME_OUTPUT_PATH = join(
MOBILE_ROOT,
"src",
@@ -441,12 +460,39 @@ export interface SkippedToken {
reason: string;
}
+export interface NativeRadii {
+ base: number;
+ sm: number;
+ md: number;
+ lg: number;
+ xl: number;
+ /** Tailwind's `rounded-2xl` step (`--radius-2xl`). */
+ xl2: number;
+ /** Tailwind's `rounded-full` (`--radius-full`): any pill/circle. */
+ full: number;
+}
+
+export const RADII_KEYS = [
+ "base",
+ "sm",
+ "md",
+ "lg",
+ "xl",
+ "xl2",
+ "full",
+] as const satisfies readonly (keyof NativeRadii)[];
+
export interface NativeThemeModel {
/** palette → mode → camelCase token → RN color string. */
themes: Map>>;
/** Sorted camelCase color token names (identical across palettes/modes). */
tokenKeys: string[];
- radii: { base: number; sm: number; md: number; lg: number; xl: number };
+ /**
+ * Kebab-case names the mobile layer declares that theme.css does not. The
+ * web has no utility class for these; global.css maps them by hand.
+ */
+ mobileOnlyTokens: string[];
+ radii: NativeRadii;
/** Ordered by font size. */
typography: [name: string, style: NativeTextStyle][];
skipped: SkippedToken[];
@@ -454,12 +500,18 @@ export interface NativeThemeModel {
export interface ThemeSources {
themeCss: string;
+ /**
+ * Mobile-only override layer (`MOBILE_OVERRIDES_CSS_PATH`). Cascades after
+ * theme.css and before every palette, so palettes keep winning.
+ */
+ mobileCss: string;
/** Palette override CSS per built-in id ("" for `default`). */
paletteCss: ReadonlyMap;
}
function readSources(): ThemeSources {
const themeCss = readFileSync(THEME_CSS_PATH, "utf8");
+ const mobileCss = readFileSync(MOBILE_OVERRIDES_CSS_PATH, "utf8");
const paletteCss = new Map();
for (const id of BUILTIN_THEME_IDS) {
// "default" is theme.css itself (the registry maps it to "").
@@ -474,7 +526,7 @@ function readSources(): ThemeSources {
}
paletteCss.set(id, css);
}
- return { themeCss, paletteCss };
+ return { themeCss, mobileCss, paletteCss };
}
function webOnlyReason(name: string): string | null {
@@ -484,11 +536,29 @@ function webOnlyReason(name: string): string | null {
return null;
}
-/** Radii from the `@theme inline` block: `--radius-*` derived from `--radius`. */
+/** Custom properties of every `@theme` block (not `@theme inline`) in `css`. */
+function themeBlockProperties(css: string): Map {
+ const declared = new Map();
+ for (const rule of splitRules(stripComments(css))) {
+ if (rule.prelude !== "@theme") continue;
+ for (const [name, value] of parseCustomProperties(rule.body)) {
+ declared.set(name, value);
+ }
+ }
+ return declared;
+}
+
+/**
+ * Radii: `--radius-sm|md|lg|xl` from theme.css's `@theme inline` block
+ * (derived from `--radius`), plus `--radius-2xl` / `--radius-full` from the
+ * mobile layer's `@theme` block — the web leaves those at Tailwind's defaults
+ * (1rem and `calc(infinity * 1px)`), which the native side needs as numbers.
+ */
function readRadii(
themeCss: string,
+ mobileCss: string,
lightTokens: ReadonlyMap,
-): NativeThemeModel["radii"] {
+): NativeRadii {
const inline = splitRules(stripComments(themeCss)).find(
(rule) => rule.prelude === "@theme inline",
);
@@ -500,28 +570,39 @@ function readRadii(
throw new Error(`--${name} missing in @theme inline`);
return lengthToPx(substituteVars(raw, lightTokens));
};
+ const mobile = themeBlockProperties(mobileCss);
+ const mobileRadius = (name: string): number => {
+ const raw = mobile.get(name);
+ if (raw === undefined) {
+ throw new Error(`--${name} missing in mobile-overrides.css @theme`);
+ }
+ return lengthToPx(raw);
+ };
return {
base: lengthToPx(substituteVars("var(--radius)", lightTokens)),
sm: radius("radius-sm"),
md: radius("radius-md"),
lg: radius("radius-lg"),
xl: radius("radius-xl"),
+ xl2: mobileRadius("radius-2xl"),
+ full: mobileRadius("radius-full"),
};
}
/**
- * `--text-*` scale: the `@theme` overrides, then the coarse-pointer
- * `@media … (pointer: coarse) { :root {…} }` block on top. Touch sizes are the
- * native base (there is no fine pointer on a phone).
+ * `--text-*` scale: theme.css's `@theme` overrides, then its coarse-pointer
+ * `@media … (pointer: coarse) { :root {…} }` block (touch sizes are the
+ * native base; there is no fine pointer on a phone), then the mobile layer's
+ * `@theme` block on top, which carries the Apple text-style ramp.
*/
-function readTypography(themeCss: string): NativeThemeModel["typography"] {
+function readTypography(
+ themeCss: string,
+ mobileCss: string,
+): NativeThemeModel["typography"] {
const declared = new Map();
const rules = splitRules(stripComments(themeCss));
- for (const rule of rules) {
- if (rule.prelude !== "@theme") continue;
- for (const [name, value] of parseCustomProperties(rule.body)) {
- if (name.startsWith("text-")) declared.set(name, value);
- }
+ for (const [name, value] of themeBlockProperties(themeCss)) {
+ if (name.startsWith("text-")) declared.set(name, value);
}
for (const rule of rules) {
if (
@@ -537,6 +618,9 @@ function readTypography(themeCss: string): NativeThemeModel["typography"] {
}
}
}
+ for (const [name, value] of themeBlockProperties(mobileCss)) {
+ if (name.startsWith("text-")) declared.set(name, value);
+ }
const styles: [string, NativeTextStyle][] = [];
for (const [name, value] of declared) {
if (name.includes("--")) continue;
@@ -565,15 +649,53 @@ function describeNonColor(name: string, value: string): string {
return `unsupported value (${value.slice(0, 40)})`;
}
-/** Builds the full native theme model from theme.css + palette CSS strings. */
+/**
+ * A token the mobile layer sets under `:root` must also get a `.dark` value:
+ * `:root` reaches dark mode too, so without one the override would silently
+ * replace theme.css's dark value. `.dark`-only overrides are fine (light keeps
+ * the web value), as are `.light`-only ones.
+ */
+function assertRootOverridesHaveDarkTwins(rules: ModeRule[]): void {
+ const viaRoot = new Set();
+ const viaDark = new Set();
+ for (const rule of rules) {
+ const target =
+ rule.modes.length === 2
+ ? viaRoot
+ : rule.modes[0] === "dark"
+ ? viaDark
+ : null;
+ if (target === null) continue;
+ for (const [name] of rule.declarations) target.add(name);
+ }
+ const missing = [...viaRoot].filter((name) => !viaDark.has(name));
+ if (missing.length > 0) {
+ throw new Error(
+ `mobile-overrides.css sets ${missing.map((name) => `--${name}`).join(", ")} under \`:root\` (which reaches dark mode) without a \`.dark\` value`,
+ );
+ }
+}
+
+/**
+ * Builds the full native theme model from theme.css, the mobile override
+ * layer, and the palette CSS strings.
+ */
export function buildNativeThemeModel(
sources: ThemeSources = readSources(),
): NativeThemeModel {
const baseRules = modeRules(sources.themeCss);
+ const mobileRules = modeRules(sources.mobileCss);
+ assertRootOverridesHaveDarkTwins(mobileRules);
+ // The default palette is theme.css with the mobile layer on top; every other
+ // palette cascades after both, so its anchors and literals keep winning.
const defaultTokens = {
- light: cascade("light", [baseRules]),
- dark: cascade("dark", [baseRules]),
+ light: cascade("light", [baseRules, mobileRules]),
+ dark: cascade("dark", [baseRules, mobileRules]),
};
+ const webNames = new Set([
+ ...cascade("light", [baseRules]).keys(),
+ ...cascade("dark", [baseRules]).keys(),
+ ]);
// Classify every token once, from the default palette. The set of native
// color tokens must be identical in both modes: a token added to one mode
@@ -624,7 +746,7 @@ export function buildNativeThemeModel(
}
if (oneModeOnly.length > 0) {
throw new Error(
- `theme.css defines tokens in one mode only: ${oneModeOnly.map((name) => `--${name}`).join(", ")}`,
+ `theme.css + mobile-overrides.css define tokens in one mode only: ${oneModeOnly.map((name) => `--${name}`).join(", ")}`,
);
}
@@ -643,7 +765,7 @@ export function buildNativeThemeModel(
}
}
const resolveMode = (mode: Mode): Record => {
- const tokens = cascade(mode, [baseRules, paletteRules]);
+ const tokens = cascade(mode, [baseRules, mobileRules, paletteRules]);
const resolved: Record = {};
for (const name of colorNames) {
const raw = tokens.get(name);
@@ -665,8 +787,9 @@ export function buildNativeThemeModel(
return {
themes,
tokenKeys: colorNames.map(camelCase).sort(),
- radii: readRadii(sources.themeCss, defaultTokens.light),
- typography: readTypography(sources.themeCss),
+ mobileOnlyTokens: colorNames.filter((name) => !webNames.has(name)),
+ radii: readRadii(sources.themeCss, sources.mobileCss, defaultTokens.light),
+ typography: readTypography(sources.themeCss, sources.mobileCss),
skipped,
};
}
@@ -694,19 +817,29 @@ export function renderNativeThemeSource(model: NativeThemeModel): string {
const skippedLines = model.skipped.map(
({ name, reason }) => ` * --${name}: ${reason}`,
);
+ const mobileOnlyLines = model.mobileOnlyTokens.map(
+ (name) => ` * --${name}`,
+ );
const header = [
"/**",
" * GENERATED FILE — run pnpm --filter @bb/mobile theme:generate",
" *",
- " * Source: apps/app/src/components/ui/theme.css and the built-in palettes in",
- " * apps/app/src/lib/themes/*.ts, replayed through the web cascade per palette",
- " * and mode by apps/mobile/scripts/generate-native-theme.ts.",
+ " * Source: apps/app/src/components/ui/theme.css, then the mobile-only override",
+ " * layer apps/mobile/src/theme/mobile-overrides.css, then the built-in palettes",
+ " * in apps/app/src/lib/themes/*.ts, replayed through the web cascade per",
+ " * palette and mode by apps/mobile/scripts/generate-native-theme.ts. The",
+ " * mobile layer re-tunes the default palette to the iOS system look; palettes",
+ " * cascade after it, so their anchors and literals still win.",
" *",
" * `var()` is substituted textually; `color-mix(in oklch|oklab, …)` is",
" * evaluated like Chrome (premultiplied alpha, shorter hue arc, converted",
" * near-achromatic operands lose their hue). Opaque results are `#rrggbb`,",
- " * translucent ones `rgba(r, g, b, a)`. Typography uses the coarse-pointer",
- " * (touch) sizes as the base scale, in CSS pixels.",
+ " * translucent ones `rgba(r, g, b, a)`. Typography is the Apple text-style",
+ " * ramp from the mobile layer's `@theme` block, in CSS pixels.",
+ " *",
+ " * Tokens only the mobile layer defines (no web utility class; global.css",
+ " * maps them by hand):",
+ ...mobileOnlyLines,
" *",
" * Tokens deliberately left out (edit the generator to add them):",
...skippedLines,
@@ -749,11 +882,12 @@ export function renderNativeThemeSource(model: NativeThemeModel): string {
];
const radiiLines = [
- "/** `--radius` and the Tailwind `--radius-*` steps, in CSS pixels. */",
+ "/**",
+ " * `--radius` and the Tailwind `--radius-*` steps, in CSS pixels. `xl2` is",
+ " * `rounded-2xl`; `full` is `rounded-full` (pills, circles).",
+ " */",
"export const nativeRadii = {",
- ...(["base", "sm", "md", "lg", "xl"] as const).map(
- (key) => ` ${key}: ${model.radii[key]},`,
- ),
+ ...RADII_KEYS.map((key) => ` ${key}: ${model.radii[key]},`),
"};",
"",
];
@@ -765,9 +899,9 @@ export function renderNativeThemeSource(model: NativeThemeModel): string {
"}",
"",
"/**",
- " * The `--text-*` scale theme.css overrides, using the coarse-pointer (touch)",
- " * values as the base, in CSS pixels. Sizes theme.css does not override keep",
- " * Tailwind's defaults.",
+ " * The `--text-*` scale: theme.css's coarse-pointer (touch) values with the",
+ " * mobile layer's Apple text-style ramp on top (caption2 → largeTitle), in",
+ " * CSS pixels. Mirrored as ratios in global.css (theme-vars.test.ts).",
" */",
"export const nativeTypography = {",
...model.typography.flatMap(([name, style]) => [
diff --git a/apps/mobile/src/ansi/TerminalOutputBlock.tsx b/apps/mobile/src/ansi/TerminalOutputBlock.tsx
index 783d84e1dc..c3394d692a 100644
--- a/apps/mobile/src/ansi/TerminalOutputBlock.tsx
+++ b/apps/mobile/src/ansi/TerminalOutputBlock.tsx
@@ -30,11 +30,16 @@ export interface TerminalOutputBlockProps {
testID?: string;
}
+/** Card corners: continuous 10pt, the grouped-card radius. */
+const CARD_STYLE = { borderRadius: 10, borderCurve: "continuous" } as const;
+
/**
* Command card mirroring the web `TerminalOutputBlock`: command line,
* metadata, ANSI-colored output in a horizontally scrolling monospace block
* that collapses to its tail with an "N earlier lines" toggle, exit code.
- * The web dims the whole card to 70%; so does this one.
+ * The web dims the whole card to 70%; here the card stays opaque and the
+ * secondary text tiers carry the recession (a translucent card washes out
+ * over the timeline background).
*/
export const TerminalOutputBlock = memo(function TerminalOutputBlock({
output,
@@ -65,7 +70,7 @@ export const TerminalOutputBlock = memo(function TerminalOutputBlock({
"overflow-hidden rounded-lg border border-border bg-card",
className,
)}
- style={{ opacity: 0.7 }}
+ style={CARD_STYLE}
testID={testID}
accessibilityState={streaming ? { busy: true } : undefined}
>
@@ -82,6 +87,7 @@ export const TerminalOutputBlock = memo(function TerminalOutputBlock({
variant="mono"
className="text-xs text-foreground"
numberOfLines={commandExpanded ? undefined : 2}
+ selectable
testID={testID ? `${testID}-command` : undefined}
>
{commandLine}
@@ -130,6 +136,7 @@ export const TerminalOutputBlock = memo(function TerminalOutputBlock({
fontSize={TERMINAL_FONT_SIZE}
lineHeight={TERMINAL_LINE_HEIGHT}
numberOfLines={1}
+ selectable
/>
))}
@@ -155,6 +162,7 @@ export const TerminalOutputBlock = memo(function TerminalOutputBlock({
"text-xs text-muted-foreground",
(hasOutput || commandLine) && "mt-1.5",
)}
+ numeric
testID={testID ? `${testID}-exit-code` : undefined}
>
exit code {exitCode}
diff --git a/apps/mobile/src/composer/AttachmentChips.tsx b/apps/mobile/src/composer/AttachmentChips.tsx
index 55dfa59316..014184184d 100644
--- a/apps/mobile/src/composer/AttachmentChips.tsx
+++ b/apps/mobile/src/composer/AttachmentChips.tsx
@@ -1,7 +1,13 @@
import type { PromptDraftAttachment } from "@bb/client-core";
import { Image } from "expo-image";
import { useState } from "react";
-import { Pressable, ScrollView, View } from "react-native";
+import { Pressable, ScrollView, StyleSheet, View } from "react-native";
+import Animated, {
+ FadeIn,
+ FadeOut,
+ LinearTransition,
+} from "react-native-reanimated";
+import { haptic } from "@/lib/haptics";
import {
ImageLightbox,
openLightbox,
@@ -27,16 +33,18 @@ export interface AttachmentChipsProps {
const THUMB = 64;
const THUMB_RADIUS = 12;
-/** The corner remove button on an image thumbnail. */
+/** The corner remove badge on an image thumbnail. */
const REMOVE_BUTTON = 20;
-// The remove button sits on the photograph, so it is black/white like the
-// lightbox chrome (web `bg-black/55 text-white`), not a palette token.
-const REMOVE_BUTTON_BACKGROUND = "rgba(0, 0, 0, 0.6)";
-const REMOVE_BUTTON_PRESSED_BACKGROUND = "rgba(0, 0, 0, 0.8)";
+// The remove badge sits on the photograph, so it is black/white like the
+// lightbox chrome (web `bg-black/55 text-white`), not a palette token: a
+// white `xmark.circle.fill` over a soft shadow.
const REMOVE_BUTTON_FOREGROUND = "#ffffff";
+const REMOVE_BUTTON_SHADOW = "0 1px 3px rgba(0, 0, 0, 0.4)";
+const CHIP_ENTER_MS = 180;
+const CHIP_EXIT_MS = 140;
/**
- * An image thumbnail with a small remove button in its top-right corner. A
+ * An image thumbnail with a small remove badge in its top-right corner. A
* tap on the picture opens the lightbox.
*/
function ImageChip({
@@ -54,7 +62,10 @@ function ImageChip({
}) {
const { tokens } = useTheme();
return (
-
-
+
) : null}
-
+
);
}
+/** A file chip: capsule, hairline outline, optional remove. */
function ChipFrame({
children,
onRemove,
@@ -123,18 +140,21 @@ function ChipFrame({
}) {
const { tokens } = useTheme();
return (
-
-
+
) : null}
-
+
);
}
@@ -172,6 +197,7 @@ interface ResolvedAttachment {
/**
* Horizontal strip of attached files (image thumbnails, file chips, uploads
* in flight). Image thumbnails open the same lightbox as timeline images.
+ * Chips fade in and out and the strip reflows as they come and go.
*/
export function AttachmentChips({
attachments,
@@ -199,6 +225,10 @@ export function AttachmentChips({
({ attachment, uri }) =>
uri === null ? [] : [{ src: uri, alt: attachment.name }],
);
+ const remove = (path: string) => {
+ haptic("selection");
+ onRemove(path);
+ };
return (
<>
@@ -214,9 +244,9 @@ export function AttachmentChips({
testID={testID}
>
{resolved.map(({ attachment, uri }, index) => {
- const remove = disabled
+ const removeThis = disabled
? undefined
- : () => onRemove(attachment.path);
+ : () => remove(attachment.path);
if (uri !== null) {
const imageIndex = lightboxImages.findIndex(
(image) => image.src === uri,
@@ -229,7 +259,7 @@ export function AttachmentChips({
onPress={() =>
setLightbox(openLightbox(lightboxImages, imageIndex))
}
- onRemove={remove}
+ onRemove={removeThis}
testID={`${testID}-${index}`}
/>
);
@@ -238,14 +268,17 @@ export function AttachmentChips({
-
+
@@ -258,16 +291,20 @@ export function AttachmentChips({
})}
{pending.map((entry) =>
entry.previewUri ? (
-
-
+
) : (
-
+
{entry.name}
diff --git a/apps/mobile/src/composer/Composer.tsx b/apps/mobile/src/composer/Composer.tsx
index 9a6531c9e0..47ba432fba 100644
--- a/apps/mobile/src/composer/Composer.tsx
+++ b/apps/mobile/src/composer/Composer.tsx
@@ -14,22 +14,34 @@ import {
type ReactNode,
type RefObject,
} from "react";
-import { Pressable, View, type StyleProp, type ViewStyle } from "react-native";
+import {
+ Pressable,
+ StyleSheet,
+ View,
+ type StyleProp,
+ type ViewStyle,
+} from "react-native";
+import Animated, {
+ FadeIn,
+ FadeOut,
+ LinearTransition,
+} from "react-native-reanimated";
import { haptic } from "@/lib/haptics";
import { useProfileClient } from "@/app-shell/ProfilesProvider";
import { buildProjectAttachmentContentUrl } from "@/data/thread-detail";
import { useSystemConfig, useSystemProviders } from "@/data/system";
import { useTheme } from "@/theme";
import {
- ActionSheet,
Button,
+ GlassSurface,
Icon,
LONG_PRESS_DELAY_MS,
+ NativeMenu,
SheetPresenceContext,
Spinner,
useOverlayBounds,
- useSheet,
- type ActionSheetAction,
+ type NativeMenuAction,
+ type SFSymbol,
} from "@/ui";
import { AttachmentChips } from "./AttachmentChips";
import { ComposerInput, type ComposerInputHandle } from "./ComposerInput";
@@ -66,6 +78,8 @@ import {
import { useComposerVoice } from "./useComposerVoice";
import { VoiceBar } from "./VoiceBar";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
export interface ComposerHandle {
focus: () => void;
blur: () => void;
@@ -124,12 +138,36 @@ const EMPTY_ACTIONS: readonly ComposerAction[] = [];
/** A blur this close before a sheet opens is the sheet's keyboard dismissal. */
const BLUR_FOR_SHEET_MS = 600;
+/** Card corners: the pill and the expanded card (web parity). */
+const CARD_RADIUS_COLLAPSED = 26;
+const CARD_RADIUS_EXPANDED = 22;
+/** Footer / pill control metrics: the 36pt circle the send button draws. */
+const CONTROL_SIZE = 36;
+/** iOS: the filled circle symbols are the buttons (send / queue / stop). */
+const SYMBOL_SIZE = 32;
+/** iOS: the "+" and mic glyph sizes. */
+const PLUS_SYMBOL_SIZE = 28;
+const MIC_SYMBOL_SIZE = 22;
+const ROW_FADE_MS = 120;
+const CARD_LAYOUT_MS = 200;
+
+/** The iOS symbol for a "+" menu entry (the map covers the rest). */
+const ATTACHMENT_SYMBOLS: Record<
+ "photo-library" | "camera" | "file",
+ SFSymbol
+> = {
+ "photo-library": "photo.on.rectangle",
+ camera: "camera",
+ file: "paperclip",
+};
+
/**
* The shared native composer (root compose + follow-up): mention pills in a
* native `TextInput`, `@` / `#` / `/` typeahead, attachments (library,
* camera, files → `POST /projects/:id/attachments`), voice (expo-audio →
- * `POST /system/voice-transcription`), the "+" actions menu, execution
- * pills, and a submit button driven by the client-core submit mode.
+ * `POST /system/voice-transcription`), the "+" actions menu (a native
+ * pull-down menu on iOS, the action sheet elsewhere), execution pills,
+ * and a submit button driven by the client-core submit mode.
*/
export const Composer = forwardRef(
function Composer(
@@ -344,18 +382,18 @@ export const Composer = forwardRef(
voice.state === "recording" || voice.state === "transcribing";
// --- "+" menu -----------------------------------------------------------
- const actionsSheet = useSheet();
const applyPromptAction = usePromptActionApplier({
valueRef,
inputRef,
commit,
});
- const sheetActions = useMemo((): ActionSheetAction[] => {
- const rows: ActionSheetAction[] = [
+ const menuActions = useMemo((): NativeMenuAction[] => {
+ const rows: NativeMenuAction[] = [
{
key: "photo-library",
label: "Photo library",
icon: "Eye",
+ symbol: ATTACHMENT_SYMBOLS["photo-library"],
disabled: scope.projectId === null,
onPress: () => void attachmentsController.pickFromLibrary(),
},
@@ -363,6 +401,7 @@ export const Composer = forwardRef(
key: "camera",
label: "Take photo",
icon: "Smartphone",
+ symbol: ATTACHMENT_SYMBOLS.camera,
disabled: scope.projectId === null,
onPress: () => void attachmentsController.takePhoto(),
},
@@ -370,6 +409,7 @@ export const Composer = forwardRef(
key: "file",
label: "Attach file",
icon: "Paperclip",
+ symbol: ATTACHMENT_SYMBOLS.file,
disabled: scope.projectId === null,
onPress: () => void attachmentsController.pickDocument(),
},
@@ -402,6 +442,41 @@ export const Composer = forwardRef(
promptActionModel.actions,
scope.projectId,
]);
+ // Inert while an attachment upload is in flight too: the spinner replaces
+ // the glyph and a second pick would race the upload.
+ const plusInert =
+ disabled || isSubmitting || attachmentsController.isUploading;
+ // An icon-only trigger: the menu host is the accessible element (label,
+ // role, state, testID), the glyph view inside it is not one.
+ const plusButton = (
+
+
+ {attachmentsController.isUploading ? (
+
+ ) : (
+
+ )}
+
+
+ );
// --- Submit -------------------------------------------------------------
const hasInput = hasComposerText(value) || attachments.length > 0;
@@ -414,7 +489,7 @@ export const Composer = forwardRef(
});
const submit = useCallback(
(kind: ComposerSubmitKind) => {
- haptic("impact-medium");
+ haptic("impact-light");
void onSubmit(kind);
},
[onSubmit],
@@ -449,6 +524,38 @@ export const Composer = forwardRef(
/>
) : null;
+ const micButton = (
+ {
+ haptic("impact-light");
+ void voice.start();
+ }}
+ testID={`${testID}-voice`}
+ />
+ );
+
+ // The card's shape (the pill and the expanded card) is what Liquid Glass
+ // refracts through on iOS 26; the fill and border are the fallback
+ // surface (older iOS: secondary fill + hairline; Android: the web card).
+ const cardShape: ViewStyle = {
+ borderRadius: collapsed ? CARD_RADIUS_COLLAPSED : CARD_RADIUS_EXPANDED,
+ paddingHorizontal: collapsed ? 6 : 0,
+ };
+ const cardStyle: ViewStyle = IS_IOS
+ ? { ...cardShape, borderCurve: "continuous" }
+ : cardShape;
+ const cardFallbackStyle: ViewStyle = IS_IOS
+ ? {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: tokens.borderHairline,
+ backgroundColor: tokens.secondary,
+ }
+ : {
+ borderWidth: 1,
+ borderColor: focused && !collapsed ? tokens.ring : tokens.input,
+ backgroundColor: tokens.card,
+ };
+
return (
(
{menuNode}
) : null}
- (
>
{header}
{!collapsed && topControls ? (
-
{topControls}
-
+
) : null}
(
{/* The input keeps its tree position in both layouts so the pill
→ card transition never remounts it (that would drop focus). */}
- {collapsed ? (
-
- ) : null}
+ {collapsed ? plusButton : null}
(
testID={`${testID}-stop`}
/>
) : collapsed && showVoicePrimary ? (
- void voice.start()}
- testID={`${testID}-voice`}
- />
+ micButton
) : collapsed ? (
) : null}
@@ -571,20 +662,13 @@ export const Composer = forwardRef(
{collapsed ? null : voiceBusy ? (
) : (
-
-
+ {plusButton}
{executionControls ? (
(
/>
) : null}
{showVoicePrimary ? (
- void voice.start()}
- testID={`${testID}-voice`}
- />
+ micButton
) : affordance.kind !== null || !affordance.stop ? (
- {
if (affordance.kind) submit(affordance.kind);
@@ -626,49 +701,12 @@ export const Composer = forwardRef(
? () => submit("steer")
: undefined
}
- delayLongPress={LONG_PRESS_DELAY_MS}
testID={`${testID}-submit`}
- style={({ pressed }) => ({
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: "center",
- justifyContent: "center",
- backgroundColor: affordance.disabled
- ? tokens.muted
- : tokens.foreground,
- opacity: pressed ? 0.85 : 1,
- })}
- >
- {affordance.icon === "Spinner" ? (
-
- ) : (
-
- )}
-
+ />
) : null}
-
+
)}
-
-
+
);
@@ -741,9 +779,154 @@ function usePromptActionApplier({
}
/**
- * Round "stop the run" button, the same 36pt circle as the send button so the
- * collapsed pill and the expanded footer keep one silhouette. A filled square
- * (web: `Square` with `fill-current`), not the stroked icon, reads as stop.
+ * Send / queue. iOS: the filled circle symbol is the button (`arrow.up.
+ * circle.fill` in the tint, `plus.circle.fill` to queue; tertiary while
+ * nothing can be sent). Elsewhere: the web's 36pt foreground-filled circle
+ * with the stroked glyph.
+ */
+function SubmitButton({
+ icon,
+ label,
+ disabled,
+ onPress,
+ onLongPress,
+ testID,
+}: {
+ icon: "ArrowUp" | "Plus" | "Square" | "Spinner";
+ label: string;
+ disabled: boolean;
+ onPress: () => void;
+ onLongPress?: () => void;
+ testID: string;
+}) {
+ const { tokens } = useTheme();
+ if (IS_IOS) {
+ const tint = disabled ? tokens.mutedForeground : tokens.primary;
+ return (
+ ({
+ width: CONTROL_SIZE,
+ height: CONTROL_SIZE,
+ alignItems: "center",
+ justifyContent: "center",
+ opacity: pressed ? 0.6 : 1,
+ })}
+ >
+ {icon === "Spinner" ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+ return (
+ ({
+ width: CONTROL_SIZE,
+ height: CONTROL_SIZE,
+ borderRadius: CONTROL_SIZE / 2,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: disabled ? tokens.muted : tokens.foreground,
+ opacity: pressed ? 0.85 : 1,
+ })}
+ >
+ {icon === "Spinner" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/** Voice input. iOS: the `mic.fill` symbol; elsewhere the ghost icon button. */
+function MicButton({
+ onPress,
+ testID,
+}: {
+ onPress: () => void;
+ testID: string;
+}) {
+ const { tokens } = useTheme();
+ if (IS_IOS) {
+ return (
+ ({
+ width: CONTROL_SIZE,
+ height: CONTROL_SIZE,
+ alignItems: "center",
+ justifyContent: "center",
+ opacity: pressed ? 0.6 : 1,
+ })}
+ >
+
+
+ );
+ }
+ return (
+
+ );
+}
+
+/**
+ * "Stop the run". iOS: the `stop.circle.fill` symbol in the label color.
+ * Elsewhere the same 36pt circle as the send button (a filled square, web:
+ * `Square` with `fill-current`) so the collapsed pill and the expanded
+ * footer keep one silhouette. Stopping is an interruption: the warning
+ * haptic.
*/
function StopButton({
onPress,
@@ -755,21 +938,50 @@ function StopButton({
testID: string;
}) {
const { tokens } = useTheme();
+ const press = () => {
+ haptic("warning");
+ onPress();
+ };
+ if (IS_IOS) {
+ return (
+ [
+ {
+ width: CONTROL_SIZE,
+ height: CONTROL_SIZE,
+ alignItems: "center",
+ justifyContent: "center",
+ opacity: pressed ? 0.6 : 1,
+ },
+ style,
+ ]}
+ >
+
+
+ );
+ }
return (
{
- haptic("impact-medium");
- onPress();
- }}
+ onPress={press}
testID={testID}
style={({ pressed }) => [
{
- width: 36,
- height: 36,
- borderRadius: 18,
+ width: CONTROL_SIZE,
+ height: CONTROL_SIZE,
+ borderRadius: CONTROL_SIZE / 2,
alignItems: "center",
justifyContent: "center",
backgroundColor: tokens.secondary,
diff --git a/apps/mobile/src/composer/TypeaheadMenu.tsx b/apps/mobile/src/composer/TypeaheadMenu.tsx
index 38a312ab2c..2922a462f6 100644
--- a/apps/mobile/src/composer/TypeaheadMenu.tsx
+++ b/apps/mobile/src/composer/TypeaheadMenu.tsx
@@ -7,12 +7,21 @@ import type {
ProviderCommandSuggestion,
} from "@bb/client-core";
import { memo, useMemo } from "react";
-import { Pressable, ScrollView, View } from "react-native";
+import { Pressable, ScrollView, StyleSheet, View } from "react-native";
+import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
+import { haptic } from "@/lib/haptics";
+import { withAlpha } from "@/markdown/colors";
import { useTheme } from "@/theme";
-import { Icon, Spinner, Text, type IconName } from "@/ui";
+import { Icon, Spinner, Text, type IconName, type SFSymbol } from "@/ui";
import { TYPEAHEAD_MAX_HEIGHT } from "./model";
import type { TypeaheadMenuModel } from "./useComposerTypeahead";
+const IS_IOS = process.env.EXPO_OS === "ios";
+/** Popover corners: continuous 14pt (the system menu radius). */
+const POPOVER_RADIUS = 14;
+const ENTER_MS = 150;
+const EXIT_MS = 100;
+
export interface TypeaheadMenuProps {
menu: TypeaheadMenuModel;
onPickMention: (suggestion: PromptMentionSuggestion) => void;
@@ -28,21 +37,35 @@ const COMMAND_SECTION_LABELS: Record = {
"user-command": "User commands",
};
-function mentionIcon(suggestion: PromptMentionSuggestion): IconName {
+/** Row glyph: the Android icon name and the iOS symbol (the spec's set). */
+interface RowGlyph {
+ icon: IconName;
+ symbol: SFSymbol;
+}
+
+function mentionGlyph(suggestion: PromptMentionSuggestion): RowGlyph {
switch (suggestion.kind) {
case "thread":
- return "UserRound";
+ return { icon: "MessageSquare", symbol: "bubble.left" };
case "project":
- return "Folder";
+ return { icon: "Folder", symbol: "folder" };
case "section":
- return "SectionAdd";
+ return { icon: "SectionAdd", symbol: "text.badge.plus" };
case "plugin":
- return "ElectricPlugs";
+ return { icon: "ElectricPlugs", symbol: "powerplug" };
case "path":
- return suggestion.entryKind === "directory" ? "Folder" : "File";
+ return suggestion.entryKind === "directory"
+ ? { icon: "Folder", symbol: "folder" }
+ : { icon: "File", symbol: "doc" };
}
}
+function commandGlyph(suggestion: ProviderCommandSuggestion): RowGlyph {
+ return suggestion.source === "skill"
+ ? { icon: "Zap", symbol: "bolt.fill" }
+ : { icon: "Terminal", symbol: "terminal" };
+}
+
function mentionTitle(suggestion: PromptMentionSuggestion): string {
switch (suggestion.kind) {
case "thread":
@@ -92,7 +115,7 @@ function mentionSectionLabel(suggestion: PromptMentionSuggestion): string {
interface MenuRow {
key: string;
- icon: IconName;
+ glyph: RowGlyph;
title: string;
subtitle: string | null;
section: string;
@@ -112,7 +135,10 @@ const Row = memo(function Row({ row }: { row: MenuRow }) {
const { tokens } = useTheme();
return (
{
+ haptic("selection");
+ row.onPress();
+ }}
accessibilityRole="button"
accessibilityLabel={row.title}
testID={row.testID}
@@ -123,12 +149,21 @@ const Row = memo(function Row({ row }: { row: MenuRow }) {
minHeight: 44,
paddingHorizontal: 12,
paddingVertical: 6,
- backgroundColor: pressed ? tokens.stateHover : "transparent",
+ backgroundColor: pressed
+ ? IS_IOS
+ ? tokens.stateActive
+ : tokens.stateHover
+ : "transparent",
})}
>
-
+
-
+
{row.title}
{row.subtitle ? (
@@ -159,7 +194,7 @@ export function TypeaheadMenu({
if (menu.kind === "command") {
return menu.suggestions.map((suggestion, index) => ({
key: `${suggestion.source}:${suggestion.name}`,
- icon: suggestion.source === "skill" ? "Zap" : "Terminal",
+ glyph: commandGlyph(suggestion),
title: `/${suggestion.name}`,
subtitle: suggestion.description ?? suggestion.argumentHint,
section: COMMAND_SECTION_LABELS[providerCommandSection(suggestion)],
@@ -171,7 +206,7 @@ export function TypeaheadMenu({
key: `${suggestion.kind}:${suggestion.replacement}:${
suggestion.kind === "plugin" ? suggestion.itemId : ""
}`,
- icon: mentionIcon(suggestion),
+ glyph: mentionGlyph(suggestion),
title: mentionTitle(suggestion),
subtitle: mentionSubtitle(suggestion),
section: mentionSectionLabel(suggestion),
@@ -192,21 +227,21 @@ export function TypeaheadMenu({
}
return (
-
{status !== null ? (
@@ -235,6 +270,6 @@ export function TypeaheadMenu({
})}
)}
-
+
);
}
diff --git a/apps/mobile/src/composer/VoiceBar.tsx b/apps/mobile/src/composer/VoiceBar.tsx
index 38f8b1440a..8a8dbb5418 100644
--- a/apps/mobile/src/composer/VoiceBar.tsx
+++ b/apps/mobile/src/composer/VoiceBar.tsx
@@ -1,5 +1,5 @@
import { useEffect } from "react";
-import { View } from "react-native";
+import { Pressable, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -7,10 +7,17 @@ import Animated, {
withSequence,
withTiming,
} from "react-native-reanimated";
-import { Button } from "@/ui";
+import { haptic } from "@/lib/haptics";
+import { useTheme } from "@/theme";
+import { Button, Icon, Spinner } from "@/ui";
import type { ComposerVoiceController } from "./useComposerVoice";
import { VoiceWaveform } from "./VoiceWaveform";
+const IS_IOS = process.env.EXPO_OS === "ios";
+/** iOS: the filled circle symbols are the buttons. */
+const SYMBOL_BUTTON = 36;
+const SYMBOL_SIZE = 32;
+
export type VoiceBarController = Pick<
ComposerVoiceController,
"state" | "readLevel" | "stop" | "cancel"
@@ -20,9 +27,11 @@ export type VoiceBarController = Pick<
* Replaces the footer while recording / transcribing (web `VoiceRecordingBar`):
* cancel · the live sound-wave bars · confirm. While transcribing the bars
* freeze and breathe (the web `animate-shine-icon`) and the confirm button
- * shows a spinner.
+ * shows a spinner. iOS draws the two buttons as the system's filled circle
+ * symbols (`xmark.circle.fill` / `arrow.up.circle.fill`).
*/
export function VoiceBar({ voice }: { voice: VoiceBarController }) {
+ const { tokens } = useTheme();
const transcribing = voice.state === "transcribing";
const opacity = useSharedValue(1);
useEffect(() => {
@@ -41,6 +50,16 @@ export function VoiceBar({ voice }: { voice: VoiceBarController }) {
);
}, [opacity, transcribing]);
const breathe = useAnimatedStyle(() => ({ opacity: opacity.get() }));
+ const cancelLabel = transcribing
+ ? "Cancel transcription"
+ : "Cancel recording";
+ const stopLabel = transcribing
+ ? "Transcribing voice input"
+ : "Stop and transcribe";
+ const stop = () => {
+ haptic("impact-medium");
+ void voice.stop();
+ };
return (
-
+ {IS_IOS ? (
+
+
+
+ ) : (
+
+ )}
- void voice.stop()}
- testID="composer-voice-stop"
- />
+ {IS_IOS ? (
+
+ {transcribing ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+ void voice.stop()}
+ testID="composer-voice-stop"
+ />
+ )}
);
}
diff --git a/apps/mobile/src/diff/DiffFileCard.tsx b/apps/mobile/src/diff/DiffFileCard.tsx
index 069ffba0ae..d068bf13b5 100644
--- a/apps/mobile/src/diff/DiffFileCard.tsx
+++ b/apps/mobile/src/diff/DiffFileCard.tsx
@@ -45,6 +45,9 @@ export interface DiffFileCardProps {
testID?: string;
}
+/** Card corners: continuous 10pt, the grouped-card radius. */
+const CARD_STYLE = { borderRadius: 10, borderCurve: "continuous" } as const;
+
const CHANGE_KIND_LABEL: Record = {
added: "added",
deleted: "deleted",
@@ -105,6 +108,7 @@ export const DiffFileCard = memo(function DiffFileCard({
return (
{showAdditions ? (
-
+
+{formatDiffCount(file.stats.additions)}
) : null}
{showDeletions ? (
-
+
-{formatDiffCount(file.stats.deletions)}
) : null}
diff --git a/apps/mobile/src/diff/DiffHunkView.tsx b/apps/mobile/src/diff/DiffHunkView.tsx
index 625312b053..da7eb1ba6d 100644
--- a/apps/mobile/src/diff/DiffHunkView.tsx
+++ b/apps/mobile/src/diff/DiffHunkView.tsx
@@ -159,6 +159,7 @@ const ContentRow = memo(function ContentRow({
includeFontPadding: false,
}}
numberOfLines={1}
+ selectable
>
{text}
diff --git a/apps/mobile/src/diff/FileChangeDiffBlock.tsx b/apps/mobile/src/diff/FileChangeDiffBlock.tsx
index d3cb813be2..3c9a9b51ee 100644
--- a/apps/mobile/src/diff/FileChangeDiffBlock.tsx
+++ b/apps/mobile/src/diff/FileChangeDiffBlock.tsx
@@ -54,6 +54,7 @@ export const FileChangeDiffBlock = memo(function FileChangeDiffBlock({
return (
No diff available.
@@ -68,6 +69,12 @@ interface PlainDiffBlockProps {
testID?: string;
}
+/** Card corners: continuous 10pt, the grouped-card radius. */
+const PLAIN_CARD_STYLE = {
+ borderRadius: 10,
+ borderCurve: "continuous",
+} as const;
+
/** Monospace fallback for diffs that do not parse: the web's `EventCodeBlock`. */
function PlainDiffBlock({ text, maxLines, testID }: PlainDiffBlockProps) {
const [expanded, setExpanded] = useState(false);
@@ -80,6 +87,7 @@ function PlainDiffBlock({ text, maxLines, testID }: PlainDiffBlockProps) {
return (
{line.length === 0 ? " " : line}
diff --git a/apps/mobile/src/markdown/CodeBlock.tsx b/apps/mobile/src/markdown/CodeBlock.tsx
index c59902a39d..f1f4f1ce78 100644
--- a/apps/mobile/src/markdown/CodeBlock.tsx
+++ b/apps/mobile/src/markdown/CodeBlock.tsx
@@ -1,6 +1,7 @@
import * as Clipboard from "expo-clipboard";
import { memo, useMemo } from "react";
import { Pressable, ScrollView, Text as RNText, View } from "react-native";
+import { haptic } from "@/lib/haptics";
import { FONT_FAMILIES } from "@/theme/fonts";
import { nativeTypography } from "@/theme/theme.native";
import { Icon } from "@/ui/Icon";
@@ -24,6 +25,7 @@ export interface CodeBlockProps {
function copyCodeToClipboard(code: string): void {
void Clipboard.setStringAsync(code)
.then(() => {
+ haptic("success");
toast.success("Copied");
})
.catch(() => {
@@ -58,7 +60,8 @@ export const CodeBlock = memo(function CodeBlock({
onLongPress={copy}
accessibilityHint="Long press to copy"
style={{
- borderRadius: 6,
+ borderRadius: 10,
+ borderCurve: "continuous",
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.surfaceRecessed,
diff --git a/apps/mobile/src/markdown/MarkdownImage.tsx b/apps/mobile/src/markdown/MarkdownImage.tsx
index 068a6ad97c..2dcf4ea4ae 100644
--- a/apps/mobile/src/markdown/MarkdownImage.tsx
+++ b/apps/mobile/src/markdown/MarkdownImage.tsx
@@ -75,7 +75,8 @@ export const MarkdownImage = memo(function MarkdownImage({
maxHeight: MAX_IMAGE_HEIGHT,
aspectRatio: aspectRatio ?? undefined,
height: aspectRatio === null ? PENDING_IMAGE_HEIGHT : undefined,
- borderRadius: 6,
+ borderRadius: 10,
+ borderCurve: "continuous",
overflow: "hidden",
backgroundColor: tokens.surfaceRecessed,
}}
diff --git a/apps/mobile/src/markdown/MarkdownTable.tsx b/apps/mobile/src/markdown/MarkdownTable.tsx
index 2a1c4f42c3..41b850dc90 100644
--- a/apps/mobile/src/markdown/MarkdownTable.tsx
+++ b/apps/mobile/src/markdown/MarkdownTable.tsx
@@ -17,7 +17,9 @@ import { renderInline } from "./render-inline";
const MIN_COLUMN_WIDTH = 64;
const MAX_COLUMN_WIDTH = 260;
const CELL_HORIZONTAL_PADDING = 8;
-// Average glyph advance of Inter at 14px; over-estimates so short cells and
+// Average glyph advance at the 13px footnote size, tuned for the widest
+// system face in play (Inter-class metrics on Android; SF Pro is a touch
+// narrower, so it only gains slack); over-estimates so short cells and
// medium-weight headers do not wrap.
const APPROX_CHAR_WIDTH = 8.4;
// Absorbs wide glyph runs (`m`, capitals) the average misses on short words.
@@ -145,7 +147,8 @@ export const MarkdownTable = memo(function MarkdownTable({
borderWidth: 1,
borderRightWidth: 0,
borderColor: tokens.border,
- borderRadius: 4,
+ borderRadius: 8,
+ borderCurve: "continuous",
overflow: "hidden",
}}
>
diff --git a/apps/mobile/src/markdown/render-inline.tsx b/apps/mobile/src/markdown/render-inline.tsx
index 4fda42a3f2..0b10afcbc6 100644
--- a/apps/mobile/src/markdown/render-inline.tsx
+++ b/apps/mobile/src/markdown/render-inline.tsx
@@ -39,7 +39,7 @@ export const DEFAULT_INLINE_STATE: InlineState = {
function spanFont(
state: InlineState,
-): Pick {
+): Pick {
if (state.italic) {
return resolveItalicFont(state.weight);
}
diff --git a/apps/mobile/src/screens/compose/ComposeDock.tsx b/apps/mobile/src/screens/compose/ComposeDock.tsx
index 681b0f755c..b81ce12e77 100644
--- a/apps/mobile/src/screens/compose/ComposeDock.tsx
+++ b/apps/mobile/src/screens/compose/ComposeDock.tsx
@@ -237,7 +237,8 @@ function WhereControls({
horizontal
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
- contentContainerStyle={{ gap: 2, alignItems: "center" }}
+ // Capsule pills need room between them (the ghost pills touched).
+ contentContainerStyle={{ gap: 8, alignItems: "center" }}
style={{ flexGrow: 1, flexShrink: 1 }}
testID="compose-environment-controls"
>
diff --git a/apps/mobile/src/screens/connect/AccountServersList.tsx b/apps/mobile/src/screens/connect/AccountServersList.tsx
index f04bc7e4a8..35382fda43 100644
--- a/apps/mobile/src/screens/connect/AccountServersList.tsx
+++ b/apps/mobile/src/screens/connect/AccountServersList.tsx
@@ -4,7 +4,8 @@ import { View } from "react-native";
import { useProfiles } from "@/app-shell";
import { accountServerProfile, useAccountServers } from "@/data/connect";
import { describeError } from "@/lib/describe-error";
-import { Button, ListRow, Pill, Spinner, Text, toast } from "@/ui";
+import { Button, GroupedRow, Spinner, Text, toast } from "@/ui";
+import { SettingsSection } from "../settings/SettingsRows";
/**
* The other bb servers on the same getbb.app account, one tap to save each
@@ -46,71 +47,79 @@ export function AccountServersList({
};
return (
-
- Servers on this account
+
{state.status === "loading" || state.status === "idle" ? (
-
-
- Loading your servers…
+
+
+
+ Loading your servers…
+
) : state.status === "error" ? (
-
-
+
+
{state.failure.title}: {state.failure.message}
-
+
Try again
) : state.servers.length === 0 ? (
-
- No servers are paired with this account yet.
-
- ) : (
-
- {state.servers.map((server) => {
- const isSelf = server.handle === state.selfHandle;
- const saved = savedHandles.has(`${server.handle} ${server.url}`);
- return (
-
- {isSelf ? "This server" : "Saved"}
-
- ) : (
-
-
- {server.live ? "Online" : "Offline"}
-
- void add(server)}
- testID={`account-server-add-${server.handle}`}
- >
- Add
-
-
- )
- }
- testID={`account-server-${server.handle}`}
- />
- );
- })}
+
+
+ No servers are paired with this account yet.
+
+ ) : (
+ state.servers.map((server) => {
+ const isSelf = server.handle === state.selfHandle;
+ const saved = savedHandles.has(`${server.handle} ${server.url}`);
+ return (
+ void add(server)}
+ testID={`account-server-add-${server.handle}`}
+ >
+ Add
+
+ )
+ }
+ testID={`account-server-${server.handle}`}
+ />
+ );
+ })
)}
-
- One pairing covers every server on the account: the credential and the
- session cookie are account-wide. Servers paired later show up here too.
-
-
+
);
}
diff --git a/apps/mobile/src/screens/connect/ConnectEnrollScreen.tsx b/apps/mobile/src/screens/connect/ConnectEnrollScreen.tsx
index 866b6e8ff3..113bbd735a 100644
--- a/apps/mobile/src/screens/connect/ConnectEnrollScreen.tsx
+++ b/apps/mobile/src/screens/connect/ConnectEnrollScreen.tsx
@@ -1,5 +1,10 @@
import type { ConnectCredential } from "@bb/connect-client";
-import { useLocalSearchParams, useRouter } from "expo-router";
+import {
+ Stack,
+ useLocalSearchParams,
+ useNavigation,
+ useRouter,
+} from "expo-router";
import { useState } from "react";
import { View } from "react-native";
import { useProfiles } from "@/app-shell";
@@ -15,11 +20,24 @@ import {
import { describeError } from "@/lib/describe-error";
import type { SessionState } from "@/lib/session";
import { useTheme } from "@/theme";
-import { Button, Icon, Input, Spinner, Text, toast } from "@/ui";
-import { Screen } from "../shell/Screen";
+import {
+ Button,
+ GroupedRow,
+ Icon,
+ Input,
+ SheetProvider,
+ Spinner,
+ Text,
+ toast,
+} from "@/ui";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import { useBadgeColors } from "../settings/settings-badges";
+import { SettingsSection } from "../settings/SettingsRows";
import { AccountServersList } from "./AccountServersList";
import { ConnectScanner } from "./ConnectScanner";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
type Phase =
| { kind: "form" }
| { kind: "redeeming" }
@@ -38,17 +56,18 @@ interface FieldError {
}
/**
- * bb connect enrollment: scan the pairing QR or type the code, redeem it at
- * the apex for this phone's machine credential, save the profile, make it
- * active (the connector then mints the desktop-session cookie and opens
- * realtime), and offer the account's other servers — one enrollment covers
- * all of them because the credential and the session cookie are
- * account-scoped. With `profileId` the same flow re-pairs an existing
- * profile whose credential was revoked.
+ * bb connect enrollment (a modal on iOS with Cancel in the header): scan the
+ * pairing QR or type the code, redeem it at the apex for this phone's
+ * machine credential, save the profile, make it active (the connector then
+ * mints the desktop-session cookie and opens realtime), and offer the
+ * account's other servers — one enrollment covers all of them because the
+ * credential and the session cookie are account-scoped. With `profileId`
+ * the same flow re-pairs an existing profile whose credential was revoked.
*/
export function ConnectEnrollScreen() {
const router = useRouter();
- const { tokens } = useTheme();
+ const navigation = useNavigation();
+ const colors = useBadgeColors();
const params = useLocalSearchParams<{
code?: string;
serverUrl?: string;
@@ -144,206 +163,274 @@ export function ConnectEnrollScreen() {
});
};
+ const done = () => router.dismissTo("/");
+
+ // This screen is a modal: a push would land the Add server card beneath
+ // it on iOS. Pop back to Add server when it opened this screen; otherwise
+ // (the bb://connect deep link) replace the modal with it.
+ const openDirectUrlForm = () => {
+ const state = navigation.getState();
+ const below = state ? state.routes[state.index - 1] : undefined;
+ if (below?.name === "settings/servers/add") router.back();
+ else router.replace("/settings/servers/add");
+ };
+
if (phase.kind === "enrolled") {
return (
-
-
-
-
-
- {reauth ? "Paired again" : "Paired with bb connect"}
-
-
-
- This phone is now a device on your getbb.app account. You can revoke
- it any time in the dashboard under Machines.
-
-
-
-
- {phase.label}
-
- {phase.credential.serverUrl}
-
-
-
+ <>
+
+ {IS_IOS ? (
+
+
+ Done
+
+
+ ) : null}
+
+
+
+
+
+
+ {phase.label}
+
+
+ {phase.credential.serverUrl}
+
+
+
+
+
-
+
- router.dismissTo("/")}
- icon="ArrowRight"
- iconPosition="right"
- testID="connect-done"
- >
- Done
-
-
+
+ Done
+
+
+ >
);
}
return (
-
-
-
- {reauth
+ <>
+
-
- {reauth
- ? "This phone's access was revoked or has expired. Generate a new pairing code on the server and enter it here; your saved server keeps its place."
- : "Pair this phone with your bb server through getbb.app. Generate a code in bb Settings → Remote access → Add mobile device, or run `bb connect machine-code`."}
-
-
-
-
- {scanning ? (
-
- ) : null}
- setScanning((value) => !value)}
- disabled={busy}
- testID="connect-scan-toggle"
- >
- {scanning ? "Stop scanning" : "Scan QR code"}
-
-
-
-
- Pairing code
- {
- setCode(next);
- if (phase.kind === "failed") setPhase({ kind: "form" });
- }}
- placeholder="ABCD-EFGH"
- autoCapitalize="characters"
- autoCorrect={false}
- returnKeyType="next"
- invalid={fieldError?.field === "code"}
- mono
- editable={!busy}
- testID="connect-code-input"
- />
- {fieldError?.field === "code" ? (
-
- {fieldError.message}
-
- ) : null}
-
-
-
- Server (handle or URL)
-
-
- {reauth
- ? "The server is fixed when signing in again."
- : "Optional: the code already names the server. A URL also sets the bb connect address for self-hosted gates."}
-
- {fieldError?.field === "server" ? (
-
- {fieldError.message}
-
- ) : null}
-
-
- {showAdvanced ? (
-
- bb connect address
-
- {fieldError?.field === "apexUrl" ? (
-
- {fieldError.message}
-
+ : "Pair with bb connect",
+ }}
+ />
+ {IS_IOS ? (
+
+ router.back()}
+ >
+ Cancel
+
+
+ ) : null}
+ {/* Own sheet host: this route is a native modal. */}
+
+
+
+ setScanning((value) => !value)}
+ disabled={busy}
+ testID="connect-scan-toggle"
+ />
+
+ {scanning ? (
+
) : null}
-
- ) : (
- setShowAdvanced(true)}
- testID="connect-advanced-toggle"
- >
- Self-hosted bb connect…
-
- )}
- {phase.kind === "failed" ? (
-
-
- {phase.failure.title}
-
-
- {phase.failure.message}
-
-
- ) : null}
+
+ {fieldError.message}
+
+ ) : undefined
+ }
+ >
+
+ {
+ setCode(next);
+ if (phase.kind === "failed") setPhase({ kind: "form" });
+ }}
+ placeholder="ABCD-EFGH"
+ autoCapitalize="characters"
+ autoCorrect={false}
+ returnKeyType="next"
+ invalid={fieldError?.field === "code"}
+ mono
+ grouped
+ editable={!busy}
+ testID="connect-code-input"
+ />
+
+
-
- {phase.kind === "redeeming"
- ? "Pairing…"
- : phase.kind === "saving"
- ? "Saving…"
- : reauth
- ? "Sign in again"
- : "Pair"}
-
+
+ {fieldError.message}
+
+ ) : reauth ? (
+ "The server is fixed when signing in again."
+ ) : (
+ "Optional: the code already names the server. A URL also sets the bb connect address for self-hosted gates."
+ )
+ }
+ >
+
+
+
+
- {!reauth ? (
- router.push("/settings/servers/add")}
- disabled={busy}
- testID="connect-use-direct"
- >
- Use a direct URL instead
-
- ) : null}
-
+ {showAdvanced ? (
+
+ {fieldError.message}
+
+ ) : (
+ "The self-hosted bb connect gate this phone pairs through."
+ )
+ }
+ >
+
+
+
+
+ ) : (
+ setShowAdvanced(true)}
+ testID="connect-advanced-toggle"
+ >
+ Self-hosted bb connect…
+
+ )}
+
+
+ {phase.kind === "failed" ? (
+
+
+ {phase.failure.title}
+
+
+ {phase.failure.message}
+
+
+ ) : null}
+
+ {phase.kind === "redeeming"
+ ? "Pairing…"
+ : phase.kind === "saving"
+ ? "Saving…"
+ : reauth
+ ? "Sign in again"
+ : "Pair"}
+
+ {!reauth ? (
+
+ Use a direct URL instead
+
+ ) : null}
+
+
+
+ >
);
}
@@ -353,7 +440,7 @@ function SessionStatusLine({ session }: { session: SessionState | null }) {
if (session === null || session.status === "idle") {
return (
-
+
Activating…
);
@@ -362,7 +449,7 @@ function SessionStatusLine({ session }: { session: SessionState | null }) {
case "authenticating":
return (
-
+
Signing in…
@@ -371,7 +458,12 @@ function SessionStatusLine({ session }: { session: SessionState | null }) {
case "authenticated":
return (
-
+
bb connect rejected the new credential: {session.detail}
@@ -393,7 +486,12 @@ function SessionStatusLine({ session }: { session: SessionState | null }) {
);
case "error":
return (
-
+
Could not sign in yet ({describeError(session.detail)}). Retrying…
);
diff --git a/apps/mobile/src/screens/connect/ConnectScanner.tsx b/apps/mobile/src/screens/connect/ConnectScanner.tsx
index 506a4752c1..784c9a92af 100644
--- a/apps/mobile/src/screens/connect/ConnectScanner.tsx
+++ b/apps/mobile/src/screens/connect/ConnectScanner.tsx
@@ -5,7 +5,7 @@ import {
parseConnectPairingPayload,
type ConnectPairingInput,
} from "@/data/connect";
-import { Button, Text } from "@/ui";
+import { Button, GROUPED_CARD_RADIUS, Text } from "@/ui";
interface ConnectScannerProps {
/** Called once per recognized pairing payload; the scanner then pauses. */
@@ -14,6 +14,11 @@ interface ConnectScannerProps {
active: boolean;
}
+const CARD_STYLE = {
+ borderRadius: GROUPED_CARD_RADIUS,
+ borderCurve: "continuous" as const,
+};
+
/**
* Camera viewfinder that recognizes the pairing QR (JSON / URL / bare code,
* see `parseConnectPairingPayload`). Anything else is ignored so a stray
@@ -33,10 +38,11 @@ export function ConnectScanner({ onScanned, active }: ConnectScannerProps) {
if (!permission.granted) {
return (
-
+
bb needs the camera to scan the pairing QR code.
{permission.canAskAgain ? (
@@ -59,8 +65,8 @@ export function ConnectScanner({ onScanned, active }: ConnectScannerProps) {
return (
-
+
{lastIgnored
? `Not a bb pairing code: ${lastIgnored}`
: "Point the camera at the QR code from bb Settings → Remote access → Add mobile device."}
diff --git a/apps/mobile/src/screens/diff-tab/DiffTabContent.tsx b/apps/mobile/src/screens/diff-tab/DiffTabContent.tsx
index c5fd8be78d..d7dc9fffb2 100644
--- a/apps/mobile/src/screens/diff-tab/DiffTabContent.tsx
+++ b/apps/mobile/src/screens/diff-tab/DiffTabContent.tsx
@@ -29,7 +29,8 @@ import {
import { useEnvironment } from "@/data/environments";
import { removeEnvironmentDiffPatchQueries } from "@/lib/query/diff-patch-cache";
import { environmentWorkStatusQueryKeyPrefix } from "@/lib/query/query-keys";
-import { Button, EmptyStatePanel, Skeleton, Text, useSheet } from "@/ui";
+import { useTheme } from "@/theme";
+import { Button, GROUPED_CARD_RADIUS, Skeleton, Text, useSheet } from "@/ui";
import { MergeBasePickerSheet } from "../thread/context/MergeBasePickerSheet";
import { DiffTabFileCard } from "./DiffTabFileCard";
import { DiffTabHeader } from "./DiffTabHeader";
@@ -87,6 +88,7 @@ function DiffSkeleton() {
);
}
+/** A raised, continuous-corner note (workspace unavailable / not applicable). */
function Notice({
title,
message,
@@ -96,13 +98,28 @@ function Notice({
message: string;
testID: string;
}) {
+ const { tokens } = useTheme();
return (
-
-
+
+
{title ? (
- {title}
+
+ {title}
+
) : null}
-
+
{message}
@@ -110,6 +127,17 @@ function Notice({
);
}
+/** Centered footnote for the no-diff states. */
+function EmptyNote({ children, testID }: { children: string; testID: string }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
/**
* The workspace panel's Diff tab: the changed-file table of contents for the
* picked target (all / committed / uncommitted changes against the merge
@@ -265,16 +293,26 @@ export function DiffTabContent({
],
);
+ const mergeBase =
+ targetState.mergeBase.showMergeBase &&
+ targetState.mergeBase.effectiveMergeBaseBranch
+ ? {
+ branch: targetState.mergeBase.effectiveMergeBaseBranch,
+ onPress: () => {
+ targetSheet.dismiss();
+ mergeBaseSheet.present();
+ },
+ }
+ : null;
+
let body: ReactElement;
if (environmentId === null || (environment && !environment.isGitRepo)) {
body = (
-
-
- {environmentId === null
- ? "This thread has no workspace to diff."
- : "This workspace is not a git repository."}
-
-
+
+ {environmentId === null
+ ? "This thread has no workspace to diff."
+ : "This workspace is not a git repository."}
+
);
} else if (
(!environment && environmentQuery.isPending) ||
@@ -283,26 +321,22 @@ export function DiffTabContent({
body = ;
} else if (filesQuery.error && !response) {
body = (
-
-
-
+
+
+
Could not load the diff.
-
+
{filesQuery.error.message}
-
+
Retry
);
} else if (!response) {
- body = (
-
- No changes.
-
- );
+ body = No changes.;
} else if (response.outcome === "unavailable") {
body = (
;
} else if (response.files.length === 0) {
- body = (
-
- No changes.
-
- );
+ body = No changes.;
} else {
body = (
{body}
+ {/* The picker sheet behind the header's target capsule (both platforms). */}
{
- targetSheet.dismiss();
- mergeBaseSheet.present();
- },
- }
- : null
- }
+ mergeBase={mergeBase}
/>
- {state.message}
+
+ {state.message}
+
void;
targetDisabled: boolean;
areAllCollapsed: boolean;
@@ -24,7 +29,7 @@ interface DiffTabHeaderProps {
/**
* Totals from the TOC (the same `--numstat` the shortstat summarizes), so
- * the pills are exact without any patch text in hand.
+ * the tallies are exact without any patch text in hand.
*/
function summarizeDiffFiles(files: readonly DiffFileEntry[]): {
fileCount: number;
@@ -40,6 +45,7 @@ function summarizeDiffFiles(files: readonly DiffFileEntry[]): {
return { fileCount: files.length, additions, deletions };
}
+/** A tinted bar-button glyph (32pt). */
function IconButton({
icon,
label,
@@ -64,24 +70,26 @@ function IconButton({
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled: disabled || busy }}
- className={cn(
- "h-8 w-8 items-center justify-center rounded-md active:bg-state-hover",
- disabled && "opacity-40",
- )}
+ style={({ pressed }) => [
+ styles.iconButton,
+ { opacity: disabled ? 0.35 : pressed ? 0.5 : 1 },
+ ]}
testID={testID}
>
{busy ? (
) : (
-
+
)}
);
}
/**
- * The diff tab's toolbar: file count and +/- totals, the target picker
- * trigger, collapse-all / expand-all, refresh.
+ * The diff tab's toolbar: the target capsule (a pressable that presents the
+ * target picker sheet on both platforms — it shows text, so it is not a
+ * native-menu trigger; see `NativeMenu`), file count and +/- totals in
+ * tabular figures, collapse-all / expand-all, refresh.
*/
export function DiffTabHeader({
files,
@@ -98,45 +106,63 @@ export function DiffTabHeader({
}: DiffTabHeaderProps) {
const { tokens } = useTheme();
const stats = useMemo(() => summarizeDiffFiles(files), [files]);
+ const label = describeDiffTarget(target);
return (
-
+
[
+ styles.trigger,
+ {
+ backgroundColor: pressed ? tokens.stateActive : tokens.secondary,
+ opacity: targetDisabled ? 0.4 : 1,
+ },
+ ]}
testID="diff-tab-target"
>
-
- {describeDiffTarget(target)}
+
+ {label}
-
+
-
+
{stats.fileCount === 1 ? "1 file" : `${stats.fileCount} files`}
{truncated ? "+" : ""}
{stats.additions > 0 ? (
-
+
+{formatDiffCount(stats.additions)}
) : null}
{stats.deletions > 0 ? (
-{formatDiffCount(stats.deletions)}
@@ -162,3 +188,32 @@ export function DiffTabHeader({
);
}
+
+const styles = StyleSheet.create({
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ trigger: {
+ minWidth: 0,
+ flexShrink: 1,
+ height: TRIGGER_HEIGHT,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 4,
+ paddingLeft: 12,
+ paddingRight: 8,
+ borderRadius: TRIGGER_HEIGHT / 2,
+ borderCurve: "continuous",
+ },
+ iconButton: {
+ width: ICON_BUTTON_SIZE,
+ height: ICON_BUTTON_SIZE,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/src/screens/diff-tab/DiffTargetPickerSheet.tsx b/apps/mobile/src/screens/diff-tab/DiffTargetPickerSheet.tsx
index b2dc9b34cb..90e0e7ac10 100644
--- a/apps/mobile/src/screens/diff-tab/DiffTargetPickerSheet.tsx
+++ b/apps/mobile/src/screens/diff-tab/DiffTargetPickerSheet.tsx
@@ -1,14 +1,6 @@
import { View } from "react-native";
import type { DiffSelectionOption } from "@/data/diff";
-import { useTheme } from "@/theme";
-import {
- Icon,
- ListRow,
- Separator,
- Sheet,
- Text,
- type SheetController,
-} from "@/ui";
+import { ListRow, Separator, Sheet, Text, type SheetController } from "@/ui";
import { usePickerSheetMaxHeight } from "../pickers/OptionSheet";
interface DiffTargetPickerSheetProps {
@@ -27,9 +19,11 @@ interface DiffTargetPickerSheetProps {
}
/**
- * The diff target picker (web `GitDiffToolbar` select): all / committed /
- * uncommitted changes, then one row per commit above the merge base, with the
- * merge-base row at the bottom opening the branch picker.
+ * The diff target picker (web `GitDiffToolbar` select) as a sheet, presented
+ * from the header's target capsule on both platforms. All / committed /
+ * uncommitted changes, then one row per commit above the merge base, with
+ * the merge-base row at the bottom opening the branch picker. The current
+ * choice carries the tinted check mark.
*/
export function DiffTargetPickerSheet({
controller,
@@ -39,7 +33,6 @@ export function DiffTargetPickerSheet({
mergeBase,
stackBehavior,
}: DiffTargetPickerSheetProps) {
- const { tokens } = useTheme();
const maxHeight = usePickerSheetMaxHeight();
return (
+
{option.monoPrefix}
) : undefined
}
- trailing={
- option.value === value ? (
-
- ) : null
- }
selected={option.value === value}
onPress={() => {
controller.dismiss();
diff --git a/apps/mobile/src/screens/extensions/RegistrySkillDetailScreen.tsx b/apps/mobile/src/screens/extensions/RegistrySkillDetailScreen.tsx
index 0f4a160105..a3defc65aa 100644
--- a/apps/mobile/src/screens/extensions/RegistrySkillDetailScreen.tsx
+++ b/apps/mobile/src/screens/extensions/RegistrySkillDetailScreen.tsx
@@ -18,15 +18,16 @@ import { Markdown } from "@/markdown";
import {
Button,
EmptyStatePanel,
- ListRow,
- Pill,
+ GroupedRow,
+ IconBadge,
Skeleton,
Text,
toast,
} from "@/ui";
-import { SettingsSection } from "../plugins/plugin-ui";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import { useBadgeColors } from "../settings/settings-badges";
+import { SettingsSection } from "../settings/SettingsRows";
import { skillDetailHref } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
function describeError(error: unknown): string {
if (error instanceof BbHttpError && error.status === 503) {
@@ -42,6 +43,12 @@ function isMarkdownPath(path: string): boolean {
return /\.(md|mdx|markdown)$/iu.test(path);
}
+function openLink(url: string | null | undefined): void {
+ Linking.openURL(url ?? "").catch(() =>
+ toast.error("Could not open the link"),
+ );
+}
+
/**
* One skills.sh skill (`/settings/skills/registry/[registrySkillId]`; web
* RegistrySkillDetailView): the entry facts, its files (SKILL.md rendered as
@@ -53,6 +60,7 @@ export function RegistrySkillDetailScreen() {
const registrySkillId =
typeof params.registrySkillId === "string" ? params.registrySkillId : null;
const router = useRouter();
+ const colors = useBadgeColors();
const entry = useRegistrySkillEntry(registrySkillId);
const detail = useRegistrySkillDetail({
source: entry.data?.source ?? null,
@@ -74,7 +82,7 @@ export function RegistrySkillDetailScreen() {
return (
<>
-
+
{entry.isPending ? (
@@ -93,35 +101,38 @@ export function RegistrySkillDetailScreen() {
) : (
<>
-
-
- {entry.data.name}
-
-
-
- {formatRegistrySource(entry.data.source)}
-
-
- {`${formatInstallCount(entry.data.installs)} installs`}
-
- {entry.data.stars !== null ? (
- {`${formatInstallCount(entry.data.stars)} stars`}
- ) : null}
- {entry.data.topic ? (
-
- {entry.data.topic}
-
- ) : null}
+
+
+
+
+
+ {entry.data.name}
+
+
+ {[
+ formatRegistrySource(entry.data.source),
+ `${formatInstallCount(entry.data.installs)} installs`,
+ entry.data.stars !== null
+ ? `${formatInstallCount(entry.data.stars)} stars`
+ : null,
+ entry.data.topic,
+ ]
+ .filter((part): part is string => !!part)
+ .join(" · ")}
+
+
- {entry.data.summary ? (
-
- {entry.data.summary}
-
- ) : null}
-
+
{installedSkill ? (
@@ -164,11 +175,7 @@ export function RegistrySkillDetailScreen() {
{
- Linking.openURL(entry.data?.url ?? "").catch(() =>
- toast.error("Could not open the link"),
- );
- }}
+ onPress={() => openLink(entry.data?.url)}
accessibilityLabel="Open on skills.sh"
>
skills.sh
@@ -176,10 +183,12 @@ export function RegistrySkillDetailScreen() {
{detail.isPending ? (
-
-
-
-
+
+
+
+
+
+
) : detail.isError || detail.data === undefined ? (
{describeError(detail.error)}
@@ -217,52 +226,50 @@ export function RegistrySkillDetailScreen() {
))}
) : null}
-
- {isMarkdownPath(file.path) ? (
-
- ) : (
-
- {file.contents}
-
- )}
-
+
+
+ {isMarkdownPath(file.path) ? (
+
+ ) : (
+
+ {file.contents}
+
+ )}
+
+
>
)}
-
-
+
{entry.data.installUrl ? (
- {
- Linking.openURL(entry.data?.installUrl ?? "").catch(() =>
- toast.error("Could not open the link"),
- );
- }}
+ onPress={() => openLink(entry.data?.installUrl)}
/>
) : null}
-
- Treat registry skills as untrusted source material: bb installs
- the files into your user skill library and agents follow them.
-
>
)}
-
+
>
);
}
diff --git a/apps/mobile/src/screens/extensions/RegistrySkillsScreen.tsx b/apps/mobile/src/screens/extensions/RegistrySkillsScreen.tsx
index 4c767dd538..f410e97df6 100644
--- a/apps/mobile/src/screens/extensions/RegistrySkillsScreen.tsx
+++ b/apps/mobile/src/screens/extensions/RegistrySkillsScreen.tsx
@@ -1,6 +1,6 @@
import { PERSONAL_PROJECT_ID } from "@bb/domain";
import { BbHttpError } from "@bb/sdk/browser";
-import { useRouter } from "expo-router";
+import { Stack } from "expo-router";
import { useMemo, useState } from "react";
import { View } from "react-native";
import {
@@ -12,18 +12,13 @@ import {
type RegistrySkillsAccumulator,
} from "@/data/skills";
import { useDebouncedValue } from "@/lib/use-debounced-value";
-import {
- Button,
- EmptyStatePanel,
- Input,
- ListRow,
- Pill,
- Skeleton,
- Text,
-} from "@/ui";
+import { Button, EmptyStatePanel, Input, Skeleton, Text } from "@/ui";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import { LinkRow } from "../settings/LinkRow";
+import { SettingsSection } from "../settings/SettingsRows";
import { registrySkillDetailHref } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
+const IS_IOS = process.env.EXPO_OS === "ios";
const SEARCH_DEBOUNCE_MS = 300;
/** skills.sh outages surface as 503 `skills_registry_unavailable`. */
@@ -38,10 +33,9 @@ function describeRegistryError(error: unknown): string {
* skills.sh registry browse (`/settings/skills/registry`; web Extensions →
* Skills → Browse): trending (or all-time, when searching) skills, one page
* at a time with "Load more"; installed entries are marked. Tap → detail +
- * install.
+ * install (peekable on iOS). Search lives in the header.
*/
export function RegistrySkillsScreen() {
- const router = useRouter();
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
const trimmed = debouncedQuery.trim();
@@ -72,92 +66,97 @@ export function RegistrySkillsScreen() {
const firstPageLoading = registry.isPending && skills.length === 0;
return (
-
-
- {firstPageLoading ? (
-
-
-
-
-
- ) : registry.isError && skills.length === 0 ? (
-
-
- {describeRegistryError(registry.error)}
-
- void registry.refetch()}
- >
- Retry
-
-
- ) : skills.length === 0 ? (
-
-
- {trimmed.length > 0
- ? `No skills match “${trimmed}”.`
- : "No skills listed right now."}
-
-
- ) : (
-
-
- {loaded.ranking === "trending" ? "Trending" : "All time"}
-
-
- {skills.map((skill) => {
- const installedSkill = resolveInstalledRegistrySkill(
- skill,
- installed,
- );
- return (
-
- Installed
-
- ) : (
- "chevron"
- )
- }
- onPress={() => router.push(registrySkillDetailHref(skill.id))}
- testID={`registry-skill-row-${skill.skillId}`}
- />
- );
- })}
+ <>
+ {IS_IOS ? (
+ setQuery(event.nativeEvent.text)}
+ onCancelButtonPress={() => setQuery("")}
+ />
+ ) : null}
+
+ {IS_IOS ? null : (
+
+ )}
+ {firstPageLoading ? (
+
+
+
+
- {registry.hasNextPage ? (
+ ) : registry.isError && skills.length === 0 ? (
+
+
+ {describeRegistryError(registry.error)}
+
void registry.fetchNextPage()}
- testID="registry-skills-load-more"
+ icon="RotateCcw"
+ onPress={() => void registry.refetch()}
>
- Load more
+ Retry
- ) : null}
- {registry.isError ? (
-
- {describeRegistryError(registry.error)}
-
- ) : null}
-
- )}
-
+
+ ) : skills.length === 0 ? (
+
+
+ {trimmed.length > 0
+ ? `No skills match “${trimmed}”.`
+ : "No skills listed right now."}
+
+
+ ) : (
+
+
+ {describeRegistryError(registry.error)}
+
+ ) : undefined
+ }
+ >
+ {skills.map((skill) => {
+ const installedSkill = resolveInstalledRegistrySkill(
+ skill,
+ installed,
+ );
+ return (
+
+ );
+ })}
+
+ {registry.hasNextPage ? (
+ void registry.fetchNextPage()}
+ testID="registry-skills-load-more"
+ >
+ Load more
+
+ ) : null}
+
+ )}
+
+ >
);
}
diff --git a/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx b/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx
index 746fb24c28..b6e1fb702f 100644
--- a/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx
+++ b/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx
@@ -13,18 +13,18 @@ import {
import { copyWithToast } from "@/lib/clipboard";
import { Markdown } from "@/markdown";
import {
- ActionSheet,
Button,
+ confirmDestructive,
EmptyStatePanel,
- ListRow,
- Pill,
+ GroupedRow,
+ IconBadge,
Skeleton,
Text,
toast,
- useSheet,
} from "@/ui";
-import { SettingsSection } from "../plugins/plugin-ui";
-import { Screen } from "../shell/Screen";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import { useBadgeColors } from "../settings/settings-badges";
+import { SettingsSection } from "../settings/SettingsRows";
import { useProviderDisplayNames } from "./SkillsLibraryScreen";
const SKILL_MAIN_FILE = "SKILL.md";
@@ -35,9 +35,10 @@ function isMarkdownPath(path: string): boolean {
/**
* One library skill, read-only (`/settings/skills/[skillId]?projectId=`;
- * web SkillDetailDialogView): scope + description, the skill folder's files
- * as a chip strip, SKILL.md rendered as markdown (other files as mono
- * text), copy path, and Delete for user-owned local skills.
+ * web SkillDetailDialogView): the identity cell with scope and description,
+ * the skill folder's files as a chip strip, SKILL.md rendered as markdown
+ * (other files as mono text), copy path, and Delete for user-owned local
+ * skills.
*/
export function SkillDetailScreen() {
const params = useLocalSearchParams<{
@@ -50,6 +51,7 @@ export function SkillDetailScreen() {
? params.projectId
: PERSONAL_PROJECT_ID;
const router = useRouter();
+ const colors = useBadgeColors();
const providerNames = useProviderDisplayNames();
const { skill, isPending, isError, error, refetch } = useProjectSkill(
projectId,
@@ -67,15 +69,34 @@ export function SkillDetailScreen() {
const files = useSkillFiles({ projectId, skillId });
const content = useSkillContent({ projectId, skillId, path: selectedPath });
const deleteSkill = useDeleteSkill();
- const confirmDelete = useSheet();
const fileList = files.data?.files ?? [SKILL_MAIN_FILE];
const title = skill?.name ?? "Skill";
+ const confirmDelete = () => {
+ if (!skill) return;
+ confirmDestructive({
+ title: `Delete ${skill.name}?`,
+ message:
+ "The skill folder is deleted from the machine. This cannot be undone.",
+ actionLabel: "Delete skill",
+ onConfirm: () =>
+ deleteSkill.mutate(
+ { projectId, skillId: skill.id },
+ {
+ onSuccess: () => {
+ toast.success(`${skill.name} deleted`);
+ router.back();
+ },
+ },
+ ),
+ });
+ };
+
return (
<>
-
+
{isPending ? (
@@ -83,7 +104,7 @@ export function SkillDetailScreen() {
) : isError ? (
-
+
Could not load the skill:{" "}
{error instanceof Error ? error.message : String(error)}
@@ -101,37 +122,42 @@ export function SkillDetailScreen() {
) : (
<>
-
-
- {skill.name}
-
-
-
- {skillScopeLabel(
- skill,
- skill.provider === null
- ? undefined
- : providerNames.get(skill.provider),
- )}
-
- {skill.registrySkillId !== null ? (
-
- skills.sh
-
- ) : null}
- {skill.pluginId !== null ? (
- {`plugin · ${skill.pluginId}`}
- ) : null}
+
+
+
+
+
+ {skill.name}
+
+
+ {[
+ skillScopeLabel(
+ skill,
+ skill.provider === null
+ ? undefined
+ : providerNames.get(skill.provider),
+ ),
+ skill.registrySkillId !== null ? "skills.sh" : null,
+ skill.pluginId !== null
+ ? `plugin · ${skill.pluginId}`
+ : null,
+ ]
+ .filter((part): part is string => part !== null)
+ .join(" · ")}
+
+
- {skill.description ? (
-
- {skill.description}
-
- ) : null}
-
+
{fileList.length > 1 ? (
) : null}
-
- {content.isPending ? (
-
-
-
-
-
- ) : content.isError ? (
-
-
- Could not read {selectedPath}:{" "}
- {content.error instanceof Error
- ? content.error.message
- : String(content.error)}
+
+
+ {content.isPending ? (
+
+
+
+
+
+ ) : content.isError ? (
+
+
+ Could not read {selectedPath}:{" "}
+ {content.error instanceof Error
+ ? content.error.message
+ : String(content.error)}
+
+ void content.refetch()}
+ >
+ Retry
+
+
+ ) : isMarkdownPath(selectedPath) ? (
+
+ ) : (
+
+ {content.data?.content ?? ""}
- void content.refetch()}
- >
- Retry
-
-
- ) : isMarkdownPath(selectedPath) ? (
-
- ) : (
-
- {content.data?.content ?? ""}
-
- )}
-
+ )}
+
+
- copyWithToast(skill.filePath, "Path copied")}
+ accessibilityHint="Copies the path"
testID="skill-detail-path"
/>
{isSkillDeletable(skill) ? (
-
) : null}
>
)}
-
-
-
- deleteSkill.mutate(
- { projectId, skillId: skill.id },
- {
- onSuccess: () => {
- toast.success(`${skill.name} deleted`);
- router.back();
- },
- },
- ),
- },
- ]
- : []
- }
- />
+
>
);
}
diff --git a/apps/mobile/src/screens/extensions/SkillsLibraryScreen.tsx b/apps/mobile/src/screens/extensions/SkillsLibraryScreen.tsx
index 8be4bfe38a..e241989b6a 100644
--- a/apps/mobile/src/screens/extensions/SkillsLibraryScreen.tsx
+++ b/apps/mobile/src/screens/extensions/SkillsLibraryScreen.tsx
@@ -1,27 +1,38 @@
import { PERSONAL_PROJECT_ID } from "@bb/domain";
-import { useRouter } from "expo-router";
+import type { SkillSummary } from "@bb/server-contract";
+import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import { View } from "react-native";
import {
filterSkills,
groupSkillsByScope,
+ isSkillDeletable,
+ useDeleteSkill,
useProjectSkills,
type ProviderDisplayNames,
} from "@/data/skills";
import { useSystemProviders } from "@/data/system";
import { describeError } from "@/lib/describe-error";
+import { haptic } from "@/lib/haptics";
import {
+ ActionSheet,
Button,
+ confirmDestructive,
EmptyStatePanel,
Input,
- ListRow,
- Pill,
Skeleton,
Text,
+ toast,
+ useSheet,
+ type ActionSheetAction,
} from "@/ui";
-import { SettingsSection } from "../plugins/plugin-ui";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import { LinkRow } from "../settings/LinkRow";
+import { useBadgeColors } from "../settings/settings-badges";
+import { SettingsSection } from "../settings/SettingsRows";
import { registrySkillsHref, skillDetailHref } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
/**
* Skill rows carry only a provider id (open-ended: every custom ACP agent is
@@ -45,13 +56,18 @@ export function useProviderDisplayNames(): ProviderDisplayNames {
* The skills library (`/settings/skills`; web Extensions → Skills → My
* skills): every skill the personal project's default workspace discovers
* (user / built-in / provider / plugin scopes), grouped by scope, with a
- * filter and the registry browse entry point. Read-only here; tap → detail.
+ * header search bar and the registry browse entry point. Tap → detail;
+ * the long-press action sheet (both platforms) deletes user-owned skills.
*/
export function SkillsLibraryScreen() {
const router = useRouter();
+ const colors = useBadgeColors();
const [query, setQuery] = useState("");
const skills = useProjectSkills(PERSONAL_PROJECT_ID);
const providerNames = useProviderDisplayNames();
+ const deleteSkill = useDeleteSkill();
+ const menu = useSheet();
+ const [target, setTarget] = useState(null);
const groups = useMemo(
() =>
groupSkillsByScope(filterSkills(skills.data ?? [], query), providerNames),
@@ -59,103 +75,146 @@ export function SkillsLibraryScreen() {
);
const total = skills.data?.length ?? 0;
+ const confirmDelete = (skill: SkillSummary) =>
+ confirmDestructive({
+ title: `Delete ${skill.name}?`,
+ message:
+ "The skill folder is deleted from the machine. This cannot be undone.",
+ actionLabel: "Delete skill",
+ onConfirm: () =>
+ deleteSkill.mutate(
+ { projectId: PERSONAL_PROJECT_ID, skillId: skill.id },
+ { onSuccess: () => toast.success(`${skill.name} deleted`) },
+ ),
+ });
+
+ const actionsFor = (skill: SkillSummary): ActionSheetAction[] => [
+ {
+ key: "open",
+ label: "Open",
+ icon: "ChevronRight",
+ onPress: () =>
+ router.push(skillDetailHref(skill.id, PERSONAL_PROJECT_ID)),
+ },
+ ...(isSkillDeletable(skill)
+ ? [
+ {
+ key: "delete",
+ label: "Delete skill",
+ icon: "Trash2" as const,
+ destructive: true,
+ onPress: () => confirmDelete(skill),
+ },
+ ]
+ : []),
+ ];
+
return (
-
-
- router.push(registrySkillsHref())}
- testID="skills-browse"
+ <>
+ {IS_IOS ? (
+ setQuery(event.nativeEvent.text)}
+ onCancelButtonPress={() => setQuery("")}
/>
-
+ ) : null}
+
+
+
+
-
-
- {total > 0 ? `My skills (${total})` : "My skills"}
-
- {total > 6 ? (
+ {IS_IOS ? null : (
- ) : null}
- {skills.isPending ? (
-
-
-
-
- ) : skills.isError ? (
-
-
- Could not load skills: {describeError(skills.error)}
-
- void skills.refetch()}
- >
- Retry
-
-
- ) : total === 0 ? (
-
-
- No skills yet. Agents read SKILL.md files from your bb and
- provider skill folders; install one from skills.sh to start.
-
-
- ) : groups.length === 0 ? (
- No skills match “{query}”.
- ) : (
-
- {groups.map((group) => (
-
+
+ {total > 0 ? `My skills (${total})` : "My skills"}
+
+ {skills.isPending ? (
+
+
+
+
+
+
+ ) : skills.isError ? (
+
+
+
+ Could not load skills: {describeError(skills.error)}
+
+ void skills.refetch()}
+ >
+ Retry
+
+
+
+ ) : total === 0 ? (
+
+
+ No skills yet. Agents read SKILL.md files from your bb and
+ provider skill folders; install one from skills.sh to start.
+
+
+ ) : groups.length === 0 ? (
+ No skills match “{query}”.
+ ) : (
+ groups.map((group) => (
+
-
- {group.label}
-
-
- {group.skills.map((skill) => (
-
- skills.sh
-
- ) : (
- "chevron"
- )
- }
- onPress={() =>
- router.push(
- skillDetailHref(skill.id, PERSONAL_PROJECT_ID),
- )
- }
- testID={`skill-row-${skill.name}`}
- />
- ))}
-
-
- ))}
-
- )}
-
-
+ {group.skills.map((skill) => (
+ {
+ haptic("impact-heavy");
+ setTarget(skill);
+ menu.present();
+ }}
+ testID={`skill-row-${skill.name}`}
+ />
+ ))}
+
+ ))
+ )}
+
+
+
+
+ >
);
}
diff --git a/apps/mobile/src/screens/files/FilePathRow.tsx b/apps/mobile/src/screens/files/FilePathRow.tsx
index 6a4eb34ed2..15034d4e7a 100644
--- a/apps/mobile/src/screens/files/FilePathRow.tsx
+++ b/apps/mobile/src/screens/files/FilePathRow.tsx
@@ -2,7 +2,17 @@ import { memo } from "react";
import { Pressable, View } from "react-native";
import { buildHighlightSegments, splitPathForRow } from "@/data/files";
import { useTheme } from "@/theme";
-import { cn, Icon, LONG_PRESS_DELAY_MS, Text, type IconName } from "@/ui";
+import {
+ cn,
+ DisclosureChevron,
+ Icon,
+ LIST_ROW_ICON_SIZE,
+ LONG_PRESS_DELAY_MS,
+ Text,
+ type IconName,
+} from "@/ui";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
interface FilePathRowProps {
/** Root-relative (or absolute) path shown split into name + directory. */
@@ -14,14 +24,20 @@ interface FilePathRowProps {
trailingText?: string;
trailing?: "chevron" | null;
onPress: () => void;
+ /**
+ * The long-press menu: the host presents one shared `ActionSheet` for the
+ * whole list. (A native context menu per row would put a SwiftUI host in
+ * every recycled list cell.) Keep it referentially stable per row so the
+ * memo holds.
+ */
onLongPress?: () => void;
testID?: string;
}
/**
- * A file (or directory) row: name on the first line, directory on the
- * second, both with the matched characters emphasized. Long-press is the
- * copy menu.
+ * A file (or directory) row (44pt, 17pt name over a 13pt directory, SF
+ * doc / folder glyph), both lines with the matched characters emphasized.
+ * Long-press is the open / copy menu.
*/
export const FilePathRow = memo(function FilePathRow({
path,
@@ -55,26 +71,25 @@ export const FilePathRow = memo(function FilePathRow({
onLongPress={onLongPress}
delayLongPress={LONG_PRESS_DELAY_MS}
testID={testID}
- className="min-h-[44px] flex-row items-center gap-3 px-4 py-2 active:bg-state-hover"
+ className={cn(
+ "min-h-[44px] flex-row items-center gap-3 px-4 py-2",
+ IS_IOS ? "active:bg-state-active" : "active:bg-state-hover",
+ )}
>
-
+
{nameSegments.map((segment, index) => (
{segment.text}
@@ -95,13 +110,11 @@ export const FilePathRow = memo(function FilePathRow({
) : null}
{trailingText ? (
-
+
{trailingText}
) : null}
- {trailing === "chevron" ? (
-
- ) : null}
+ {trailing === "chevron" ? : null}
);
});
diff --git a/apps/mobile/src/screens/files/FilePreviewScreen.tsx b/apps/mobile/src/screens/files/FilePreviewScreen.tsx
index a622c125ea..4098c04d8b 100644
--- a/apps/mobile/src/screens/files/FilePreviewScreen.tsx
+++ b/apps/mobile/src/screens/files/FilePreviewScreen.tsx
@@ -1,12 +1,14 @@
import { Stack, useLocalSearchParams } from "expo-router";
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { View } from "react-native";
import { useProfiles } from "@/app-shell";
import { getFileName } from "@/data/files";
import { useThreadDetailBootstrap } from "@/data/thread-detail";
import { useThread } from "@/data/threads";
+import { useTheme } from "@/theme";
import { EmptyStatePanel, Skeleton } from "@/ui";
import { Screen } from "../shell/Screen";
+import { ScreenTitle } from "../shell/ScreenTitle";
import {
parseFilePreviewRouteParams,
type FilePreviewRouteParams,
@@ -14,6 +16,8 @@ import {
import { FilePreviewView } from "./FilePreviewView";
import { FilesTabContent } from "./FilesTabContent";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
function ThreadFilesBody({
threadId,
params,
@@ -21,10 +25,13 @@ function ThreadFilesBody({
threadId: string;
params: FilePreviewRouteParams;
}) {
+ const { tokens } = useTheme();
const bootstrap = useThreadDetailBootstrap(threadId);
const threadQuery = useThread(threadId);
const thread = threadQuery.data;
const parsed = useMemo(() => parseFilePreviewRouteParams(params), [params]);
+ // The iOS header search bar's text; Android types into the inline field.
+ const [headerQuery, setHeaderQuery] = useState("");
const environment = bootstrap.data?.environment ?? null;
const hostId = bootstrap.data?.host?.id ?? null;
const projectId = thread?.projectId ?? null;
@@ -49,12 +56,28 @@ function ThreadFilesBody({
if (parsed === null) {
return (
<>
-
+ Files
+ {IS_IOS ? (
+ setHeaderQuery(event.nativeEvent.text)}
+ onSearchButtonPress={(event) =>
+ setHeaderQuery(event.nativeEvent.text)
+ }
+ onCancelButtonPress={() => setHeaderQuery("")}
+ tintColor={tokens.primary}
+ textColor={tokens.foreground}
+ />
+ ) : null}
>
@@ -62,7 +85,17 @@ function ThreadFilesBody({
}
return (
<>
-
+ {getFileName(parsed.target.path)}
+ {IS_IOS ? (
+ // The preview keeps a fixed path line under the bar and nothing
+ // scrolls beneath it, so the bar stays opaque here.
+
+ ) : null}
>
);
}
/**
- * `/threads/[id]/files`: the Files tab full-screen (search + storage
- * browser) when no file is named, otherwise the file preview for
- * `?kind=&path=&line=[&source=&status=]` (see `file-preview-target.ts`).
+ * `/threads/[id]/files`: the Files tab full-screen (header search bar +
+ * storage browser) when no file is named, otherwise the file preview for
+ * `?kind=&path=&line=[&source=&status=]` (see `file-preview-target.ts`)
+ * with the file name as the title and its actions in the toolbar.
*/
export function FilePreviewScreen() {
const params = useLocalSearchParams<
diff --git a/apps/mobile/src/screens/files/FilePreviewStates.tsx b/apps/mobile/src/screens/files/FilePreviewStates.tsx
index 256df47e40..6e7f57606b 100644
--- a/apps/mobile/src/screens/files/FilePreviewStates.tsx
+++ b/apps/mobile/src/screens/files/FilePreviewStates.tsx
@@ -1,5 +1,5 @@
import { View } from "react-native";
-import { Button, EmptyStatePanel, Skeleton, Text } from "@/ui";
+import { Button, Skeleton, Text } from "@/ui";
export function FilePreviewLoading() {
return (
@@ -20,7 +20,10 @@ export interface FilePreviewMessageProps {
testID?: string;
}
-/** not-found / too-large / error / empty / unsupported bodies. */
+/**
+ * not-found / too-large / error / empty / unsupported bodies: a centered
+ * headline + footnote (iOS empty state) with tinted actions.
+ */
export function FilePreviewMessage({
title,
detail,
@@ -29,17 +32,22 @@ export function FilePreviewMessage({
testID,
}: FilePreviewMessageProps) {
return (
-
-
-
+
+
+
{title}
{detail ? (
-
+
{detail}
) : null}
-
+
{onRetry ? (
Retry
diff --git a/apps/mobile/src/screens/files/FilePreviewView.tsx b/apps/mobile/src/screens/files/FilePreviewView.tsx
index 2cfc31530e..9b7865ee12 100644
--- a/apps/mobile/src/screens/files/FilePreviewView.tsx
+++ b/apps/mobile/src/screens/files/FilePreviewView.tsx
@@ -1,6 +1,16 @@
import type { FilePreviewLineRange } from "@bb/client-core";
+import { SegmentedControl } from "@expo/ui/community/segmented-control";
+import { Stack } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react";
-import { Linking, Pressable, View } from "react-native";
+import {
+ Alert,
+ Linking,
+ Pressable,
+ StyleSheet,
+ View,
+ type StyleProp,
+ type ViewStyle,
+} from "react-native";
import { useProfileClient } from "@/app-shell/ProfilesProvider";
import {
buildFileLineSelectionText,
@@ -16,16 +26,17 @@ import {
type FilePreviewContent,
} from "@/data/files";
import { copyWithToast } from "@/lib/clipboard";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
import {
ActionSheet,
Button,
Icon,
- Pill,
Sheet,
SheetTextInput,
Text,
toast,
+ useInputFieldProps,
useSheet,
type ActionSheetAction,
} from "@/ui";
@@ -53,6 +64,8 @@ import {
} from "./TextFilePreviewBody";
import { useThreadLocalFileLinks } from "./use-thread-local-file-links";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
interface FilePreviewViewProps {
/** Null for the root-compose panel (project files only). */
threadId: string | null;
@@ -69,6 +82,14 @@ interface FilePreviewViewProps {
onAddedToChat?: () => void;
/** Rendered inside the workspace panel sheet: the markdown body uses the sheet-aware scroller. */
inSheet?: boolean;
+ /**
+ * Who owns the chrome. `"inline"` (default) draws the name, path and
+ * actions in the body (the panel tab; Android). `"header"` — the iOS
+ * full-screen route — puts the actions in the navigation toolbar and
+ * keeps only the path line (plus the Preview / Source control) in the
+ * body; the route sets the file name as the navigation title.
+ */
+ chrome?: "inline" | "header";
testID?: string;
}
@@ -78,12 +99,68 @@ function initialViewMode(lineRange: FilePreviewLineRange | null): ViewMode {
return lineRange === null ? "preview" : "source";
}
+/** Preview / Source: the native segmented control on iOS, the pill toggle elsewhere. */
+function ViewModeToggle({
+ viewMode,
+ onChange,
+ style,
+}: {
+ viewMode: ViewMode;
+ onChange: (mode: ViewMode) => void;
+ /** iOS only: the segmented control's frame in its row. */
+ style?: StyleProp;
+}) {
+ if (IS_IOS) {
+ return (
+ {
+ haptic("selection");
+ onChange(
+ event.nativeEvent.selectedSegmentIndex === 1 ? "source" : "preview",
+ );
+ }}
+ style={style}
+ testID="file-preview-mode"
+ />
+ );
+ }
+ return (
+
+ {(["preview", "source"] as const).map((mode) => (
+ onChange(mode)}
+ className={
+ viewMode === mode
+ ? "bg-surface-selected px-2.5 py-1"
+ : "px-2.5 py-1 active:bg-state-hover"
+ }
+ testID={`file-preview-mode-${mode}`}
+ >
+
+ {mode === "preview" ? "Preview" : "Source"}
+
+
+ ))}
+
+ );
+}
+
/**
- * The file preview (full-screen route body and panel tab body): header
- * (name, source, size, tappable path, open-in-browser, jump-to-line,
- * preview/source toggle) over a body per content kind — code with line
- * numbers, markdown, CSV grid, HTML in a WebView, image, video hand-off,
- * and the loading / not-found / too-large / error / empty / binary states.
+ * The file preview (full-screen route body and panel tab body): a compact
+ * header (name, source, size, tappable path, Preview / Source, jump to
+ * line, open in browser, reload — or, with `chrome="header"`, the native
+ * toolbar and a single path line) over a body per content kind —
+ * code with line numbers, markdown, CSV grid, HTML in a WebView, image,
+ * video hand-off, and the loading / not-found / too-large / error / empty /
+ * binary states.
*/
export function FilePreviewView({
threadId,
@@ -95,6 +172,7 @@ export function FilePreviewView({
lineRange,
onAddedToChat,
inSheet = false,
+ chrome = "inline",
testID = "file-preview",
}: FilePreviewViewProps) {
const { tokens } = useTheme();
@@ -185,19 +263,47 @@ export function FilePreviewView({
() => copyWithToast(target.path, "Path copied"),
[target.path],
);
+ const reload = useCallback(() => void query.refetch(), [query]);
// Jump to line.
const textBodyRef = useRef(null);
const jumpSheet = useSheet();
const [jumpValue, setJumpValue] = useState("");
+ const jumpField = useInputFieldProps({ className: IS_IOS ? "h-11" : "h-10" });
+ const goToLine = useCallback(
+ (raw: string) => {
+ const line = Number.parseInt(raw.trim(), 10);
+ if (!Number.isFinite(line) || line <= 0) return;
+ if (!showsLines) setViewMode("source");
+ // Let a mode switch mount the line list before scrolling.
+ setTimeout(() => textBodyRef.current?.scrollToLine(line), 50);
+ },
+ [showsLines],
+ );
const jumpToLine = useCallback(() => {
- const line = Number.parseInt(jumpValue.trim(), 10);
jumpSheet.dismiss();
- if (!Number.isFinite(line) || line <= 0) return;
- if (!showsLines) setViewMode("source");
- // Let a mode switch mount the line list before scrolling.
- setTimeout(() => textBodyRef.current?.scrollToLine(line), 50);
- }, [jumpSheet, jumpValue, showsLines]);
+ goToLine(jumpValue);
+ }, [goToLine, jumpSheet, jumpValue]);
+ const promptJumpToLine = useCallback(() => {
+ if (process.env.EXPO_OS === "ios" && chrome === "header") {
+ // The full-screen route asks through the system text-field alert;
+ // the panel keeps its sheet-stacked field (the sheet owns the keyboard).
+ Alert.prompt(
+ "Jump to line",
+ undefined,
+ [
+ { text: "Cancel", style: "cancel" },
+ { text: "Go", onPress: (value?: string) => goToLine(value ?? "") },
+ ],
+ "plain-text",
+ "",
+ "number-pad",
+ );
+ return;
+ }
+ setJumpValue("");
+ jumpSheet.present();
+ }, [chrome, goToLine, jumpSheet]);
// Long-pressed line → actions.
const lineMenu = useSheet();
@@ -268,7 +374,7 @@ export function FilePreviewView({
void query.refetch()}
+ onRetry={reload}
testID="file-preview-not-found"
/>
);
@@ -288,7 +394,7 @@ export function FilePreviewView({
void query.refetch()}
+ onRetry={reload}
testID="file-preview-error"
/>
);
@@ -377,136 +483,173 @@ export function FilePreviewView({
break;
}
+ const headerStyle = [
+ styles.header,
+ { borderBottomColor: tokens.borderHairline },
+ ];
+ const sizeText = sizeLabel ? (
+
+ {sizeLabel}
+
+ ) : null;
+
return (
-
-
-
-
- {name}
-
-
- {describeFilePreviewTargetSource(target)}
-
-
-
-
- {target.path}
-
-
-
-
- {sizeLabel ? (
-
- {sizeLabel}
-
- ) : null}
-
- {hasSourceToggle ? (
-
- {(["preview", "source"] as const).map((mode) => (
- setViewMode(mode)}
- className={
- viewMode === mode
- ? "bg-surface-selected px-2.5 py-1"
- : "px-2.5 py-1 active:bg-state-hover"
- }
- testID={`file-preview-mode-${mode}`}
+ {chrome === "header" ? (
+ <>
+
+
+
+ {externalUrl !== null ? (
+
+ Open in browser
+
+ ) : null}
+
+ Copy path
+
+ {sourceText !== null ? (
+
-
- {mode === "preview" ? "Preview" : "Source"}
-
-
- ))}
+ Jump to line…
+
+ ) : null}
+
+
+
+
+
+ {target.path}
+
+ {sizeText}
- ) : null}
- {sourceText !== null ? (
- {
- setJumpValue("");
- jumpSheet.present();
- }}
- testID="file-preview-jump"
+ {hasSourceToggle ? (
+
+ ) : null}
+
+ >
+ ) : (
+
+
+
+
- Line
-
- ) : null}
- {externalUrl !== null ? (
+ {name}
+
+
+ {describeFilePreviewTargetSource(target)}
+
+
+
+
+
+ {target.path}
+
+
+
+ {sizeText}
+
+
+ {hasSourceToggle ? (
+
+ ) : null}
+
+ {sourceText !== null ? (
+
+ Line
+
+ ) : null}
+ {externalUrl !== null ? (
+
+ ) : null}
- ) : null}
- void query.refetch()}
- testID="file-preview-refresh"
- />
+
-
+ )}
{body}
-
-
-
-
- Go
-
-
-
+ {chrome === "inline" ? (
+
+
+
+
+ Go
+
+
+
+ ) : null}
);
}
+
+const styles = StyleSheet.create({
+ header: {
+ paddingHorizontal: 16,
+ paddingTop: 8,
+ paddingBottom: 10,
+ gap: 8,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ /** The inline header's segmented control shares its row with the action buttons. */
+ segmented: { flex: 1, maxWidth: 200 },
+});
diff --git a/apps/mobile/src/screens/files/FilesTabContent.tsx b/apps/mobile/src/screens/files/FilesTabContent.tsx
index c13883ddab..acfceb65b6 100644
--- a/apps/mobile/src/screens/files/FilesTabContent.tsx
+++ b/apps/mobile/src/screens/files/FilesTabContent.tsx
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useState, type ComponentType } from "react";
import {
FlatList,
Pressable,
+ StyleSheet,
+ TextInput,
View,
type FlatListProps,
type ListRenderItem,
@@ -16,12 +18,15 @@ import {
type StorageEntry,
} from "@/data/files";
import { copyWithToast } from "@/lib/clipboard";
-import { useTheme } from "@/theme";
+import { haptic } from "@/lib/haptics";
+import { nativeTypography, resolveFont, useTheme } from "@/theme";
import {
ActionSheet,
- EmptyStatePanel,
+ GROUPED_ROW_PADDING_X,
Icon,
- Input,
+ INPUT_RADIUS,
+ LIST_ROW_ICON_SIZE,
+ Separator,
SheetFlatList,
Skeleton,
Spinner,
@@ -35,6 +40,31 @@ import { buildFilesTabRows, type FilesTabRow } from "./files-tab-model";
import { FilePathRow } from "./FilePathRow";
import { StorageBreadcrumbs } from "./ThreadStorageBrowser";
+const IS_IOS = process.env.EXPO_OS === "ios";
+/** The inline search field (UISearchBar's text field height). */
+const SEARCH_FIELD_HEIGHT = 36;
+/** Hairlines between file rows start at the text column (padding + glyph + gap). */
+const FILE_ROW_SEPARATOR_INSET =
+ GROUPED_ROW_PADDING_X + LIST_ROW_ICON_SIZE + 12;
+const FILE_ROW_KINDS: ReadonlySet = new Set<
+ FilesTabRow["kind"]
+>(["search-result", "recent", "storage-entry"]);
+/** The rows rendered as a `FilePathRow` (tap opens, long-press is the menu). */
+type FileRow = Extract<
+ FilesTabRow,
+ { kind: "search-result" | "recent" | "storage-entry" }
+>;
+
+interface FileRowHandlers {
+ onPress: () => void;
+ onLongPress: () => void;
+}
+
+// Module-level so the list (a PureComponent) keeps its props stable across
+// the host's re-renders (the menu sheet opening, a query tick).
+const keyExtractor = (row: FilesTabRow) => row.key;
+const LIST_CONTENT_STYLE = { paddingBottom: 24 } as const;
+
interface FilesTabContentProps {
/** Null for the root-compose panel (no thread storage, no recents). */
threadId: string | null;
@@ -49,23 +79,127 @@ interface FilesTabContentProps {
scroll?: "screen" | "sheet";
/** Seed the search box (the panel's Files launcher params). */
initialQuery?: string | null;
+ /**
+ * Where the query is typed: the inline search field (default), or a host
+ * bar — the full-screen route's native header search on iOS — in which
+ * case `externalQuery` is the live query and no field renders here.
+ */
+ searchField?: "inline" | "external";
+ externalQuery?: string;
testID?: string;
}
-interface CopyMenuTarget {
+interface FileMenuTarget {
path: string;
name: string;
+ kind: "file" | "directory";
+ open: () => void;
}
function sourceLabel(source: FileSearchSource): string {
return source === "workspace" ? "Workspace" : "Thread storage";
}
+/** Open / Copy path / Copy name: the long-press sheet's rows. */
+function fileMenuActions(target: FileMenuTarget): ActionSheetAction[] {
+ return [
+ {
+ key: "open",
+ label: target.kind === "directory" ? "Open folder" : "Open",
+ icon: target.kind === "directory" ? "FolderOpen" : "FileText",
+ onPress: target.open,
+ },
+ {
+ key: "copy-path",
+ label: "Copy path",
+ icon: "Copy",
+ onPress: () => copyWithToast(target.path, "Path copied"),
+ },
+ {
+ key: "copy-name",
+ label: "Copy name",
+ icon: "Copy",
+ onPress: () => copyWithToast(target.name, "Name copied"),
+ },
+ ];
+}
+
+/** Hairline under a file row; section headers and states draw none. */
+function FileRowSeparator({ leadingItem }: { leadingItem: FilesTabRow }) {
+ return FILE_ROW_KINDS.has(leadingItem.kind) ? (
+
+ ) : null;
+}
+
+/** The iOS search field: 36pt, muted fill, continuous corners, magnifier + clear glyphs. */
+function SearchField({
+ value,
+ onChangeText,
+}: {
+ value: string;
+ onChangeText: (text: string) => void;
+}) {
+ const { tokens, mode } = useTheme();
+ return (
+
+
+
+ {value.length > 0 ? (
+ onChangeText("")}
+ style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
+ testID="files-search-clear"
+ >
+
+
+ ) : null}
+
+ );
+}
+
+function StateText({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
/**
- * The Files tab: a search box over the workspace (environment or project
+ * The Files tab: a search field over the workspace (environment or project
* paths) and thread storage, and — when idle — the thread's recent files
* plus a storage browser with breadcrumbs. Tapping a file opens the preview;
- * long-press copies the path / name.
+ * long-press is the open / copy menu — one action sheet shared by every row
+ * (a native context menu per row would host a SwiftUI view in each list
+ * cell).
*/
export function FilesTabContent({
threadId,
@@ -74,10 +208,12 @@ export function FilesTabContent({
hostId,
scroll = "screen",
initialQuery = null,
+ searchField = "inline",
+ externalQuery = "",
testID = "files-tab",
}: FilesTabContentProps) {
- const { tokens } = useTheme();
- const [query, setQuery] = useState(initialQuery ?? "");
+ const [inlineQuery, setInlineQuery] = useState(initialQuery ?? "");
+ const query = searchField === "external" ? externalQuery : inlineQuery;
const [directoryPath, setDirectoryPath] = useState("");
const [recentExpanded, setRecentExpanded] = useState(false);
const search = useFileSearch({
@@ -165,39 +301,74 @@ export function FilesTabContent({
[openFile, workspaceTarget],
);
- const copyMenu = useSheet();
- const [copyTarget, setCopyTarget] = useState(null);
- const presentCopyMenu = useCallback(
- (target: CopyMenuTarget) => {
- setCopyTarget(target);
- copyMenu.present();
+ // The long-press menu: one sheet for the whole list, re-targeted per row.
+ const menuSheet = useSheet();
+ const [menuTarget, setMenuTarget] = useState(null);
+ const presentMenu = useCallback(
+ (target: FileMenuTarget) => {
+ haptic("impact-heavy");
+ setMenuTarget(target);
+ menuSheet.present();
},
- [copyMenu],
+ [menuSheet],
);
- const copyActions = useMemo(
- () =>
- copyTarget === null
- ? []
- : [
- {
- key: "copy-path",
- label: "Copy path",
- icon: "Copy",
- onPress: () => copyWithToast(copyTarget.path, "Path copied"),
- },
- {
- key: "copy-name",
- label: "Copy name",
- icon: "Copy",
- onPress: () => copyWithToast(copyTarget.name, "Name copied"),
- },
- ],
- [copyTarget],
+ const storageEntryTarget = useCallback(
+ (entry: StorageEntry): FileMenuTarget => ({
+ path: entry.path,
+ name: entry.name,
+ kind: entry.kind === "directory" ? "directory" : "file",
+ open:
+ entry.kind === "directory"
+ ? () => setDirectoryPath(entry.path)
+ : () =>
+ openFile({
+ target: { kind: "storage-file", path: entry.path },
+ lineRange: null,
+ }),
+ }),
+ [openFile],
);
- const presentEntryMenu = useCallback(
- (entry: StorageEntry) =>
- presentCopyMenu({ path: entry.path, name: entry.name }),
- [presentCopyMenu],
+ const buildRowHandlers = useCallback(
+ (row: FileRow): FileRowHandlers => {
+ const target: FileMenuTarget =
+ row.kind === "search-result"
+ ? {
+ path: row.path,
+ name: getFileName(row.path),
+ kind: "file",
+ open: () => openSource(row.source, row.path),
+ }
+ : row.kind === "recent"
+ ? {
+ path: row.item.path,
+ name: getFileName(row.item.path),
+ kind: "file",
+ open: () => openSource(row.item.source, row.item.path),
+ }
+ : storageEntryTarget(row.entry);
+ return { onPress: target.open, onLongPress: () => presentMenu(target) };
+ },
+ [openSource, presentMenu, storageEntryTarget],
+ );
+ // One handler pair per row, keyed by row key and memoized on the row list,
+ // so `memo(FilePathRow)` holds across renders that leave the rows alone.
+ const rowHandlers = useMemo(() => {
+ const handlers = new Map();
+ for (const row of rows) {
+ if (
+ row.kind === "search-result" ||
+ row.kind === "recent" ||
+ row.kind === "storage-entry"
+ ) {
+ handlers.set(row.key, buildRowHandlers(row));
+ }
+ }
+ return handlers;
+ }, [buildRowHandlers, rows]);
+ const handlersFor = useCallback(
+ (row: FileRow): FileRowHandlers =>
+ rowHandlers.get(row.key) ?? buildRowHandlers(row),
+ [buildRowHandlers, rowHandlers],
);
const renderItem = useCallback>(
@@ -205,7 +376,7 @@ export function FilesTabContent({
switch (item.kind) {
case "section":
return (
-
+
{item.title}
{item.note ? {item.note} : null}
@@ -216,13 +387,7 @@ export function FilesTabContent({
path={item.path}
positions={item.positions}
icon="FileText"
- onPress={() => openSource(item.source, item.path)}
- onLongPress={() =>
- presentCopyMenu({
- path: item.path,
- name: getFileName(item.path),
- })
- }
+ {...handlersFor(item)}
testID="files-search-result"
/>
);
@@ -232,13 +397,7 @@ export function FilesTabContent({
path={item.item.path}
icon="Clock"
trailingText={sourceLabel(item.item.source)}
- onPress={() => openSource(item.item.source, item.item.path)}
- onLongPress={() =>
- presentCopyMenu({
- path: item.item.path,
- name: getFileName(item.item.path),
- })
- }
+ {...handlersFor(item)}
testID="files-recent-row"
/>
);
@@ -247,77 +406,73 @@ export function FilesTabContent({
setRecentExpanded((current) => !current)}
- className="px-4 py-2 active:opacity-70"
+ className="px-4 py-2 active:opacity-60"
testID="files-recent-toggle"
>
-
+
{item.expanded ? "Show fewer" : `Show ${item.hidden} more`}
);
case "storage-breadcrumbs":
return (
-
-
-
+
);
case "storage-entry":
- return item.entry.kind === "directory" ? (
- setDirectoryPath(item.entry.path)}
- onLongPress={() => presentEntryMenu(item.entry)}
- testID="storage-directory-row"
- />
- ) : (
+ if (item.entry.kind === "directory") {
+ return (
+
+ );
+ }
+ return (
- openFile({
- target: { kind: "storage-file", path: item.entry.path },
- lineRange: null,
- })
- }
- onLongPress={() => presentEntryMenu(item.entry)}
+ {...handlersFor(item)}
testID="storage-file-row"
/>
);
case "storage-state":
return (
-
+
{item.state === "loading" ? (
) : (
-
+
{item.state === "error"
? "Could not load thread storage."
: directoryPath.length === 0
? "No files in thread storage yet."
: "Empty directory."}
-
+
)}
);
case "search-state":
return (
-
+
{item.state === "loading" ? (
-
+
- Searching…
+
+ Searching…
+
) : (
-
+
{item.state === "error"
? "File search failed."
: item.state === "unavailable"
@@ -325,13 +480,13 @@ export function FilesTabContent({
: item.state === "hint"
? "Search the project's files by name."
: "No matching files."}
-
+
)}
);
}
},
- [directoryPath, openFile, openSource, presentCopyMenu, presentEntryMenu],
+ [directoryPath, handlersFor],
);
const List: ComponentType> =
@@ -339,52 +494,50 @@ export function FilesTabContent({
return (
-
-
-
-
-
-
- {query.length > 0 ? (
- setQuery("")}
- className="absolute inset-y-0 right-2 justify-center"
- testID="files-search-clear"
- >
-
-
- ) : null}
+ {searchField === "inline" ? (
+
+
-
+ ) : null}
row.key}
+ keyExtractor={keyExtractor}
renderItem={renderItem}
+ ItemSeparatorComponent={FileRowSeparator}
keyboardShouldPersistTaps="handled"
- keyboardDismissMode="on-drag"
- contentContainerStyle={{ paddingBottom: 24 }}
+ keyboardDismissMode={IS_IOS ? "interactive" : "on-drag"}
+ // The full-screen route's first scrollable: inset under the native
+ // header (and its search bar) automatically.
+ contentInsetAdjustmentBehavior={
+ scroll === "screen" ? "automatic" : undefined
+ }
+ contentContainerStyle={LIST_CONTENT_STYLE}
testID="files-tab-list"
/>
);
}
+
+const styles = StyleSheet.create({
+ searchField: {
+ height: SEARCH_FIELD_HEIGHT,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ paddingHorizontal: 8,
+ borderRadius: INPUT_RADIUS,
+ borderCurve: "continuous",
+ },
+ searchInput: {
+ flex: 1,
+ height: SEARCH_FIELD_HEIGHT,
+ paddingVertical: 0,
+ fontSize: nativeTypography.base.fontSize,
+ },
+});
diff --git a/apps/mobile/src/screens/files/ThreadStorageBrowser.tsx b/apps/mobile/src/screens/files/ThreadStorageBrowser.tsx
index c2ef570ab7..cc7ceb745b 100644
--- a/apps/mobile/src/screens/files/ThreadStorageBrowser.tsx
+++ b/apps/mobile/src/screens/files/ThreadStorageBrowser.tsx
@@ -1,9 +1,15 @@
-import { Pressable, ScrollView, View } from "react-native";
+import { Pressable, ScrollView, StyleSheet, View } from "react-native";
import { buildStorageBreadcrumbs } from "@/data/files";
import { useTheme } from "@/theme";
import { Icon, Text } from "@/ui";
-/** Breadcrumb strip: root › dir › dir, the last crumb current. */
+/** Crumb capsule height. */
+const CRUMB_HEIGHT = 28;
+
+/**
+ * Breadcrumb strip: root › dir › dir as capsule chips, the last (current)
+ * crumb filled, the others tinted and tappable.
+ */
export function StorageBreadcrumbs({
directoryPath,
onNavigate,
@@ -18,11 +24,7 @@ export function StorageBreadcrumbs({
horizontal
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
- contentContainerStyle={{
- alignItems: "center",
- paddingHorizontal: 16,
- gap: 2,
- }}
+ contentContainerStyle={styles.strip}
testID="storage-breadcrumbs"
>
{crumbs.map((crumb, index) => {
@@ -32,19 +34,29 @@ export function StorageBreadcrumbs({
{index > 0 ? (
) : null}
onNavigate(crumb.path)}
- className="rounded-sm px-1 py-1 active:bg-state-hover"
+ hitSlop={4}
+ style={({ pressed }) => [
+ styles.crumb,
+ {
+ backgroundColor: current ? tokens.secondary : "transparent",
+ opacity: pressed ? 0.6 : 1,
+ },
+ ]}
testID={`storage-crumb-${index}`}
>
@@ -57,3 +69,19 @@ export function StorageBreadcrumbs({
);
}
+
+const styles = StyleSheet.create({
+ strip: {
+ alignItems: "center",
+ paddingHorizontal: 16,
+ paddingVertical: 6,
+ gap: 4,
+ },
+ crumb: {
+ height: CRUMB_HEIGHT,
+ paddingHorizontal: 12,
+ borderRadius: CRUMB_HEIGHT / 2,
+ borderCurve: "continuous",
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/src/screens/home/HomeScreen.tsx b/apps/mobile/src/screens/home/HomeScreen.tsx
index 7689a6b34f..dd254eaff7 100644
--- a/apps/mobile/src/screens/home/HomeScreen.tsx
+++ b/apps/mobile/src/screens/home/HomeScreen.tsx
@@ -1,5 +1,6 @@
import {
Redirect,
+ Stack,
useLocalSearchParams,
useNavigation,
useRouter,
@@ -12,11 +13,21 @@ import {
useRef,
useState,
} from "react";
-import { Animated, Keyboard, Pressable, View } from "react-native";
+import {
+ Animated,
+ Keyboard,
+ Pressable,
+ StyleSheet,
+ View,
+ type LayoutChangeEvent,
+ type ViewStyle,
+} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useProfiles } from "@/app-shell";
import type { ComposerHandle } from "@/composer";
-import { blendOver, withAlpha } from "@/markdown/colors";
+import { useSidebarPreferences } from "@/data/sidebar";
+import { haptic } from "@/lib/haptics";
+import { withAlpha } from "@/markdown/colors";
import { scrimBaseColor, useTheme } from "@/theme";
import {
Button,
@@ -25,99 +36,174 @@ import {
COMPOSER_KEYBOARD_GAP,
KeyboardPaddingView,
OverlayBounds,
+ sfSymbolFor,
Spinner,
Text,
+ useLiquidGlass,
} from "@/ui";
import { ComposeDock } from "../compose/ComposeDock";
import {
useComposeController,
type ComposeParams,
} from "../compose/useComposeController";
+import { ConnectionBanner } from "../shell/ConnectionBanner";
import { threadHref, threadSearchHref } from "../shell/hrefs";
import { Screen } from "../shell/Screen";
-import { WorkspaceMenuButton } from "../shell/WorkspaceMenu";
+import { ScreenTitle } from "../shell/ScreenTitle";
+import { WorkspaceMenuButton, WorkspaceToolbar } from "../shell/WorkspaceMenu";
import {
+ ORGANIZE_OPTIONS,
SidebarActionsProvider,
SidebarThreadList,
+ SORT_OPTIONS,
useSidebarActions,
} from "../sidebar";
+import { ThreadSearchResults } from "../threads/ThreadSearchResults";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
const SCRIM_DURATION_MS = 180;
/** Opacity of the scrim over the list while the dock is expanded. */
const SCRIM_ALPHA = 0.35;
+/** Gap between the last row and the dock (in flow, or floating over it). */
+const LIST_BOTTOM_GAP = 16;
+/** Liquid Glass: the dock host floats at the bottom of the overlay bounds. */
+const FLOATING_DOCK_STYLE: ViewStyle = {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: 0,
+};
-/**
- * Home header: the server label as the title, the workspace menu (server
- * switcher / archived / Settings) on the left. Rendered in every ready state
- * so the menu is reachable before a server connects.
- */
-function HomeHeaderShell({ dimmed = false }: { dimmed?: boolean }) {
+/** Android: the workspace avatar button on the header's left. */
+function HomeWorkspaceButton() {
const navigation = useNavigation();
- const { activeProfile } = useProfiles();
useLayoutEffect(() => {
navigation.setOptions({
- title: activeProfile?.label ?? "bb",
- headerLeft: () => ,
+ headerLeft: () => ,
});
- }, [activeProfile?.label, dimmed, navigation]);
+ }, [navigation]);
return null;
}
/**
- * Search + display-options buttons in the home header (set from inside the
- * provider). While the dock is expanded the header is painted the same gray
- * as the scrim (it is navigator chrome above the screen, so the scrim view
- * cannot cover it) and its controls are muted.
+ * Home header: the server label as the (large) title and the workspace menu
+ * — server switcher / archived / Settings — on the left, a native pull-down
+ * on iOS and the avatar button's sheet on Android. Rendered in every ready
+ * state so the menu is reachable before a server connects.
+ */
+function HomeHeaderShell() {
+ const { activeProfile } = useProfiles();
+ return (
+ <>
+ {activeProfile?.label ?? "bb"}
+ {IS_IOS ? : }
+ >
+ );
+}
+
+/**
+ * iOS: the display-options pull-down on the header's right — organize and
+ * sort as checked groups, then the section commands. Preferences apply as
+ * soon as an item is picked.
+ */
+function HomeDisplayOptionsToolbar() {
+ const actions = useSidebarActions();
+ const [preferences, preferenceActions] = useSidebarPreferences();
+ return (
+
+
+
+ {ORGANIZE_OPTIONS.map((option) => (
+ {
+ haptic("selection");
+ preferenceActions.setOrganize(option.mode);
+ }}
+ >
+ {option.label}
+
+ ))}
+
+
+ {SORT_OPTIONS.map((option) => (
+ {
+ haptic("selection");
+ preferenceActions.setSort(option.sort);
+ }}
+ >
+ {option.label}
+
+ ))}
+
+ actions.openSectionCreate(null)}
+ >
+ New section…
+
+
+ Reorder sections…
+
+
+
+ );
+}
+
+/**
+ * Android: search + display-options buttons in the home header (set from
+ * inside the provider; iOS has the header search bar and the native menu).
*/
-function HomeHeaderActions({ dimmed }: { dimmed: boolean }) {
+function HomeHeaderActionsAndroid() {
const navigation = useNavigation();
const router = useRouter();
- const { tokens, fonts, mode } = useTheme();
+ const { tokens } = useTheme();
const actions = useSidebarActions();
- const scrimColor = scrimBaseColor(mode, tokens);
- const background = dimmed
- ? blendOver(tokens.background, scrimColor, SCRIM_ALPHA)
- : tokens.background;
- const foreground = dimmed
- ? blendOver(tokens.foreground, scrimColor, SCRIM_ALPHA)
- : tokens.foreground;
useLayoutEffect(() => {
navigation.setOptions({
- headerStyle: { backgroundColor: background },
- headerTintColor: foreground,
- headerTitleStyle: {
- fontFamily: fonts.sans.semibold,
- fontWeight: "600",
- color: foreground,
- },
headerRight: () => (
router.push(threadSearchHref())}
className="h-10 w-10 items-center justify-center rounded-full active:bg-state-hover"
testID="home-search"
>
-
+
-
+
),
});
- }, [actions, background, dimmed, fonts, foreground, navigation, router]);
+ }, [actions, navigation, router, tokens.foreground]);
return null;
}
@@ -196,7 +282,9 @@ function useNewThreadRouteParams(): {
* project's "+", or a routed new-thread request) expands it in place over a
* scrim that dims the list, with the where-it-runs pickers on top and the
* agent pickers below the prompt. Creating a thread collapses the dock and
- * opens the thread.
+ * opens the thread. On iOS the header search bar searches in place: while
+ * it is open the list shows recent threads, then results, and the dock
+ * steps aside.
*/
function HomeBody() {
const insets = useSafeAreaInsets();
@@ -208,6 +296,25 @@ function HomeBody() {
const [expanded, setExpanded] = useState(false);
const [scrim] = useState(() => new Animated.Value(0));
const [scrimMounted, setScrimMounted] = useState(false);
+ const [query, setQuery] = useState("");
+ const [searchOpen, setSearchOpen] = useState(false);
+ const searching = IS_IOS && (searchOpen || query.trim().length > 0);
+
+ // Liquid Glass (iOS 26): the dock floats over the list as a glass pill
+ // and the rows scroll under it. The host reports its height; the rows pad
+ // for the part of it above the list's bottom edge (the home-indicator
+ // padding sits below that edge).
+ const glass = useLiquidGlass();
+ const dockBottomPadding = Math.max(insets.bottom, 8);
+ const [dockHeight, setDockHeight] = useState(0);
+ const handleDockLayout = useCallback((event: LayoutChangeEvent) => {
+ setDockHeight(event.nativeEvent.layout.height);
+ }, []);
+ const floatingDock = glass && !searching;
+ const dockOverlap = floatingDock
+ ? Math.max(0, dockHeight - dockBottomPadding)
+ : 0;
+ const listBottomPadding = dockOverlap + LIST_BOTTOM_GAP;
const animateScrim = useCallback(
(open: boolean) => {
@@ -262,10 +369,39 @@ function HomeBody() {
[router],
);
+ const closeSearch = useCallback(() => {
+ setSearchOpen(false);
+ setQuery("");
+ }, []);
+
+ // The banner scrolls with the rows, right under the large title.
+ const banner = ;
+
return (
-
-
+
+ {IS_IOS ? (
+ <>
+
+ setQuery(event.nativeEvent.text)}
+ onOpen={() => setSearchOpen(true)}
+ onFocus={() => setSearchOpen(true)}
+ onClose={closeSearch}
+ onCancelButtonPress={closeSearch}
+ />
+ >
+ ) : (
+
+ )}
-
-
+ {/* Floating dock: the list's frame ends at the pill's bottom edge
+ (the home-indicator inset stays outside it), so the scroll
+ view's own safe-area inset stays zero and the content padding
+ below is exact — RN's `scrollToEnd` ignores that inset. */}
+
+ {searching ? (
+
+ ) : (
+
+ )}
{/* The scrim dims everything under the card — the list and the
- dock's own margins — so the expanded card floats over it. It
- overhangs the bounds by the keyboard gap: on devices without a
- home-indicator inset the bounds end that far above the
- keyboard, and the strip would otherwise show undimmed. */}
+ dock's own margins — so the expanded card floats over it (and
+ shows through the header's material). It overhangs the bounds
+ by the keyboard gap: on devices without a home-indicator inset
+ the bounds end that far above the keyboard, and the strip
+ would otherwise show undimmed. */}
{scrimMounted ? (
) : null}
+ {/* The dock host: a hairline above the collapsed pill on the page
+ background; while the card is expanded the scrim owns the
+ region and the rule disappears. With Liquid Glass the host is
+ a transparent overlay at the bottom of the bounds instead —
+ the glass pill floats over the rows, and the host's own
+ margins pass touches through (to the rows, or to the scrim
+ while expanded). Hidden (kept mounted) while the header
+ search bar is open. */}
+
);
diff --git a/apps/mobile/src/screens/home/ServerInfoCard.tsx b/apps/mobile/src/screens/home/ServerInfoCard.tsx
index c88148e7da..930cc61770 100644
--- a/apps/mobile/src/screens/home/ServerInfoCard.tsx
+++ b/apps/mobile/src/screens/home/ServerInfoCard.tsx
@@ -116,7 +116,7 @@ export function ServerInfoCard() {
{config.isPending ? (
) : config.isError ? (
-
+
{describeError(config.error)}
) : (
diff --git a/apps/mobile/src/screens/index.ts b/apps/mobile/src/screens/index.ts
index 039d24f2a6..7495b12180 100644
--- a/apps/mobile/src/screens/index.ts
+++ b/apps/mobile/src/screens/index.ts
@@ -15,6 +15,7 @@ export { MachinesScreen } from "./machines/MachinesScreen";
export { TerminalScreen } from "./terminal/TerminalScreen";
export { ThreadTerminalsScreen } from "./terminal/ThreadTerminalsScreen";
export { RootNavigator } from "./shell/RootNavigator";
+export { ScreenTitle } from "./shell/ScreenTitle";
export { RouteErrorBoundary } from "./shell/RouteErrorBoundary";
export { ArchivedThreadsScreen } from "./threads/ArchivedThreadsScreen";
export { ThreadDetailScreen } from "./thread/ThreadDetailScreen";
diff --git a/apps/mobile/src/screens/machines/AddMachineSheet.tsx b/apps/mobile/src/screens/machines/AddMachineSheet.tsx
index 8f216214ba..d5dcf4003d 100644
--- a/apps/mobile/src/screens/machines/AddMachineSheet.tsx
+++ b/apps/mobile/src/screens/machines/AddMachineSheet.tsx
@@ -1,11 +1,12 @@
import * as Clipboard from "expo-clipboard";
import { useRouter } from "expo-router";
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import { Linking, View } from "react-native";
import { formatCountdown, type AddMachineSession } from "@/data/hosts";
import { useTheme } from "@/theme";
import {
Button,
+ GROUPED_CARD_RADIUS,
Sheet,
Spinner,
Text,
@@ -24,6 +25,29 @@ interface AddMachineSheetProps {
session: AddMachineSession;
}
+/** A recessed panel inside the sheet (the command, the connection status). */
+function SheetPanel({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ const { tokens } = useTheme();
+ return (
+
+ {children}
+
+ );
+}
+
/**
* Add-a-machine pairing (web AddMachineDialog): mints a join code (and a
* connect machine code when bb connect is paired), shows the installer
@@ -55,7 +79,7 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) {
onDismiss={session.end}
>
-
+
{presentation.kind === "unreachable"
? "Pair a machine to run projects and threads on it."
: "Run this on the machine you want to add. It pairs the machine to this server and keeps it available for your projects."}
@@ -71,7 +95,7 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) {
) : presentation.kind === "error" ||
presentation.kind === "connect-unavailable" ? (
-
+
{presentation.kind === "connect-unavailable"
? "Remote access isn't ready yet."
: presentation.message}
@@ -87,8 +111,10 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) {
) : presentation.kind === "unreachable" ? (
-
- Another machine cannot use this address.
+
+
+ Another machine cannot use this address.
+
The pairing command would target{" "}
@@ -124,10 +150,10 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) {
Other options
-
+
) : (
-
+
{presentation.command}
-
+
>
) : session.remainingMs !== null ? (
-
+
Code expires in {formatCountdown(session.remainingMs)}
) : null}
@@ -177,7 +203,7 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) {
)}
{presentation.kind === "unreachable" ? null : (
-
+
{session.connectedNewHost !== null ? (
<>
@@ -204,7 +230,7 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) {
>
)}
-
+
)}
diff --git a/apps/mobile/src/screens/machines/MachineDetailScreen.tsx b/apps/mobile/src/screens/machines/MachineDetailScreen.tsx
index 2f3f43ea4a..17faa72bee 100644
--- a/apps/mobile/src/screens/machines/MachineDetailScreen.tsx
+++ b/apps/mobile/src/screens/machines/MachineDetailScreen.tsx
@@ -1,7 +1,7 @@
import type { PermissionMode } from "@bb/domain";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useMemo, useState } from "react";
-import { Pressable, View } from "react-native";
+import { View } from "react-native";
import { useProfiles } from "@/app-shell";
import { buildPermissionModeOptions } from "@/data/compose";
import {
@@ -17,42 +17,54 @@ import {
useHosts,
useProviderCliInstallRunner,
useRemoveHost,
+ useRenameHost,
useRetryHostUpdate,
useServerProtocolVersion,
useUpdateHostPermissionCeiling,
} from "@/data/hosts";
import { useSidebarBootstrap } from "@/data/sidebar";
import { useSystemConfig } from "@/data/system";
-import { useTheme } from "@/theme";
import {
- ActionSheet,
Button,
+ confirmDestructive,
EmptyStatePanel,
- Icon,
- ListRow,
- Pill,
+ GroupedRow,
Spinner,
Text,
toast,
useSheet,
+ type IconName,
} from "@/ui";
-import { HostStatusDot, PermissionModePicker } from "../pickers";
+import { HostStatusDot } from "../pickers";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import { LinkRow } from "../settings/LinkRow";
+import { MenuValueRow } from "../settings/MenuValueRow";
import {
+ HeaderIconButton,
+ ICON_ROW_SEPARATOR_INSET,
SettingsControlRow,
SettingsSection,
SettingsValueRow,
} from "../settings/SettingsRows";
import { firstParam, projectSettingsHref } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
import { useNow } from "../shell/use-now";
import { MachineRenameSheet } from "./MachineRenameSheet";
import { ProviderCliInstallLogHost, ProviderCliRows } from "./ProviderCliRows";
+import { promptRenameMachine } from "./rename-machine-prompt";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
+
+const PERMISSION_MODE_ICON: Record = {
+ "accept-edits": "EditFile",
+ auto: "CircleCheck",
+ full: "Zap",
+};
/**
* `/settings/machines/[hostId]` (web MachineSettingsView): presence /
* platform / pairing age, rename, the permission ceiling, the projects with
* a source here, provider CLIs with Install / Update, the daemon update
- * retry, and Remove.
+ * retry, and Remove. Rename and the overflow menu live in the header.
*/
export function MachineDetailScreen() {
const params = useLocalSearchParams<{ hostId?: string | string[] }>();
@@ -60,17 +72,22 @@ export function MachineDetailScreen() {
const { connection } = useProfiles();
if (!connection) {
return (
-
+
Add a server first.
-
+
);
}
return ;
}
+function describeError(error: unknown, fallback: string): string {
+ return error instanceof Error && error.message.length > 0
+ ? error.message
+ : fallback;
+}
+
function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
const router = useRouter();
- const { tokens } = useTheme();
const hostsQuery = useHosts();
const configQuery = useSystemConfig();
const bootstrap = useSidebarBootstrap();
@@ -86,8 +103,8 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
const updateCeiling = useUpdateHostPermissionCeiling();
const retryUpdate = useRetryHostUpdate();
const removeHost = useRemoveHost();
+ const renameHost = useRenameHost();
const renameSheet = useSheet();
- const removeConfirm = useSheet();
const [renaming, setRenaming] = useState(false);
const now = useNow();
@@ -113,21 +130,21 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
if (hosts === undefined) {
return (
-
+
-
+
);
}
if (host === null) {
return (
-
+
Machine is no longer paired.
router.back()}>
Back to machines
-
+
);
}
@@ -136,92 +153,189 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
? HOST_PLATFORM_LABELS[configQuery.data.primaryHostPlatform]
: null;
const updateStatus = formatHostUpdateStatus(host, serverProtocolVersion);
+ const canRetry = hostCanRetryUpdate(host, serverProtocolVersion);
+
+ const rename = () => {
+ const handled = promptRenameMachine({
+ currentName: host.name,
+ onSubmit: (name) =>
+ renameHost.mutate(
+ { hostId: host.id, name },
+ {
+ onSuccess: (updated) => toast.success(`Renamed to ${updated.name}`),
+ onError: (error) =>
+ toast.error(`Couldn't rename ${host.name}`, {
+ description: describeError(
+ error,
+ "The server refused the request.",
+ ),
+ }),
+ },
+ ),
+ });
+ if (handled) return;
+ setRenaming(true);
+ renameSheet.present();
+ };
+
+ const retry = () =>
+ retryUpdate.mutate(host.id, {
+ onSuccess: () => toast.success(`Update retry requested for ${host.name}`),
+ });
+
+ const confirmRemove = () =>
+ confirmDestructive({
+ title: `Remove ${host.name}?`,
+ message: `This revokes ${host.name}'s access to this server. Project checkouts stay on its disk, but its environments become read-only history and it can't run new work until it's paired again.`,
+ actionLabel: "Remove machine",
+ onConfirm: () => {
+ const name = host.name;
+ removeHost.mutate(host.id, {
+ onSuccess: () => {
+ toast.success(`Removed ${name}`);
+ router.back();
+ },
+ onError: (error) =>
+ toast.error(`Couldn't remove ${name}`, {
+ description: describeError(
+ error,
+ "The server refused the request.",
+ ),
+ }),
+ });
+ },
+ });
+
+ const selectCeiling = (maxPermissionMode: PermissionMode) => {
+ if (maxPermissionMode === host.maxPermissionMode) return;
+ updateCeiling.mutate(
+ { hostId: host.id, maxPermissionMode },
+ {
+ onSuccess: () =>
+ toast.success(
+ `${host.name} limited to ${PERMISSION_MODE_SHORT_LABELS[maxPermissionMode]}`,
+ ),
+ },
+ );
+ };
return (
<>
(
- {
- setRenaming(true);
- renameSheet.present();
- }}
- testID="machine-rename"
- >
-
-
- ),
+ ...(IS_IOS
+ ? {}
+ : {
+ headerRight: () => (
+
+ ),
+ }),
}}
/>
-
-
-
-
+ {IS_IOS ? (
+
+
+
+
+ Rename
+
+ {online ? (
+ void statusQuery.refetch()}
+ >
+ Recheck provider CLIs
+
+ ) : null}
+ {canRetry ? (
+
+ Retry update
+
+ ) : null}
+
+ Remove machine
+
+
+
+ ) : null}
+
+
+
+
+
+
{host.name}
- {hosts.length > 1 && isPrimary ? (
-
- Primary
-
- ) : null}
+
+ {hosts.length > 1 && isPrimary
+ ? "Primary"
+ : online
+ ? "Online"
+ : "Offline"}
+
-
- {machineHeaderMeta({ host, platformLabel, now })}
-
-
-
-
- {
- setRenaming(true);
- renameSheet.present();
- }}
+ onPress={rename}
testID="machine-rename-row"
/>
- {
- if (maxPermissionMode === host.maxPermissionMode) return;
- updateCeiling.mutate(
- { hostId: host.id, maxPermissionMode },
- {
- onSuccess: () =>
- toast.success(
- `${host.name} limited to ${PERMISSION_MODE_SHORT_LABELS[maxPermissionMode]}`,
- ),
- },
- );
- }}
- testID="machine-permission-ceiling"
- />
- }
+ ({
+ value: option.value,
+ label: option.label,
+ icon: PERMISSION_MODE_ICON[option.value],
+ disabled: option.disabled,
+ }))}
+ selected={host.maxPermissionMode}
+ onSelect={selectCeiling}
+ disabled={updateCeiling.isPending}
+ testID="machine-permission-ceiling"
+ accessibilityLabel="Permission mode"
/>
@@ -230,12 +344,11 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
) : (
projects.map((project) => (
- router.push(projectSettingsHref(project.id))}
testID={`machine-project-${project.id}`}
/>
))
@@ -247,19 +360,12 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
label="Updates"
description={updateStatus ?? "Up to date"}
control={
- hostCanRetryUpdate(host, serverProtocolVersion) ? (
+ canRetry ? (
- retryUpdate.mutate(host.id, {
- onSuccess: () =>
- toast.success(
- `Update retry requested for ${host.name}`,
- ),
- })
- }
+ onPress={retry}
testID="machine-retry-update"
>
Retry update
@@ -272,25 +378,14 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
void statusQuery.refetch()}
testID="machine-provider-clis-refresh"
- >
- {statusQuery.isFetching ? (
-
- ) : (
-
- )}
-
+ />
) : undefined
}
>
@@ -306,23 +401,22 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
-
-
+
setRenaming(false)}
/>
- {
- const name = host.name;
- removeHost.mutate(host.id, {
- onSuccess: () => {
- toast.success(`Removed ${name}`);
- router.back();
- },
- onError: (error) =>
- toast.error(`Couldn't remove ${name}`, {
- description:
- error instanceof Error && error.message.length > 0
- ? error.message
- : "The server refused the request.",
- }),
- });
- },
- },
- ]}
- />
>
);
}
diff --git a/apps/mobile/src/screens/machines/MachineRenameSheet.tsx b/apps/mobile/src/screens/machines/MachineRenameSheet.tsx
index 56889effea..7194874a7a 100644
--- a/apps/mobile/src/screens/machines/MachineRenameSheet.tsx
+++ b/apps/mobile/src/screens/machines/MachineRenameSheet.tsx
@@ -18,7 +18,11 @@ function describeError(error: unknown): string {
: "Couldn't rename the machine.";
}
-/** Rename sheet (web MachineRenameDialog): one field, inline error, Save. */
+/**
+ * Rename sheet (web MachineRenameDialog): one field, inline error, Save.
+ * The Android path; iOS renames through the system prompt
+ * (`rename-machine-prompt.ios.ts`).
+ */
export function MachineRenameSheet({
controller,
host,
@@ -89,7 +93,7 @@ function RenameForm({
testID="machine-rename-input"
/>
{renameHost.isError ? (
-
+
{describeError(renameHost.error)}
) : null}
diff --git a/apps/mobile/src/screens/machines/MachinesScreen.tsx b/apps/mobile/src/screens/machines/MachinesScreen.tsx
index 560e24f81d..cf8afd847b 100644
--- a/apps/mobile/src/screens/machines/MachinesScreen.tsx
+++ b/apps/mobile/src/screens/machines/MachinesScreen.tsx
@@ -1,7 +1,7 @@
import type { Host } from "@bb/domain";
import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
-import { Pressable, View } from "react-native";
+import { View } from "react-native";
import { useProfiles } from "@/app-shell";
import {
countProjectsByHost,
@@ -14,45 +14,49 @@ import {
useHosts,
useAddMachineSession,
useRemoveHost,
+ useRenameHost,
useRetryHostUpdate,
useServerProtocolVersion,
} from "@/data/hosts";
import { useSidebarBootstrap } from "@/data/sidebar";
import { useSystemConfig } from "@/data/system";
-import { useTheme } from "@/theme";
+import { haptic } from "@/lib/haptics";
import {
ActionSheet,
- Button,
+ confirmDestructive,
EmptyStatePanel,
- Icon,
- ListRow,
- Pill,
+ GroupedRow,
Spinner,
Text,
toast,
useSheet,
+ type ActionSheetAction,
} from "@/ui";
import { HostStatusDot } from "../pickers";
-import { SettingsSection } from "../settings/SettingsRows";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import { LinkRow } from "../settings/LinkRow";
+import { HeaderIconButton, SettingsSection } from "../settings/SettingsRows";
import { machineDetailHref } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
import { useNow } from "../shell/use-now";
import { AddMachineSheet } from "./AddMachineSheet";
import { MachineRenameSheet } from "./MachineRenameSheet";
+import { promptRenameMachine } from "./rename-machine-prompt";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
/**
* `/settings/machines` (web MachinesSettingsSection): every paired machine
* with its presence, platform, project count and permission limit; tap
- * opens the detail screen, long-press the rename / retry / remove menu,
- * "+" the pairing sheet.
+ * opens the detail screen, long-press the row's action sheet (rename /
+ * retry / remove) on both platforms, "+" the pairing sheet.
*/
export function MachinesScreen() {
const { connection } = useProfiles();
if (!connection) {
return (
-
+
Add a server first.
-
+
);
}
return ;
@@ -64,14 +68,17 @@ function describeError(error: unknown, fallback: string): string {
: fallback;
}
+/** Presents the Android rename sheet after the menu sheet has left the modal host. */
+const SHEET_HANDOFF_MS = 250;
+
function ConnectedMachinesScreen() {
const router = useRouter();
- const { tokens } = useTheme();
const hostsQuery = useHosts();
const configQuery = useSystemConfig();
const bootstrap = useSidebarBootstrap();
const serverProtocolVersion = useServerProtocolVersion();
const removeHost = useRemoveHost();
+ const renameHost = useRenameHost();
const retryUpdate = useRetryHostUpdate();
const hosts = hostsQuery.data;
@@ -91,48 +98,147 @@ function ConnectedMachinesScreen() {
};
const menu = useSheet();
const renameSheet = useSheet();
- const removeConfirm = useSheet();
const [target, setTarget] = useState(null);
- const targetIsPrimary = target !== null && target.id === primaryHostId;
+
+ const rename = (host: Host, fromMenu = false) => {
+ const handled = promptRenameMachine({
+ currentName: host.name,
+ onSubmit: (name) =>
+ renameHost.mutate(
+ { hostId: host.id, name },
+ {
+ onSuccess: (updated) => toast.success(`Renamed to ${updated.name}`),
+ onError: (error) =>
+ toast.error(`Couldn't rename ${host.name}`, {
+ description: describeError(
+ error,
+ "The server refused the request.",
+ ),
+ }),
+ },
+ ),
+ });
+ if (handled) return;
+ setTarget(host);
+ if (fromMenu) setTimeout(() => renameSheet.present(), SHEET_HANDOFF_MS);
+ else renameSheet.present();
+ };
+
+ const confirmRemove = (host: Host) =>
+ confirmDestructive({
+ title: `Remove ${host.name}?`,
+ message: `This revokes ${host.name}'s access to this server. Project checkouts stay on its disk, but its environments become read-only history and it can't run new work until it's paired again.`,
+ actionLabel: "Remove machine",
+ onConfirm: () => {
+ const name = host.name;
+ removeHost.mutate(host.id, {
+ onSuccess: () => toast.success(`Removed ${name}`),
+ onError: (error) =>
+ toast.error(`Couldn't remove ${name}`, {
+ description: describeError(
+ error,
+ "The server refused the request.",
+ ),
+ }),
+ });
+ },
+ });
+
+ const actionsFor = (host: Host): ActionSheetAction[] => {
+ const isPrimary = host.id === primaryHostId;
+ return [
+ {
+ key: "open",
+ label: "Open",
+ icon: "ChevronRight",
+ onPress: () => router.push(machineDetailHref(host.id)),
+ },
+ {
+ key: "rename",
+ label: "Rename",
+ icon: "Edit",
+ onPress: () => rename(host, true),
+ },
+ ...(hostCanRetryUpdate(host, serverProtocolVersion)
+ ? [
+ {
+ key: "retry",
+ label: "Retry update",
+ icon: "RotateCcw" as const,
+ onPress: () => {
+ retryUpdate.mutate(host.id, {
+ onSuccess: () =>
+ toast.success(`Update retry requested for ${host.name}`),
+ });
+ },
+ },
+ ]
+ : []),
+ {
+ key: "remove",
+ label: "Remove machine",
+ subtitle: isPrimary ? PRIMARY_HOST_REMOVE_DISABLED_REASON : undefined,
+ icon: "Trash2",
+ destructive: true,
+ disabled: isPrimary,
+ onPress: () => confirmRemove(host),
+ },
+ ];
+ };
+
+ const openMenu = (host: Host) => {
+ haptic("impact-heavy");
+ setTarget(host);
+ menu.present();
+ };
return (
<>
- (
-
-
-
- ),
- }}
- />
-
+ {IS_IOS ? (
+
+
+
+ ) : (
+ (
+
+ ),
+ }}
+ />
+ )}
+
{hosts === undefined ? (
) : hosts.length === 0 ? (
-
- No machines yet.
+
+
+ No machines yet.
+
) : (
hosts.map((host) => {
const isPrimary = host.id === primaryHostId;
return (
- 1 && isPrimary ? "Primary · " : ""}${machineMetaLine(
{
host,
@@ -150,130 +256,32 @@ function ConnectedMachinesScreen() {
}
- trailing={
-
-
- {PERMISSION_MODE_SHORT_LABELS[host.maxPermissionMode]}
-
-
-
- }
- onPress={() => router.push(machineDetailHref(host.id))}
- onLongPress={() => {
- setTarget(host);
- menu.present();
- }}
+ value={PERMISSION_MODE_SHORT_LABELS[host.maxPermissionMode]}
+ onLongPress={() => openMenu(host)}
testID={`machine-row-${host.id}`}
/>
);
})
)}
+
-
- Add a machine
-
-
- Tap a machine for its permission limit, provider CLIs and updates.
- Long-press for rename and remove.
-
-
+
{
- if (target) router.push(machineDetailHref(target.id));
- },
- },
- {
- key: "rename",
- label: "Rename",
- icon: "Edit",
- onPress: () => {
- setTimeout(() => renameSheet.present(), 250);
- },
- },
- ...(target && hostCanRetryUpdate(target, serverProtocolVersion)
- ? [
- {
- key: "retry",
- label: "Retry update",
- icon: "RotateCcw" as const,
- onPress: () => {
- retryUpdate.mutate(target.id, {
- onSuccess: () =>
- toast.success(
- `Update retry requested for ${target.name}`,
- ),
- });
- },
- },
- ]
- : []),
- {
- key: "remove",
- label: targetIsPrimary
- ? `Remove machine — ${PRIMARY_HOST_REMOVE_DISABLED_REASON}`
- : "Remove machine",
- icon: "Trash2",
- destructive: true,
- disabled: targetIsPrimary,
- onPress: () => {
- setTimeout(() => removeConfirm.present(), 250);
- },
- },
- ]}
+ actions={target ? actionsFor(target) : []}
/>
-
- {
- if (!target) return;
- const name = target.name;
- removeHost.mutate(target.id, {
- onSuccess: () => toast.success(`Removed ${name}`),
- onError: (error) =>
- toast.error(`Couldn't remove ${name}`, {
- description: describeError(
- error,
- "The server refused the request.",
- ),
- }),
- });
- },
- },
- ]}
- />
>
);
}
diff --git a/apps/mobile/src/screens/machines/ProviderCliRows.tsx b/apps/mobile/src/screens/machines/ProviderCliRows.tsx
index 3dbfb27fee..a2f4fed315 100644
--- a/apps/mobile/src/screens/machines/ProviderCliRows.tsx
+++ b/apps/mobile/src/screens/machines/ProviderCliRows.tsx
@@ -18,6 +18,7 @@ import {
import { useTheme } from "@/theme";
import {
Button,
+ GROUPED_CARD_RADIUS,
Separator,
Sheet,
Spinner,
@@ -27,9 +28,9 @@ import {
} from "@/ui";
/**
- * One machine's provider CLI rows (web `MachineUpdatesRows` body): name +
- * version → latest, the state label, and the Install / Update / Retry /
- * View log actions backed by the app-wide install runner.
+ * One machine's provider CLI rows (web `MachineUpdatesRows` body) inside a
+ * grouped card: name + version → latest, the state label, and the Install /
+ * Update / Retry / View log actions backed by the app-wide install runner.
*/
interface ProviderCliRowsProps {
@@ -52,7 +53,7 @@ function toneColor(
case "destructive":
return tokens.destructiveText;
default:
- return tokens.subtleForeground;
+ return tokens.mutedForeground;
}
}
@@ -102,20 +103,20 @@ function ProviderCliRow({
: running
? "attention"
: (state?.tone ?? "subtle");
+ const version = status.currentVersion
+ ? `${status.currentVersion}${latest !== null && latest !== status.currentVersion ? ` → ${latest}` : ""}`
+ : null;
return (
-
+
-
-
+
+
{status.displayName}
- {status.currentVersion ? (
-
- {status.currentVersion}
- {latest !== null && latest !== status.currentVersion
- ? ` → ${latest}`
- : ""}
+ {version ? (
+
+ {version}
) : null}
@@ -123,7 +124,8 @@ function ProviderCliRow({
{running ? : null}
{label ? (
{label}
@@ -152,7 +154,7 @@ function ProviderCliRow({
{failure?.message ? (
-
+
{failure.message}
) : issue !== null && !hasProviderCliAction(issue) ? (
@@ -174,14 +176,16 @@ export function ProviderCliRows({
if (host.status !== "connected") {
return (
- Offline — connect to check provider CLIs.
+
+ Offline — connect to check provider CLIs.
+
);
}
if (statusError) {
return (
-
+
Couldn't check provider CLIs on {host.name}.
@@ -191,7 +195,9 @@ export function ProviderCliRows({
return (
- Checking provider CLIs…
+
+ Checking provider CLIs…
+
);
}
@@ -206,7 +212,7 @@ export function ProviderCliRows({
if (entry === undefined) return null;
return (
- {index > 0 ? : null}
+ {index > 0 ? : null}
{record?.message ? (
{record.message}
) : null}
diff --git a/apps/mobile/src/screens/machines/rename-machine-prompt-types.ts b/apps/mobile/src/screens/machines/rename-machine-prompt-types.ts
new file mode 100644
index 0000000000..a3fa261912
--- /dev/null
+++ b/apps/mobile/src/screens/machines/rename-machine-prompt-types.ts
@@ -0,0 +1,8 @@
+// Shared by rename-machine-prompt.ts (Android / default) and its .ios sibling; a
+// separate module because "./rename-machine-prompt" resolves to the .ios file on iOS.
+
+export interface RenameMachinePromptOptions {
+ currentName: string;
+ /** Receives the trimmed, non-empty, changed name. */
+ onSubmit: (name: string) => void;
+}
diff --git a/apps/mobile/src/screens/machines/rename-machine-prompt.ios.ts b/apps/mobile/src/screens/machines/rename-machine-prompt.ios.ts
new file mode 100644
index 0000000000..599c4c8dbd
--- /dev/null
+++ b/apps/mobile/src/screens/machines/rename-machine-prompt.ios.ts
@@ -0,0 +1,36 @@
+import { Alert } from "react-native";
+import type { RenameMachinePromptOptions } from "./rename-machine-prompt-types";
+
+/** Name length the server accepts (web MachineRenameDialog). */
+const MAX_NAME_LENGTH = 100;
+
+/**
+ * iOS: the system text prompt (Cancel / Save) pre-filled with the current
+ * name; the same rename the web MachineRenameDialog does. Always handled
+ * here, so the caller never falls back to the sheet on iOS.
+ */
+export function promptRenameMachine({
+ currentName,
+ onSubmit,
+}: RenameMachinePromptOptions): boolean {
+ Alert.prompt(
+ "Rename machine",
+ "The name shown for this machine everywhere in bb.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Save",
+ onPress: (value?: string) => {
+ const name = (value ?? "").trim().slice(0, MAX_NAME_LENGTH);
+ if (name.length === 0 || name === currentName) return;
+ onSubmit(name);
+ },
+ },
+ ],
+ "plain-text",
+ currentName,
+ );
+ return true;
+}
+
+export type { RenameMachinePromptOptions } from "./rename-machine-prompt-types";
diff --git a/apps/mobile/src/screens/machines/rename-machine-prompt.ts b/apps/mobile/src/screens/machines/rename-machine-prompt.ts
new file mode 100644
index 0000000000..0e37f13fe2
--- /dev/null
+++ b/apps/mobile/src/screens/machines/rename-machine-prompt.ts
@@ -0,0 +1,14 @@
+import type { RenameMachinePromptOptions } from "./rename-machine-prompt-types";
+
+/**
+ * Opens the system rename prompt when the platform has one. Android /
+ * default: none (returns `false`; the caller presents `MachineRenameSheet`).
+ * `rename-machine-prompt.ios.ts` shows `Alert.prompt`.
+ */
+export function promptRenameMachine(
+ _options: RenameMachinePromptOptions,
+): boolean {
+ return false;
+}
+
+export type { RenameMachinePromptOptions } from "./rename-machine-prompt-types";
diff --git a/apps/mobile/src/screens/panel/PanelPlaceholders.tsx b/apps/mobile/src/screens/panel/PanelPlaceholders.tsx
index 5bbe3ea729..8518fc9f23 100644
--- a/apps/mobile/src/screens/panel/PanelPlaceholders.tsx
+++ b/apps/mobile/src/screens/panel/PanelPlaceholders.tsx
@@ -7,6 +7,7 @@ import type {
PanelTabContentProps,
} from "./registry";
+/** iOS empty state: a large light symbol, a headline, a footnote. */
function PlaceholderCard({
icon,
title,
@@ -20,13 +21,21 @@ function PlaceholderCard({
}) {
const { tokens } = useTheme();
return (
-
-
-
-
+
+
+
+
{title}
-
+
{message}
diff --git a/apps/mobile/src/screens/panel/PanelTabStrip.tsx b/apps/mobile/src/screens/panel/PanelTabStrip.tsx
index 40e528430a..c33d167e00 100644
--- a/apps/mobile/src/screens/panel/PanelTabStrip.tsx
+++ b/apps/mobile/src/screens/panel/PanelTabStrip.tsx
@@ -1,16 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { Pressable, ScrollView, View } from "react-native";
+import { Pressable, ScrollView, StyleSheet, View } from "react-native";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
import {
ActionSheet,
- cn,
Icon,
Text,
useSheet,
type ActionSheetAction,
+ type IconName,
} from "@/ui";
import type { PanelStripEntry, PanelStripTarget } from "./panel-model";
+const IS_IOS = process.env.EXPO_OS === "ios";
+/** Capsule height of one strip entry (the iOS filter-bar pill). */
+const PILL_HEIGHT = 32;
+/** Longest label before it truncates (file names). */
+const LABEL_MAX_WIDTH = 140;
+
interface PanelTabStripProps {
entries: readonly PanelStripEntry[];
onActivate: (target: PanelStripTarget) => void;
@@ -30,10 +37,21 @@ function stripEntryTestId(entry: PanelStripEntry): string {
}
/**
- * Horizontal tab strip of the workspace panel: fixed entries (Info, Diff,
- * Files, Terminal) then closable file tabs with an "x"; long-press a file
- * tab for Close / Close others / Close all. The active entry scrolls into
- * view when it changes.
+ * File tabs read as documents on iOS (one glyph for every file kind, like a
+ * Files app tab row); the fixed entries keep their own symbols.
+ */
+function stripEntryIcon(entry: PanelStripEntry): IconName {
+ return IS_IOS && entry.closable ? "File" : entry.icon;
+}
+
+/**
+ * The workspace panel's tab strip as an iOS pill bar: the fixed entries
+ * (Info, Diff, Files, Terminal) then the closable file tabs, the active one
+ * a filled capsule. Switching tabs ticks the selection haptic; a file tab's
+ * close actions (Close / Close others / Close all) are a long-press action
+ * sheet shared by every tab (a native context menu per tab would host a
+ * SwiftUI view in each pill). The active entry scrolls into view when it
+ * changes.
*/
export function PanelTabStrip({
entries,
@@ -56,42 +74,57 @@ export function PanelTabStrip({
scrollRef.current?.scrollTo({ x: Math.max(0, x - 48), animated: true });
}, [activeKey]);
+ const closeActionsFor = useCallback(
+ (tabId: string): ActionSheetAction[] => [
+ {
+ key: "close",
+ label: "Close tab",
+ icon: "X",
+ onPress: () => onCloseTab(tabId),
+ },
+ {
+ key: "close-others",
+ label: "Close other tabs",
+ icon: "CircleX",
+ onPress: () => onCloseOtherTabs(tabId),
+ },
+ {
+ key: "close-all",
+ label: "Close all tabs",
+ icon: "Trash2",
+ destructive: true,
+ onPress: () => onCloseAllTabs(),
+ },
+ ],
+ [onCloseAllTabs, onCloseOtherTabs, onCloseTab],
+ );
+
const openMenu = useCallback(
(entry: PanelStripEntry) => {
+ haptic("impact-heavy");
setMenuEntry(entry);
menu.present();
},
[menu],
);
+ const activate = useCallback(
+ (entry: PanelStripEntry) => {
+ if (!entry.active) haptic("selection");
+ onActivate(entry.target);
+ },
+ [onActivate],
+ );
+
const menuTabId =
menuEntry?.target.kind === "tab" ? menuEntry.target.tabId : null;
- const menuActions: ActionSheetAction[] =
- menuTabId === null
- ? []
- : [
- {
- key: "close",
- label: "Close tab",
- icon: "X",
- onPress: () => onCloseTab(menuTabId),
- },
- {
- key: "close-others",
- label: "Close other tabs",
- onPress: () => onCloseOtherTabs(menuTabId),
- },
- {
- key: "close-all",
- label: "Close all tabs",
- destructive: true,
- onPress: () => onCloseAllTabs(),
- },
- ];
return (
{entries.map((entry) => {
const closableTabId =
@@ -112,17 +140,21 @@ export function PanelTabStrip({
? entry.target.tabId
: null;
return (
+ // A direct child of the scroll content, so its layout x is the
+ // offset the scroll-into-view needs.
{
offsetsRef.current.set(entry.key, event.nativeEvent.layout.x);
}}
- className={cn(
- "flex-row items-center rounded-md border",
- entry.active
- ? "border-border bg-surface-selected"
- : "border-transparent",
- )}
+ style={[
+ styles.pill,
+ {
+ backgroundColor: entry.active
+ ? tokens.secondary
+ : "transparent",
+ },
+ ]}
>
onActivate(entry.target)}
- onLongPress={entry.closable ? () => openMenu(entry) : undefined}
- className={cn(
- "h-8 flex-row items-center gap-1.5 rounded-md pl-2.5 active:bg-state-hover",
- entry.closable ? "pr-1" : "pr-2.5",
- )}
+ onPress={() => activate(entry)}
+ onLongPress={
+ closableTabId !== null ? () => openMenu(entry) : undefined
+ }
+ style={({ pressed }) => [
+ styles.pillPress,
+ {
+ paddingRight: closableTabId === null ? 12 : 4,
+ opacity: pressed ? 0.6 : 1,
+ },
+ ]}
testID={stripEntryTestId(entry)}
>
{entry.label}
{entry.statusLabel ? (
-
+
{entry.statusLabel}
) : null}
@@ -171,10 +206,18 @@ export function PanelTabStrip({
accessibilityLabel={`Close ${entry.label}`}
hitSlop={6}
onPress={() => onCloseTab(closableTabId)}
- className="h-8 w-7 items-center justify-center rounded-md active:bg-state-hover"
+ style={({ pressed }) => [
+ styles.close,
+ { opacity: pressed ? 0.5 : 1 },
+ ]}
testID="panel-tab-close"
>
-
+
) : null}
@@ -184,10 +227,40 @@ export function PanelTabStrip({
setMenuEntry(null)}
stackBehavior="push"
/>
);
}
+
+const styles = StyleSheet.create({
+ strip: {
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ gap: 6,
+ alignItems: "center",
+ },
+ pill: {
+ flexDirection: "row",
+ alignItems: "center",
+ height: PILL_HEIGHT,
+ borderRadius: PILL_HEIGHT / 2,
+ borderCurve: "continuous",
+ },
+ pillPress: {
+ height: PILL_HEIGHT,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ paddingLeft: 12,
+ },
+ close: {
+ height: PILL_HEIGHT,
+ width: 28,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ label: { maxWidth: LABEL_MAX_WIDTH },
+});
diff --git a/apps/mobile/src/screens/panel/README.md b/apps/mobile/src/screens/panel/README.md
index 4becb1e20b..e52964bb80 100644
--- a/apps/mobile/src/screens/panel/README.md
+++ b/apps/mobile/src/screens/panel/README.md
@@ -139,9 +139,10 @@ launcher does the same with `filesParams`.
## Entry points
-- The thread screen's native header → `PanelToggleButton`
- (`thread-panel-button`, icon `PanelBottom`) → `panel.open()`; also the
- "Workspace" row of the "…" menu.
+- The thread screen's native header → the "Workspace panel" item (a
+ `Stack.Toolbar.Button` on iOS — no test id, Maestro taps the label — or
+ `PanelToggleButton` / `thread-panel-button` on Android, icon `PanelBottom`)
+ → `panel.open()`; also the "Workspace" row of the "…" menu.
- Home `ComposeDock` → "Workspace" in the composer's "+" menu.
- Info tab: changed files → `openDiff(path)`, storage row →
`openFiles({ section: "storage" })`, parent / forks → thread route.
@@ -154,6 +155,10 @@ launcher does the same with `filesParams`.
`workspace-panel-content`, `panel-info`, `panel-info-`
(`directory`, `branch`, `git-status`, `changed-files`, …),
`panel-content-unsupported`, `panel-content-placeholder-`.
+A file tab's close actions are the long-press
+`action-sheet-close|close-others|close-all` sheet on both platforms (one
+sheet shared by the strip; a native context menu per tab would host a
+SwiftUI view in every pill).
## Tests
diff --git a/apps/mobile/src/screens/panel/ThreadInfoTabContent.tsx b/apps/mobile/src/screens/panel/ThreadInfoTabContent.tsx
index dd094a588a..18f7b36ef1 100644
--- a/apps/mobile/src/screens/panel/ThreadInfoTabContent.tsx
+++ b/apps/mobile/src/screens/panel/ThreadInfoTabContent.tsx
@@ -10,7 +10,7 @@ import type {
import type { ThreadResponse } from "@bb/server-contract";
import { useRouter } from "expo-router";
import { useCallback, useMemo, type ReactNode } from "react";
-import { Linking, Pressable, ScrollView, View } from "react-native";
+import { Linking, ScrollView, View } from "react-native";
import {
formatChangeSummary,
formatPullRequestRowLabel,
@@ -37,13 +37,16 @@ import { useTheme } from "@/theme";
import {
Button,
cn,
+ DisclosureChevron,
+ GROUPED_ROW_PADDING_X,
+ GroupedRow,
+ GroupedSection,
Icon,
- Pill,
+ LIST_ROW_ICON_SIZE,
Skeleton,
Text,
toast,
useSheet,
- type IconName,
} from "@/ui";
import { MergeBasePickerSheet } from "../thread/context/MergeBasePickerSheet";
import {
@@ -56,88 +59,41 @@ import { usePanel } from "./PanelProvider";
import type { PanelTabContentProps } from "./registry";
/**
- * The Info tab: the mobile port of the web ThreadMetadataContent rows —
- * parent, forks, environment, directory, branch / checkout, merge base, git
- * status, pull request, archived, commits, changed files, thread storage.
- * Every row derives from the cached thread / environment / workspace queries
- * the screen already holds; the rows that lead somewhere (changed files →
- * Diff tab, storage → Files tab, parent / forks → thread) go through the
- * panel controller and the router.
+ * The Info tab: the mobile port of the web ThreadMetadataContent rows as
+ * inset-grouped cards — parent, forks, environment, directory, branch /
+ * checkout, merge base, git status, pull request, archived; commits; changed
+ * files; thread storage. Every row derives from the cached thread /
+ * environment / workspace queries the screen already holds; the rows that
+ * lead somewhere (changed files → Diff tab, storage → Files tab, parent /
+ * forks → thread) go through the panel controller and the router. The cards
+ * sit on the panel's raised surface (`surface="raised"`).
*/
-function DetailRow({
- icon,
- label,
- children,
- onPress,
- onLongPress,
- accessibilityLabel,
- chevron = true,
- testID,
-}: {
- icon: IconName | null;
- label: string;
- children: ReactNode;
- onPress?: () => void;
- onLongPress?: () => void;
- accessibilityLabel?: string;
- /** Pressable rows lead somewhere by default; copy rows turn this off. */
- chevron?: boolean;
- testID: string;
-}) {
- const { tokens } = useTheme();
- const interactive = Boolean(onPress || onLongPress);
- return (
-
-
- {icon ? (
-
- ) : null}
-
- {label}
-
-
-
- {children}
-
- {onPress && chevron ? (
-
- ) : null}
-
- );
-}
+/**
+ * Separator inset of the detail cards: every row leads with a glyph, so the
+ * hairlines start at the text column (row padding + glyph + gap).
+ */
+const GLYPH_ROW_SEPARATOR_INSET =
+ GROUPED_ROW_PADDING_X + LIST_ROW_ICON_SIZE + 12;
function ValueText({
children,
mono = false,
- tone,
+ tone = "muted",
testID,
}: {
children: string;
mono?: boolean;
- tone?: "muted" | "destructive";
+ tone?: "muted" | "foreground" | "destructive";
testID?: string;
}) {
return (
{children}
@@ -145,38 +101,42 @@ function ValueText({
);
}
-function SectionHeader({ children }: { children: string }) {
+/** A row's right-hand slot: value(s) plus an optional glyph, capped so the label keeps room. */
+function Trailing({ children }: { children: ReactNode }) {
return (
-
+
{children}
-
+
);
}
+function CopyGlyph() {
+ const { tokens } = useTheme();
+ return ;
+}
+
// ---------------------------------------------------------------------------
// Rows
-function ParentRow({ thread }: { thread: ThreadResponse }) {
+function ParentRow({ parentId }: { parentId: string }) {
const router = useRouter();
- const parentId = thread.parentThreadId;
- const parentQuery = useThread(parentId ?? "", { enabled: parentId !== null });
- if (parentId === null) return null;
+ const parentQuery = useThread(parentId);
const title = parentQuery.data
? getThreadDisplayTitle(parentQuery.data)
: "Parent thread";
return (
- router.push(threadHref(parentId))}
testID="panel-info-parent"
- >
- {title}
-
+ />
);
}
-function ForksRow({ thread }: { thread: ThreadResponse }) {
+function ForksSection({ thread }: { thread: ThreadResponse }) {
const router = useRouter();
const forksQuery = useThreadsList({
projectId: thread.projectId,
@@ -187,20 +147,27 @@ function ForksRow({ thread }: { thread: ThreadResponse }) {
const forks = forksQuery.data ?? [];
if (forks.length === 0) return null;
return (
-
- {forks.map((fork, index) => (
- router.push(threadHref(fork.id))}
- accessibilityLabel={`Open fork ${getThreadDisplayTitle(fork)}`}
- testID="panel-info-fork"
- >
- {getThreadDisplayTitle(fork)}
-
- ))}
-
+
+ {forks.map((fork) => {
+ const title = getThreadDisplayTitle(fork);
+ return (
+ router.push(threadHref(fork.id))}
+ accessibilityLabel={`Open fork ${title}`}
+ testID="panel-info-fork"
+ />
+ );
+ })}
+
);
}
@@ -221,56 +188,62 @@ function EnvironmentRow({
: null,
},
});
+ const hostSuffix = host
+ ? ` · ${host.name}${host.status === "connected" ? "" : " (offline)"}`
+ : "";
return (
-
+
+ {display.compactModeLabel}
+ {hostSuffix}
+
+ {environment.managed ? (
+
+
+ managed
+
+
+ ) : null}
+
+ }
testID="panel-info-environment"
- >
-
- {display.compactModeLabel}
- {host ? (
- {` · ${host.name}${
- host.status === "connected" ? "" : " (offline)"
- }`}
- ) : null}
-
- {environment.managed ? (
-
- managed
-
- ) : null}
-
+ />
);
}
function DirectoryRow({ path }: { path: string }) {
return (
-
+
+ {path}
+
+
+
+ }
onPress={() => copyWithToast(path, "Directory copied")}
accessibilityLabel="Copy directory"
- chevron={false}
testID="panel-info-directory"
- >
-
- {path}
-
-
-
+ />
);
}
-function CopyGlyph() {
- const { tokens } = useTheme();
- return ;
-}
-
function describeCheckout(checkout: GitCheckoutRef): {
rowLabel: "Branch" | "Checkout";
label: string;
@@ -317,23 +290,27 @@ function describeCheckout(checkout: GitCheckoutRef): {
function BranchRow({ checkout }: { checkout: GitCheckoutRef }) {
const display = describeCheckout(checkout);
+ const copyValue = display.copyValue;
return (
-
+
+ {display.label}
+
+ {copyValue === null ? null : }
+
+ }
onPress={
- display.copyValue === null
+ copyValue === null
? undefined
- : () => copyWithToast(display.copyValue ?? "", display.copiedMessage)
+ : () => copyWithToast(copyValue, display.copiedMessage)
}
accessibilityLabel={`${display.rowLabel}: ${display.label}`}
- chevron={false}
testID="panel-info-branch"
- >
-
- {display.label}
-
-
+ />
);
}
@@ -345,29 +322,36 @@ function MergeBaseRow({
onPress: (() => void) | null;
}) {
return (
-
+ {branch}
+ {onPress ? : null}
+
+ }
onPress={onPress ?? undefined}
testID="panel-info-merge-base"
- >
- {branch}
-
+ />
);
}
function GitStatusRow({ label, summary }: { label: string; summary: string }) {
return (
-
+
+ {label}
+
+ {summary ? {summary} : null}
+
+ }
testID="panel-info-git-status"
- >
-
- {label}
-
- {summary ? {summary} : null}
-
+ />
);
}
@@ -388,48 +372,55 @@ function PullRequestRow({
});
};
return (
-
+
+
+ {formatPullRequestRowLabel(pullRequest)}
+
+ {attention ? (
+
+ {attention.label}
+
+ ) : null}
+
+
+ }
onPress={open}
accessibilityLabel={`Open pull request ${pullRequest.number}`}
testID="panel-info-pull-request"
- >
-
- {formatPullRequestRowLabel(pullRequest)}
- {attention ? (
-
- {attention.label}
-
- ) : null}
-
+ />
);
}
function ArchivedRow({ thread }: { thread: ThreadResponse }) {
const unarchive = useUnarchiveThread();
- if (thread.archivedAt === null) return null;
const pending = unarchive.isPending && unarchive.variables?.id === thread.id;
return (
- unarchive.mutate({ id: thread.id })}
+ testID="panel-info-unarchive"
+ >
+ Unarchive
+
+ }
testID="panel-info-archived"
- >
- unarchive.mutate({ id: thread.id })}
- testID="panel-info-unarchive"
- >
- Unarchive
-
-
+ />
);
}
@@ -440,34 +431,26 @@ function CommitsSection({
}) {
if (commits.length === 0) return null;
return (
-
- Commits
+
{commits.map((commit) => (
- copyWithToast(commit.sha, "Commit SHA copied")}
- className="min-h-9 flex-row items-center gap-2 rounded-md px-2 py-1 active:bg-state-hover"
- testID="panel-info-commit"
- >
-
- {commit.subject}
-
- copyWithToast(commit.sha, "Commit SHA copied")}
- className="rounded-sm px-1.5 py-0.5 active:bg-state-hover"
- >
-
+ title={commit.subject}
+ trailing={
+
{commit.shortSha}
-
-
+ }
+ onPress={() => copyWithToast(commit.sha, "Commit SHA copied")}
+ accessibilityLabel={`Copy commit ${commit.shortSha}`}
+ testID="panel-info-commit"
+ />
))}
-
+
);
}
@@ -478,55 +461,52 @@ function ChangedFilesSection({
sections: readonly WorkspaceChangedFilesSection[];
onOpenDiff: (path: string | null) => void;
}) {
- const { tokens } = useTheme();
if (sections.length === 0) return null;
const onPressFile = (file: WorkspaceFileStatus) => onOpenDiff(file.path);
return (
-
- Changed files
- {sections.map((section) => (
-
-
+ {sections.map((section, index) => (
+
+ onOpenDiff(null)}
- className="min-h-9 flex-row items-center gap-2 rounded-md px-2 py-1 active:bg-state-hover"
+ accessibilityLabel={`Open diff: ${section.label}`}
testID={`panel-info-changed-files-${section.kind}`}
- >
-
-
- {`${section.label} · ${formatChangeSummary(toChangeTally(section.stats))}`}
-
-
-
-
+ />
+
-
+
))}
);
}
-function ThreadStorageRow({ onPress }: { onPress: () => void }) {
+function StorageSection({ onPress }: { onPress: () => void }) {
return (
-
- Files the thread saved
-
+
+
+
);
}
@@ -597,6 +577,11 @@ export function ThreadInfoTabContent({ scope }: PanelTabContentProps) {
workspace.workspaceUnavailable !== undefined ||
environment?.status === "destroyed") &&
!(thread.archivedAt !== null && environment?.managed !== true);
+ const mergeBaseBranch =
+ workspace.mergeBase.showMergeBase &&
+ workspace.mergeBase.effectiveMergeBaseBranch
+ ? workspace.mergeBase.effectiveMergeBaseBranch
+ : null;
const openDiff = useCallback(
(path: string | null) => panel.openDiff(path),
@@ -618,42 +603,66 @@ export function ThreadInfoTabContent({ scope }: PanelTabContentProps) {
);
}
+ const hasDetails =
+ thread.parentThreadId !== null ||
+ environment !== undefined ||
+ workspaceStatus !== undefined ||
+ mergeBaseBranch !== null ||
+ showGitStatus ||
+ pullRequest !== null ||
+ thread.archivedAt !== null;
+
return (
-
-
- {environment ? (
-
- ) : null}
- {environment?.path ? : null}
- {workspaceStatus ? (
-
- ) : null}
- {workspace.mergeBase.showMergeBase &&
- workspace.mergeBase.effectiveMergeBaseBranch ? (
-
- ) : null}
- {showGitStatus ? (
-
+ {/* Rows are conditional here, not inside the row components: the
+ section draws a hairline between every rendered child, so a child
+ that rendered null would leave a doubled separator. */}
+ {hasDetails ? (
+
+ {thread.parentThreadId !== null ? (
+
+ ) : null}
+ {environment ? (
+
+ ) : null}
+ {environment?.path ? : null}
+ {workspaceStatus ? (
+
+ ) : null}
+ {mergeBaseBranch !== null ? (
+
+ ) : null}
+ {showGitStatus ? (
+
+ ) : null}
+ {pullRequest ? : null}
+ {thread.archivedAt !== null ? : null}
+
) : null}
- {pullRequest ? : null}
-
+
-
-
-
+
{canUseGitUi ? (
{
+ const choose = (effect: () => void) => {
+ haptic("selection");
sheet.dismiss();
- onSelect(name);
+ effect();
};
+ const pick = (name: string) => choose(() => onSelect(name));
const trimmedQuery = searchQuery.trim();
const showRemote = remoteBranches.length > 0;
@@ -106,15 +107,7 @@ export function BranchPicker({
}
leading="GitBranch"
selected={selected === null}
- trailing={
- selected === null ? (
-
- ) : null
- }
- onPress={() => {
- sheet.dismiss();
- onClear();
- }}
+ onPress={() => choose(onClear)}
testID="branch-picker-default"
/>
{mode === "local" && onCreateFrom && baseForNew ? (
@@ -123,15 +116,7 @@ export function BranchPicker({
subtitle="bb names the branch after the thread."
leading="Plus"
selected={selected?.isNew === true}
- trailing={
- selected?.isNew ? (
-
- ) : null
- }
- onPress={() => {
- sheet.dismiss();
- onCreateFrom(baseForNew);
- }}
+ onPress={() => choose(() => onCreateFrom(baseForNew))}
testID="branch-picker-create"
/>
) : null}
@@ -151,46 +136,30 @@ export function BranchPicker({
) : null}
- {branches.map((name) => {
- const isSelected = selected?.name === name && !selected.isNew;
- return (
-
- ) : null
- }
- onPress={() => pick(name)}
- testID={`branch-picker-option-${name}`}
- />
- );
- })}
+ {branches.map((name) => (
+ pick(name)}
+ testID={`branch-picker-option-${name}`}
+ />
+ ))}
{showRemote ? (
<>
Remote branches
- {remoteBranches.map((name) => {
- const isSelected = selected?.name === name && !selected.isNew;
- return (
-
- ) : null
- }
- onPress={() => pick(name)}
- testID={`branch-picker-remote-${name}`}
- />
- );
- })}
+ {remoteBranches.map((name) => (
+ pick(name)}
+ testID={`branch-picker-remote-${name}`}
+ />
+ ))}
>
) : null}
diff --git a/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx b/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx
index 089de99433..333e578dd5 100644
--- a/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx
+++ b/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx
@@ -5,9 +5,8 @@ import type {
ReuseEnvironmentOption,
ThreadEnvironmentSelection,
} from "@/data/compose";
-import { useTheme } from "@/theme";
+import { haptic } from "@/lib/haptics";
import {
- Icon,
ListRow,
Separator,
Sheet,
@@ -39,11 +38,26 @@ interface EnvironmentPickerProps {
disabled?: boolean;
}
+/** One environment mode as the choice model behind the sheet rows. */
+interface EnvironmentChoice {
+ key: string;
+ label: string;
+ description: string | undefined;
+ icon: IconName;
+ selected: boolean;
+ disabled: boolean;
+ onPress: () => void;
+ testID: string;
+}
+
/**
* Environment (where the thread runs) picker: project default (server
* policy), work in the project checkout on a machine, a new managed
* worktree, or reuse an existing worktree from the project's threads.
- * Branch and path refinements have their own pickers.
+ * Branch and path refinements have their own pickers. The pill presents a
+ * sheet on both platforms — the three modes as check-mark rows, the
+ * existing worktrees as a titled section (the pill shows text, so it is a
+ * plain pressable rather than a native-menu trigger; see `NativeMenu`).
*/
export function EnvironmentPicker({
value,
@@ -57,7 +71,6 @@ export function EnvironmentPicker({
disabled,
}: EnvironmentPickerProps) {
const sheet = useSheet();
- const { tokens } = useTheme();
const maxHeight = usePickerSheetMaxHeight();
const summary = useMemo(
() => describeEnvironmentSelection(value, host, reuseOptions),
@@ -96,10 +109,100 @@ export function EnvironmentPicker({
: "local";
const pick = (selection: ThreadEnvironmentSelection) => {
+ haptic("selection");
sheet.dismiss();
onChange(selection);
};
+ const modeChoices: EnvironmentChoice[] = [
+ {
+ key: "project-default",
+ label: "Project default",
+ description: isPersonalProject
+ ? "Personal workspace on the primary machine."
+ : "bb picks: a fresh worktree from the default branch on the primary machine.",
+ icon: "Laptop",
+ selected: selectedMode === "project-default",
+ disabled: false,
+ onPress: () => pick({ type: "project-default" }),
+ testID: "environment-picker-option-project-default",
+ },
+ {
+ key: "local",
+ label: isPersonalProject
+ ? host
+ ? `Personal workspace on ${host.name}`
+ : "Personal workspace"
+ : host
+ ? `Work in the checkout on ${host.name}`
+ : "Work in the checkout",
+ description:
+ workspaceDisabledReason ??
+ (isPersonalProject
+ ? undefined
+ : "Runs directly in the project folder; pick a branch or path next."),
+ icon: "Folder",
+ selected: selectedMode === "local",
+ disabled: workspaceDisabledReason !== null || hostId === null,
+ onPress: () => {
+ if (hostId === null) return;
+ pick({
+ type: "host",
+ hostId,
+ workspace: isPersonalProject
+ ? { type: "personal" }
+ : { type: "unmanaged", path: null, branch: null },
+ });
+ },
+ testID: "environment-picker-option-local",
+ },
+ {
+ key: "worktree",
+ label: "New worktree",
+ description:
+ worktreeReason ?? "Creates a worktree from a base branch you pick.",
+ icon: "FolderGit",
+ selected: selectedMode === "worktree",
+ disabled: worktreeReason !== null || hostId === null,
+ onPress: () => {
+ if (hostId === null) return;
+ pick({
+ type: "host",
+ hostId,
+ workspace: { type: "managed-worktree", baseBranch: null },
+ });
+ },
+ testID: "environment-picker-option-worktree",
+ },
+ ];
+ const reuseChoices: EnvironmentChoice[] = reuseOptions.map((option) => {
+ const isSelected =
+ value.type === "reuse" && value.environmentId === option.environmentId;
+ const title = option.name ?? option.branchName ?? option.environmentId;
+ const threadPreview = option.threads
+ .slice(0, 2)
+ .map((thread) => thread.title)
+ .join(" · ");
+ const subtitle = [
+ option.hostName,
+ option.name && option.branchName ? option.branchName : null,
+ threadPreview || null,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ return {
+ key: `reuse-${option.environmentId}`,
+ label: title,
+ description: subtitle || undefined,
+ icon: "FolderGit",
+ selected: isSelected,
+ disabled: false,
+ onPress: () =>
+ pick({ type: "reuse", environmentId: option.environmentId }),
+ testID: `environment-picker-option-reuse-${option.environmentId}`,
+ };
+ });
+
return (
<>
- pick({ type: "project-default" })}
- testID="environment-picker-option-project-default"
- />
- {
- if (hostId === null) return;
- pick({
- type: "host",
- hostId,
- workspace: isPersonalProject
- ? { type: "personal" }
- : { type: "unmanaged", path: null, branch: null },
- });
- }}
- testID="environment-picker-option-local"
- />
- {
- if (hostId === null) return;
- pick({
- type: "host",
- hostId,
- workspace: { type: "managed-worktree", baseBranch: null },
- });
- }}
- testID="environment-picker-option-worktree"
- />
+ {modeChoices.map((choice) => (
+
+ ))}
Existing worktrees
@@ -192,82 +246,19 @@ export function EnvironmentPicker({
Loading worktrees…
) : (
- reuseOptions.map((option) => {
- const isSelected =
- value.type === "reuse" &&
- value.environmentId === option.environmentId;
- const title =
- option.name ?? option.branchName ?? option.environmentId;
- const threadPreview = option.threads
- .slice(0, 2)
- .map((thread) => thread.title)
- .join(" · ");
- const subtitle = [
- option.hostName,
- option.name && option.branchName ? option.branchName : null,
- threadPreview || null,
- ]
- .filter(Boolean)
- .join(" · ");
- return (
-
- ) : null
- }
- onPress={() =>
- pick({ type: "reuse", environmentId: option.environmentId })
- }
- testID={`environment-picker-option-reuse-${option.environmentId}`}
- />
- );
- })
+ reuseChoices.map((choice) => (
+
+ ))
)}
>
);
}
-
-interface ModeRowProps {
- label: string;
- description?: string;
- icon: IconName;
- selected: boolean;
- disabled?: boolean;
- onPress: () => void;
- testID: string;
-}
-
-function ModeRow({
- label,
- description,
- icon,
- selected,
- disabled = false,
- onPress,
- testID,
-}: ModeRowProps) {
- const { tokens } = useTheme();
- return (
-
- ) : null
- }
- onPress={onPress}
- testID={testID}
- />
- );
-}
diff --git a/apps/mobile/src/screens/pickers/HostPicker.tsx b/apps/mobile/src/screens/pickers/HostPicker.tsx
index 24dcaff7b1..d38d162330 100644
--- a/apps/mobile/src/screens/pickers/HostPicker.tsx
+++ b/apps/mobile/src/screens/pickers/HostPicker.tsx
@@ -1,5 +1,6 @@
import type { Host } from "@bb/domain";
import { View } from "react-native";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
import {
Icon,
@@ -125,16 +126,17 @@ export function HostPicker({
}
+ // A selected row shows the tinted check mark (ListRow
+ // `selected`); a machine awaiting setup shows the plus.
trailing={
- isSelected ? (
-
- ) : !hasSource && connected && onRequestSetup ? (
+ !isSelected && !hasSource && connected && onRequestSetup ? (
- ) : null
+ ) : undefined
}
selected={isSelected}
disabled={!connected || (!hasSource && !onRequestSetup)}
onPress={() => {
+ haptic("selection");
sheet.dismiss();
if (!hasSource) {
onRequestSetup?.(host);
diff --git a/apps/mobile/src/screens/pickers/ModelReasoningPicker.tsx b/apps/mobile/src/screens/pickers/ModelReasoningPicker.tsx
index 1a7d5307fd..eff9541393 100644
--- a/apps/mobile/src/screens/pickers/ModelReasoningPicker.tsx
+++ b/apps/mobile/src/screens/pickers/ModelReasoningPicker.tsx
@@ -6,6 +6,7 @@ import {
type ModelPickerOption,
type ReasoningPickerOption,
} from "@/data/compose";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
import {
cn,
@@ -46,10 +47,12 @@ export interface ModelReasoningPickerProps {
}
/**
- * Model + reasoning effort (+ Fast) in one sheet, mirroring the web
- * ModelReasoningPicker's essentials: model rows with a check mark, a
- * "More models" disclosure for retired ids, reasoning chips per model, and
- * the service-tier switch.
+ * Model + reasoning effort (+ Fast) in one sheet on both platforms,
+ * mirroring the web ModelReasoningPicker's essentials: model rows with a
+ * check mark (search, "More models"), reasoning chips per model, and the
+ * service-tier switch. The pill shows the model with the reasoning level as
+ * its detail; it is text, so it is a plain pressable rather than a
+ * native-menu trigger (see `NativeMenu`).
*/
export function ModelReasoningPicker({
modelOptions,
@@ -106,6 +109,10 @@ export function ModelReasoningPicker({
filtered.isSearching &&
filtered.modelOptions.length === 0 &&
filtered.moreModelOptions.length === 0;
+ const pickModel = (model: string) => {
+ haptic("selection");
+ onModelChange(model);
+ };
return (
<>
@@ -134,7 +141,10 @@ export function ModelReasoningPicker({
}}
>
{loadErrorMessage ? (
-
+
{loadErrorMessage}
@@ -180,7 +190,7 @@ export function ModelReasoningPicker({
key={option.value}
option={option}
selected={option.value === modelValue}
- onSelect={onModelChange}
+ onSelect={pickModel}
testID={`model-picker-option-${option.value}`}
/>
))}
@@ -202,7 +212,7 @@ export function ModelReasoningPicker({
key={option.value}
option={option}
selected={option.value === modelValue}
- onSelect={onModelChange}
+ onSelect={pickModel}
testID={`model-picker-option-${option.value}`}
/>
))}
@@ -219,7 +229,10 @@ export function ModelReasoningPicker({
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected: active }}
- onPress={() => onReasoningChange(option.value)}
+ onPress={() => {
+ haptic("selection");
+ onReasoningChange(option.value);
+ }}
testID={`model-picker-reasoning-${option.value}`}
className={cn(
"h-9 flex-row items-center rounded-md border px-3",
@@ -255,7 +268,10 @@ export function ModelReasoningPicker({
{
+ haptic("selection");
+ fastMode.onChange(enabled);
+ }}
testID="model-picker-fast"
/>
@@ -274,17 +290,11 @@ interface ModelRowProps {
}
function ModelRow({ option, selected, onSelect, testID }: ModelRowProps) {
- const { tokens } = useTheme();
return (
- ) : null
- }
onPress={() => onSelect(option.value)}
testID={testID}
/>
diff --git a/apps/mobile/src/screens/pickers/OptionSheet.tsx b/apps/mobile/src/screens/pickers/OptionSheet.tsx
index 7423a5daa1..5fa3e24286 100644
--- a/apps/mobile/src/screens/pickers/OptionSheet.tsx
+++ b/apps/mobile/src/screens/pickers/OptionSheet.tsx
@@ -40,7 +40,7 @@ interface OptionRowProps {
testID?: string;
}
-/** One selectable row: glyph, label/description, check mark when selected. */
+/** One selectable row: glyph, label/description, tinted check mark when selected. */
function OptionRow({
option,
selected,
@@ -69,11 +69,6 @@ function OptionRow({
/>
) : undefined)
}
- trailing={
- selected ? (
-
- ) : null
- }
selected={selected}
disabled={disabled}
onPress={() => onSelect(option.value)}
diff --git a/apps/mobile/src/screens/pickers/PathPicker.tsx b/apps/mobile/src/screens/pickers/PathPicker.tsx
index 6e84144b26..6b5c98ad32 100644
--- a/apps/mobile/src/screens/pickers/PathPicker.tsx
+++ b/apps/mobile/src/screens/pickers/PathPicker.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
import { View } from "react-native";
+import { haptic } from "@/lib/haptics";
import { Button, ListRow, Sheet, Text, useSheet } from "@/ui";
import { usePickerSheetMaxHeight } from "./OptionSheet";
import { PickerTrigger } from "./PickerTrigger";
@@ -60,6 +61,7 @@ export function PathPicker({
leading="FolderGit"
selected={value === null}
onPress={() => {
+ haptic("selection");
sheet.dismiss();
onChange(null);
}}
diff --git a/apps/mobile/src/screens/pickers/PermissionModePicker.tsx b/apps/mobile/src/screens/pickers/PermissionModePicker.tsx
index c75e51fe3c..c57eb5b4c9 100644
--- a/apps/mobile/src/screens/pickers/PermissionModePicker.tsx
+++ b/apps/mobile/src/screens/pickers/PermissionModePicker.tsx
@@ -20,7 +20,13 @@ export interface PermissionModePickerProps {
testID?: string;
}
-/** Permission mode picker; labels/descriptions come from client-core. */
+/**
+ * Permission mode picker; labels/descriptions come from client-core. Three
+ * choices at most, in the option sheet (check mark on the current mode,
+ * ceiling-blocked modes disabled with the reason) on both platforms: the
+ * pill shows text, so it is a plain pressable rather than a native-menu
+ * trigger (see `NativeMenu`).
+ */
export function PermissionModePicker({
options,
value,
@@ -43,13 +49,14 @@ export function PermissionModePicker({
[options],
);
const selected = options.find((option) => option.value === value);
+ const inert = disabled || options.length <= 1;
return (
<>
diff --git a/apps/mobile/src/screens/pickers/PickerTrigger.tsx b/apps/mobile/src/screens/pickers/PickerTrigger.tsx
index 5fecdf4914..3d0a6def30 100644
--- a/apps/mobile/src/screens/pickers/PickerTrigger.tsx
+++ b/apps/mobile/src/screens/pickers/PickerTrigger.tsx
@@ -3,6 +3,8 @@ import { Pressable, View } from "react-native";
import { useTheme } from "@/theme";
import { cn, Icon, Spinner, Text, type IconName } from "@/ui";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
interface PickerTriggerProps {
label: string;
/** Leading glyph. */
@@ -11,6 +13,7 @@ interface PickerTriggerProps {
leading?: ReactNode;
/** Muted second segment after the label (e.g. reasoning level). */
detail?: string;
+ /** Presents the picker sheet. */
onPress?: () => void;
disabled?: boolean;
/** Replaces the chevron with a spinner (catalog still loading). */
@@ -20,6 +23,7 @@ interface PickerTriggerProps {
/**
* `ghost` (default): borderless, for the composer's pill rows. `outline`:
* the bordered pill for pickers that stand alone on a settings screen.
+ * On iOS both render as a `secondary` capsule (the option-pill look).
*/
variant?: "ghost" | "outline";
testID?: string;
@@ -29,9 +33,12 @@ interface PickerTriggerProps {
/**
* The composer's control pill: a compact pressable that opens a picker
- * sheet. Mirrors the web prompt-box option triggers (icon · label · chevron)
- * at touch size (36px). Ghost by default so a row of them reads as one
- * quiet line under the prompt.
+ * sheet (icon · label · chevron). iOS: a 32pt `secondary` capsule with
+ * subheadline copy and the `chevron.up.chevron.down` menu glyph; Android:
+ * the 36px ghost/outline pill mirroring the web prompt-box option triggers.
+ * The pill shows text, so it is never the trigger of a `NativeMenu` (whose
+ * iOS host hides the wrapped subtree from VoiceOver); the tap presents the
+ * picker's sheet on both platforms.
*/
export function PickerTrigger({
label,
@@ -65,9 +72,16 @@ export function PickerTrigger({
onPress={onPress}
testID={testID}
className={cn(
- "h-9 max-w-[220px] flex-row items-center gap-1.5 rounded-full px-2.5",
- variant === "outline" && "border border-pill-surface-border bg-secondary",
- interactive && "active:bg-state-hover",
+ "max-w-[220px] flex-row items-center gap-1.5 rounded-full",
+ IS_IOS
+ ? "h-8 bg-secondary px-3"
+ : cn(
+ "h-9 px-2.5",
+ variant === "outline" &&
+ "border border-pill-surface-border bg-secondary",
+ ),
+ interactive &&
+ (IS_IOS ? "active:bg-state-active" : "active:bg-state-hover"),
disabled && "opacity-50",
)}
>
@@ -91,7 +105,14 @@ export function PickerTrigger({
{loading ? (
) : interactive ? (
-
+
) : null}
);
diff --git a/apps/mobile/src/screens/pickers/ProviderPicker.tsx b/apps/mobile/src/screens/pickers/ProviderPicker.tsx
index 7e2a2d38b8..24f0783577 100644
--- a/apps/mobile/src/screens/pickers/ProviderPicker.tsx
+++ b/apps/mobile/src/screens/pickers/ProviderPicker.tsx
@@ -19,7 +19,9 @@ interface ProviderPickerProps {
* server (`GET /system/providers/:id/logo`, `currentColor` SVGs) painted in
* the theme foreground; a provider that declared a named glyph instead of a
* logo file (`icon: "Zap"` on its declaration) gets that glyph when this app
- * knows it, and any other provider gets the Zap glyph.
+ * knows it, and any other provider gets the Zap glyph. The option sheet
+ * lists the providers on both platforms (the pill shows text, so it is a
+ * plain pressable rather than a native-menu trigger — see `NativeMenu`).
*/
function providerGlyph(option: ProviderPickerOption): IconName {
return option.glyph !== null && isIconName(option.glyph)
@@ -60,6 +62,7 @@ export function ProviderPicker({
[options, tokens.foreground, tokens.subtleForeground],
);
const selected = options.find((option) => option.value === value);
+ const inert = disabled || options.length === 0;
return (
<>
{
- invalid?: boolean;
- mono?: boolean;
- className?: string;
-}
+const IS_IOS = process.env.EXPO_OS === "ios";
+
+interface SheetInputProps
+ extends ComponentProps, InputFieldOptions {}
/**
- * The `Input` primitive's styling on `BottomSheetTextInput`, which keeps the
- * sheet's keyboard handling (interactive avoidance) working. Use this for
- * every text field that lives inside a `Sheet`.
+ * The `Input` primitive's appearance on `BottomSheetTextInput`, which keeps
+ * the sheet's keyboard handling (interactive avoidance) working. Use this
+ * for every text field that lives inside a `Sheet`.
*/
export function SheetInput({
- invalid = false,
- mono = false,
+ invalid,
+ mono,
+ grouped,
editable = true,
className,
style,
...props
}: SheetInputProps) {
- const { tokens } = useTheme();
+ const field = useInputFieldProps({
+ invalid,
+ mono,
+ grouped,
+ editable,
+ className: cn(IS_IOS ? "h-11" : "h-10", className),
+ });
return (
);
diff --git a/apps/mobile/src/screens/plugins/AddPluginSheet.tsx b/apps/mobile/src/screens/plugins/AddPluginSheet.tsx
index a3e9038e0b..9af7bd15be 100644
--- a/apps/mobile/src/screens/plugins/AddPluginSheet.tsx
+++ b/apps/mobile/src/screens/plugins/AddPluginSheet.tsx
@@ -5,7 +5,7 @@ import type {
PluginCatalogResolvedSource,
PluginCatalogSearchResult,
} from "@bb/server-contract";
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import { View } from "react-native";
import {
catalogInstallNeedsSourceConfirmation,
@@ -18,6 +18,7 @@ import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
import {
Button,
+ GROUPED_CARD_RADIUS,
Icon,
Sheet,
Spinner,
@@ -48,6 +49,29 @@ interface AddPluginSheetProps {
onDismiss?: () => void;
}
+/** A recessed panel inside the sheet (the entry facts, the trust warning). */
+function SheetPanel({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ const { tokens } = useTheme();
+ return (
+
+ {children}
+
+ );
+}
+
function resolvedSourceRows(
source: PluginCatalogResolvedSource,
): { label: string; value: string }[] {
@@ -116,7 +140,7 @@ function ThirdPartySourceDisclosure({
}
if (error !== null && error !== undefined) {
return (
-
+
Could not resolve this listing’s source:{" "}
{error instanceof Error ? error.message : String(error)}
@@ -126,7 +150,7 @@ function ThirdPartySourceDisclosure({
return null;
}
return (
-
+
Listed by {plan.marketplaceDisplayName}, a third-party marketplace that
BB does not review.
@@ -135,7 +159,7 @@ function ThirdPartySourceDisclosure({
author
-
+
{plan.author.name}
@@ -144,7 +168,7 @@ function ThirdPartySourceDisclosure({
{row.label}
-
+
{row.value}
@@ -156,9 +180,9 @@ function ThirdPartySourceDisclosure({
function FullTrustWarning() {
const { tokens } = useTheme();
return (
-
+
-
+
Plugins run inside the bb server with full trust: they can read your
data, run commands on your machines, and call the network. Install only
plugins you trust.
@@ -224,6 +248,13 @@ export function AddPluginSheet({
return (
{
@@ -234,22 +265,15 @@ export function AddPluginSheet({
{target === null ? null : (
<>
-
-
- {entry !== null
- ? `Install ${entry.displayName}?`
- : "Add plugin"}
-
-
- {entry === null
- ? "Install from npm, a Git repository, or a local path on the server."
- : thirdParty
- ? "Install this plugin from the source its marketplace lists."
- : describeCatalogInstall(entry)}
-
-
+
+ {entry === null
+ ? "Install from npm, a Git repository, or a local path on the server."
+ : thirdParty
+ ? "Install this plugin from the source its marketplace lists."
+ : describeCatalogInstall(entry)}
+
{entry !== null ? (
-
+
@@ -267,7 +291,12 @@ export function AddPluginSheet({
{entry.entryId}
-
+
{entry.source}
{thirdParty ? (
@@ -277,7 +306,7 @@ export function AddPluginSheet({
error={planQuery.error}
/>
) : null}
-
+
) : (
(null);
const marketplaces = list.data ?? [];
@@ -90,35 +98,81 @@ export function MarketplacesScreen() {
);
};
+ const confirmRemove = (marketplace: PluginMarketplace) =>
+ confirmDestructive({
+ title: `Remove ${marketplace.displayName}?`,
+ message:
+ "Plugins installed from it keep running as direct installs; bb just stops reading its catalog.",
+ actionLabel: "Remove marketplace",
+ onConfirm: () =>
+ remove.mutate(
+ { name: marketplace.name },
+ {
+ onSuccess: (result) => {
+ toast.success("Marketplace removed", {
+ description:
+ result.convertedPluginIds.length === 0
+ ? undefined
+ : `Kept as direct installs: ${result.convertedPluginIds.join(", ")}`,
+ });
+ },
+ },
+ ),
+ });
+
+ const actionsFor = (marketplace: PluginMarketplace): ActionSheetAction[] => [
+ {
+ key: "refresh",
+ label: "Refresh",
+ icon: "RotateCcw",
+ onPress: () => refreshOne(marketplace.name),
+ },
+ ...(marketplace.official
+ ? []
+ : [
+ {
+ key: "remove",
+ label: "Remove",
+ icon: "Trash2" as const,
+ destructive: true,
+ onPress: () => confirmRemove(marketplace),
+ },
+ ]),
+ ];
+
return (
<>
- (
-
-
-
- ),
- }}
- />
-
-
- bb reads plugin catalogs from these marketplaces. Adding one validates
- and caches its catalog; it never installs, updates, or runs plugin
- code.
-
+ {IS_IOS ? (
+
+
+
+ ) : (
+ (
+
+ ),
+ }}
+ />
+ )}
+
0
? `Marketplaces (${marketplaces.length})`
: "Marketplaces"
}
+ separatorInset={ICON_ROW_SEPARATOR_INSET}
+ footnote={MARKETPLACES_FOOTER}
>
{list.isPending ? (
@@ -127,7 +181,7 @@ export function MarketplacesScreen() {
) : list.isError ? (
-
+
Could not load marketplaces: {describeError(list.error)}
) : (
marketplaces.map((marketplace) => (
-
- {marketplace.lastError !== null ? (
-
- ) : null}
- {marketplace.official ? (
-
- Official
-
- ) : (
-
- {marketplace.sourceKind}
-
- )}
-
- }
- onPress={() => {
+ marketplace={marketplace}
+ onOpenMenu={() => {
setTarget(marketplace);
menu.present();
}}
- onLongPress={() => {
- setTarget(marketplace);
- haptic("impact-heavy");
- menu.present();
- }}
- testID={`marketplace-row-${marketplace.name}`}
/>
))
)}
-
{marketplaces.length > 0 ? (
-
) : null}
-
+
setSource("")}
>
-
- Add marketplace
-
- {
- "An https://…/marketplace.json manifest URL, git:[@ref], or path: on the server."
- }
-
-
+
+ {
+ "An https://…/marketplace.json manifest URL, git:[@ref], or path: on the server."
+ }
+
refreshOne(target.name),
- },
- ...(target.official
- ? []
- : [
- {
- key: "remove",
- label: "Remove",
- icon: "Trash2" as const,
- destructive: true,
- onPress: () => confirmRemove.present(),
- },
- ]),
- ]
- : []
- }
- />
-
-
- remove.mutate(
- { name: target.name },
- {
- onSuccess: (result) => {
- toast.success("Marketplace removed", {
- description:
- result.convertedPluginIds.length === 0
- ? undefined
- : `Kept as direct installs: ${result.convertedPluginIds.join(", ")}`,
- });
- },
- },
- ),
- },
- ]
- : []
- }
+ actions={target ? actionsFor(target) : []}
/>
>
);
}
+
+/**
+ * One marketplace: no detail screen, so the row's tap (and long-press)
+ * opens its action sheet. A plain row on both platforms: a native
+ * pull-down would hide it from VoiceOver.
+ */
+function MarketplaceRow({
+ marketplace,
+ onOpenMenu,
+}: {
+ marketplace: PluginMarketplace;
+ onOpenMenu: () => void;
+}) {
+ const { tokens } = useTheme();
+ return (
+
+ {marketplace.lastError !== null ? (
+
+ ) : null}
+
+
+ }
+ onPress={onOpenMenu}
+ onLongPress={onOpenMenu}
+ testID={`marketplace-row-${marketplace.name}`}
+ />
+ );
+}
diff --git a/apps/mobile/src/screens/plugins/PluginBrowseScreen.tsx b/apps/mobile/src/screens/plugins/PluginBrowseScreen.tsx
index 8103d8fef7..b84aac4bed 100644
--- a/apps/mobile/src/screens/plugins/PluginBrowseScreen.tsx
+++ b/apps/mobile/src/screens/plugins/PluginBrowseScreen.tsx
@@ -1,5 +1,5 @@
import type { PluginCatalogSearchResult } from "@bb/server-contract";
-import { useRouter } from "expo-router";
+import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import { View } from "react-native";
import {
@@ -12,18 +12,18 @@ import { useTheme } from "@/theme";
import {
Button,
EmptyStatePanel,
+ GroupedRow,
Icon,
Input,
- ListRow,
- Pill,
Skeleton,
Text,
useSheet,
} from "@/ui";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import { LinkRow } from "../settings/LinkRow";
+import { SettingsSection } from "../settings/SettingsRows";
import { marketplacesHref, pluginDetailHref } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
import { AddPluginSheet } from "./AddPluginSheet";
-import { SettingsSection } from "./plugin-ui";
import { PluginIcon } from "./ServerSvgIcon";
/** Store counts are read at a glance: "1.2k installs", not the exact number. */
@@ -32,6 +32,8 @@ const INSTALL_COUNT_FORMATTER = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
});
+const IS_IOS = process.env.EXPO_OS === "ios";
+
function entrySubtitle(entry: PluginCatalogSearchResult): string {
const parts = [entry.category];
if (!entry.official) parts.push(entry.marketplaceDisplayName);
@@ -50,7 +52,7 @@ function entrySubtitle(entry: PluginCatalogSearchResult): string {
/**
* Plugin catalog browse (`/settings/plugins/browse`; web Extensions →
* Plugins → Browse): `GET /plugin-catalog/search` grouped by publisher, a
- * search field, installed / incompatible markers, and a tap → install
+ * header search bar, installed / incompatible markers, and a tap → install
* confirmation (or the detail screen when already installed).
*/
export function PluginBrowseScreen() {
@@ -69,15 +71,26 @@ export function PluginBrowseScreen() {
return (
<>
-
- setQuery(event.nativeEvent.text)}
+ onCancelButtonPress={() => setQuery("")}
/>
+ ) : null}
+
+ {IS_IOS ? null : (
+
+ )}
{search.isPending ? (
@@ -86,7 +99,7 @@ export function PluginBrowseScreen() {
) : search.isError ? (
-
+
Could not load the catalog: {describeError(search.error)}
- {group.entries.map((entry) => (
- {
+ const leading = (
+
+ );
+ const key = `${entry.marketplace}:${entry.entryId}`;
+ if (entry.installed) {
+ return (
+
- }
- trailing={
- entry.installed ? (
-
-
- Installed
-
+ );
+ }
+ return (
+
-
- ) : entry.compatible ? (
-
- ) : (
-
- Incompatible
-
- )
- }
- disabled={!entry.installed && !entry.compatible}
- onPress={() => {
- if (entry.installed) {
- router.push(pluginDetailHref(entry.pluginId));
- return;
+ ) : undefined
}
- setTarget(entry);
- installSheet.present();
- }}
- titleLines={1}
- testID={`plugin-browse-${entry.entryId}`}
- />
- ))}
+ disabled={!entry.compatible}
+ onPress={() => {
+ setTarget(entry);
+ installSheet.present();
+ }}
+ testID={`plugin-browse-${entry.entryId}`}
+ />
+ );
+ })}
))
)}
-
+
{marketplaceCount > 0
? `Listing ${marketplaceCount} ${marketplaceCount === 1 ? "marketplace" : "marketplaces"}. Plugins run with full trust inside the bb server.`
: "Plugins run with full trust inside the bb server."}
-
+
= {
@@ -47,35 +46,33 @@ const CAPABILITY_LABELS: Record = {
"thread-integration": "Thread integration",
};
-function PluginHeader({ plugin }: { plugin: InstalledPlugin }) {
+/** The identity cell: icon, name, version · publisher, and the runtime state on the right. */
+function PluginIdentityRow({ plugin }: { plugin: InstalledPlugin }) {
+ const { tokens } = useTheme();
+ const running = plugin.enabled && plugin.status === "running";
return (
-
-
-
+
+
+
-
-
+
+
{pluginDisplayName(plugin)}
-
- {`v${plugin.version}`}
- {plugin.publisherLabel !== null ? (
-
- {plugin.publisherLabel}
-
- ) : null}
-
- {plugin.enabled ? plugin.status : "disabled"}
-
-
+
+ {`v${plugin.version}${plugin.publisherLabel !== null ? ` · ${plugin.publisherLabel}` : ""}`}
+
+
+ {plugin.enabled ? plugin.status : "disabled"}
+
);
}
@@ -91,7 +88,6 @@ export function PluginDetailScreen() {
const { pluginId } = useLocalSearchParams<{ pluginId: string }>();
const id = typeof pluginId === "string" ? pluginId : null;
const router = useRouter();
- const { tokens } = useTheme();
const { plugin, isPending, isError, error, refetch } = usePlugin(id);
const updates = usePluginUpdates();
const setEnabled = useSetPluginEnabled();
@@ -99,7 +95,6 @@ export function PluginDetailScreen() {
const remove = useRemovePlugin();
const checkUpdates = useCheckPluginUpdates();
const applyUpdate = useApplyPluginUpdate();
- const confirmRemove = useSheet();
const updateEntry = useMemo(
() => updates.data?.find((entry) => entry.id === id),
@@ -110,16 +105,35 @@ export function PluginDetailScreen() {
if (id === null) {
return (
-
+
No plugin selected.
-
+
);
}
+ const confirmRemove = () => {
+ if (!plugin) return;
+ confirmDestructive({
+ title: `${pluginRemovalLabel(plugin)} ${name}?`,
+ message: pluginRemovalDescription(plugin),
+ actionLabel: pluginRemovalLabel(plugin),
+ onConfirm: () =>
+ remove.mutate(
+ { pluginId: plugin.id },
+ {
+ onSuccess: () => {
+ toast.success(`${name} removed`);
+ router.back();
+ },
+ },
+ ),
+ });
+ };
+
return (
<>
-
+
{isPending ? (
@@ -127,7 +141,7 @@ export function PluginDetailScreen() {
) : isError ? (
-
+
Could not load the plugin:{" "}
{error instanceof Error ? error.message : String(error)}
@@ -201,40 +215,10 @@ export function PluginDetailScreen() {
)
}
reloading={reload.isPending}
- onOpenLogs={() => router.push(pluginLogsHref(plugin.id))}
- onRemove={confirmRemove.present}
- tokens={tokens}
+ onRemove={confirmRemove}
/>
)}
-
-
-
- remove.mutate(
- { pluginId: plugin.id },
- {
- onSuccess: () => {
- toast.success(`${name} removed`);
- router.back();
- },
- },
- ),
- },
- ]
- : []
- }
- />
+
>
);
}
@@ -250,9 +234,7 @@ function PluginDetailBody({
onApplyUpdate,
onReload,
reloading,
- onOpenLogs,
onRemove,
- tokens,
}: {
plugin: InstalledPlugin;
updateSummary: ReturnType;
@@ -264,9 +246,7 @@ function PluginDetailBody({
onApplyUpdate: () => void;
onReload: () => void;
reloading: boolean;
- onOpenLogs: () => void;
onRemove: () => void;
- tokens: { subtleForeground: string };
}) {
const health = pluginRuntimeStatusPresentation(plugin);
const settings = pluginSettingsAvailability(plugin);
@@ -274,12 +254,22 @@ function PluginDetailBody({
const lastFailure = plugin.updateState.lastFailure;
return (
<>
-
- {plugin.description ? (
-
- {plugin.description}
-
- ) : null}
+
+ {plugin.description}
+
+ ) : undefined
+ }
+ >
+
+
{lastFailure !== undefined ? (
) : null}
-
-
-
- Enabled
-
- {plugin.enabled
- ? "The plugin's server half is loaded."
- : "bb does not load this plugin."}
-
-
-
-
-
+
+
+ }
+ />
) : (
- <>
-
-
- {updateSummary?.title ?? "Updates not checked yet"}
-
- {updateSummary?.detail ? (
- {updateSummary.detail}
- ) : plugin.updateState.lastCheckAt !== undefined ? (
-
- Last checked{" "}
- {new Date(plugin.updateState.lastCheckAt).toLocaleString()}
-
- ) : null}
-
-
-
+
+ )}
+ {plugin.provenance === "builtin" ? null : (
+
+
+ Check for updates
+
+ {updateSummary?.canApply ? (
- Check for updates
+ Update
- {updateSummary?.canApply ? (
-
- Update
-
- ) : null}
-
- >
+ ) : null}
+
)}
@@ -396,30 +383,22 @@ function PluginDetailBody({
: "Enable the plugin to see what it contributes."}
) : (
- plugin.capabilities.map((capability, index) => (
-
- {index > 0 ? : null}
-
- {CAPABILITY_LABELS[capability.kind]}
-
- }
- />
-
+ plugin.capabilities.map((capability) => (
+
))
)}
@@ -457,12 +436,12 @@ function PluginDetailBody({
- copyWithToast(plugin.rootDir, "Path copied")}
- titleLines={1}
+ accessibilityHint="Copies the path"
/>
{plugin.handlerStats.count > 0 ? (
-
-
+
-
-
-
- Plugin id {plugin.id}
-
>
);
}
diff --git a/apps/mobile/src/screens/plugins/PluginLogsScreen.tsx b/apps/mobile/src/screens/plugins/PluginLogsScreen.tsx
index b6a7bc317e..56824db325 100644
--- a/apps/mobile/src/screens/plugins/PluginLogsScreen.tsx
+++ b/apps/mobile/src/screens/plugins/PluginLogsScreen.tsx
@@ -10,20 +10,31 @@ import {
type PluginLogLine,
} from "@/data/plugins";
import { copyWithToast } from "@/lib/clipboard";
-import { useTheme } from "@/theme";
-import { Button, EmptyStatePanel, Icon, Spinner, Text } from "@/ui";
+import { Button, EmptyStatePanel, Spinner, Text } from "@/ui";
+import { SegmentedChoice } from "../settings/SegmentedChoice";
+import { HeaderIconButton } from "../settings/SettingsRows";
import { Screen } from "../shell/Screen";
-const TAIL_OPTIONS = [100, PLUGIN_LOGS_DEFAULT_TAIL, 1000] as const;
+const IS_IOS = process.env.EXPO_OS === "ios";
+
+const TAIL_OPTIONS = [100, PLUGIN_LOGS_DEFAULT_TAIL, 1000].map((tail) => ({
+ value: String(tail),
+ label: String(tail),
+}));
function LogLine({ line }: { line: PluginLogLine }) {
return (
copyWithToast(line.text, "Line copied")}
- className="flex-row gap-3 px-4 py-1 active:bg-state-hover"
+ className={`flex-row gap-3 px-4 py-1 ${IS_IOS ? "active:bg-state-active" : "active:bg-state-hover"}`}
accessibilityRole="text"
>
-
+
{line.index + 1}
@@ -36,90 +47,95 @@ function LogLine({ line }: { line: PluginLogLine }) {
/**
* A plugin's log tail (`/settings/plugins/[pluginId]/logs`, `GET
* /plugins/:id/logs?tail=`): numbered mono lines, newest last, a tail-size
- * picker, pull-free refresh (the header button), long-press to copy a line.
+ * segmented control at the top of the list, refresh from the header, and
+ * long-press to copy a line. The list is the route's first scrollable, so
+ * it owns the header inset.
*/
export function PluginLogsScreen() {
const { pluginId } = useLocalSearchParams<{ pluginId: string }>();
const id = typeof pluginId === "string" ? pluginId : null;
- const { tokens } = useTheme();
const insets = useSafeAreaInsets();
const [tail, setTail] = useState(PLUGIN_LOGS_DEFAULT_TAIL);
const logs = usePluginLogs({ pluginId: id, tail });
const lines = useMemo(() => toPluginLogLines(logs.data ?? []), [logs.data]);
+ const refresh = () => void logs.refetch();
+
+ const header = (
+
+ setTail(Number(value))}
+ testID="plugin-logs-tail"
+ testIDPrefix="plugin-logs-tail"
+ />
+
+ );
+ const empty = logs.isPending ? (
+
+
+
+ ) : logs.isError ? (
+
+
+ {logs.error instanceof Error
+ ? logs.error.message
+ : "Could not load logs"}
+
+
+ Retry
+
+
+ ) : (
+
+ No log lines yet.
+
+ );
return (
<>
(
- void logs.refetch()}
- testID="plugin-logs-refresh"
- >
- {logs.isFetching ? (
-
- ) : (
-
- )}
-
- ),
+ ...(IS_IOS
+ ? {}
+ : {
+ headerRight: () => (
+
+ ),
+ }),
}}
/>
-
-
- Tail
- {TAIL_OPTIONS.map((option) => (
- setTail(option)}
- testID={`plugin-logs-tail-${option}`}
- >
- {String(option)}
-
- ))}
-
- {logs.isPending ? (
-
-
-
- ) : logs.isError ? (
-
-
- {logs.error instanceof Error
- ? logs.error.message
- : "Could not load logs"}
-
- void logs.refetch()}
- >
- Retry
-
-
- ) : lines.length === 0 ? (
-
- No log lines yet.
-
- ) : (
- line.key}
- renderItem={({ item }) => }
- contentContainerStyle={{
- paddingVertical: 8,
- paddingBottom: insets.bottom + 16,
- }}
- testID="plugin-logs-list"
+ {IS_IOS ? (
+
+
- )}
+
+ ) : null}
+
+ line.key}
+ renderItem={({ item }) => }
+ ListHeaderComponent={header}
+ ListEmptyComponent={empty}
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{
+ paddingVertical: 8,
+ paddingBottom: insets.bottom + 16,
+ }}
+ testID="plugin-logs-list"
+ />
>
);
diff --git a/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx b/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx
index b05627b9b7..aea460d954 100644
--- a/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx
+++ b/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx
@@ -14,69 +14,58 @@ import { useSidebarBootstrap } from "@/data/sidebar";
import { haptic } from "@/lib/haptics";
import {
Button,
+ GroupedRow,
Input,
- Pill,
Separator,
Skeleton,
Switch,
Text,
TextArea,
toast,
- useSheet,
} from "@/ui";
-import { OptionSheet, type PickerOption } from "../pickers/OptionSheet";
import { ProjectPicker } from "../pickers/ProjectPicker";
+import { MenuValueRow } from "../settings/MenuValueRow";
import { CardNote } from "./plugin-ui";
/**
* Host-rendered declarative settings form (web PluginSettingsForm) over
- * `GET/PUT /plugins/:id/settings`: string (incl. write-only secrets, and a
- * monospace multi-line editor for `experimental_multiline`), boolean, select
- * (option sheet), project (the ProjectPicker). Drafts live in local state;
- * Save sends only the changed keys.
+ * `GET/PUT /plugins/:id/settings` as grouped cells: string (incl.
+ * write-only secrets, and a monospace multi-line editor for
+ * `experimental_multiline`), boolean (switch row), select (a value row
+ * opening the option sheet; its rows are `-option-`),
+ * project (the ProjectPicker). Drafts live in local state; Save sends only
+ * the changed keys.
*/
function SelectField({
label,
+ description,
options,
value,
+ disabled,
onChange,
testID,
}: {
label: string;
+ description?: string;
options: readonly string[];
value: string;
+ disabled: boolean;
onChange: (value: string) => void;
testID: string;
}) {
- const sheet = useSheet();
- const rows = useMemo(
- (): PickerOption[] =>
- options.map((option) => ({ value: option, label: option })),
- [options],
- );
return (
- <>
-
- {value.length > 0 ? value : "Select…"}
-
- 0 ? value : null}
- onChange={onChange}
- testIDPrefix={`${testID}-option`}
- />
- >
+ 0 ? value : "Select…"}
+ options={options.map((option) => ({ value: option, label: option }))}
+ selected={value.length > 0 ? value : null}
+ onSelect={onChange}
+ disabled={disabled}
+ testID={testID}
+ accessibilityLabel={label}
+ />
);
}
@@ -135,93 +124,103 @@ function SettingField({
descriptor.type === "string" &&
descriptor.experimental_multiline === true &&
!isSecret;
- const control = (() => {
- switch (descriptor.type) {
- case "boolean":
- return (
- onChange(next)}
- disabled={disabled}
- testID={testID}
- accessibilityLabel={descriptor.label}
- />
- );
- case "select":
- return (
-
- );
- case "project":
- return (
-
- );
- case "string":
- return null;
- }
- })();
- return (
-
-
-
-
-
- {descriptor.label}
-
- {isSecret ? (
-
- secret
-
- ) : null}
-
- {descriptor.description ? (
- {descriptor.description}
- ) : null}
-
- {control}
-
- {isMultiline ? (
-
- );
+ );
+ case "string":
+ return (
+
+
+
+
+ {descriptor.label}
+
+ {descriptor.description ? (
+ {descriptor.description}
+ ) : null}
+
+ {isSecret ? (
+
+ Secret
+
+ ) : null}
+
+ {isMultiline ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
}
interface PluginSettingsFormProps {
@@ -244,7 +243,7 @@ export function PluginSettingsForm({ pluginId }: PluginSettingsFormProps) {
if (view.isError || view.data === undefined) {
return (
-
+
Could not load settings:{" "}
{view.error instanceof Error ? view.error.message : "unknown error"}
@@ -275,7 +274,7 @@ export function PluginSettingsForm({ pluginId }: PluginSettingsFormProps) {
{entries.map(([key, descriptor], index) => (
- {index > 0 ? : null}
+ {index > 0 ? : null}
))}
-
-
+
+
{hasChanges ? (
(null);
@@ -68,196 +77,277 @@ export function PluginsScreen() {
);
const total = list.data?.length ?? 0;
- const openMenu = (plugin: InstalledPlugin) => {
- setTarget(plugin);
- haptic("impact-heavy");
- menu.present();
- };
+ const checkForUpdates = () =>
+ checkUpdates.mutate(
+ {},
+ {
+ onSuccess: (results) => {
+ const available = results.filter(
+ (entry) => entry.outcome === "update-available",
+ ).length;
+ toast.success(
+ available === 0
+ ? "Every plugin is up to date"
+ : `${available} ${available === 1 ? "update" : "updates"} available`,
+ );
+ },
+ },
+ );
+ const reloadAll = () =>
+ reload.mutate({}, { onSuccess: () => toast.success("Plugins reloaded") });
+
+ const confirmRemove = (plugin: InstalledPlugin) =>
+ confirmDestructive({
+ title: `${pluginRemovalLabel(plugin)} ${pluginDisplayName(plugin)}?`,
+ message: pluginRemovalDescription(plugin),
+ actionLabel: pluginRemovalLabel(plugin),
+ onConfirm: () =>
+ remove.mutate(
+ { pluginId: plugin.id },
+ {
+ onSuccess: () =>
+ toast.success(`${pluginDisplayName(plugin)} removed`),
+ },
+ ),
+ });
+
+ const actionsFor = (plugin: InstalledPlugin): ActionSheetAction[] => [
+ {
+ key: "open",
+ label: "Open",
+ icon: "ChevronRight",
+ onPress: () => router.push(pluginDetailHref(plugin.id)),
+ },
+ {
+ key: plugin.enabled ? "disable" : "enable",
+ label: plugin.enabled ? "Disable" : "Enable",
+ icon: plugin.enabled ? "Pause" : "Play",
+ onPress: () =>
+ setEnabled.mutate({ pluginId: plugin.id, enabled: !plugin.enabled }),
+ },
+ {
+ key: "reload",
+ label: "Reload",
+ icon: "RotateCcw",
+ disabled: !plugin.enabled,
+ onPress: () =>
+ reload.mutate(
+ { pluginId: plugin.id },
+ {
+ onSuccess: () =>
+ toast.success(`${pluginDisplayName(plugin)} reloaded`),
+ },
+ ),
+ },
+ {
+ key: "remove",
+ label: pluginRemovalLabel(plugin),
+ icon: "Trash2",
+ destructive: true,
+ onPress: () => confirmRemove(plugin),
+ },
+ ];
return (
<>
- (
-
+ setQuery(event.nativeEvent.text)}
+ onCancelButtonPress={() => setQuery("")}
+ />
+
+
+
-
-
- ),
- }}
- />
-
+
+ Check for updates
+
+
+ Reload all
+
+
+
+ >
+ ) : (
+ (
+
+ ),
+ }}
+ />
+ )}
+
- router.push(pluginBrowseHref())}
+ badge={{ icon: "Explore", symbol: "book.fill", color: colors.blue }}
testID="plugins-browse"
/>
- router.push(marketplacesHref())}
+ badge={{
+ icon: "PackageReceive",
+ symbol: "shippingbox.fill",
+ color: colors.teal,
+ }}
testID="plugins-marketplaces"
/>
-
-
-
- {total > 0 ? `Installed (${total})` : "Installed"}
-
- {total > 4 ? (
-
- ) : null}
-
- {list.isPending ? (
-
-
-
-
-
- ) : list.isError ? (
-
-
- Could not load plugins: {describeError(list.error)}
-
- void list.refetch()}
- >
- Retry
-
-
- ) : total === 0 ? (
-
-
- No plugins installed on this server.
-
- router.push(pluginBrowseHref())}
- >
- Browse catalog
-
-
- ) : plugins.length === 0 ? (
-
- No plugins match “{query}”.
-
- ) : (
- plugins.map((plugin) => {
- const signal = pluginRowSignal(plugin);
- return (
-
- }
- trailing={
-
- {signal ? (
-
- ) : null}
-
+ )}
+
+ 0 ? `Installed (${total})` : "Installed"}
+ footnote={
+ total > 0
+ ? `Tap a plugin for its settings and logs${IS_IOS ? "." : "; long-press to enable, reload or uninstall it."}`
+ : undefined
+ }
+ >
+ {list.isPending ? (
+
+
+
+
+
+ ) : list.isError ? (
+
+
+ Could not load plugins: {describeError(list.error)}
+
+ void list.refetch()}
+ >
+ Retry
+
+
+ ) : total === 0 ? (
+
+
+ No plugins installed on this server.
+
+ router.push(pluginBrowseHref())}
+ >
+ Browse catalog
+
+
+ ) : plugins.length === 0 ? (
+
+ No plugins match “{query}”.
+
+ ) : (
+ plugins.map((plugin) => {
+ const signal = pluginRowSignal(plugin);
+ return (
+
+ }
+ trailing={
+
+ {signal ? (
+
-
- }
- onPress={() => router.push(pluginDetailHref(plugin.id))}
- onLongPress={() => openMenu(plugin)}
- testID={`plugin-row-${plugin.id}`}
- />
- );
- })
- )}
-
-
+ ) : null}
+
+
+ }
+ onLongPress={() => {
+ setTarget(plugin);
+ haptic("impact-heavy");
+ menu.present();
+ }}
+ testID={`plugin-row-${plugin.id}`}
+ />
+ );
+ })
+ )}
+
- {total > 0 ? (
+ {total > 0 && !IS_IOS ? (
-
- checkUpdates.mutate(
- {},
- {
- onSuccess: (results) => {
- const available = results.filter(
- (entry) => entry.outcome === "update-available",
- ).length;
- toast.success(
- available === 0
- ? "Every plugin is up to date"
- : `${available} ${available === 1 ? "update" : "updates"} available`,
- );
- },
- },
- )
- }
+ onPress={checkForUpdates}
testID="plugins-check-updates"
/>
-
- reload.mutate(
- {},
- { onSuccess: () => toast.success("Plugins reloaded") },
- )
- }
+ onPress={reloadAll}
testID="plugins-reload-all"
/>
) : null}
-
+
router.push(pluginDetailHref(target.id)),
- },
- {
- key: target.enabled ? "disable" : "enable",
- label: target.enabled ? "Disable" : "Enable",
- icon: target.enabled ? "Pause" : "Play",
- onPress: () =>
- setEnabled.mutate({
- pluginId: target.id,
- enabled: !target.enabled,
- }),
- },
- {
- key: "reload",
- label: "Reload",
- icon: "RotateCcw",
- disabled: !target.enabled,
- onPress: () =>
- reload.mutate(
- { pluginId: target.id },
- {
- onSuccess: () =>
- toast.success(
- `${pluginDisplayName(target)} reloaded`,
- ),
- },
- ),
- },
- {
- key: "remove",
- label: pluginRemovalLabel(target),
- icon: "Trash2",
- destructive: true,
- onPress: () => confirmRemove.present(),
- },
- ]
- : []
- }
- />
-
-
- remove.mutate(
- { pluginId: target.id },
- {
- onSuccess: () =>
- toast.success(`${pluginDisplayName(target)} removed`),
- },
- ),
- },
- ]
- : []
- }
+ actions={target ? actionsFor(target) : []}
/>
>
);
diff --git a/apps/mobile/src/screens/plugins/plugin-ui.tsx b/apps/mobile/src/screens/plugins/plugin-ui.tsx
index bef47da7ff..523239ee17 100644
--- a/apps/mobile/src/screens/plugins/plugin-ui.tsx
+++ b/apps/mobile/src/screens/plugins/plugin-ui.tsx
@@ -1,39 +1,19 @@
-import type { ReactNode } from "react";
import { View } from "react-native";
import type { PluginRowSignal, PluginStatusTone } from "@/data/plugins";
import { useTheme } from "@/theme";
-import { Icon, Pill, Text } from "@/ui";
+import { GROUPED_CARD_RADIUS, Icon, Pill, Text } from "@/ui";
-/** Card-styled section with a label, shared by the plugin / extension screens. */
-export function SettingsSection({
- title,
- description,
- children,
- testID,
-}: {
- title: string;
- description?: string;
- children: ReactNode;
- testID?: string;
-}) {
- return (
-
-
- {title}
-
- {description ? (
-
- {description}
-
- ) : null}
-
- {children}
-
-
- );
-}
+/** The grouped section shared by the plugin / extension screens. */
+export { SettingsSection } from "../settings/SettingsRows";
+
+/** Values up to this length sit on the row's right; longer ones wrap under the label. */
+const INLINE_VALUE_MAX_LENGTH = 28;
-/** A `label: value` definition row inside a card. */
+/**
+ * A `label: value` definition row inside a card. Short values read like an
+ * iOS value cell (label left, muted value right); long or mono values
+ * (sources, schedules, paths) wrap under the label. Values are selectable.
+ */
export function DetailRow({
label,
value,
@@ -45,16 +25,32 @@ export function DetailRow({
mono?: boolean;
testID?: string;
}) {
- return (
-
-
- {label}
-
-
+
+ {label}
+
+
+ {value}
+
+
+ );
+ }
+ return (
+
+ {label}
+
{value}
@@ -71,7 +67,9 @@ export function CardNote({
}) {
return (
- {children}
+
+ {children}
+
);
}
@@ -119,7 +117,7 @@ export function PluginSignalPill({
);
}
-/** A tinted banner (status condition + recovery, third-party warnings). */
+/** A grouped card carrying a status condition + recovery, or a third-party warning. */
export function NoticeCard({
tone,
icon,
@@ -146,13 +144,18 @@ export function NoticeCard({
tone === "info" ? tokens.mutedForeground : toneColor(tone, tokens);
return (
-
+
- {title}
- {body ? {body} : null}
+ {title}
+ {body ? (
+
+ {body}
+
+ ) : null}
);
diff --git a/apps/mobile/src/screens/projects/NewProjectScreen.tsx b/apps/mobile/src/screens/projects/NewProjectScreen.tsx
index e34da1fb8c..ac8b80dde0 100644
--- a/apps/mobile/src/screens/projects/NewProjectScreen.tsx
+++ b/apps/mobile/src/screens/projects/NewProjectScreen.tsx
@@ -4,33 +4,49 @@ import {
normalizeProjectPathInput,
type Host,
} from "@bb/domain";
-import { useRouter } from "expo-router";
+import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { useProfiles } from "@/app-shell";
import { useHosts, usePrimaryHost } from "@/data/hosts";
import { useCreateProject } from "@/data/projects";
import { useTheme } from "@/theme";
-import { Button, Icon, Input, ListRow, Text, toast, useSheet } from "@/ui";
+import {
+ Button,
+ EmptyStatePanel,
+ GroupedRow,
+ Icon,
+ Input,
+ Text,
+ toast,
+ useSheet,
+ SheetProvider,
+} from "@/ui";
import { HostPicker, HostStatusDot, RemotePathBrowserSheet } from "../pickers";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import {
+ ICON_ROW_SEPARATOR_INSET,
+ SettingsSection,
+} from "../settings/SettingsRows";
import { newThreadHref } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
/**
- * `/projects/new`: name + machine + folder (remote path browser, may create
- * a folder) → `POST /projects` with one `local_path` source → the compose
- * screen for the new project. Mirrors the web ProjectPathDialog "create".
- * Cloning onto another machine is a per-project follow-up (Project settings
- * → Add source), as in the web app.
+ * `/projects/new` (a modal on iOS with Cancel / Create in the header): name
+ * + machine + folder (remote path browser, may create a folder) →
+ * `POST /projects` with one `local_path` source → the compose screen for
+ * the new project. Mirrors the web ProjectPathDialog "create". Cloning onto
+ * another machine is a per-project follow-up (Project settings → Add
+ * source), as in the web app.
*/
export function NewProjectScreen() {
const { connection } = useProfiles();
if (!connection) {
return (
-
- New project
- Add a server first.
-
+
+ Add a server first.
+
);
}
return ;
@@ -63,6 +79,11 @@ function ConnectedNewProjectScreen() {
const effectiveName = (nameTouched ? name : name || derivedName).trim();
const noMachineOnline =
hosts.length > 0 && !hosts.some((h) => h.status === "connected");
+ const canSubmit =
+ !createProject.isPending &&
+ host !== null &&
+ host.status === "connected" &&
+ path !== null;
const submit = async () => {
if (createProject.isPending) return;
@@ -91,147 +112,183 @@ function ConnectedNewProjectScreen() {
source: { type: "local_path", hostId: host.id, path: normalizedPath },
});
toast.success(`Added ${project.name}`);
- router.navigate(newThreadHref({ projectId: project.id }));
+ // Pop back to the home entry (dismissing this modal) and hand it the
+ // compose params; `navigate` would push the compose route beneath the
+ // still-presented sheet on iOS.
+ router.dismissTo(newThreadHref({ projectId: project.id }));
} catch {
// The profile QueryClient's mutation error toast already reported it.
}
};
return (
-
-
- New project
-
- Point bb at a folder on one of your machines. The folder is resolved
- on that machine, not on this phone.
-
-
-
-
- Machine
-
-
+ {IS_IOS ? (
+ <>
+
+ router.back()}
+ >
+ Cancel
+
+
+
+ void submit()}
+ >
+ Create
+
+
+ >
+ ) : null}
+ {/* Own sheet host: this route is a native modal, and sheets from the root
+ provider would open behind it. */}
+
+
+ 1 && host.id === primaryHost?.id
- ? "Primary machine"
- : undefined
- : "Offline"
- : noMachineOnline
- ? "Every machine is offline. Bring one online to browse its folders."
+ >
+ 1 && host.id === primaryHost?.id
+ ? "Primary machine"
+ : undefined
+ : "Offline"
: undefined
- }
- leading={
- host ? (
-
-
-
- ) : (
-
- )
- }
- trailing="chevron"
- disabled={hosts.length === 0}
- onPress={hostSheet.present}
- testID="new-project-host"
+ }
+ leading={
+ host ? (
+
+
+
+ ) : (
+
+ )
+ }
+ trailing="chevron"
+ disabled={hosts.length === 0}
+ onPress={hostSheet.present}
+ testID="new-project-host"
+ />
+
+ {
+ setPickedHostId(hostId);
+ setPath(null);
+ setValidationMessage(null);
+ }}
+ hostIdsWithSource={null}
+ primaryHostId={primaryHost?.id ?? null}
+ testID="new-project-host-picker"
/>
-
- {
- setPickedHostId(hostId);
- setPath(null);
- setValidationMessage(null);
- }}
- hostIdsWithSource={null}
- primaryHostId={primaryHost?.id ?? null}
- testID="new-project-host-picker"
- />
-
-
- Folder
-
-
+
+
+ {
+ setPath(selected);
+ setValidationMessage(null);
+ }}
+ testID="new-project-path-sheet"
/>
-
- {
- setPath(selected);
- setValidationMessage(null);
- }}
- testID="new-project-path-sheet"
- />
-
-
- Name
- {
- setNameTouched(true);
- setName(next);
- setValidationMessage(null);
- }}
- placeholder={derivedName || "Project name"}
- autoCapitalize="words"
- returnKeyType="done"
- onSubmitEditing={() => void submit()}
- editable={!createProject.isPending}
- testID="new-project-name"
- />
-
-
- {validationMessage ? (
-
-
- {validationMessage}
-
-
- ) : null}
+
+ {validationMessage}
+
+ ) : (
+ "Defaults to the folder name."
+ )
+ }
+ >
+
+ {
+ setNameTouched(true);
+ setName(next);
+ setValidationMessage(null);
+ }}
+ placeholder={derivedName || "Project name"}
+ autoCapitalize="words"
+ returnKeyType="done"
+ onSubmitEditing={() => void submit()}
+ grouped
+ editable={!createProject.isPending}
+ accessibilityLabel="Project name"
+ testID="new-project-name"
+ />
+
+
- void submit()}
- loading={createProject.isPending}
- disabled={!host || host.status !== "connected" || !path}
- icon="FolderPlus"
- testID="new-project-submit"
- >
- Add project
-
-
+ {IS_IOS ? null : (
+ void submit()}
+ loading={createProject.isPending}
+ disabled={!canSubmit}
+ icon="FolderPlus"
+ testID="new-project-submit"
+ >
+ Add project
+
+ )}
+
+
+ >
);
}
diff --git a/apps/mobile/src/screens/projects/ProjectMachineSetupSheet.tsx b/apps/mobile/src/screens/projects/ProjectMachineSetupSheet.tsx
index 4cde02ff95..b71f02bed8 100644
--- a/apps/mobile/src/screens/projects/ProjectMachineSetupSheet.tsx
+++ b/apps/mobile/src/screens/projects/ProjectMachineSetupSheet.tsx
@@ -5,11 +5,18 @@ import {
} from "@bb/domain";
import { BbHttpError } from "@bb/sdk/browser";
import { useState, type ReactNode } from "react";
-import { Pressable, View } from "react-native";
+import { Pressable, StyleSheet, View } from "react-native";
import { useHostCloneDefaultPath } from "@/data/hosts";
import { useAddProjectSource } from "@/data/projects";
import { useTheme } from "@/theme";
-import { Button, cn, Sheet, Spinner, Text, type SheetController } from "@/ui";
+import {
+ Button,
+ GROUPED_CARD_RADIUS,
+ Sheet,
+ Spinner,
+ Text,
+ type SheetController,
+} from "@/ui";
import {
describeRequestError,
RemotePathBrowser,
@@ -340,8 +347,8 @@ function SetupBody({
) : null}
{errorMessage ? (
-
-
+
+
{errorMessage}
{isTargetNotEmpty ? (
@@ -399,26 +406,32 @@ function SetupOptionCard({
disabled={disabled}
onPress={onSelect}
testID={testID}
- className={cn(
- "flex-row items-start gap-3 rounded-md border p-3",
- selected ? "border-primary" : "border-border",
- )}
+ className="flex-row items-start gap-3 p-3"
+ style={{
+ borderRadius: GROUPED_CARD_RADIUS,
+ borderCurve: "continuous",
+ backgroundColor: tokens.surfaceRecessed,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: selected ? tokens.primary : "transparent",
+ }}
>
{selected ? (
) : null}
- {title}
+ {title}
{children}
diff --git a/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx b/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx
index 9266241741..0bc5412d77 100644
--- a/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx
+++ b/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx
@@ -14,29 +14,37 @@ import { useTheme } from "@/theme";
import {
ActionSheet,
Button,
+ confirmDestructive,
EmptyStatePanel,
+ GroupedRow,
Icon,
Input,
ListRow,
- Separator,
Sheet,
Spinner,
Text,
toast,
useSheet,
+ type ActionSheetAction,
} from "@/ui";
import { HostStatusDot } from "../pickers";
+import { GroupedScreen } from "../settings/GroupedScreen";
+import {
+ ICON_ROW_SEPARATOR_INSET,
+ SettingsSection,
+} from "../settings/SettingsRows";
import { firstParam } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
import {
ProjectMachineSetupSheet,
type ProjectMachineSetupTarget,
} from "./ProjectMachineSetupSheet";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
/**
* `/projects/[id]/settings`: rename, the project's sources per machine
* (add through the guided clone/folder flow, remove with confirmation), and
- * delete. Mirrors the web ProjectSettingsView essentials.
+ * delete. Mirrors the web ProjectSettingsView essentials as grouped forms.
*/
export function ProjectSettingsScreen() {
const params = useLocalSearchParams<{ id?: string | string[] }>();
@@ -44,10 +52,9 @@ export function ProjectSettingsScreen() {
const { connection } = useProfiles();
if (!connection) {
return (
-
- Project settings
- Add a server first.
-
+
+ Add a server first.
+
);
}
return ;
@@ -55,7 +62,6 @@ export function ProjectSettingsScreen() {
function ConnectedProjectSettingsScreen({ projectId }: { projectId: string }) {
const router = useRouter();
- const { tokens } = useTheme();
const bootstrap = useSidebarBootstrap();
const project = useSidebarProject(projectId);
const hostsQuery = useHosts();
@@ -73,7 +79,6 @@ function ConnectedProjectSettingsScreen({ projectId }: { projectId: string }) {
const nameDirty = nameDraft !== null && nameDraft.trim() !== project?.name;
const sourceMenu = useSheet();
- const removeConfirm = useSheet();
const [sourceForMenu, setSourceForMenu] = useState(
null,
);
@@ -81,25 +86,24 @@ function ConnectedProjectSettingsScreen({ projectId }: { projectId: string }) {
const setupSheet = useSheet();
const [setupTarget, setSetupTarget] =
useState(null);
- const deleteConfirm = useSheet();
if (bootstrap.isLoading && !project) {
return (
-
+
-
+
);
}
if (!project) {
return (
-
+
This project no longer exists.
router.back()}>
Go back
-
+
);
}
const isPersonal = project.kind === "personal";
@@ -134,23 +138,66 @@ function ConnectedProjectSettingsScreen({ projectId }: { projectId: string }) {
setupSheet.present();
};
+ const confirmRemoveSource = (source: ProjectSource) =>
+ confirmDestructive({
+ title: "Remove this source?",
+ message: `bb stops using ${source.path} on ${hostById.get(source.hostId)?.name ?? "that machine"} for this project. The folder stays on disk.`,
+ actionLabel: "Remove",
+ onConfirm: () =>
+ removeSource.mutate(
+ { projectId: project.id, sourceId: source.id },
+ { onSuccess: () => toast.success("Source removed") },
+ ),
+ });
+
+ const sourceActions = (source: ProjectSource): ActionSheetAction[] => [
+ {
+ key: "remove",
+ label: "Remove source",
+ icon: "FolderMinus",
+ destructive: true,
+ onPress: () => confirmRemoveSource(source),
+ },
+ ];
+
+ const confirmDelete = () =>
+ confirmDestructive({
+ title: `Delete ${project.name}?`,
+ message:
+ "This removes the project and all of its threads from bb. This cannot be undone.",
+ actionLabel: "Delete project",
+ onConfirm: () => {
+ deleteProject.mutate(project.id, {
+ onSuccess: () => {
+ toast.success(`Deleted ${project.name}`);
+ router.dismissTo("/");
+ },
+ });
+ },
+ });
+
return (
-
-
- Name
-
+
+
+
{nameDirty ? (
) : null}
- {project.gitRemoteUrl ? (
-
- {project.gitRemoteUrl}
-
- ) : null}
-
+
{isPersonal ? (
-
+
The personal project has no sources; its threads run in each machine's
personal workspace.
-
+
) : (
-
- Sources
-
- Where this project is checked out. One folder per machine.
-
-
- {project.sources.length === 0 ? (
-
- No sources yet.
-
- ) : (
- project.sources.map((source, index) => {
- const host = hostById.get(source.hostId);
- return (
-
- {index > 0 ? : null}
-
-
-
- }
- trailing={
-
- }
- onPress={() => {
- setSourceForMenu(source);
- sourceMenu.present();
- }}
- onLongPress={() => {
- setSourceForMenu(source);
- sourceMenu.present();
- }}
- testID={`project-source-${source.hostId}`}
- />
-
- );
- })
- )}
-
-
-
-
+
+ {project.sources.length === 0 ? (
+
+
+ No sources yet.
+
+
+ ) : (
+ project.sources.map((source) => (
+ {
+ setSourceForMenu(source);
+ sourceMenu.present();
+ }}
+ />
+ ))
+ )}
+
+
)}
{isPersonal ? null : (
-
-
+
- Delete project
-
-
- Deletes the project and every thread in it from bb. Files on your
- machines are left alone.
-
-
+ />
+
)}
removeConfirm.present(),
- },
- ]}
- />
- {
- if (!sourceForMenu) return;
- removeSource.mutate(
- { projectId: project.id, sourceId: sourceForMenu.id },
- { onSuccess: () => toast.success("Source removed") },
- );
- },
- },
- ]}
+ actions={sourceForMenu ? sourceActions(sourceForMenu) : []}
/>
{addableHosts.map((host) => (
@@ -329,27 +310,46 @@ function ConnectedProjectSettingsScreen({ projectId }: { projectId: string }) {
toast.success("Source added", { description: source.path })
}
/>
- {
- deleteProject.mutate(project.id, {
- onSuccess: () => {
- toast.success(`Deleted ${project.name}`);
- router.dismissTo("/");
- },
- });
- },
- },
- ]}
- />
-
+
+ );
+}
+
+/**
+ * One checkout: the machine, its path, and its action sheet on tap /
+ * long-press. A plain row on both platforms: a native pull-down would hide
+ * it from VoiceOver.
+ */
+function SourceRow({
+ source,
+ host,
+ onOpenMenu,
+}: {
+ source: ProjectSource;
+ host: Host | undefined;
+ onOpenMenu: () => void;
+}) {
+ const { tokens } = useTheme();
+ return (
+
+
+
+ }
+ trailing={
+
+ }
+ onPress={onOpenMenu}
+ onLongPress={onOpenMenu}
+ selectable
+ testID={`project-source-${source.hostId}`}
+ />
);
}
diff --git a/apps/mobile/src/screens/settings/AddServerScreen.tsx b/apps/mobile/src/screens/settings/AddServerScreen.tsx
index d980fce428..db22c621ed 100644
--- a/apps/mobile/src/screens/settings/AddServerScreen.tsx
+++ b/apps/mobile/src/screens/settings/AddServerScreen.tsx
@@ -1,6 +1,6 @@
-import { useLocalSearchParams, useRouter } from "expo-router";
-import { useState } from "react";
-import { View } from "react-native";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useRef, useState } from "react";
+import { View, type TextInput } from "react-native";
import { useProfiles } from "@/app-shell";
import {
PROFILE_LABEL_MAX_LENGTH,
@@ -8,10 +8,11 @@ import {
validateDirectServerUrl,
} from "@/lib/profiles";
import { describeError } from "@/lib/describe-error";
-import { useTheme } from "@/theme";
-import { Button, Icon, Input, ListRow, Text, toast } from "@/ui";
+import { Button, GroupedRow, Input, Text, toast } from "@/ui";
import { connectEnrollHref, rawPathHref } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
+import { GroupedScreen } from "./GroupedScreen";
+import { useBadgeColors } from "./settings-badges";
+import { SettingsSection } from "./SettingsRows";
type SubmitState =
| { phase: "idle" }
@@ -27,26 +28,32 @@ function defaultLabel(serverUrl: string): string {
}
}
+const URL_HELP =
+ "A LAN address, a Tailscale Serve URL, or http://127.0.0.1: in the simulator.";
+
/**
- * "Add server": the bb connect entry (pairing code / QR → `/connect`) and
- * the Direct-mode form — URL entry with live validation, the `/health` +
- * `/system/config` probe, the plain-http warning the plan requires for
- * non-loopback hosts, then save + activate.
+ * "Add server" as a grouped form: the bb connect entry (pairing code / QR →
+ * `/connect`) in its own group, then the direct URL fields as cells — URL
+ * entry with live validation, the `/health` + `/system/config` probe, the
+ * plain-http warning the plan requires for non-loopback hosts as the
+ * group's footer — and the Connect button.
*/
export function AddServerScreen() {
const router = useRouter();
// A deep link to a server the phone does not know arrives here with the
// origin prefilled and the in-app path to open once the server is added.
const params = useLocalSearchParams<{ serverUrl?: string; next?: string }>();
- const { tokens } = useTheme();
+ const colors = useBadgeColors();
const { profiles, addProfile, setActiveProfile } = useProfiles();
const [url, setUrl] = useState(params.serverUrl ?? "");
const [label, setLabel] = useState("");
const [urlTouched, setUrlTouched] = useState(false);
const [submit, setSubmit] = useState({ phase: "idle" });
+ const labelRef = useRef(null);
const validation = validateDirectServerUrl(url);
const showUrlError = urlTouched && !validation.ok && url.trim().length > 0;
+ const insecure = validation.ok && validation.warning === "insecure-http";
const busy = submit.phase === "probing" || submit.phase === "saving";
const firstRun = profiles.length === 0;
@@ -92,115 +99,121 @@ export function AddServerScreen() {
};
return (
-
-
-
- {firstRun ? "Connect to a bb server" : "Add a server"}
-
-
- Pair through getbb.app from anywhere, or enter a direct URL: a LAN
- address, a Tailscale Serve URL, or http://127.0.0.1:<port> in
- the simulator.
-
-
-
-
- router.push(connectEnrollHref())}
- disabled={busy}
- testID="add-server-connect"
- />
-
+ <>
+ {/* The first-run heading lives in the header; the body has no title. */}
+
+
+
+ router.push(connectEnrollHref())}
+ disabled={busy}
+ testID="add-server-connect"
+ />
+
- Direct URL
+
+ {validation.message}
+
+ ) : insecure ? (
+
+ Plain http is unencrypted: anyone on this network can read your
+ threads. Prefer https (Tailscale Serve) outside a trusted LAN.
+
+ ) : (
+ URL_HELP
+ )
+ }
+ >
+
+ {
+ setUrl(next);
+ if (submit.phase === "failed") setSubmit({ phase: "idle" });
+ }}
+ onBlur={() => setUrlTouched(true)}
+ placeholder="https://bb.example.ts.net"
+ keyboardType="url"
+ textContentType="URL"
+ autoCapitalize="none"
+ autoCorrect={false}
+ autoFocus
+ returnKeyType="next"
+ submitBehavior="submit"
+ onSubmitEditing={() => labelRef.current?.focus()}
+ invalid={showUrlError}
+ mono
+ grouped
+ editable={!busy}
+ testID="server-url-input"
+ />
+
+
+ void onSubmit()}
+ grouped
+ editable={!busy}
+ testID="server-label-input"
+ />
+
+
-
- Server URL
- {
- setUrl(next);
- if (submit.phase === "failed") setSubmit({ phase: "idle" });
- }}
- onBlur={() => setUrlTouched(true)}
- placeholder="https://bb.example.ts.net"
- keyboardType="url"
- textContentType="URL"
- autoCapitalize="none"
- autoCorrect={false}
- autoFocus
- returnKeyType="go"
- onSubmitEditing={() => void onSubmit()}
- invalid={showUrlError}
- mono
- editable={!busy}
- testID="server-url-input"
- />
- {showUrlError ? (
-
- {validation.message}
-
- ) : null}
- {validation.ok && validation.warning === "insecure-http" ? (
-
+ void onSubmit()}
+ loading={busy}
+ disabled={url.trim().length === 0}
+ icon="ArrowRight"
+ iconPosition="right"
+ testID="add-server-submit"
>
-
-
- Plain http is unencrypted: anyone on this network can read your
- threads. Prefer https (Tailscale Serve) outside a trusted LAN.
+ {submit.phase === "probing"
+ ? "Checking server…"
+ : submit.phase === "saving"
+ ? "Saving…"
+ : "Connect"}
+
+ {submit.phase === "failed" ? (
+
+ {submit.message}
-
- ) : null}
-
-
-
- Label (optional)
- void onSubmit()}
- editable={!busy}
- testID="server-label-input"
- />
-
-
- {submit.phase === "failed" ? (
-
-
- {submit.message}
-
+ ) : null}
- ) : null}
-
- void onSubmit()}
- loading={busy}
- disabled={url.trim().length === 0}
- icon="ArrowRight"
- iconPosition="right"
- testID="add-server-submit"
- >
- {submit.phase === "probing"
- ? "Checking server…"
- : submit.phase === "saving"
- ? "Saving…"
- : "Connect"}
-
-
+
+ >
);
}
diff --git a/apps/mobile/src/screens/settings/AppearanceSettingsScreen.tsx b/apps/mobile/src/screens/settings/AppearanceSettingsScreen.tsx
index aabc30690a..7b13882203 100644
--- a/apps/mobile/src/screens/settings/AppearanceSettingsScreen.tsx
+++ b/apps/mobile/src/screens/settings/AppearanceSettingsScreen.tsx
@@ -16,20 +16,17 @@ import {
} from "@/data/settings";
import { useSystemConfig } from "@/data/system";
import { useTheme, type ThemeModePreference } from "@/theme";
-import { Button, Icon, ListRow, Sheet, Text, useSheet } from "@/ui";
+import { ListRow, Sheet, Text, useSheet } from "@/ui";
import {
OptionSheet,
usePickerSheetMaxHeight,
type PickerOption,
} from "../pickers";
-import { Screen } from "../shell/Screen";
-import {
- SettingsControlRow,
- SettingsSection,
- SettingsValueRow,
-} from "./SettingsRows";
+import { GroupedScreen } from "./GroupedScreen";
+import { SegmentedChoice } from "./SegmentedChoice";
+import { SettingsSection, SettingsValueRow } from "./SettingsRows";
-const MODES: { value: ThemeModePreference; label: string }[] = [
+const MODES: readonly { value: ThemeModePreference; label: string }[] = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
@@ -39,50 +36,40 @@ const PALETTE_DESCRIPTION =
"Palettes change bb's colors on every client of this server. The six built-in palettes render natively here; a custom or plugin palette shows as the default palette on mobile.";
/**
- * `/settings/appearance`: light/dark mode (device-local, `bb.theme`), the
- * server-wide palette (`PUT /settings/appearance`, picked from
- * `GET /settings/themes`) and the favicon tint that rides along with it.
+ * `/settings/appearance`: light/dark mode (device-local, `bb.theme`) as a
+ * segmented control, the server-wide palette (`PUT /settings/appearance`,
+ * picked from `GET /settings/themes`) and the favicon tint that rides
+ * along with it.
*/
export function AppearanceSettingsScreen() {
const { connection } = useProfiles();
const theme = useTheme();
return (
-
-
-
-
- {MODES.map((mode) => (
- theme.setMode(mode.value)}
- testID={`appearance-mode-${mode.value}`}
- >
- {mode.label}
-
- ))}
-
-
- Light or dark is a choice for this phone; the palette below is
- shared with the server.
-
+
+
+
+ theme.setMode(mode)}
+ testIDPrefix="appearance-mode"
+ />
{connection ? (
) : (
-
-
+
+
)}
-
+
);
}
@@ -133,11 +120,14 @@ function ConnectedAppearanceSections() {
<>
+ {`“${activeLabel}” is a custom palette; this phone renders the default palette while it is active. ${PALETTE_DESCRIPTION}`}
+
+ )
}
>
- {FAVICON_COLOR_OPTIONS.map((option) => {
- const selected = option.value === appearance.faviconColor;
- return (
-
- }
- trailing={
- selected ? (
-
- ) : null
- }
- selected={selected}
- onPress={() => {
- faviconSheet.dismiss();
- selectFaviconColor(option.value);
- }}
- testID={`appearance-favicon-option-${option.value}`}
- />
- );
- })}
+
+
+ Tints the browser tab icon of the web app.
+
+
+ {FAVICON_COLOR_OPTIONS.map((option) => (
+
+ }
+ selected={option.value === appearance.faviconColor}
+ onPress={() => {
+ faviconSheet.dismiss();
+ selectFaviconColor(option.value);
+ }}
+ testID={`appearance-favicon-option-${option.value}`}
+ />
+ ))}
>
);
diff --git a/apps/mobile/src/screens/settings/ExperimentsSettingsScreen.tsx b/apps/mobile/src/screens/settings/ExperimentsSettingsScreen.tsx
index fb4daf300b..aa38247fd3 100644
--- a/apps/mobile/src/screens/settings/ExperimentsSettingsScreen.tsx
+++ b/apps/mobile/src/screens/settings/ExperimentsSettingsScreen.tsx
@@ -3,14 +3,14 @@ import { useProfiles } from "@/app-shell";
import { useUpdateExperiments } from "@/data/settings";
import { useSystemConfig } from "@/data/system";
import { EmptyStatePanel } from "@/ui";
-import { Screen } from "../shell/Screen";
+import { GroupedScreen } from "./GroupedScreen";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRows";
interface ExperimentRow {
key: ExperimentKey;
label: string;
description: string;
- badge?: string;
+ tag?: string;
}
/** Same copy as the web Experiments section (SettingsView.tsx). */
@@ -40,9 +40,9 @@ export function ExperimentsSettingsScreen() {
const { connection } = useProfiles();
if (!connection) {
return (
-
+
Add a server first.
-
+
);
}
return ;
@@ -54,17 +54,17 @@ function ConnectedExperimentsSettingsScreen() {
const experiments = configQuery.data?.experiments ?? defaultExperiments;
const disabled = configQuery.data === undefined;
return (
-
+
{EXPERIMENT_ROWS.map((row) => (
@@ -74,6 +74,6 @@ function ConnectedExperimentsSettingsScreen() {
/>
))}
-
+
);
}
diff --git a/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx b/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx
index cabee93736..2dc5bf4c4a 100644
--- a/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx
+++ b/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx
@@ -4,22 +4,23 @@ import { useComposePreferences } from "@/data/compose";
import { useLocalPreferences, useUpdateGeneralSettings } from "@/data/settings";
import { useSystemConfig } from "@/data/system";
import { EmptyStatePanel } from "@/ui";
-import { Screen } from "../shell/Screen";
+import { GroupedScreen } from "./GroupedScreen";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRows";
/**
* `/settings/general`: the server-persisted General toggles
* (`PUT /settings/general`; `showKeyboardHints` has no meaning on a phone
* and is left out) plus the two device-local ones the web keeps in
- * localStorage (navigate after create, rewrite localhost links).
+ * localStorage (navigate after create, rewrite localhost links). Each
+ * toggle's explanation is its group's footer, like iOS Settings.
*/
export function GeneralSettingsScreen() {
const { connection } = useProfiles();
if (!connection) {
return (
-
+
Add a server first.
-
+
);
}
return ;
@@ -34,11 +35,13 @@ function ConnectedGeneralSettingsScreen() {
const serverDisabled = configQuery.data === undefined;
return (
-
-
+
+
composeStore.setNavigateAfterCreate(value)
@@ -47,7 +50,6 @@ function ConnectedGeneralSettingsScreen() {
/>
@@ -60,10 +62,12 @@ function ConnectedGeneralSettingsScreen() {
/>
-
+
localStore.setRewriteLocalhostLinks(value)
@@ -72,10 +76,12 @@ function ConnectedGeneralSettingsScreen() {
/>
-
+
@@ -88,10 +94,12 @@ function ConnectedGeneralSettingsScreen() {
/>
-
+
@@ -103,6 +111,6 @@ function ConnectedGeneralSettingsScreen() {
testID="general-unhandled-provider-events"
/>
-
+
);
}
diff --git a/apps/mobile/src/screens/settings/GroupedScreen.tsx b/apps/mobile/src/screens/settings/GroupedScreen.tsx
new file mode 100644
index 0000000000..91a6a6b4ec
--- /dev/null
+++ b/apps/mobile/src/screens/settings/GroupedScreen.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from "react";
+import type { StyleProp, ViewStyle } from "react-native";
+import { Screen } from "../shell/Screen";
+
+interface GroupedScreenProps {
+ children: ReactNode;
+ /** Wrap content in a ScrollView (default). Lists supply their own. */
+ scroll?: boolean;
+ /** Extra scroll content container styles (merged after the grouped ones). */
+ contentStyle?: StyleProp;
+ testID?: string;
+}
+
+/**
+ * `Screen` on the iOS grouped page color: every settings / management
+ * screen is a stack of `GroupedSection` cards on `surface-grouped`. The
+ * page color is painted by `Screen` itself (`surface="grouped"`) so the
+ * header inset and overscroll regions match; the content container grows to
+ * the viewport so short screens lay out against the whole page. The scroll
+ * view itself still belongs to `Screen`.
+ */
+export function GroupedScreen({
+ children,
+ scroll,
+ contentStyle,
+ testID,
+}: GroupedScreenProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/mobile/src/screens/settings/HapticsSettingsRow.tsx b/apps/mobile/src/screens/settings/HapticsSettingsRow.tsx
index 568fbadbca..8d8f246c85 100644
--- a/apps/mobile/src/screens/settings/HapticsSettingsRow.tsx
+++ b/apps/mobile/src/screens/settings/HapticsSettingsRow.tsx
@@ -1,4 +1,5 @@
import { useHapticsEnabled } from "@/lib/haptics";
+import { useBadgeColors } from "./settings-badges";
import { SettingsSwitchRow } from "./SettingsRows";
/**
@@ -9,11 +10,15 @@ import { SettingsSwitchRow } from "./SettingsRows";
*/
export function HapticsSettingsRow() {
const [enabled, setEnabled] = useHapticsEnabled();
+ const colors = useBadgeColors();
return (
{
+ /** Destination; the row pushes it on tap. */
+ href: Href;
+}
+
+/**
+ * A navigating grouped row: a plain `GroupedRow` whose tap pushes `href`,
+ * with `onLongPress` (the screen's own `ActionSheet`) on both platforms.
+ * Never a `Link.Preview` / `Link.Menu`: the native context menu removes the
+ * wrapped row from the iOS accessibility tree, so VoiceOver and Maestro see
+ * only the host. Keeps `leading` / `badge` in its own props so
+ * `GroupedSection` insets the separator to the text column.
+ */
+export function LinkRow({ href, trailing = "chevron", ...row }: LinkRowProps) {
+ const router = useRouter();
+ return (
+ router.push(href)}
+ />
+ );
+}
diff --git a/apps/mobile/src/screens/settings/MenuValueRow.tsx b/apps/mobile/src/screens/settings/MenuValueRow.tsx
new file mode 100644
index 0000000000..6eefe0d356
--- /dev/null
+++ b/apps/mobile/src/screens/settings/MenuValueRow.tsx
@@ -0,0 +1,101 @@
+import { useTheme } from "@/theme";
+import {
+ GroupedRow,
+ Icon,
+ useSheet,
+ type GroupedRowProps,
+ type IconName,
+} from "@/ui";
+import { OptionSheet, type PickerOption } from "../pickers/OptionSheet";
+
+export interface MenuValueOption {
+ value: T;
+ label: string;
+ icon?: IconName;
+ disabled?: boolean;
+}
+
+export interface MenuValueRowProps {
+ title: string;
+ subtitle?: string;
+ /** The current choice's label (the row value). */
+ value: string;
+ valueTone?: GroupedRowProps["valueTone"];
+ leading?: GroupedRowProps["leading"];
+ options: readonly MenuValueOption[];
+ selected: T | null;
+ onSelect: (value: T) => void;
+ disabled?: boolean;
+ testID?: string;
+ accessibilityLabel?: string;
+}
+
+/** The pop-up glyph of a choice-backed value row (`chevron.up.chevron.down`). */
+function MenuChevron() {
+ const { tokens } = useTheme();
+ return (
+
+ );
+}
+
+/**
+ * A value row in the iOS pop-up button cell look (value + pop-up glyph)
+ * that opens a single-choice `OptionSheet` on both platforms. Not a native
+ * `MenuView`: that removes the row from the iOS accessibility tree, so
+ * VoiceOver and Maestro could not reach it. The option rows carry
+ * `-option-` ids.
+ */
+export function MenuValueRow({
+ title,
+ subtitle,
+ value,
+ valueTone,
+ leading,
+ options,
+ selected,
+ onSelect,
+ disabled = false,
+ testID,
+ accessibilityLabel,
+}: MenuValueRowProps) {
+ const sheet = useSheet();
+ const pickerOptions: PickerOption[] = options.map((option) => ({
+ value: option.value,
+ label: option.label,
+ icon: option.icon,
+ disabled: option.disabled,
+ }));
+ return (
+ <>
+ }
+ disabled={disabled}
+ onPress={sheet.present}
+ testID={testID}
+ accessibilityLabel={accessibilityLabel ?? `${title}: ${value}`}
+ />
+ {
+ if (next === selected) return;
+ onSelect(next);
+ }}
+ testIDPrefix={testID ? `${testID}-option` : undefined}
+ />
+ >
+ );
+}
diff --git a/apps/mobile/src/screens/settings/SegmentedChoice.ios.tsx b/apps/mobile/src/screens/settings/SegmentedChoice.ios.tsx
new file mode 100644
index 0000000000..5566fa91ba
--- /dev/null
+++ b/apps/mobile/src/screens/settings/SegmentedChoice.ios.tsx
@@ -0,0 +1,40 @@
+import SegmentedControl from "@expo/ui/community/segmented-control";
+import { haptic } from "@/lib/haptics";
+import type { SegmentedChoiceProps } from "./segmented-choice-types";
+
+/**
+ * iOS: the native `UISegmentedControl` (through `@expo/ui`), stretched to
+ * its container. Metro picks this file on iOS; `SegmentedChoice.tsx` is the
+ * Android / default sibling with the same surface.
+ */
+export function SegmentedChoice({
+ options,
+ value,
+ onChange,
+ disabled = false,
+ testID,
+}: SegmentedChoiceProps) {
+ const selectedIndex = Math.max(
+ 0,
+ options.findIndex((option) => option.value === value),
+ );
+ return (
+ option.label)}
+ selectedIndex={selectedIndex}
+ enabled={!disabled}
+ onChange={(event) => {
+ const option = options[event.nativeEvent.selectedSegmentIndex];
+ if (option === undefined || option.value === value) return;
+ haptic("selection");
+ onChange(option.value);
+ }}
+ testID={testID}
+ />
+ );
+}
+
+export type {
+ SegmentedChoiceOption,
+ SegmentedChoiceProps,
+} from "./segmented-choice-types";
diff --git a/apps/mobile/src/screens/settings/SegmentedChoice.tsx b/apps/mobile/src/screens/settings/SegmentedChoice.tsx
new file mode 100644
index 0000000000..a4acfe834d
--- /dev/null
+++ b/apps/mobile/src/screens/settings/SegmentedChoice.tsx
@@ -0,0 +1,39 @@
+import { View } from "react-native";
+import { Button } from "@/ui";
+import type { SegmentedChoiceProps } from "./segmented-choice-types";
+
+/**
+ * A small single-choice control. Android / default: a row of compact
+ * buttons (`SegmentedChoice.ios.tsx` renders the native segmented control).
+ */
+export function SegmentedChoice({
+ options,
+ value,
+ onChange,
+ disabled = false,
+ testID,
+ testIDPrefix,
+}: SegmentedChoiceProps) {
+ const prefix = testIDPrefix ?? testID;
+ return (
+
+ {options.map((option) => (
+ onChange(option.value)}
+ testID={prefix ? `${prefix}-${option.value}` : undefined}
+ >
+ {option.label}
+
+ ))}
+
+ );
+}
+
+export type {
+ SegmentedChoiceOption,
+ SegmentedChoiceProps,
+} from "./segmented-choice-types";
diff --git a/apps/mobile/src/screens/settings/ServerStatusScreen.tsx b/apps/mobile/src/screens/settings/ServerStatusScreen.tsx
index bd5036b35e..32ee87d77d 100644
--- a/apps/mobile/src/screens/settings/ServerStatusScreen.tsx
+++ b/apps/mobile/src/screens/settings/ServerStatusScreen.tsx
@@ -1,18 +1,18 @@
import { useProfiles } from "@/app-shell";
import { EmptyStatePanel } from "@/ui";
import { ServerInfoCard } from "../home/ServerInfoCard";
-import { Screen } from "../shell/Screen";
+import { GroupedScreen } from "./GroupedScreen";
/** `/settings/server`: the active server's status card (URL, realtime, host, version). */
export function ServerStatusScreen() {
const { connection } = useProfiles();
return (
-
+
{connection ? (
) : (
No active server.
)}
-
+
);
}
diff --git a/apps/mobile/src/screens/settings/ServersScreen.tsx b/apps/mobile/src/screens/settings/ServersScreen.tsx
index 1717a273e9..0e309d73a7 100644
--- a/apps/mobile/src/screens/settings/ServersScreen.tsx
+++ b/apps/mobile/src/screens/settings/ServersScreen.tsx
@@ -1,38 +1,40 @@
import { Stack, useRouter } from "expo-router";
import { useState } from "react";
-import { Pressable, View } from "react-native";
import { useProfiles } from "@/app-shell";
import { describeError } from "@/lib/describe-error";
+import { haptic } from "@/lib/haptics";
import type { ServerProfile } from "@/lib/profiles";
-import { useTheme } from "@/theme";
import {
ActionSheet,
- Button,
- EmptyStatePanel,
- Icon,
- ListRow,
- Pill,
- Text,
+ confirmDestructive,
+ GroupedRow,
toast,
useSheet,
+ type ActionSheetAction,
} from "@/ui";
import { connectEnrollHref } from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
+import { GroupedScreen } from "./GroupedScreen";
+import {
+ HeaderIconButton,
+ ICON_ROW_SEPARATOR_INSET,
+ SettingsSection,
+} from "./SettingsRows";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
-/** Saved servers: tap to switch, long-press for actions, "+" to add. */
+/**
+ * Saved servers: tap to switch, long-press for the row's action sheet
+ * (sign-in again, remove) on both platforms — a native context menu would
+ * hide the row from VoiceOver — and "+" in the header to add one.
+ */
export function ServersScreen() {
const router = useRouter();
- const { tokens } = useTheme();
const { profiles, activeProfile, setActiveProfile, removeProfile } =
useProfiles();
const menu = useSheet();
- const confirmRemove = useSheet();
const [target, setTarget] = useState(null);
- const openMenu = (profile: ServerProfile) => {
- setTarget(profile);
- menu.present();
- };
+ const addServer = () => router.push("/settings/servers/add");
const activate = (profile: ServerProfile) => {
if (profile.id === activeProfile?.id) return;
@@ -53,38 +55,99 @@ export function ServersScreen() {
});
};
+ const confirmRemove = (profile: ServerProfile) =>
+ confirmDestructive({
+ title: `Remove ${profile.label}?`,
+ message:
+ profile.mode === "connect"
+ ? "The app forgets this server and its device credential. The phone stays listed under Machines in the getbb.app dashboard until you revoke it there."
+ : "The app forgets this server. Nothing on the server changes.",
+ actionLabel: "Remove",
+ onConfirm: () => remove(profile),
+ });
+
+ const actionsFor = (profile: ServerProfile): ActionSheetAction[] => [
+ {
+ key: "activate",
+ label: "Use this server",
+ icon: "Check",
+ disabled: profile.id === activeProfile?.id,
+ onPress: () => activate(profile),
+ },
+ ...(profile.mode === "connect"
+ ? [
+ {
+ key: "reauth",
+ label: "Sign in again",
+ icon: "Lock" as const,
+ onPress: () => {
+ router.push(connectEnrollHref({ profileId: profile.id }));
+ },
+ },
+ ]
+ : []),
+ {
+ key: "remove",
+ label: "Remove",
+ icon: "Trash2",
+ destructive: true,
+ onPress: () => confirmRemove(profile),
+ },
+ ];
+
+ const openMenu = (profile: ServerProfile) => {
+ haptic("impact-heavy");
+ setTarget(profile);
+ menu.present();
+ };
+
return (
<>
- (
- router.push("/settings/servers/add")}
- testID="servers-add"
- >
-
-
- ),
- }}
- />
-
+ {IS_IOS ? (
+
+
+
+ ) : (
+ (
+
+ ),
+ }}
+ />
+ )}
+
{profiles.length === 0 ? (
-
- No servers saved yet.
- router.push("/settings/servers/add")}
- >
- Add server
-
-
+
+
+
) : (
-
+
{profiles.map((profile) => (
-
-
- {profile.mode === "connect" ? "bb connect" : "direct"}
-
- {profile.id === activeProfile?.id ? (
-
- ) : (
-
- )}
-
+ profile.id === activeProfile?.id ? "checkmark" : undefined
}
onPress={() => activate(profile)}
onLongPress={() => openMenu(profile)}
testID={`server-row-${profile.id}`}
/>
))}
-
+
)}
-
- Tap a server to make it active. Long-press for more.
-
-
+
{
- if (target) activate(target);
- },
- },
- ...(target?.mode === "connect"
- ? [
- {
- key: "reauth",
- label: "Sign in again",
- icon: "Lock" as const,
- onPress: () => {
- if (target) {
- router.push(connectEnrollHref({ profileId: target.id }));
- }
- },
- },
- ]
- : []),
- {
- key: "remove",
- label: "Remove",
- icon: "Trash2",
- destructive: true,
- onPress: () => {
- // Present after the menu has started dismissing so the two
- // sheets do not fight over the modal host.
- setTimeout(() => confirmRemove.present(), 250);
- },
- },
- ]}
- />
- {
- if (target) remove(target);
- },
- },
- ]}
+ actions={target ? actionsFor(target) : []}
/>
>
);
diff --git a/apps/mobile/src/screens/settings/SettingsRows.tsx b/apps/mobile/src/screens/settings/SettingsRows.tsx
index fb82fd9b64..48bcb5bfa3 100644
--- a/apps/mobile/src/screens/settings/SettingsRows.tsx
+++ b/apps/mobile/src/screens/settings/SettingsRows.tsx
@@ -1,23 +1,40 @@
import type { ReactNode } from "react";
import { Pressable, View } from "react-native";
import { useTheme } from "@/theme";
-import { cn, Icon, Spinner, Switch, Text, type IconName } from "@/ui";
+import {
+ GroupedRow,
+ GroupedSection,
+ Icon,
+ Spinner,
+ Switch,
+ Text,
+ type GroupedRowProps,
+ type GroupedSectionProps,
+ type IconName,
+} from "@/ui";
-/**
- * The settings screens' building blocks: a titled card of rows
- * (`SettingsSection`), a label + description + control row
- * (`SettingsControlRow`), and the switch row on top of it. Mirrors the web
- * `SettingsSection` / `SettingsWithControl` at touch size.
+const IS_IOS = process.env.EXPO_OS === "ios";
+
+/*
+ * The settings screens' building blocks, thin wrappers over the iOS
+ * inset-grouped primitives (`GroupedSection` / `GroupedRow` / `IconBadge`
+ * in @/ui): a titled card of rows (`SettingsSection`), a label + control
+ * row (`SettingsControlRow`), the switch row and the value row on top of
+ * it. Wrapper rows keep the `leading` / `badge` prop names so the section
+ * can inset its separators to the text column.
*/
export interface SettingsSectionProps {
title?: string;
+ /** Copy between the header and the card; prefer `footnote` (iOS footer). */
description?: string;
/** Right-hand slot next to the title (a refresh button, a picker). */
action?: ReactNode;
children: ReactNode;
- /** Quiet line below the card. */
- footnote?: string;
+ /** Footnote under the card: help copy, or a toned node for warnings. */
+ footnote?: string | ReactNode;
+ separatorInset?: GroupedSectionProps["separatorInset"];
+ className?: string;
testID?: string;
}
@@ -27,50 +44,41 @@ export function SettingsSection({
action,
children,
footnote,
+ separatorInset,
+ className,
testID,
}: SettingsSectionProps) {
return (
-
- {title || action ? (
-
- {title ? {title} : }
- {action}
-
- ) : null}
- {description ? (
-
- {description}
-
- ) : null}
-
- {children}
-
- {footnote ? (
-
- {footnote}
-
- ) : null}
-
+
+ {children}
+
);
}
-interface SettingsControlRowProps {
+export interface SettingsControlRowProps {
label: string;
description?: string;
- /** Small pill after the label ("dev-only", "Installed"). */
- badge?: string;
+ /** Short state after the label, drawn as the row value ("Installed", "dev-only"). */
+ tag?: string;
/** The control on the right (switch, button, picker trigger). */
control?: ReactNode;
- /** Leading glyph. */
- icon?: IconName;
+ /** Leading glyph (an icon name) or node. */
+ leading?: GroupedRowProps["leading"];
+ /** Tinted square badge (Settings home); wins over `leading`. */
+ badge?: GroupedRowProps["badge"];
/** Make the whole row pressable (opens the control's picker). */
onPress?: () => void;
disabled?: boolean;
- /**
- * Let the control take the remaining width (a long value that truncates)
- * instead of the label column.
- */
- controlGrows?: boolean;
+ /** Lines before the label truncates (default 2). */
+ titleLines?: number;
testID?: string;
accessibilityLabel?: string;
}
@@ -78,78 +86,39 @@ interface SettingsControlRowProps {
export function SettingsControlRow({
label,
description,
- badge,
+ tag,
control,
- icon,
+ leading,
+ badge,
onPress,
disabled = false,
- controlGrows = false,
+ titleLines = 2,
testID,
accessibilityLabel,
}: SettingsControlRowProps) {
- const { tokens } = useTheme();
- const body = (
- <>
- {icon ? : null}
-
-
-
- {label}
-
- {badge ? (
-
-
- {badge}
-
-
- ) : null}
-
- {description ? {description} : null}
-
- {control ? (
-
- {control}
-
- ) : null}
- >
- );
- const className = cn(
- "min-h-[44px] flex-row items-center gap-3 px-4 py-2.5",
- disabled && "opacity-50",
- );
- if (onPress) {
- return (
-
- {body}
-
- );
- }
return (
-
- {body}
-
+
);
}
-interface SettingsSwitchRowProps {
+export interface SettingsSwitchRowProps {
label: string;
description?: string;
- badge?: string;
- icon?: IconName;
+ tag?: string;
+ leading?: GroupedRowProps["leading"];
+ badge?: GroupedRowProps["badge"];
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
@@ -161,8 +130,9 @@ interface SettingsSwitchRowProps {
export function SettingsSwitchRow({
label,
description,
+ tag,
+ leading,
badge,
- icon,
checked,
onCheckedChange,
disabled = false,
@@ -174,8 +144,9 @@ export function SettingsSwitchRow({
{pending ? (
@@ -194,70 +165,52 @@ export function SettingsSwitchRow({
);
}
-interface SettingsValueRowProps {
+export interface SettingsValueRowProps {
label: string;
value: string;
description?: string;
- icon?: IconName;
+ leading?: GroupedRowProps["leading"];
+ badge?: GroupedRowProps["badge"];
onPress?: () => void;
disabled?: boolean;
/** Paint the value in the warning tone (offline, fallback). */
tone?: "default" | "warning" | "destructive";
+ /** Lets the user select/copy the value (data rows). */
+ selectable?: boolean;
testID?: string;
}
-/** Label on the left, the current value + a chevron on the right (opens a picker). */
+/** Label on the left, the current value (+ a chevron when pressable) on the right. */
export function SettingsValueRow({
label,
value,
description,
- icon,
+ leading,
+ badge,
onPress,
disabled,
tone = "default",
+ selectable = false,
testID,
}: SettingsValueRowProps) {
- const { tokens } = useTheme();
- const color =
- tone === "warning"
- ? tokens.warningText
- : tone === "destructive"
- ? tokens.destructiveText
- : tokens.mutedForeground;
return (
-
-
- {value}
-
- {onPress ? (
-
- ) : null}
-
- }
/>
);
}
-/** Inline hint card for a host-dependent screen that cannot work right now. */
+/** Inline note inside a card for a host-dependent screen that cannot work right now. */
export function SettingsHint({
title,
message,
@@ -268,12 +221,62 @@ export function SettingsHint({
testID?: string;
}) {
return (
-
- {title}
+
+ {title}
{message}
);
}
+
+/** Separator inset for rows whose custom `leading` node is a 20px glyph column. */
+export const ICON_ROW_SEPARATOR_INSET = 16 + 20 + 12;
+/** Separator inset for sections of badge rows that include a wrapper row (no `badge` prop to read). */
+export const BADGE_ROW_SEPARATOR_INSET = 16 + 29 + 12;
+
+export interface HeaderIconButtonProps {
+ icon: IconName;
+ accessibilityLabel: string;
+ onPress: () => void;
+ disabled?: boolean;
+ /** Replaces the glyph with a spinner. */
+ loading?: boolean;
+ testID?: string;
+}
+
+/**
+ * The Android header action: a bare glyph `Pressable` in `headerRight`.
+ * iOS screens render `Stack.Toolbar` items instead (native bar buttons
+ * cannot carry a `testID`, so the Maestro flows tap them by label).
+ */
+export function HeaderIconButton({
+ icon,
+ accessibilityLabel,
+ onPress,
+ disabled = false,
+ loading = false,
+ testID,
+}: HeaderIconButtonProps) {
+ const { tokens } = useTheme();
+ return (
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/screens/settings/SettingsScreen.tsx b/apps/mobile/src/screens/settings/SettingsScreen.tsx
index 80fdec1ca5..9f86e69d7f 100644
--- a/apps/mobile/src/screens/settings/SettingsScreen.tsx
+++ b/apps/mobile/src/screens/settings/SettingsScreen.tsx
@@ -3,7 +3,7 @@ import { useRouter } from "expo-router";
import { Linking } from "react-native";
import { e2eModeEnabled, resetLocalState, useProfiles } from "@/app-shell";
import { useTheme } from "@/theme";
-import { ActionSheet, Icon, ListRow, toast, useSheet } from "@/ui";
+import { confirmDestructive, GroupedRow, Icon, toast } from "@/ui";
import {
archivedThreadsHref,
machinesHref,
@@ -13,9 +13,10 @@ import {
settingsSectionHref,
skillsHref,
} from "../shell/hrefs";
-import { Screen } from "../shell/Screen";
+import { GroupedScreen } from "./GroupedScreen";
import { HapticsSettingsRow } from "./HapticsSettingsRow";
-import { SettingsSection } from "./SettingsRows";
+import { useBadgeColors } from "./settings-badges";
+import { BADGE_ROW_SEPARATOR_INSET, SettingsSection } from "./SettingsRows";
const DISCORD_INVITE_URL = "https://discord.gg/kvBU6tJhcJ";
const GITHUB_REPO_URL = "https://github.com/get-bb/bb";
@@ -27,40 +28,76 @@ function openExternal(url: string): void {
}
/**
- * Settings home: the web settings buckets (settings-nav.tsx) minus the
- * desktop-only ones (Keyboard, Files), each a row into its own screen.
- * Server / Notifications / Developer / About are mobile-specific.
+ * Settings home, laid out like the iOS Settings app: inset-grouped rows
+ * with tinted icon badges, the current value on the right and a chevron
+ * into each bucket (the web settings-nav.tsx buckets minus the
+ * desktop-only ones). Server / Machines / Developer / About are
+ * mobile-specific.
*/
export function SettingsScreen() {
const router = useRouter();
const theme = useTheme();
+ const colors = useBadgeColors();
const { profiles, activeProfile } = useProfiles();
- const resetSheet = useSheet();
const appVersion = Constants.expoConfig?.version ?? "dev";
const connected = activeProfile !== null;
+ const modeLabel =
+ theme.preference === "system"
+ ? "System"
+ : theme.preference === "dark"
+ ? "Dark"
+ : "Light";
const externalLinkGlyph = (
-
+
);
+ const resetLocal = () =>
+ confirmDestructive({
+ title: "Reset local state?",
+ message:
+ "Saved servers and preferences are removed. The app returns to first run.",
+ actionLabel: "Reset",
+ onConfirm: () => {
+ resetLocalState()
+ .then(() => {
+ toast.success("Local state reset");
+ router.dismissTo("/");
+ })
+ .catch((error: unknown) => {
+ toast.error("Reset failed", { description: String(error) });
+ });
+ },
+ });
+
return (
-
+
- 1
+ ? `${activeProfile.label} · ${profiles.length}`
+ : activeProfile.label
+ : "None"
}
- leading="Laptop"
+ badge={{ icon: "Cloud", symbol: "server.rack", color: colors.blue }}
trailing="chevron"
onPress={() => router.push("/settings/servers")}
testID="settings-servers"
/>
- router.push(serverStatusHref())}
@@ -68,28 +105,38 @@ export function SettingsScreen() {
/>
-
-
+ router.push(settingsSectionHref("general"))}
testID="settings-general"
/>
- router.push(settingsSectionHref("appearance"))}
testID="settings-appearance"
/>
- router.push(settingsSectionHref("experiments"))}
@@ -98,41 +145,46 @@ export function SettingsScreen() {
-
-
+ router.push(pluginsHref())}
testID="settings-provider-plugins"
/>
- router.push(settingsSectionHref("usage"))}
testID="settings-usage"
/>
-
-
-
- router.push(machinesHref())}
testID="settings-machines"
/>
- router.push(settingsSectionHref("updates"))}
@@ -141,28 +193,37 @@ export function SettingsScreen() {
- router.push(pluginsHref())}
testID="settings-plugins"
/>
- router.push(skillsHref())}
testID="settings-skills"
/>
- router.push(marketplacesHref())}
@@ -171,10 +232,13 @@ export function SettingsScreen() {
- router.push(archivedThreadsHref())}
@@ -183,18 +247,18 @@ export function SettingsScreen() {
- openExternal(DISCORD_INVITE_URL)}
testID="settings-discord"
/>
- openExternal(GITHUB_REPO_URL)}
testID="settings-github"
@@ -202,113 +266,108 @@ export function SettingsScreen() {
{e2eModeEnabled ? (
-
-
+ router.push("/dev/ui")}
testID="settings-dev-ui"
/>
- router.push("/dev/diff")}
testID="settings-dev-diff"
/>
- router.push("/dev/work-rows")}
testID="settings-dev-work-rows"
/>
- router.push("/dev/interactions")}
testID="settings-dev-interactions"
/>
- router.push("/dev/composer")}
testID="settings-dev-composer"
/>
- router.push("/dev/markdown")}
testID="settings-dev-markdown"
/>
- router.push("/dev/spike")}
testID="settings-dev-spike"
/>
- router.push("/dev/connect-spike")}
testID="settings-dev-connect-spike"
/>
-
) : null}
-
-
- {
- resetLocalState()
- .then(() => {
- toast.success("Local state reset");
- router.dismissTo("/");
- })
- .catch((error: unknown) => {
- toast.error("Reset failed", { description: String(error) });
- });
- },
- },
- ]}
- />
-
+
);
}
diff --git a/apps/mobile/src/screens/settings/UpdatesScreen.tsx b/apps/mobile/src/screens/settings/UpdatesScreen.tsx
index 1f34f9cef3..1ccdc05330 100644
--- a/apps/mobile/src/screens/settings/UpdatesScreen.tsx
+++ b/apps/mobile/src/screens/settings/UpdatesScreen.tsx
@@ -1,8 +1,8 @@
import type { Host } from "@bb/domain";
-import type { SystemVersionResponse } from "@bb/server-contract";
import * as Clipboard from "expo-clipboard";
+import { Stack } from "expo-router";
import { useMemo, useState } from "react";
-import { Linking, Pressable, View } from "react-native";
+import { Linking, View } from "react-native";
import { useProfiles } from "@/app-shell";
import {
formatRelativeAge,
@@ -29,16 +29,13 @@ import {
useUpdateInventory,
type UpdateInventoryMachine,
} from "@/data/updates";
-import { useTheme } from "@/theme";
import {
Button,
EmptyStatePanel,
- Icon,
+ GroupedRow,
ListRow,
- Pill,
Separator,
Sheet,
- Spinner,
Text,
toast,
useSheet,
@@ -48,84 +45,73 @@ import {
ProviderCliRows,
} from "../machines/ProviderCliRows";
import { HostStatusDot } from "../pickers";
-import { Screen } from "../shell/Screen";
import { useNow } from "../shell/use-now";
-import { SettingsControlRow, SettingsSection } from "./SettingsRows";
+import { GroupedScreen } from "./GroupedScreen";
+import {
+ HeaderIconButton,
+ ICON_ROW_SEPARATOR_INSET,
+ SettingsControlRow,
+ SettingsSection,
+} from "./SettingsRows";
+const IS_IOS = process.env.EXPO_OS === "ios";
const CHANGELOG_URL = "https://github.com/get-bb/bb/blob/main/CHANGELOG.md";
+function openChangelog(): void {
+ Linking.openURL(CHANGELOG_URL).catch(() => {
+ toast.error("Could not open the changelog");
+ });
+}
+
/**
* `/settings/updates` (web UpdatesSettingsSection + CliSkillsSettingsSection):
* the bb-app version against the registry, every machine's provider CLIs
* with Install / Update, stranded daemons with Retry, and the bb CLI skills
- * install per machine.
+ * install per machine. Check / What's new live in the header on iOS.
*/
export function UpdatesScreen() {
const { connection } = useProfiles();
if (!connection) {
return (
-
+
Add a server first.
-
+
);
}
return ;
}
-function BbAppRow({
- systemVersion,
-}: {
- systemVersion: SystemVersionResponse | undefined;
-}) {
- const state = bbAppRowState(systemVersion);
- const copyUpgradeCommand = (command: string) => {
- void Clipboard.setStringAsync(command)
- .then(() => toast.success("Upgrade command copied"))
- .catch(() => toast.error("Couldn't copy upgrade command"));
- };
+function copyUpgradeCommand(command: string): void {
+ void Clipboard.setStringAsync(command)
+ .then(() => toast.success("Upgrade command copied"))
+ .catch(() => toast.error("Couldn't copy upgrade command"));
+}
+
+/** The bb-app version row: current (→ latest) under the name, the state as the value. */
+function BbAppRow({ state }: { state: ReturnType }) {
+ const version =
+ state.kind === "checking"
+ ? undefined
+ : state.kind === "available" && state.latest !== null
+ ? `${state.current} → ${state.latest}`
+ : state.current;
+ const status =
+ state.kind === "checking"
+ ? "Checking…"
+ : state.kind === "development"
+ ? "Development mode"
+ : state.kind === "available"
+ ? "Update available"
+ : "Up to date";
return (
-
-
-
- bb-app
- {state.kind !== "checking" ? (
-
- {state.current}
- {state.kind === "available" && state.latest !== null
- ? ` → ${state.latest}`
- : ""}
-
- ) : null}
-
- {state.kind === "checking" ? (
- Checking…
- ) : state.kind === "development" ? (
- Development mode
- ) : state.kind === "available" ? (
-
- Available
-
- ) : (
- Up to date
- )}
-
- {state.kind === "available" ? (
-
-
- {state.upgradeCommand}
-
- copyUpgradeCommand(state.upgradeCommand)}
- testID="updates-copy-upgrade"
- >
- Copy
-
-
- ) : null}
-
+
);
}
@@ -149,36 +135,36 @@ function MachineUpdatesBlock({
const daemonStatus = formatHostUpdateStatus(host, serverProtocolVersion);
return (
-
-
-
- {host.name}
-
- {showPrimaryBadge ? (
-
- Primary
-
- ) : null}
-
- {stranded ? (
-
- Retry update
-
- ) : null}
-
+
+
+
+ }
+ trailing={
+ stranded ? (
+
+ Retry update
+
+ ) : undefined
+ }
+ />
+
{stranded ? (
-
+
Can't connect — its bb agent is out of date
- Usually it updates itself.
- {daemonStatus ? {daemonStatus} : null}
+ Usually it updates itself.
+ {daemonStatus ? {daemonStatus} : null}
) : (
-
+
}
- trailing={
- checked ? (
-
- ) : null
- }
selected={checked}
disabled={!connected}
onPress={() => {
@@ -328,7 +310,6 @@ function CliSkillsSection({ hosts }: { hosts: readonly Host[] }) {
}
function ConnectedUpdatesScreen() {
- const { tokens } = useTheme();
const inventory = useUpdateInventory();
const hostsQuery = useHosts();
const hosts = useMemo(() => hostsQuery.data ?? [], [hostsQuery.data]);
@@ -364,57 +345,88 @@ function ConnectedUpdatesScreen() {
const connectedHostIds = inventory.machines
.filter((machine) => machine.host.status === "connected")
.map((machine) => machine.host.id);
+ const checkForUpdates = () => check.mutate(connectedHostIds);
+ const bbAppState = bbAppRowState(inventory.systemVersion);
return (
<>
-
+ {IS_IOS ? (
+
+
+
+
+ What's new
+
+
+ Check for updates
+
+
+
+ ) : null}
+
+ IS_IOS ? undefined : (
+
+
+
+ What's new
+
+
+ )
+ }
+ footnote={
+
+
+ Connected machines follow the server version automatically.
+
{check.isPending || checkedLabel !== null ? (
-
+
{check.isPending ? "Checking…" : checkedLabel}
) : null}
- check.mutate(connectedHostIds)}
- testID="updates-check"
- >
- {check.isPending ? (
-
- ) : (
-
- )}
-
- {
- Linking.openURL(CHANGELOG_URL).catch(() => {
- toast.error("Could not open the changelog");
- });
- }}
- testID="updates-whats-new"
- >
-
- What's new
-
-
}
>
-
+
+ {bbAppState.kind === "available" ? (
+ copyUpgradeCommand(bbAppState.upgradeCommand)}
+ testID="updates-copy-upgrade"
+ />
+ ) : null}
{inventory.machines.length === 0 ? (
-
-
+
+
{inventory.isLoading ? "Loading…" : "No machines yet."}
) : (
- inventory.machines.map((machine, index) => (
-
- {index > 0 ? : null}
- 1 && machine.isPrimary
- }
- serverProtocolVersion={inventory.serverProtocolVersion}
- runner={runner}
- retryPending={
- retryUpdate.isPending &&
- retryUpdate.variables === machine.host.id
- }
- onRetry={() =>
- retryUpdate.mutate(machine.host.id, {
- onSuccess: () =>
- toast.success(
- `Update retry requested for ${machine.host.name}`,
- ),
- })
- }
- />
-
+ inventory.machines.map((machine) => (
+ 1 && machine.isPrimary
+ }
+ serverProtocolVersion={inventory.serverProtocolVersion}
+ runner={runner}
+ retryPending={
+ retryUpdate.isPending &&
+ retryUpdate.variables === machine.host.id
+ }
+ onRetry={() =>
+ retryUpdate.mutate(machine.host.id, {
+ onSuccess: () =>
+ toast.success(
+ `Update retry requested for ${machine.host.name}`,
+ ),
+ })
+ }
+ />
))
)}
-
+
>
);
diff --git a/apps/mobile/src/screens/settings/UsageLimitsScreen.tsx b/apps/mobile/src/screens/settings/UsageLimitsScreen.tsx
index 79b95e8bc3..4f62ac8f23 100644
--- a/apps/mobile/src/screens/settings/UsageLimitsScreen.tsx
+++ b/apps/mobile/src/screens/settings/UsageLimitsScreen.tsx
@@ -1,11 +1,11 @@
-import type { Host } from "@bb/domain";
import type {
ProviderUsage,
ProviderUsageResponse,
ProviderUsageWindow,
} from "@bb/host-daemon-contract";
+import { Stack } from "expo-router";
import { useMemo, useState } from "react";
-import { Pressable, View } from "react-native";
+import { View } from "react-native";
import { useProfiles } from "@/app-shell";
import { selectPrimaryHost, useHosts } from "@/data/hosts";
import {
@@ -21,25 +21,17 @@ import {
} from "@/data/settings";
import { useSystemConfig, useSystemProviders } from "@/data/system";
import { useTheme } from "@/theme";
-import {
- EmptyStatePanel,
- Icon,
- ListRow,
- Pill,
- Separator,
- Sheet,
- Spinner,
- Text,
- useSheet,
-} from "@/ui";
-import {
- HostStatusDot,
- PickerTrigger,
- usePickerSheetMaxHeight,
-} from "../pickers";
-import { Screen } from "../shell/Screen";
+import { EmptyStatePanel, Separator, Spinner, Text } from "@/ui";
import { useNow } from "../shell/use-now";
-import { SettingsHint, SettingsSection } from "./SettingsRows";
+import { GroupedScreen } from "./GroupedScreen";
+import { MenuValueRow } from "./MenuValueRow";
+import {
+ HeaderIconButton,
+ SettingsHint,
+ SettingsSection,
+} from "./SettingsRows";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
/**
* `/settings/usage`: `GET /system/usage-limits?hostId=` for the primary or a
@@ -50,9 +42,9 @@ export function UsageLimitsScreen() {
const { connection } = useProfiles();
if (!connection) {
return (
-
+
Add a server first.
-
+
);
}
return ;
@@ -77,14 +69,12 @@ function UsageWindowRow({
return (
-
- {window.label}
-
-
+ {window.label}
+
{usageWindowValue(window)}
-
+
- {config.name}
+ {config.name}
{heading.accountEmail ? (
-
+
{heading.accountEmail}
) : null}
{heading.planLabel ? (
-
+
{heading.planLabel}
-
+
) : null}
{body.kind === "windows" ? (
@@ -144,7 +134,6 @@ function ProviderUsageBlock({
}
function ConnectedUsageLimitsScreen() {
- const { tokens } = useTheme();
const configQuery = useSystemConfig();
const hostsQuery = useHosts();
const hosts = useMemo(() => hostsQuery.data ?? [], [hostsQuery.data]);
@@ -161,8 +150,6 @@ function ConnectedUsageLimitsScreen() {
hostId: selectedHost?.id,
enabled: configQuery.data !== undefined && hostReady,
});
- const pickerSheet = useSheet();
- const maxHeight = usePickerSheetMaxHeight();
const now = useNow();
const providersQuery = useSystemProviders({
...(selectedHost === null ? {} : { hostId: selectedHost.id }),
@@ -175,119 +162,99 @@ function ConnectedUsageLimitsScreen() {
);
const loaded =
hostsQuery.data !== undefined && configQuery.data !== undefined;
+ const refreshDisabled = !hostReady || usageQuery.isFetching;
+ const refresh = () => void usageQuery.refetch();
return (
-
-
- {hosts.length > 1 ? (
-
- ) : null}
- void usageQuery.refetch()}
- testID="usage-refresh"
- >
- {usageQuery.isFetching ? (
-
- ) : (
-
- )}
-
-
- }
- >
- {!loaded ? (
-
-
-
- ) : selectedHost === null ? (
-
- ) : !hostReady ? (
-
+ {IS_IOS ? (
+
+
- ) : providers.length === 0 && !usageQuery.isLoading ? (
-
-
- No provider CLIs are installed on {selectedHost.name}.
-
-
- ) : (
- providers.map((config, index) => (
-
- {index > 0 ? : null}
-
+ ) : null}
+
+ {hosts.length > 1 ? (
+
+ ({
+ value: host.id,
+ label:
+ host.status === "connected"
+ ? host.name
+ : `${host.name} (offline)`,
+ icon: "Laptop" as const,
+ disabled: host.status !== "connected",
+ }))}
+ selected={selectedHost?.id ?? null}
+ onSelect={setSelectedHostId}
+ testID="usage-machine-picker"
+ accessibilityLabel="Usage limits machine"
+ />
+
+ ) : null}
+
+
+ )
+ }
+ >
+ {!loaded ? (
+
+
- ))
- )}
-
-
-
- {hosts.map((host: Host) => {
- const connected = host.status === "connected";
- const selected = host.id === selectedHost?.id;
- return (
-
-
-
- }
- trailing={
- selected ? (
-
- ) : null
- }
- selected={selected}
- disabled={!connected}
- onPress={() => {
- pickerSheet.dismiss();
- setSelectedHostId(host.id);
- }}
- testID={`usage-machine-option-${host.id}`}
+ ) : selectedHost === null ? (
+
+ ) : !hostReady ? (
+
- );
- })}
-
-
+ ) : providers.length === 0 && !usageQuery.isLoading ? (
+
+
+ No provider CLIs are installed on {selectedHost.name}.
+
+
+ ) : (
+ providers.map((config, index) => (
+
+ {index > 0 ? : null}
+
+
+ ))
+ )}
+
+
+ >
);
}
diff --git a/apps/mobile/src/screens/settings/segmented-choice-types.ts b/apps/mobile/src/screens/settings/segmented-choice-types.ts
new file mode 100644
index 0000000000..284118eaeb
--- /dev/null
+++ b/apps/mobile/src/screens/settings/segmented-choice-types.ts
@@ -0,0 +1,18 @@
+// Shared by SegmentedChoice.tsx (Android / default) and SegmentedChoice.ios.tsx;
+// a separate module because "./SegmentedChoice" resolves to the .ios sibling on iOS.
+
+export interface SegmentedChoiceOption {
+ value: T;
+ label: string;
+}
+
+export interface SegmentedChoiceProps {
+ options: readonly SegmentedChoiceOption[];
+ value: T;
+ onChange: (value: T) => void;
+ disabled?: boolean;
+ /** The control's id (iOS: the whole segmented control). */
+ testID?: string;
+ /** Android per-option ids: `${testIDPrefix}-${value}`. */
+ testIDPrefix?: string;
+}
diff --git a/apps/mobile/src/screens/settings/settings-badges.ts b/apps/mobile/src/screens/settings/settings-badges.ts
new file mode 100644
index 0000000000..6e8f0a76cc
--- /dev/null
+++ b/apps/mobile/src/screens/settings/settings-badges.ts
@@ -0,0 +1,54 @@
+import { useTheme } from "@/theme";
+
+/*
+ * Fills for the Settings `IconBadge`s. Blue / green / orange / red come
+ * from the palette tokens so a custom palette (Nord, Dracula) keeps its
+ * accent; the rest are the iOS system colors Apple's Settings uses, fixed
+ * per mode because no token carries them.
+ */
+const SYSTEM_BADGE_COLORS = {
+ light: {
+ gray: "#8e8e93",
+ purple: "#af52de",
+ pink: "#ff2d55",
+ indigo: "#5856d6",
+ teal: "#30b0c7",
+ discord: "#5865f2",
+ github: "#24292f",
+ },
+ dark: {
+ gray: "#8e8e93",
+ purple: "#bf5af2",
+ pink: "#ff375f",
+ indigo: "#5e5ce6",
+ teal: "#40c8e0",
+ discord: "#5865f2",
+ github: "#6e7681",
+ },
+} as const;
+
+export interface BadgeColors {
+ blue: string;
+ green: string;
+ orange: string;
+ red: string;
+ gray: string;
+ purple: string;
+ pink: string;
+ indigo: string;
+ teal: string;
+ discord: string;
+ github: string;
+}
+
+/** The badge fills for the current palette and mode. */
+export function useBadgeColors(): BadgeColors {
+ const { tokens, mode } = useTheme();
+ return {
+ blue: tokens.primary,
+ green: tokens.success,
+ orange: tokens.warning,
+ red: tokens.destructive,
+ ...SYSTEM_BADGE_COLORS[mode],
+ };
+}
diff --git a/apps/mobile/src/screens/shell/ConnectionBanner.tsx b/apps/mobile/src/screens/shell/ConnectionBanner.tsx
index f38c878e11..0cd2a72aad 100644
--- a/apps/mobile/src/screens/shell/ConnectionBanner.tsx
+++ b/apps/mobile/src/screens/shell/ConnectionBanner.tsx
@@ -1,9 +1,16 @@
import { useRouter } from "expo-router";
-import { Pressable, View } from "react-native";
+import { useEffect } from "react";
+import { Pressable, StyleSheet, View, type ViewStyle } from "react-native";
+import Animated, {
+ FadeInUp,
+ FadeOutUp,
+ LinearTransition,
+} from "react-native-reanimated";
import { useConnectionBanner, useProfiles } from "@/app-shell";
import type { ConnectionBannerKind } from "@/lib/connection";
-import { Icon, Text, cn, type IconName } from "@/ui";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
+import { Icon, Text, type IconName } from "@/ui";
import { connectEnrollHref } from "./hrefs";
interface BannerCopy {
@@ -35,19 +42,46 @@ const COPY: Record, BannerCopy> = {
},
};
+const ENTER_MS = 220;
+const EXIT_MS = 160;
+
+/** Inset card: continuous corners, tinted fill, no hard border. */
+const CARD_RADIUS = 12;
+
+/** Outer spacing for hosts that do not pad the card (a list header). */
+const INSET_STYLE: ViewStyle = { marginHorizontal: 16, marginTop: 8 };
+
+interface ConnectionBannerProps {
+ /**
+ * Adds the row margins (16 horizontal, 8 top) when the host does not pad
+ * the card itself — e.g. as a list's `ListHeaderComponent`.
+ */
+ inset?: boolean;
+}
+
/**
- * Persistent strip under the header while the active profile is not
- * connected (offline, server restart, connect session trouble). Renders
- * nothing when the socket is up. `auth-required` (the connect gate refused
- * this phone's credential: revoked in the dashboard, or the account's
- * device list was pruned) offers "Sign in again", which re-pairs the same
- * profile with a fresh code.
+ * Card under the header while the active profile is not connected
+ * (offline, server restart, connect session trouble). Renders nothing when
+ * the socket is up, and animates in and out of the layout. `auth-required`
+ * (the connect gate refused this phone's credential: revoked in the
+ * dashboard, or the account's device list was pruned) offers "Sign in
+ * again", which re-pairs the same profile with a fresh code — the whole
+ * card is the action — and is announced with the warning haptic.
+ *
+ * Placement: `Screen` renders it as the first scroll item (scrolling
+ * screens) or floating under the bar (list screens on iOS); list screens
+ * that want it in the flow pass it as their `ListHeaderComponent`.
*/
-export function ConnectionBanner() {
+export function ConnectionBanner({ inset = false }: ConnectionBannerProps) {
const router = useRouter();
const kind = useConnectionBanner();
const { activeProfile } = useProfiles();
const { tokens } = useTheme();
+
+ useEffect(() => {
+ if (kind === "auth-required") haptic("warning");
+ }, [kind]);
+
if (kind === "hidden" || !activeProfile) return null;
const copy = COPY[kind];
const color = copy.destructive ? tokens.destructiveText : tokens.warningText;
@@ -55,56 +89,79 @@ export function ConnectionBanner() {
kind === "auth-required" && activeProfile.mode === "connect"
? () => router.push(connectEnrollHref({ profileId: activeProfile.id }))
: null;
+ const message = copy.message(activeProfile.label);
+ const cardStyle: ViewStyle = {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ borderRadius: CARD_RADIUS,
+ borderCurve: "continuous",
+ backgroundColor: copy.destructive
+ ? tokens.surfaceDestructive
+ : tokens.surfaceAttention,
+ };
const content = (
<>
-
+
- {copy.message(activeProfile.label)}
+ {message}
{reauth ? (
-
+
Sign in again
) : null}
>
);
- const className = cn(
- "flex-row items-center gap-2 border-b px-4 py-2",
- copy.destructive
- ? "border-surface-destructive-border bg-surface-destructive"
- : "border-border bg-surface-attention",
- );
- // The whole strip is the action when there is one: a thumb-sized target
- // that does not depend on hitting the small "Sign in again" label.
- return reauth ? (
-
- {content}
-
- ) : (
-
- {content}
-
+ {reauth ? (
+ [cardStyle, { opacity: pressed ? 0.7 : 1 }]}
+ >
+ {content}
+
+ ) : (
+
+ {content}
+
+ )}
+
);
}
diff --git a/apps/mobile/src/screens/shell/HeaderGlass.tsx b/apps/mobile/src/screens/shell/HeaderGlass.tsx
new file mode 100644
index 0000000000..b1ae25bcaa
--- /dev/null
+++ b/apps/mobile/src/screens/shell/HeaderGlass.tsx
@@ -0,0 +1,38 @@
+import { useHeaderHeight } from "expo-router/react-navigation";
+import { StyleSheet, View } from "react-native";
+import { GlassSurface } from "@/ui";
+
+/**
+ * Liquid Glass backing for an inline-title bar. The native stack renders it
+ * as the header background: a view sized to the bar (status bar included)
+ * behind a transparent `UINavigationBar`, so the timeline refracts through
+ * the same glass the composer uses instead of a flat blur material.
+ *
+ * The glass is oversized and clipped so only its bottom edge is visible:
+ * the other rims would draw bright lines along the screen edges. The clip
+ * takes the bar height from the stack's context explicitly — an absolute
+ * fill inside the stack's background view lays out at zero height. Inline
+ * bars only: on large-title screens the stack debounces the bar height, so
+ * a glass sheet there would lag the title collapse.
+ */
+export function HeaderGlass() {
+ const height = useHeaderHeight();
+ return (
+
+
+
+ );
+}
+
+const RIM_BLEED = 24;
+
+const styles = StyleSheet.create({
+ clip: { width: "100%", overflow: "hidden" },
+ glass: {
+ position: "absolute",
+ top: -RIM_BLEED,
+ left: -RIM_BLEED,
+ right: -RIM_BLEED,
+ bottom: 0,
+ },
+});
diff --git a/apps/mobile/src/screens/shell/RootNavigator.tsx b/apps/mobile/src/screens/shell/RootNavigator.tsx
index 9ab1d3ede6..a0ca2ce790 100644
--- a/apps/mobile/src/screens/shell/RootNavigator.tsx
+++ b/apps/mobile/src/screens/shell/RootNavigator.tsx
@@ -1,137 +1,240 @@
-import { Stack } from "expo-router";
+import {
+ DarkTheme,
+ DefaultTheme,
+ type NativeStackNavigationOptions,
+ Stack,
+ ThemeProvider as NavigationThemeProvider,
+} from "expo-router";
+import { useMemo } from "react";
+import { Platform } from "react-native";
import { useTheme } from "@/theme";
+import { HeaderGlass } from "./HeaderGlass";
+import { LIST_SCREEN_OPTIONS, MODAL_SCREEN_OPTIONS } from "./screen-options";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
+/**
+ * iOS 26 draws its own scroll-edge effect under transparent headers. We
+ * want frosted bars instead: Liquid Glass behind inline bars, the classic
+ * frosted blur behind large-title bars, and the system effect hidden so it
+ * does not double them. Earlier iOS gets the classic chrome material bar.
+ */
+const IOS_MAJOR = IS_IOS ? Number.parseInt(String(Platform.Version), 10) : 0;
+const IOS_SYSTEM_BAR = IOS_MAJOR >= 26;
+const GLASS_HEADER = IS_IOS && IOS_SYSTEM_BAR;
+
+const renderHeaderGlass = () => ;
/**
* Root native stack: home (the thread list) at the bottom, thread /
- * settings / dev screens pushed on top with native headers in bb's colors.
- * Home sets its own title and header buttons.
+ * settings / dev screens pushed on top. iOS gets the system chrome: a
+ * translucent material bar the content scrolls under, large titles on list
+ * screens, the tint on bar items, the system font. Android keeps an opaque
+ * bar in the canvas color. Screens set their own titles, toolbars and
+ * search bars with `Stack.Title` / `Stack.Toolbar` / `Stack.SearchBar`.
*/
export function RootNavigator() {
- const { tokens, fonts } = useTheme();
+ const { tokens, mode } = useTheme();
+ // The native stack reads react-navigation's theme, not ours: its `dark`
+ // flag becomes the UINavigationBar's `overrideUserInterfaceStyle`, and
+ // `colors` back the bar items. Without a provider the stack assumes light,
+ // forces the bar to the light trait, and every adaptive material (and
+ // UIKit's own bar content: back chevron, search field) renders in its
+ // light flavor over a dark app. Derive it from the app theme so the bar
+ // follows the in-app mode as well as the system scheme.
+ const navigationTheme = useMemo(() => {
+ const base = mode === "dark" ? DarkTheme : DefaultTheme;
+ return {
+ ...base,
+ colors: {
+ ...base.colors,
+ primary: tokens.primary,
+ background: tokens.background,
+ card: tokens.background,
+ text: tokens.foreground,
+ border: tokens.border,
+ notification: tokens.destructive,
+ },
+ };
+ }, [mode, tokens]);
+ const headerSurface: NativeStackNavigationOptions = IS_IOS
+ ? GLASS_HEADER
+ ? {
+ // Inline bars: a Liquid Glass sheet (the composer's material),
+ // rendered by the stack as the header background behind a
+ // transparent bar, so the timeline refracts through it.
+ headerTransparent: true,
+ headerBlurEffect: "none",
+ headerBackground: renderHeaderGlass,
+ headerLargeStyle: { backgroundColor: "transparent" },
+ scrollEdgeEffects: { top: "hidden" },
+ }
+ : { headerTransparent: true, headerBlurEffect: "systemChromeMaterial" }
+ : { headerStyle: { backgroundColor: tokens.background } };
+ // Large-title bars: the stack debounces their height while the title
+ // collapses, so a glass sheet would lag behind the bar. The classic
+ // frosted blur backs the compact bar instead (transparent at rest, under
+ // the large title). The blur adapts to the bar's trait, which the
+ // navigation theme above keeps in sync with the app mode.
+ const listScreen: NativeStackNavigationOptions = GLASS_HEADER
+ ? {
+ ...LIST_SCREEN_OPTIONS,
+ headerBackground: undefined,
+ headerBlurEffect: "regular",
+ }
+ : LIST_SCREEN_OPTIONS;
+ // The stack renders the header background even for hidden headers.
+ const hiddenHeader: NativeStackNavigationOptions = {
+ headerShown: false,
+ headerBackground: undefined,
+ };
+ // Opaque, inline bar with a hairline edge for the terminal: a WebView that
+ // manages its own insets (`never`), so nothing scrolls under the bar.
+ // Every platform.
+ const opaqueHeader: NativeStackNavigationOptions = {
+ headerTransparent: false,
+ headerBackground: undefined,
+ headerStyle: { backgroundColor: tokens.background },
+ headerShadowVisible: true,
+ };
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/mobile/src/screens/shell/RouteErrorBoundary.tsx b/apps/mobile/src/screens/shell/RouteErrorBoundary.tsx
index e7c8c70cb2..ce30a99213 100644
--- a/apps/mobile/src/screens/shell/RouteErrorBoundary.tsx
+++ b/apps/mobile/src/screens/shell/RouteErrorBoundary.tsx
@@ -1,17 +1,49 @@
import type { ErrorBoundaryProps } from "expo-router";
-import { Pressable, ScrollView, Text, View } from "react-native";
+import {
+ Platform,
+ Pressable,
+ ScrollView,
+ Text,
+ useColorScheme,
+ View,
+} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+/**
+ * System-ish colors for the one screen that cannot read the theme: the
+ * platform's label / secondary-label / background / tint in both modes.
+ */
+const PALETTE = {
+ light: {
+ background: "#ffffff",
+ label: "#000000",
+ secondaryLabel: "#666666",
+ tint: Platform.select({ ios: "#007aff", default: "#111111" }),
+ tintLabel: "#ffffff",
+ },
+ dark: {
+ background: "#000000",
+ label: "#ffffff",
+ secondaryLabel: "#8c8c8c",
+ tint: Platform.select({ ios: "#0a84ff", default: "#f2f2f2" }),
+ tintLabel: Platform.select({ ios: "#ffffff", default: "#111111" }),
+ },
+} as const;
+
+const MONO_FAMILY = Platform.select({ ios: "Menlo", default: "monospace" });
+
/**
* Fallback for uncaught render errors (exported as `ErrorBoundary` from the
* root layout). Deliberately theme-free: it renders when the providers
- * themselves may have failed.
+ * themselves may have failed, so it only reads the system color scheme.
*/
export function RouteErrorBoundary({ error, retry }: ErrorBoundaryProps) {
const insets = useSafeAreaInsets();
+ const colors = PALETTE[useColorScheme() === "dark" ? "dark" : "light"];
return (
-
+
Something went wrong
-
+
{error.message}
@@ -31,13 +71,19 @@ export function RouteErrorBoundary({ error, retry }: ErrorBoundaryProps) {
accessibilityRole="button"
onPress={() => void retry()}
style={({ pressed }) => ({
- backgroundColor: pressed ? "#333" : "#111",
- paddingHorizontal: 16,
- paddingVertical: 10,
- borderRadius: 8,
+ backgroundColor: colors.tint,
+ opacity: pressed ? 0.6 : 1,
+ paddingHorizontal: 20,
+ paddingVertical: 11,
+ borderRadius: Platform.select({ ios: 22, default: 8 }),
+ borderCurve: "continuous",
})}
>
- Try again
+
+ Try again
+
diff --git a/apps/mobile/src/screens/shell/Screen.tsx b/apps/mobile/src/screens/shell/Screen.tsx
index 8be1d2891a..aa83633a27 100644
--- a/apps/mobile/src/screens/shell/Screen.tsx
+++ b/apps/mobile/src/screens/shell/Screen.tsx
@@ -1,8 +1,22 @@
import type { ReactNode } from "react";
-import { ScrollView, View, type StyleProp, type ViewStyle } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
+import {
+ ScrollView,
+ StyleSheet,
+ View,
+ type StyleProp,
+ type ViewStyle,
+} from "react-native";
+import {
+ SafeAreaProvider,
+ useSafeAreaInsets,
+} from "react-native-safe-area-context";
import { ConnectionBanner } from "./ConnectionBanner";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
+/** Gap between the header's bottom edge and a floating banner. */
+const FLOATING_BANNER_GAP = 8;
+
interface ScreenProps {
children: ReactNode;
/** Wrap content in a ScrollView (default). Lists supply their own. */
@@ -13,39 +27,115 @@ interface ScreenProps {
* when an inline `contentContainerStyle` is also present.
*/
contentStyle?: StyleProp;
+ /**
+ * Whether the screen shows the connection banner (default). Pass `false`
+ * when the screen places `` itself — list screens
+ * render it as their list header so it scrolls with the rows.
+ */
+ banner?: boolean;
+ /**
+ * Page color painted on the outer view (so the header inset and overscroll
+ * regions match the content): the canvas (default) or the iOS grouped page
+ * behind inset cards.
+ */
+ surface?: "background" | "grouped";
testID?: string;
}
/**
- * Themed screen container under a native header: connection banner on top,
- * then either scrolling content or a raw flex column for screens that
- * manage their own list.
+ * Themed screen container under a native header. The scroll view is the
+ * screen's first child and adjusts for the header itself
+ * (`contentInsetAdjustmentBehavior="automatic"`: that is what drives the
+ * large-title collapse and the material bar on iOS), with the connection
+ * banner as the first item of the content. Screens that manage their own
+ * list (`scroll={false}`) must pass `contentInsetAdjustmentBehavior`
+ * themselves and keep the list their first child; the banner then floats
+ * under the header on iOS and sits above the list on Android.
*/
export function Screen({
children,
scroll = true,
contentStyle,
+ banner = true,
+ surface = "background",
testID,
}: ScreenProps) {
+ const insets = useSafeAreaInsets();
+ const rootClassName =
+ surface === "grouped"
+ ? "flex-1 bg-surface-grouped"
+ : "flex-1 bg-background";
+ if (scroll) {
+ return (
+
+ {banner ? : null}
+ {children}
+
+ );
+ }
+ // `collapsable={false}`: Fabric would otherwise flatten this root into a
+ // childless sibling (it keeps a leaf for the background + testID and hoists
+ // the content next to it), and UIKit / react-native-screens look for the
+ // screen's scroll view along the first-subview chain — with the leaf first,
+ // the large title never collapses and the bar never gets its edge effect.
+ return (
+
+ {banner && !IS_IOS ? : null}
+ {children}
+ {banner && IS_IOS ? : null}
+
+ );
+}
+
+/**
+ * iOS: the header is translucent and the content runs under it, so a banner
+ * laid out at the top of a list screen would hide behind the bar — and
+ * laying it out above the list would take the list out of the header's
+ * first-subview slot that drives large titles and the scroll-edge material.
+ * The banner floats instead. A SafeAreaProvider of its own reports the bar
+ * (status bar + navigation bar, large title included) as its top inset —
+ * the same inset UIKit hands the list — so the card sits just under it
+ * without measuring header heights. Touches pass through everywhere but
+ * the card.
+ */
+function FloatingConnectionBanner() {
+ return (
+
+
+
+ );
+}
+
+function FloatingConnectionBannerBody() {
const insets = useSafeAreaInsets();
return (
-
+
- {scroll ? (
-
- {children}
-
- ) : (
- {children}
- )}
);
}
diff --git a/apps/mobile/src/screens/shell/ScreenTitle.tsx b/apps/mobile/src/screens/shell/ScreenTitle.tsx
new file mode 100644
index 0000000000..605d84aeef
--- /dev/null
+++ b/apps/mobile/src/screens/shell/ScreenTitle.tsx
@@ -0,0 +1,22 @@
+import { Stack } from "expo-router";
+import type { ComponentProps } from "react";
+import { useTheme } from "@/theme";
+
+type ScreenTitleProps = ComponentProps;
+
+/**
+ * `Stack.Title` in the palette ink. A bare `Stack.Title` registers an empty
+ * `headerTitleStyle` that replaces the navigator's, so the native bar falls
+ * back to the tint color for the title; this wrapper restores the ink and
+ * the 600 weight (and the large-title ink) once, for every screen.
+ */
+export function ScreenTitle({ style, largeStyle, ...props }: ScreenTitleProps) {
+ const { tokens } = useTheme();
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/screens/shell/WorkspaceMenu.tsx b/apps/mobile/src/screens/shell/WorkspaceMenu.tsx
index 33c092cdd5..e1c069dbf0 100644
--- a/apps/mobile/src/screens/shell/WorkspaceMenu.tsx
+++ b/apps/mobile/src/screens/shell/WorkspaceMenu.tsx
@@ -1,21 +1,14 @@
-import { useRouter, type Href } from "expo-router";
+import { Stack, useRouter, type Href } from "expo-router";
import { Pressable, View } from "react-native";
import {
e2eModeEnabled,
useProfiles,
useRealtimeConnectionState,
} from "@/app-shell";
+import { haptic } from "@/lib/haptics";
import type { MobileRealtimeConnectionState } from "@/lib/realtime";
import { useTheme } from "@/theme";
-import {
- Icon,
- ListRow,
- Separator,
- Sheet,
- Text,
- toast,
- useSheet,
-} from "@/ui";
+import { ListRow, Separator, Sheet, Text, toast, useSheet } from "@/ui";
import { archivedThreadsHref } from "./hrefs";
import { workspaceInitials } from "./workspace-initials";
@@ -25,21 +18,98 @@ const REALTIME_LABEL: Record = {
reconnecting: "Reconnecting…",
};
+function useSwitchProfile() {
+ const { activeProfile, setActiveProfile } = useProfiles();
+ return (profileId: string) => {
+ if (profileId === activeProfile?.id) return;
+ haptic("selection");
+ setActiveProfile(profileId).catch((error: unknown) => {
+ toast.error("Could not switch server", {
+ description: String(error),
+ });
+ });
+ };
+}
+
+/**
+ * iOS: the workspace menu as a native pull-down on the home header's left
+ * — the server switcher (check on the active one), Add server, then
+ * Archived threads and Settings. The menu's title carries the realtime
+ * state. Rendered from the home route; `Stack.Toolbar` items are native bar
+ * buttons, reachable in tests by their labels.
+ */
+export function WorkspaceToolbar() {
+ const router = useRouter();
+ const { profiles, activeProfile } = useProfiles();
+ const realtimeState = useRealtimeConnectionState();
+ const switchProfile = useSwitchProfile();
+ const title = activeProfile
+ ? `${activeProfile.label} · ${REALTIME_LABEL[realtimeState]}`
+ : "No server selected";
+ return (
+
+
+ {profiles.map((profile) => (
+ switchProfile(profile.id)}
+ >
+ {profile.label}
+
+ ))}
+ router.push("/settings/servers/add")}
+ >
+ Add server…
+
+
+ {activeProfile ? (
+ router.push(archivedThreadsHref())}
+ >
+ Archived threads
+
+ ) : null}
+ router.push("/settings")}
+ >
+ Settings
+
+ {e2eModeEnabled ? (
+ router.push("/dev/ui")}
+ >
+ UI gallery
+
+ ) : null}
+
+
+
+ );
+}
+
/**
- * The home header's left button: the active server's initials with a
- * realtime dot. It opens the workspace sheet — the server switcher, archived
- * threads, and Settings — which replaces the old left drawer.
+ * Android: the home header's left button — the active server's initials
+ * with a realtime dot — opening the workspace sheet: the server switcher,
+ * archived threads, and Settings.
*/
-export function WorkspaceMenuButton({
- dimmed = false,
-}: {
- /** Muted and inert while the home compose dock's scrim is up. */
- dimmed?: boolean;
-}) {
+export function WorkspaceMenuButton() {
const router = useRouter();
const { tokens } = useTheme();
- const { profiles, activeProfile, setActiveProfile } = useProfiles();
+ const { profiles, activeProfile } = useProfiles();
const realtimeState = useRealtimeConnectionState();
+ const switchProfile = useSwitchProfile();
const sheet = useSheet();
const dotColor = !activeProfile
@@ -61,10 +131,8 @@ export function WorkspaceMenuButton({
accessibilityRole="button"
accessibilityLabel="Workspace menu"
hitSlop={8}
- disabled={dimmed}
onPress={sheet.present}
className="h-10 w-10 items-center justify-center rounded-full active:bg-state-hover"
- style={{ opacity: dimmed ? 0.5 : 1 }}
testID="home-workspace-menu"
>
-
+
{activeProfile?.label ?? "bb"}
- {activeProfile ? REALTIME_LABEL[realtimeState] : "No server selected"}
+ {activeProfile
+ ? REALTIME_LABEL[realtimeState]
+ : "No server selected"}
- {profiles.map((profile) => {
- const active = profile.id === activeProfile?.id;
- return (
-
- ) : undefined
- }
- onPress={() => {
- sheet.dismiss();
- if (active) return;
- setActiveProfile(profile.id).catch((error: unknown) => {
- toast.error("Could not switch server", {
- description: String(error),
- });
- });
- }}
- testID={`workspace-server-${profile.id}`}
- />
- );
- })}
+ {profiles.map((profile) => (
+ {
+ sheet.dismiss();
+ switchProfile(profile.id);
+ }}
+ testID={`workspace-server-${profile.id}`}
+ />
+ ))}
-
-
+
+
{title}
- {message ? {message} : null}
+ {message ? (
+
+ {message}
+
+ ) : null}
>
);
}
+/** Single-choice row: the checked one shows the tinted check mark. */
export function CheckRow({
label,
icon,
@@ -35,24 +42,18 @@ export function CheckRow({
onPress: () => void;
testID: string;
}) {
- const { tokens } = useTheme();
return (
- ) : null
- }
onPress={onPress}
testID={testID}
/>
);
}
-/** Full-width secondary row with centered copy (Cancel / Done). */
+/** Full-width row with centered tinted copy (Cancel / Done). */
export function CenteredRow({
label,
onPress,
@@ -66,10 +67,15 @@ export function CenteredRow({
- {label}
+
+ {label}
+
);
}
diff --git a/apps/mobile/src/screens/sidebar/SheetNameForm.tsx b/apps/mobile/src/screens/sidebar/SheetNameForm.tsx
index a6ac069e3a..79d38ebae4 100644
--- a/apps/mobile/src/screens/sidebar/SheetNameForm.tsx
+++ b/apps/mobile/src/screens/sidebar/SheetNameForm.tsx
@@ -1,8 +1,7 @@
import { useState } from "react";
import { View, type TextInputProps } from "react-native";
-import { resolveFont } from "@/theme/fonts";
-import { useTheme } from "@/theme";
-import { Button, SheetTextInput, Text } from "@/ui";
+import { Button, Text } from "@/ui";
+import { SheetInput } from "../pickers/SheetInput";
interface SheetNameFormProps {
title: string;
@@ -23,8 +22,10 @@ interface SheetNameFormProps {
/**
* Single-field name form for bottom sheets (rename thread/project/section,
- * new section). Uses the bottom-sheet text input so the sheet rides above
- * the keyboard; trims and rejects empty names like the web RenameDialog.
+ * new section). Uses the sheet text field (`SheetInput`, the `Input` look
+ * on `BottomSheetTextInput`) so the sheet rides above the keyboard; trims
+ * and rejects empty names like the web RenameDialog. iOS offers the system
+ * prompt (`promptName`) first; this is the fallback form.
*/
export function SheetNameForm({
title,
@@ -39,13 +40,11 @@ export function SheetNameForm({
onCancel,
testID,
}: SheetNameFormProps) {
- const { tokens, radii } = useTheme();
const [value, setValue] = useState(initialValue);
const [validationMessage, setValidationMessage] = useState(
null,
);
const shownError = validationMessage ?? errorMessage ?? null;
- const font = resolveFont({});
const submit = () => {
const trimmed = value.trim();
@@ -62,40 +61,29 @@ export function SheetNameForm({
{title}
{message ? {message} : null}
- {
setValue(next);
if (validationMessage) setValidationMessage(null);
}}
placeholder={placeholder}
- placeholderTextColor={tokens.mutedForeground}
- selectionColor={tokens.primary}
- cursorColor={tokens.primary}
autoFocus
autoCapitalize={autoCapitalize}
- autoCorrect={false}
editable={!pending}
+ invalid={shownError !== null}
returnKeyType="done"
onSubmitEditing={submit}
selectTextOnFocus
- style={[
- font,
- {
- height: 40,
- borderWidth: 1,
- borderColor: shownError ? tokens.destructive : tokens.input,
- borderRadius: radii.md,
- paddingHorizontal: 12,
- fontSize: 16,
- color: tokens.foreground,
- opacity: pending ? 0.5 : 1,
- },
- ]}
testID={`${testID}-input`}
/>
{shownError ? (
-
+
{shownError}
) : null}
diff --git a/apps/mobile/src/screens/sidebar/SidebarActionsProvider.tsx b/apps/mobile/src/screens/sidebar/SidebarActionsProvider.tsx
index 29972856ff..82d6114057 100644
--- a/apps/mobile/src/screens/sidebar/SidebarActionsProvider.tsx
+++ b/apps/mobile/src/screens/sidebar/SidebarActionsProvider.tsx
@@ -48,16 +48,20 @@ import {
useUnpinThread,
} from "@/data/threads";
import { describeError } from "@/lib/describe-error";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
import {
+ confirmDestructive,
Icon,
ListRow,
+ promptName,
Separator,
Sheet,
Text,
toast,
useSheet,
type IconName,
+ type NamePromptOptions,
} from "@/ui";
import { CenteredRow, CheckRow, SheetHeader } from "../shell/sheet-rows";
import {
@@ -70,27 +74,25 @@ import { SectionReorderList } from "./SectionReorderList";
import { SheetNameForm } from "./SheetNameForm";
/**
- * The long-press menus and follow-up forms for sidebar rows (thread, project,
- * section) plus the organize/sort options, rendered as one bottom sheet whose
- * content follows a small state machine. One sheet instead of a stack of
- * modals: a menu action swaps the content in place (rename form, confirm,
- * section picker), so nothing has to wait for a previous sheet to dismiss.
- * Mirrors the web ThreadActionsMenu / ProjectActionsMenu / section row menu.
+ * The actions behind sidebar rows (thread, project, section) and the
+ * organize/sort options. Thread mutations (read, pin, rename, move,
+ * archive, delete) are exposed on the context so the swipe actions call
+ * them directly; the long-press sheet (the thread row menu and the project
+ * / section header menus, on both platforms — a native context menu would
+ * hide the row from VoiceOver) is one bottom sheet whose content follows a
+ * small state machine, so a menu action swaps the content in place. Renames
+ * use the system prompt where the platform has one (`promptName`) and the
+ * sheet form elsewhere; destructive actions confirm through the system
+ * alert. Mirrors the web ThreadActionsMenu / ProjectActionsMenu / section
+ * menu.
*/
type SheetState =
| { kind: "thread-menu"; thread: ThreadListEntry }
| { kind: "thread-rename"; thread: ThreadListEntry }
| { kind: "thread-move"; thread: ThreadListEntry }
- | {
- kind: "thread-delete";
- thread: ThreadListEntry;
- /** Null while the child summary loads. */
- childThreadCount: number | null;
- }
| { kind: "project-menu"; project: SidebarProject }
| { kind: "project-rename"; project: SidebarProject }
- | { kind: "project-remove"; project: SidebarProject }
| { kind: "section-menu"; section: SidebarSectionDefinition }
| { kind: "section-rename"; section: SidebarSectionDefinition }
| {
@@ -98,22 +100,37 @@ type SheetState =
/** When set, the thread moves into the new section on success. */
moveThread: ThreadListEntry | null;
}
- | { kind: "section-delete"; section: SidebarSectionDefinition }
| { kind: "display-options" }
| { kind: "section-reorder" };
-interface SidebarActions {
+export interface SidebarActions {
+ /** The long-press menu for a thread row (a heavy haptic, then the sheet). */
openThreadMenu(thread: ThreadListEntry): void;
openProjectMenu(project: SidebarProject): void;
openSectionMenu(section: SidebarSectionDefinition): void;
+ /** The Android organize / sort sheet (iOS uses the header menu). */
openDisplayOptions(): void;
/** The drag-to-reorder list of top-level sections for the current mode. */
openSectionReorder(): void;
+ /**
+ * Create a section by name (system prompt or sheet form); a thread passed
+ * in moves into it on success.
+ */
+ openSectionCreate(moveThread?: ThreadListEntry | null): void;
/** Navigate to the thread detail. */
openThread(thread: Pick): void;
/** Navigate to the composer, preselecting a project and/or section. */
createThread(target?: { projectId?: string; sectionId?: string }): void;
createProject(): void;
+ toggleThreadRead(thread: ThreadListEntry): void;
+ toggleThreadPinned(thread: ThreadListEntry): void;
+ renameThread(thread: ThreadListEntry): void;
+ moveThreadToSection(thread: ThreadListEntry, sectionId: string | null): void;
+ /** Archives with an undo toast. */
+ archiveThread(thread: ThreadListEntry): void;
+ unarchiveThread(thread: ThreadListEntry): void;
+ /** Confirms (with the child count) before deleting. */
+ deleteThread(thread: ThreadListEntry): void;
}
const SidebarActionsContext = createContext(null);
@@ -130,7 +147,9 @@ export function useSidebarActions(): SidebarActions {
const ARCHIVE_UNDO_TOAST_DURATION_MS = 8000;
-const ORGANIZE_OPTIONS: readonly {
+const EMPTY_SECTIONS: readonly SidebarSectionDefinition[] = [];
+
+export const ORGANIZE_OPTIONS: readonly {
label: string;
mode: SidebarOrganizeMode;
icon: IconName;
@@ -140,7 +159,7 @@ const ORGANIZE_OPTIONS: readonly {
{ label: "Manually", mode: "manual", icon: "Layers" },
];
-const SORT_OPTIONS: readonly {
+export const SORT_OPTIONS: readonly {
label: string;
sort: SidebarSortMode;
icon: IconName;
@@ -157,6 +176,13 @@ function sectionErrorMessage(error: unknown, fallback: string): string {
return describeError(error) || fallback;
}
+function childThreadsMessage(count: number): string {
+ const children = `${count} child ${count === 1 ? "thread" : "threads"} will be deleted.`;
+ return count > 0
+ ? `${children} This action cannot be undone.`
+ : "This action cannot be undone.";
+}
+
interface MenuAction {
key: string;
label: string;
@@ -193,38 +219,6 @@ function MenuRows({ actions }: { actions: readonly MenuAction[] }) {
);
}
-function ConfirmRows({
- confirmLabel,
- confirmIcon,
- pending,
- onConfirm,
- onCancel,
-}: {
- confirmLabel: string;
- confirmIcon: IconName;
- pending: boolean;
- onConfirm: () => void;
- onCancel: () => void;
-}) {
- const { tokens } = useTheme();
- return (
- <>
-
- }
- destructive
- disabled={pending}
- onPress={onConfirm}
- testID="sidebar-confirm"
- />
-
-
- >
- );
-}
-
interface SidebarActionsProviderProps {
children: ReactNode;
/**
@@ -246,23 +240,33 @@ export function SidebarActionsProvider({
const [state, setState] = useState(null);
const [preferences, preferenceActions] = useSidebarPreferences();
const bootstrap = useSidebarBootstrap();
- const sections = bootstrap.data?.sections ?? [];
+ const bootstrapSections = bootstrap.data?.sections;
+ const sections = useMemo(
+ () => bootstrapSections ?? EMPTY_SECTIONS,
+ [bootstrapSections],
+ );
- const renameThread = useRenameThread();
- const moveThread = useMoveThreadToSection();
- const pinThread = usePinThread();
- const unpinThread = useUnpinThread();
- const archiveThread = useArchiveThread();
- const unarchiveThread = useUnarchiveThread();
- const deleteThread = useDeleteThread();
- const childSummary = useThreadChildSummary();
- const markRead = useMarkThreadRead();
- const markUnread = useMarkThreadUnread();
+ const { mutate: renameThreadMutate, isPending: renameThreadPending } =
+ useRenameThread();
+ const { mutate: moveThreadMutate } = useMoveThreadToSection();
+ const { mutate: pinThreadMutate } = usePinThread();
+ const { mutate: unpinThreadMutate } = useUnpinThread();
+ const { mutate: archiveThreadMutate } = useArchiveThread();
+ const { mutate: unarchiveThreadMutate } = useUnarchiveThread();
+ const { mutate: deleteThreadMutate } = useDeleteThread();
+ const { mutateAsync: fetchChildSummary } = useThreadChildSummary();
+ const { mutate: markReadMutate } = useMarkThreadRead();
+ const { mutate: markUnreadMutate } = useMarkThreadUnread();
const renameProject = useRenameProject();
- const deleteProject = useDeleteProject();
+ const { mutate: deleteProjectMutate } = useDeleteProject();
const createSection = useCreateSection();
+ const { mutate: createSectionMutate, reset: resetCreateSection } =
+ createSection;
const renameSection = useRenameSection();
- const deleteSection = useDeleteSection();
+ const { reset: resetRenameSection } = renameSection;
+ const { mutate: deleteSectionMutate } = useDeleteSection();
+ const { organize } = preferences;
+ const { setOrganize } = preferenceActions;
const present = useCallback(
(next: SheetState) => {
@@ -278,35 +282,31 @@ export function SidebarActionsProvider({
[router],
);
- const actions = useMemo(
- () => ({
- openThreadMenu: (thread) => present({ kind: "thread-menu", thread }),
- openProjectMenu: (project) => present({ kind: "project-menu", project }),
- openSectionMenu: (section) => present({ kind: "section-menu", section }),
- openDisplayOptions: () => present({ kind: "display-options" }),
- openSectionReorder: () => present({ kind: "section-reorder" }),
- openThread: (thread) => navigate(threadHref(thread.id)),
- createThread: (target) => {
- if (onCreateThread?.(target)) return;
- // Home already sits at the bottom of the stack: navigate (not push)
- // returns to it with the new params.
- router.navigate(newThreadHref(target));
- },
- createProject: () => navigate(newProjectHref()),
- }),
- [navigate, onCreateThread, present, router],
+ /**
+ * Rename through the system prompt when the platform has one, otherwise
+ * swap the sheet to its form.
+ */
+ const renameWithPrompt = useCallback(
+ (options: NamePromptOptions, fallback: SheetState) => {
+ if (promptName(options)) {
+ dismiss();
+ return;
+ }
+ setState(fallback);
+ },
+ [dismiss],
);
const unarchiveMany = useCallback(
(threadIds: readonly string[]) => {
- for (const id of threadIds) unarchiveThread.mutate({ id });
+ for (const id of threadIds) unarchiveThreadMutate({ id });
},
- [unarchiveThread],
+ [unarchiveThreadMutate],
);
const archiveWithUndo = useCallback(
(thread: ThreadListEntry) => {
- archiveThread.mutate(
+ archiveThreadMutate(
{ id: thread.id },
{
onSuccess: (response) => {
@@ -331,29 +331,136 @@ export function SidebarActionsProvider({
},
);
},
- [archiveThread, unarchiveMany],
+ [archiveThreadMutate, unarchiveMany],
);
- const requestDelete = useCallback(
+ const deleteWithConfirm = useCallback(
(thread: ThreadListEntry) => {
- setState({ kind: "thread-delete", thread, childThreadCount: null });
- childSummary.mutateAsync(thread.id).then(
+ const title = getThreadDisplayTitle(thread);
+ fetchChildSummary(thread.id).then(
(summary) => {
- setState((current) =>
- current?.kind === "thread-delete" && current.thread.id === thread.id
- ? { ...current, childThreadCount: summary.nonDeletedChildCount }
- : current,
- );
+ const count = summary.nonDeletedChildCount;
+ confirmDestructive({
+ title: `Delete ${title}?`,
+ message: childThreadsMessage(count),
+ actionLabel: "Delete thread",
+ onConfirm: () => {
+ deleteThreadMutate(
+ { id: thread.id, childThreadsConfirmed: count > 0 },
+ { onSuccess: () => toast.success("Thread deleted") },
+ );
+ },
+ });
},
(error: unknown) => {
toast.error("Could not check child threads", {
description: describeError(error),
});
- dismiss();
},
);
},
- [childSummary, dismiss],
+ [deleteThreadMutate, fetchChildSummary],
+ );
+
+ const createSectionNamed = useCallback(
+ (name: string, moveThread: ThreadListEntry | null) => {
+ createSectionMutate(
+ { name },
+ {
+ onSuccess: (section) => {
+ if (moveThread) {
+ moveThreadMutate({ id: moveThread.id, sectionId: section.id });
+ }
+ if (organize !== "manual") setOrganize("manual");
+ },
+ onError: (error) => {
+ toast.error(
+ sectionErrorMessage(error, "Failed to create section."),
+ );
+ },
+ },
+ );
+ },
+ [createSectionMutate, moveThreadMutate, organize, setOrganize],
+ );
+
+ const actions = useMemo(
+ () => ({
+ openThreadMenu: (thread) => {
+ haptic("impact-heavy");
+ present({ kind: "thread-menu", thread });
+ },
+ openProjectMenu: (project) => {
+ haptic("impact-heavy");
+ present({ kind: "project-menu", project });
+ },
+ openSectionMenu: (section) => {
+ haptic("impact-heavy");
+ present({ kind: "section-menu", section });
+ },
+ openDisplayOptions: () => present({ kind: "display-options" }),
+ openSectionReorder: () => present({ kind: "section-reorder" }),
+ openSectionCreate: (moveThread = null) => {
+ const handled = promptName({
+ title: "New section",
+ message: moveThread
+ ? `${getThreadDisplayTitle(moveThread)} moves into it.`
+ : "Create a section for threads.",
+ initialValue: "",
+ submitLabel: "Create",
+ onSubmit: (name) => createSectionNamed(name, moveThread),
+ });
+ if (!handled) present({ kind: "section-create", moveThread });
+ },
+ openThread: (thread) => navigate(threadHref(thread.id)),
+ createThread: (target) => {
+ if (onCreateThread?.(target)) return;
+ // Home already sits at the bottom of the stack: navigate (not push)
+ // returns to it with the new params.
+ router.navigate(newThreadHref(target));
+ },
+ createProject: () => navigate(newProjectHref()),
+ toggleThreadRead: (thread) => {
+ if (isThreadRead(thread)) markUnreadMutate(thread.id);
+ else markReadMutate(thread.id);
+ },
+ toggleThreadPinned: (thread) => {
+ if (thread.pinnedAt !== null) unpinThreadMutate({ id: thread.id });
+ else pinThreadMutate({ id: thread.id });
+ },
+ renameThread: (thread) => {
+ const handled = promptName({
+ title: "Rename thread",
+ initialValue: getThreadDisplayTitle(thread),
+ submitLabel: "Rename",
+ onSubmit: (title) => renameThreadMutate({ id: thread.id, title }),
+ });
+ if (!handled) present({ kind: "thread-rename", thread });
+ },
+ moveThreadToSection: (thread, sectionId) => {
+ if (thread.sectionId === sectionId) return;
+ moveThreadMutate({ id: thread.id, sectionId });
+ },
+ archiveThread: archiveWithUndo,
+ unarchiveThread: (thread) => unarchiveThreadMutate({ id: thread.id }),
+ deleteThread: deleteWithConfirm,
+ }),
+ [
+ archiveWithUndo,
+ createSectionNamed,
+ deleteWithConfirm,
+ markReadMutate,
+ markUnreadMutate,
+ moveThreadMutate,
+ navigate,
+ onCreateThread,
+ pinThreadMutate,
+ present,
+ renameThreadMutate,
+ router,
+ unarchiveThreadMutate,
+ unpinThreadMutate,
+ ],
);
const renderContent = (): ReactNode => {
@@ -380,8 +487,7 @@ export function SidebarActionsProvider({
icon: isRead ? "Mail" : "MailOpen",
onPress: () => {
dismiss();
- if (isRead) markUnread.mutate(thread.id);
- else markRead.mutate(thread.id);
+ actions.toggleThreadRead(thread);
},
},
{
@@ -390,15 +496,24 @@ export function SidebarActionsProvider({
icon: isPinned ? "PinOff" : "Pin",
onPress: () => {
dismiss();
- if (isPinned) unpinThread.mutate({ id: thread.id });
- else pinThread.mutate({ id: thread.id });
+ actions.toggleThreadPinned(thread);
},
},
{
key: "rename",
label: "Rename",
icon: "Edit",
- onPress: () => setState({ kind: "thread-rename", thread }),
+ onPress: () =>
+ renameWithPrompt(
+ {
+ title: "Rename thread",
+ initialValue: getThreadDisplayTitle(thread),
+ submitLabel: "Rename",
+ onSubmit: (title) =>
+ renameThreadMutate({ id: thread.id, title }),
+ },
+ { kind: "thread-rename", thread },
+ ),
},
{
key: "move",
@@ -412,8 +527,8 @@ export function SidebarActionsProvider({
icon: isArchived ? "ArchiveRestore" : "Archive",
onPress: () => {
dismiss();
- if (isArchived) unarchiveThread.mutate({ id: thread.id });
- else archiveWithUndo(thread);
+ if (isArchived) actions.unarchiveThread(thread);
+ else actions.archiveThread(thread);
},
},
{
@@ -421,7 +536,10 @@ export function SidebarActionsProvider({
label: "Delete",
icon: "Trash2",
destructive: true,
- onPress: () => requestDelete(thread),
+ onPress: () => {
+ dismiss();
+ actions.deleteThread(thread);
+ },
},
];
return (
@@ -437,10 +555,10 @@ export function SidebarActionsProvider({
title="Rename thread"
initialValue={getThreadDisplayTitle(state.thread)}
submitLabel="Rename"
- pending={renameThread.isPending}
+ pending={renameThreadPending}
autoCapitalize="sentences"
onSubmit={(title) => {
- renameThread.mutate(
+ renameThreadMutate(
{ id: state.thread.id, title },
{ onSettled: dismiss },
);
@@ -465,9 +583,7 @@ export function SidebarActionsProvider({
checked={thread.sectionId === section.id}
onPress={() => {
dismiss();
- if (thread.sectionId !== section.id) {
- moveThread.mutate({ id: thread.id, sectionId: section.id });
- }
+ actions.moveThreadToSection(thread, section.id);
}}
testID={`sidebar-move-${section.id}`}
/>
@@ -478,9 +594,7 @@ export function SidebarActionsProvider({
checked={thread.sectionId === null}
onPress={() => {
dismiss();
- if (thread.sectionId !== null) {
- moveThread.mutate({ id: thread.id, sectionId: null });
- }
+ actions.moveThreadToSection(thread, null);
}}
testID="sidebar-move-none"
/>
@@ -498,47 +612,6 @@ export function SidebarActionsProvider({
>
);
}
- case "thread-delete": {
- const { thread, childThreadCount } = state;
- const message =
- childThreadCount === null
- ? "Checking child threads…"
- : [
- childThreadCount > 0
- ? `${childThreadCount} child ${childThreadCount === 1 ? "thread" : "threads"} will be deleted.`
- : null,
- "This action cannot be undone.",
- ]
- .filter((part): part is string => part !== null)
- .join(" ");
- return (
- <>
-
- {
- if (childThreadCount === null) return;
- deleteThread.mutate(
- {
- id: thread.id,
- childThreadsConfirmed: childThreadCount > 0,
- },
- {
- onSuccess: () => toast.success("Thread deleted"),
- onSettled: dismiss,
- },
- );
- }}
- onCancel={dismiss}
- />
- >
- );
- }
case "project-menu": {
const { project } = state;
const menu: MenuAction[] = [
@@ -564,7 +637,17 @@ export function SidebarActionsProvider({
key: "rename",
label: "Rename",
icon: "Edit",
- onPress: () => setState({ kind: "project-rename", project }),
+ onPress: () =>
+ renameWithPrompt(
+ {
+ title: "Rename project",
+ initialValue: project.name,
+ submitLabel: "Rename",
+ onSubmit: (name) =>
+ renameProject.mutate({ id: project.id, name }),
+ },
+ { kind: "project-rename", project },
+ ),
},
{
key: "add-local-path",
@@ -586,7 +669,19 @@ export function SidebarActionsProvider({
label: "Remove",
icon: "Trash2",
destructive: true,
- onPress: () => setState({ kind: "project-remove", project }),
+ onPress: () => {
+ dismiss();
+ confirmDestructive({
+ title: "Remove project?",
+ message: `Remove "${project.name}" and all of its threads? This cannot be undone.`,
+ actionLabel: "Remove project",
+ onConfirm: () => {
+ deleteProjectMutate(project.id, {
+ onSuccess: () => toast.success(`Removed ${project.name}`),
+ });
+ },
+ });
+ },
},
];
return (
@@ -613,28 +708,6 @@ export function SidebarActionsProvider({
testID="rename"
/>
);
- case "project-remove":
- return (
- <>
-
- {
- deleteProject.mutate(state.project.id, {
- onSuccess: () =>
- toast.success(`Removed ${state.project.name}`),
- onSettled: dismiss,
- });
- }}
- onCancel={dismiss}
- />
- >
- );
case "section-menu": {
const { section } = state;
const menu: MenuAction[] = [
@@ -651,7 +724,28 @@ export function SidebarActionsProvider({
key: "rename",
label: "Rename",
icon: "Edit",
- onPress: () => setState({ kind: "section-rename", section }),
+ onPress: () =>
+ renameWithPrompt(
+ {
+ title: "Rename section",
+ initialValue: section.name,
+ submitLabel: "Rename",
+ onSubmit: (name) =>
+ renameSection.mutate(
+ { id: section.id, name },
+ {
+ onError: (error) =>
+ toast.error(
+ sectionErrorMessage(
+ error,
+ "Failed to rename section.",
+ ),
+ ),
+ },
+ ),
+ },
+ { kind: "section-rename", section },
+ ),
},
{
key: "reorder",
@@ -664,7 +758,15 @@ export function SidebarActionsProvider({
label: "Delete",
icon: "Trash2",
destructive: true,
- onPress: () => setState({ kind: "section-delete", section }),
+ onPress: () => {
+ dismiss();
+ confirmDestructive({
+ title: `Delete ${section.name}?`,
+ message: "Threads in this section move back to Unorganized.",
+ actionLabel: "Delete section",
+ onConfirm: () => deleteSectionMutate({ id: section.id }),
+ });
+ },
},
];
return (
@@ -722,19 +824,17 @@ export function SidebarActionsProvider({
}
onSubmit={(name) => {
const moveThreadId = state.moveThread?.id ?? null;
- createSection.mutate(
+ createSectionMutate(
{ name },
{
onSuccess: (section) => {
if (moveThreadId) {
- moveThread.mutate({
+ moveThreadMutate({
id: moveThreadId,
sectionId: section.id,
});
}
- if (preferences.organize !== "manual") {
- preferenceActions.setOrganize("manual");
- }
+ if (organize !== "manual") setOrganize("manual");
dismiss();
},
},
@@ -744,27 +844,6 @@ export function SidebarActionsProvider({
testID="section-create"
/>
);
- case "section-delete":
- return (
- <>
-
- {
- deleteSection.mutate(
- { id: state.section.id },
- { onSettled: dismiss },
- );
- }}
- onCancel={dismiss}
- />
- >
- );
case "display-options":
return (
<>
@@ -856,8 +935,8 @@ export function SidebarActionsProvider({
deferContent={false}
onDismiss={() => {
setState(null);
- createSection.reset();
- renameSection.reset();
+ resetCreateSection();
+ resetRenameSection();
}}
>
{renderContent()}
diff --git a/apps/mobile/src/screens/sidebar/SidebarRows.tsx b/apps/mobile/src/screens/sidebar/SidebarRows.tsx
index 4740534113..9ada2d6e45 100644
--- a/apps/mobile/src/screens/sidebar/SidebarRows.tsx
+++ b/apps/mobile/src/screens/sidebar/SidebarRows.tsx
@@ -1,9 +1,16 @@
import { isThreadRead, resolveThreadListIndicator } from "@bb/client-core";
-import { memo } from "react";
-import { Pressable, View } from "react-native";
+import { memo, useEffect, useRef } from "react";
+import { Pressable, StyleSheet, View, type ViewStyle } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
import { getThreadDisplayTitle } from "@/data/threads";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
import { Icon, LONG_PRESS_DELAY_MS, Text, cn } from "@/ui";
+import { useSidebarActions } from "./SidebarActionsProvider";
import {
getCollapsedActivityIndicatorState,
type SidebarEmptyRow,
@@ -11,8 +18,14 @@ import {
type SidebarHeaderRow,
type SidebarThreadRow,
} from "./sidebar-list-rows";
+import {
+ ThreadRowSwipeable,
+ type ThreadSwipeAction,
+} from "./ThreadRowSwipeable";
import { ThreadStatusGlyph } from "./ThreadStatusGlyph";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
/**
* One left text edge per depth, shared by headers, thread rows, environment
* rows, and empty rows (web `getSidebarThreadRowPaddingLeft`: base + a step
@@ -25,6 +38,8 @@ const ROW_DEPTH_STEP = 24;
const ROW_PADDING_RIGHT = 8;
const ROW_MIN_HEIGHT = 44;
const HEADER_MIN_HEIGHT = 36;
+/** Space above a top-level group header (iOS grouped sections breathe more). */
+const HEADER_GROUP_GAP = IS_IOS ? 12 : 6;
/**
* Distance from a row's text edge to the center of the hairline that ties
* its children to it (web `SIDEBAR_THREAD_ROW_GLYPH_CENTER_OFFSET_PX`).
@@ -32,32 +47,75 @@ const HEADER_MIN_HEIGHT = 36;
const GROUP_LINE_OFFSET = 8;
/** The single trailing column: status glyph, or the header "+" action. */
const TRAILING_SLOT_CLASS = "h-9 w-9 items-center justify-center";
+/** System highlight while pressed (iOS) / hover tone (Android). */
+const ROW_PRESS_CLASS = IS_IOS
+ ? "active:bg-state-active"
+ : "active:bg-state-hover";
+const CHEVRON_TURN_MS = 200;
+/** Rotation of the disclosure chevron while the group is expanded. */
+const CHEVRON_OPEN_DEG = 90;
function rowPaddingLeft(depth: number): number {
return ROW_BASE_PADDING + depth * ROW_DEPTH_STEP;
}
+/**
+ * `chevron.right` that turns to point down as a group expands. The turn
+ * only animates when the same row toggles: a recycled list cell that now
+ * shows another row (`rowKey` changed) snaps to that row's state.
+ */
function DisclosureChevron({
collapsed,
size,
+ rowKey,
}: {
collapsed: boolean;
size: number;
+ /** Identity of the row the chevron belongs to. */
+ rowKey: string;
}) {
const { tokens } = useTheme();
+ const degrees = useSharedValue(collapsed ? 0 : CHEVRON_OPEN_DEG);
+ const shown = useRef({ rowKey, collapsed });
+ useEffect(() => {
+ const previous = shown.current;
+ shown.current = { rowKey, collapsed };
+ const target = collapsed ? 0 : CHEVRON_OPEN_DEG;
+ if (previous.rowKey !== rowKey) {
+ degrees.set(target);
+ } else if (previous.collapsed !== collapsed) {
+ degrees.set(withTiming(target, { duration: CHEVRON_TURN_MS }));
+ }
+ }, [collapsed, degrees, rowKey]);
+ const turn = useAnimatedStyle(() => ({
+ transform: [{ rotate: `${degrees.get()}deg` }],
+ }));
return (
-
+
+
+
);
}
+/** Thread count of a collapsed group: tabular footnote (iOS) / chip. */
function CountChip({ count }: { count: number }) {
+ if (IS_IOS) {
+ return (
+
+ {count}
+
+ );
+ }
return (
- {count}
+
+ {count}
+
);
}
@@ -65,10 +123,11 @@ function CountChip({ count }: { count: number }) {
/**
* Hairline under the parent's text edge that runs the height of a nested row
* (web `SIDEBAR_PROJECT_GROUP_LINE_CLASS`). Rows are flat list items, so each
- * nested row paints its own segment; contiguous rows read as one line.
+ * nested row paints its own segment; contiguous rows read as one line. iOS
+ * conveys nesting with indentation and inset separators instead.
*/
function GroupLine({ depth }: { depth: number }) {
- if (depth === 0) return null;
+ if (depth === 0 || IS_IOS) return null;
return (
+ );
+}
+
export type SidebarRowSubtitle =
| { kind: "project"; name: string }
| { kind: "snippet"; text: string };
@@ -90,46 +168,52 @@ interface SidebarThreadRowViewProps {
* archive passes the project name.
*/
subtitle: SidebarRowSubtitle | null;
- onPress: (row: SidebarThreadRow) => void;
- onLongPress: (row: SidebarThreadRow) => void;
onToggleCollapsed: (threadId: string) => void;
}
+/**
+ * A thread row. Tapping opens the thread; long-pressing opens the row's
+ * action sheet (read, pin, rename, move, archive, delete) from the
+ * enclosing `SidebarActionsProvider`; swipe actions on both platforms. One
+ * plain `Pressable` on both: a native context menu (`Link.Menu`) removes
+ * the row from the iOS accessibility tree, so VoiceOver and Maestro would
+ * see nothing but the host, and a `Link.Preview` would mount the thread
+ * screen (twice) and mark the thread read, since the preview reports
+ * itself as focused.
+ */
export const SidebarThreadRowView = memo(function SidebarThreadRowView({
row,
subtitle,
- onPress,
- onLongPress,
onToggleCollapsed,
}: SidebarThreadRowViewProps) {
const { tokens } = useTheme();
+ const actions = useSidebarActions();
const { thread } = row;
const title = getThreadDisplayTitle(thread);
- const unread = !isThreadRead(thread) && thread.parentThreadId === null;
- return (
- onPress(row)}
- onLongPress={() => onLongPress(row)}
- delayLongPress={LONG_PRESS_DELAY_MS}
- className="flex-row items-center gap-1 active:bg-state-hover"
- style={{
- minHeight: ROW_MIN_HEIGHT,
- paddingLeft: rowPaddingLeft(row.depth),
- paddingRight: ROW_PADDING_RIGHT,
- }}
- testID={`thread-row-${thread.id}`}
- >
+ const read = isThreadRead(thread);
+ const unread = !read && thread.parentThreadId === null;
+ const pinned = thread.pinnedAt !== null;
+ const archived = thread.archivedAt !== null;
+ const textInset = rowPaddingLeft(row.depth);
+ const rowStyle: ViewStyle = {
+ minHeight: ROW_MIN_HEIGHT,
+ paddingLeft: textInset,
+ paddingRight: ROW_PADDING_RIGHT,
+ };
+
+ const content = (
+ <>
{title}
@@ -140,11 +224,18 @@ export const SidebarThreadRowView = memo(function SidebarThreadRowView({
row.collapsed ? "Show child threads" : "Hide child threads"
}
hitSlop={10}
- onPress={() => onToggleCollapsed(thread.id)}
+ onPress={() => {
+ haptic("selection");
+ onToggleCollapsed(thread.id);
+ }}
className="h-6 w-6 items-center justify-center rounded-sm active:bg-state-active"
testID={`thread-row-toggle-${thread.id}`}
>
-
+
) : null}
@@ -168,7 +259,70 @@ export const SidebarThreadRowView = memo(function SidebarThreadRowView({
-
+
+ >
+ );
+
+ const leading: ThreadSwipeAction = read
+ ? {
+ key: "unread",
+ label: "Unread",
+ icon: "Mail",
+ color: tokens.primary,
+ onPress: () => actions.toggleThreadRead(thread),
+ }
+ : {
+ key: "read",
+ label: "Read",
+ icon: "MailOpen",
+ color: tokens.primary,
+ onPress: () => actions.toggleThreadRead(thread),
+ };
+ const trailing: ThreadSwipeAction[] = [
+ {
+ key: pinned ? "unpin" : "pin",
+ label: pinned ? "Unpin" : "Pin",
+ icon: pinned ? "PinOff" : "Pin",
+ color: tokens.warning,
+ onPress: () => actions.toggleThreadPinned(thread),
+ },
+ archived
+ ? {
+ key: "unarchive",
+ label: "Unarchive",
+ icon: "ArchiveRestore",
+ color: tokens.success,
+ onPress: () => actions.unarchiveThread(thread),
+ }
+ : {
+ key: "archive",
+ label: "Archive",
+ icon: "Archive",
+ color: tokens.destructive,
+ onPress: () => actions.archiveThread(thread),
+ },
+ ];
+
+ return (
+
+ actions.openThread(thread)}
+ onLongPress={() => actions.openThreadMenu(thread)}
+ delayLongPress={LONG_PRESS_DELAY_MS}
+ className={cn("flex-row items-center gap-1", ROW_PRESS_CLASS)}
+ style={rowStyle}
+ testID={`thread-row-${thread.id}`}
+ >
+ {content}
+
+
);
});
@@ -192,6 +346,12 @@ interface SidebarHeaderRowViewProps {
onCreateThread: ((row: SidebarHeaderRow) => void) | null;
}
+/**
+ * A group header (pinned, project, machine, section, threads): sentence-case
+ * footnote label, a turning disclosure chevron, the tabular count and the
+ * rolled-up status while collapsed, and the "+" new-thread action. Tapping
+ * toggles the group (selection haptic); long-pressing opens its menu.
+ */
export const SidebarHeaderRowView = memo(function SidebarHeaderRowView({
row,
onToggleCollapsed,
@@ -217,29 +377,36 @@ export const SidebarHeaderRowView = memo(function SidebarHeaderRowView({
accessibilityRole="button"
accessibilityLabel={row.label}
accessibilityState={{ expanded: !row.collapsed }}
- onPress={() => onToggleCollapsed(row)}
+ onPress={() => {
+ haptic("selection");
+ onToggleCollapsed(row);
+ }}
onLongPress={() => onLongPress(row)}
delayLongPress={LONG_PRESS_DELAY_MS}
- className="flex-row items-center gap-1 active:bg-state-hover"
+ className={cn("flex-row items-center gap-1", ROW_PRESS_CLASS)}
style={{
minHeight: HEADER_MIN_HEIGHT,
paddingLeft: rowPaddingLeft(row.depth),
paddingRight: ROW_PADDING_RIGHT,
- marginTop: row.depth === 0 ? 6 : 0,
+ marginTop: row.depth === 0 ? HEADER_GROUP_GAP : 0,
}}
testID={`sidebar-header-${testIdSuffix}`}
>
{row.target.kind === "machine" ? (
-
+
) : row.target.kind === "pinned" ? (
-
+
) : null}
{row.label}
-
+
{row.collapsed && row.threadCount > 0 ? (
@@ -265,7 +432,7 @@ export const SidebarHeaderRowView = memo(function SidebarHeaderRowView({
) : null}
@@ -294,8 +461,11 @@ export const SidebarEnvironmentRowView = memo(
accessibilityRole="button"
accessibilityLabel={row.label}
accessibilityState={{ expanded: !row.collapsed }}
- onPress={() => onToggleCollapsed(row.environmentId)}
- className="flex-row items-center gap-1 active:bg-state-hover"
+ onPress={() => {
+ haptic("selection");
+ onToggleCollapsed(row.environmentId);
+ }}
+ className={cn("flex-row items-center gap-1", ROW_PRESS_CLASS)}
style={{
minHeight: HEADER_MIN_HEIGHT,
paddingLeft: rowPaddingLeft(row.depth),
@@ -304,7 +474,7 @@ export const SidebarEnvironmentRowView = memo(
testID={`environment-row-${row.environmentId}`}
>
-
+
-
+
{row.collapsed ? : null}
diff --git a/apps/mobile/src/screens/sidebar/SidebarThreadList.tsx b/apps/mobile/src/screens/sidebar/SidebarThreadList.tsx
index 966add3c46..d8224e207d 100644
--- a/apps/mobile/src/screens/sidebar/SidebarThreadList.tsx
+++ b/apps/mobile/src/screens/sidebar/SidebarThreadList.tsx
@@ -1,7 +1,13 @@
import { PERSONAL_PROJECT_ID } from "@bb/domain";
import { FlashList, type ListRenderItemInfo } from "@shopify/flash-list";
-import { useCallback, useMemo, useState } from "react";
-import { View, type StyleProp, type ViewStyle } from "react-native";
+import { useCallback, useMemo, useState, type ReactElement } from "react";
+import {
+ ScrollView,
+ View,
+ type ScrollViewProps,
+ type StyleProp,
+ type ViewStyle,
+} from "react-native";
import { useHosts } from "@/data/hosts";
import {
useSidebarBootstrap,
@@ -23,7 +29,6 @@ import {
getHeaderCollapseTarget,
type SidebarHeaderRow,
type SidebarListRow,
- type SidebarThreadRow,
} from "./sidebar-list-rows";
/**
@@ -52,18 +57,25 @@ function SidebarListSkeleton() {
interface SidebarThreadListProps {
contentContainerStyle?: StyleProp;
+ /** Keeps the indicator clear of a bar floating over the list's bottom. */
+ scrollIndicatorInsets?: ScrollViewProps["scrollIndicatorInsets"];
+ /** Scrolls with the rows (the connection banner on home). */
+ ListHeaderComponent?: ReactElement | null;
testID?: string;
}
/**
* The grouped thread list (pinned, then projects / machines / sections per
* the organize preference) as a FlashList, the body of the home screen; the
- * row menus come from the enclosing `SidebarActionsProvider`.
+ * row actions come from the enclosing `SidebarActionsProvider`. The list is
+ * the screen's first scrollable and adjusts for the native header itself.
* Data stays put across realtime refetches (the bootstrap query keeps its
* previous data), so rows update in place instead of flashing.
*/
export function SidebarThreadList({
contentContainerStyle,
+ scrollIndicatorInsets,
+ ListHeaderComponent,
testID,
}: SidebarThreadListProps) {
const [preferences, preferenceActions] = useSidebarPreferences();
@@ -96,14 +108,6 @@ export function SidebarThreadList({
);
}, [bootstrapRefetch, hostsRefetch]);
- const onThreadPress = useCallback(
- (row: SidebarThreadRow) => actions.openThread(row.thread),
- [actions],
- );
- const onThreadLongPress = useCallback(
- (row: SidebarThreadRow) => actions.openThreadMenu(row.thread),
- [actions],
- );
const onToggleThread = useCallback(
(threadId: string) => preferenceActions.toggleCollapsed("thread", threadId),
[preferenceActions],
@@ -182,8 +186,6 @@ export function SidebarThreadList({
);
@@ -201,8 +203,6 @@ export function SidebarThreadList({
[
onHeaderCreateThread,
onHeaderLongPress,
- onThreadLongPress,
- onThreadPress,
onToggleEnvironment,
onToggleHeader,
onToggleThread,
@@ -212,30 +212,37 @@ export function SidebarThreadList({
if (!model.isReady) {
if (isError) {
return (
-
+
+ {ListHeaderComponent}
Could not load threads.
-
+
{error?.message ?? "Unknown error"}
Retry
-
+
);
}
if (isLoading) {
return (
-
+
+ {ListHeaderComponent}
-
+
);
}
}
@@ -252,6 +259,7 @@ export function SidebarThreadList({
maintainVisibleContentPosition={DISABLE_MAINTAIN_POSITION}
refreshing={refreshing}
onRefresh={onRefresh}
+ ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={
isEmpty ? (
@@ -274,8 +282,14 @@ export function SidebarThreadList({
) : null
}
+ contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={contentContainerStyle}
+ scrollIndicatorInsets={scrollIndicatorInsets}
keyboardShouldPersistTaps="handled"
+ // Not "interactive": the dock under the list is padded by
+ // KeyboardPaddingView, which only follows keyboard frame notifications,
+ // not a drag.
+ keyboardDismissMode="on-drag"
testID={testID}
/>
);
diff --git a/apps/mobile/src/screens/sidebar/ThreadRowSwipeable.tsx b/apps/mobile/src/screens/sidebar/ThreadRowSwipeable.tsx
new file mode 100644
index 0000000000..4ec71a0e56
--- /dev/null
+++ b/apps/mobile/src/screens/sidebar/ThreadRowSwipeable.tsx
@@ -0,0 +1,235 @@
+import { useEffect, useRef, type ReactNode } from "react";
+import { Pressable, View, type LayoutChangeEvent } from "react-native";
+import ReanimatedSwipeable, {
+ type SwipeableMethods,
+} from "react-native-gesture-handler/ReanimatedSwipeable";
+import Animated, {
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+ type SharedValue,
+} from "react-native-reanimated";
+import { haptic } from "@/lib/haptics";
+import { useTheme } from "@/theme";
+import { Icon, Text, type IconName } from "@/ui";
+
+export interface ThreadSwipeAction {
+ key: string;
+ label: string;
+ icon: IconName;
+ /** Pane fill: a token string (the panes are Reanimated-driven views). */
+ color: string;
+ onPress: () => void;
+}
+
+interface ThreadRowSwipeableProps {
+ /** Closes a recycled row when it starts showing another thread. */
+ threadId: string;
+ /** Revealed by swiping right; a full swipe runs it. */
+ leading: ThreadSwipeAction;
+ /** Revealed by swiping left, outermost last; a full swipe runs the last one. */
+ trailing: readonly ThreadSwipeAction[];
+ children: ReactNode;
+}
+
+const ACTION_WIDTH = 76;
+/** Dragging past this share of the row width commits the outermost action. */
+const FULL_SWIPE_RATIO = 0.55;
+
+/**
+ * Mail-style swipe actions on a thread row: leading (read / unread) and
+ * trailing (pin, archive) panes behind the row, the outermost pane
+ * stretching with the finger so a full swipe commits it without a tap.
+ * Light haptic as a pane opens. Shared by the home list, search results
+ * and the archive on both platforms (RNGH, no native dependency).
+ */
+export function ThreadRowSwipeable({
+ threadId,
+ leading,
+ trailing,
+ children,
+}: ThreadRowSwipeableProps) {
+ const swipeable = useRef(null);
+ // The row's width, read on the UI thread to size the full-swipe threshold.
+ const rowWidth = useSharedValue(0);
+ // The pane a drag has committed to (1 leading, -1 trailing, 0 none),
+ // decided on the UI thread while the finger is down: on release the
+ // swipeable springs the translation back to the pane's open width before
+ // `onSwipeableWillOpen` reaches the JS thread, so sampling the translation
+ // there would miss the swipe.
+ const committed = useSharedValue(0);
+
+ useEffect(() => {
+ swipeable.current?.reset();
+ }, [threadId]);
+
+ const onLayout = (event: LayoutChangeEvent) => {
+ rowWidth.set(event.nativeEvent.layout.width);
+ };
+
+ const run = (action: ThreadSwipeAction) => {
+ swipeable.current?.close();
+ action.onPress();
+ };
+
+ const onWillOpen = () => {
+ haptic("impact-light");
+ const side = committed.get();
+ if (side > 0) {
+ run(leading);
+ } else if (side < 0) {
+ run(trailing[trailing.length - 1]);
+ }
+ };
+
+ return (
+
+ (
+
+ )}
+ renderRightActions={(_progress, translation) => (
+
+ )}
+ onSwipeableWillOpen={onWillOpen}
+ >
+ {children}
+
+
+ );
+}
+
+interface ActionPaneProps {
+ side: "left" | "right";
+ actions: readonly ThreadSwipeAction[];
+ translation: SharedValue;
+ rowWidth: SharedValue;
+ committed: SharedValue;
+ onAction: (action: ThreadSwipeAction) => void;
+}
+
+/**
+ * One side's actions. The swipeable measures the pane's natural width as
+ * the open position; past it the pane keeps growing with the drag and the
+ * outermost action fills the extra width (Mail's full-swipe affordance).
+ * The pane also tracks, on the UI thread, whether the drag on its side has
+ * crossed the full-swipe threshold.
+ */
+function ActionPane({
+ side,
+ actions,
+ translation,
+ rowWidth,
+ committed,
+ onAction,
+}: ActionPaneProps) {
+ const baseWidth = actions.length * ACTION_WIDTH;
+ useAnimatedReaction(
+ () => translation.get(),
+ (value) => {
+ // Each pane tracks the drag on its own side; a closed row clears.
+ const revealed = side === "left" ? value : -value;
+ if (revealed <= 0) {
+ if (value === 0) committed.set(0);
+ return;
+ }
+ const width = rowWidth.get();
+ if (width > 0 && revealed >= width * FULL_SWIPE_RATIO) {
+ committed.set(side === "left" ? 1 : -1);
+ } else if (revealed < baseWidth - 1) {
+ // Back under the pane's open width: only the finger gets here (a
+ // released row springs down to that width and stops), so the full
+ // swipe was cancelled.
+ committed.set(0);
+ }
+ },
+ );
+ const stretch = useAnimatedStyle(() => {
+ const revealed = side === "left" ? translation.get() : -translation.get();
+ return { width: Math.max(baseWidth, revealed) };
+ });
+ const outermost = side === "left" ? 0 : actions.length - 1;
+ return (
+
+ {actions.map((action, index) => (
+ onAction(action)}
+ />
+ ))}
+
+ );
+}
+
+function ActionButton({
+ action,
+ side,
+ stretch,
+ onPress,
+}: {
+ action: ThreadSwipeAction;
+ side: "left" | "right";
+ stretch: boolean;
+ onPress: () => void;
+}) {
+ const { tokens } = useTheme();
+ return (
+ ({
+ backgroundColor: action.color,
+ opacity: pressed ? 0.8 : 1,
+ width: stretch ? undefined : ACTION_WIDTH,
+ flexGrow: stretch ? 1 : 0,
+ justifyContent: "center",
+ // The glyph stays by the row edge while the pane stretches.
+ alignItems: side === "right" ? "flex-end" : "flex-start",
+ })}
+ testID={`thread-swipe-${action.key}`}
+ >
+
+
+
+ {action.label}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/screens/sidebar/ThreadStatusGlyph.ios.tsx b/apps/mobile/src/screens/sidebar/ThreadStatusGlyph.ios.tsx
new file mode 100644
index 0000000000..fb5c989373
--- /dev/null
+++ b/apps/mobile/src/screens/sidebar/ThreadStatusGlyph.ios.tsx
@@ -0,0 +1,100 @@
+import {
+ getThreadListIndicatorLabel,
+ type ThreadListIndicatorKind,
+} from "@bb/client-core";
+import { Image } from "expo-image";
+import { View } from "react-native";
+import type { SFSymbol } from "sf-symbols-typescript";
+import { useTheme } from "@/theme";
+import { Spinner } from "@/ui";
+
+const GLYPH_SIZE = 18;
+const UNREAD_DOT_SIZE = 8;
+
+type GlyphTone = "muted" | "destructive" | "warning";
+
+/**
+ * SF Symbol per indicator (filled variants where the status is a verdict).
+ * The remaining kinds are drawn directly: `runtime` spins, `unread-success`
+ * is the tinted dot, `none` is empty.
+ */
+const GLYPHS: Record<
+ Exclude,
+ { symbol: SFSymbol; tone: GlyphTone }
+> = {
+ "unread-error": { symbol: "xmark.circle.fill", tone: "destructive" },
+ "waiting-for-input": { symbol: "questionmark.circle", tone: "warning" },
+ "working-draft": { symbol: "pencil", tone: "muted" },
+ workflow: {
+ symbol: "point.3.connected.trianglepath.dotted",
+ tone: "muted",
+ },
+ "background-agent": { symbol: "person.badge.plus", tone: "muted" },
+ "background-command": { symbol: "terminal", tone: "muted" },
+ "plan-mode": { symbol: "checklist", tone: "muted" },
+ goal: { symbol: "target", tone: "muted" },
+ draft: { symbol: "pencil", tone: "muted" },
+};
+
+/**
+ * iOS status glyph of a thread row: SF Symbols through expo-image (the
+ * `tintColor` must stay a token string), the unread dot in the tint, the
+ * system spinner for a running thread. Metro picks this file on iOS;
+ * `ThreadStatusGlyph.tsx` is the Hugeicons sibling. Precedence lives in
+ * `resolveThreadListIndicator`; this only maps a kind to a glyph.
+ */
+export function ThreadStatusGlyph({ kind }: { kind: ThreadListIndicatorKind }) {
+ const { tokens } = useTheme();
+ const label = getThreadListIndicatorLabel(kind) ?? undefined;
+ switch (kind) {
+ case "none":
+ return null;
+ case "runtime":
+ return (
+
+
+
+ );
+ case "unread-success":
+ return (
+
+ );
+ default: {
+ const glyph = GLYPHS[kind];
+ const color =
+ glyph.tone === "destructive"
+ ? tokens.destructive
+ : glyph.tone === "warning"
+ ? tokens.warning
+ : tokens.mutedForeground;
+ return (
+
+ );
+ }
+ }
+}
diff --git a/apps/mobile/src/screens/sidebar/index.ts b/apps/mobile/src/screens/sidebar/index.ts
index 18e5636d5b..02ec6a349a 100644
--- a/apps/mobile/src/screens/sidebar/index.ts
+++ b/apps/mobile/src/screens/sidebar/index.ts
@@ -1,6 +1,9 @@
export {
+ ORGANIZE_OPTIONS,
SidebarActionsProvider,
+ SORT_OPTIONS,
useSidebarActions,
+ type SidebarActions,
} from "./SidebarActionsProvider";
export { SidebarThreadList } from "./SidebarThreadList";
export {
diff --git a/apps/mobile/src/screens/terminal/TerminalAccessoryBar.tsx b/apps/mobile/src/screens/terminal/TerminalAccessoryBar.tsx
index 7b7aa57eea..298c1fe948 100644
--- a/apps/mobile/src/screens/terminal/TerminalAccessoryBar.tsx
+++ b/apps/mobile/src/screens/terminal/TerminalAccessoryBar.tsx
@@ -1,13 +1,31 @@
-import { Pressable, ScrollView, View } from "react-native";
+import { Pressable, ScrollView, StyleSheet, View } from "react-native";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
-import { cn, Icon, Text, type IconName } from "@/ui";
+import {
+ Icon,
+ NativeMenu,
+ Text,
+ type IconName,
+ type NativeMenuAction,
+ type SFSymbol,
+} from "@/ui";
import type { TerminalAccessoryKey } from "./terminal-bridge";
+const IS_IOS = process.env.EXPO_OS === "ios";
+/** Key cap metrics (the iOS keyboard accessory row). */
+const KEY_HEIGHT = 34;
+const KEY_MIN_WIDTH = 38;
+const KEY_RADIUS = 6;
+/** The trailing "…" / keyboard buttons. */
+const BAR_BUTTON_WIDTH = 44;
+
/**
* The key bar above the soft keyboard: keys a phone keyboard lacks (Esc, Tab,
* arrows, Home / End, shell punctuation), a sticky Ctrl modifier applied to
* the next key, and paste from the clipboard. Always visible under the
- * terminal so the arrows work without the keyboard too.
+ * terminal so the arrows work without the keyboard too. Styled as an iOS
+ * keyboard accessory strip: key caps on the raised surface, the sticky Ctrl
+ * tinted, a selection tick per key.
*/
interface TerminalAccessoryBarProps {
@@ -15,21 +33,29 @@ interface TerminalAccessoryBarProps {
onToggleCtrl: () => void;
onKey: (key: TerminalAccessoryKey) => void;
onPaste: () => void;
- /** Raise the keyboard (focus the page's textarea). */
+ /** Toggle the soft keyboard (focus / blur the page's textarea). */
onKeyboard?: () => void;
+ /** Whether the keyboard is up: picks the show / hide glyph. */
+ keyboardVisible?: boolean;
/**
- * Terminal actions (rename / restart / new / close). Full screen only: it
- * duplicates the header's "…" so the menu is reachable one-handed and in
- * landscape.
+ * Android: opens the terminal actions sheet (rename / restart / new /
+ * close). Full screen only: it duplicates the header's "…" so the menu is
+ * reachable one-handed and in landscape.
*/
onMenu?: () => void;
+ /** iOS: the same actions as a native menu anchored to the "…" key. */
+ menuActions?: readonly NativeMenuAction[];
testID?: string;
}
interface AccessoryItem {
id: string;
+ /** Key cap text (also the Android glyph when only `symbol` is set). */
label?: string;
+ /** Glyph on both platforms (SF on iOS through the icon map). */
icon?: IconName;
+ /** iOS glyph for caps whose symbol is not in the icon map (`arrow.left`, `doc.on.clipboard`). */
+ symbol?: SFSymbol;
accessibilityLabel: string;
key?: TerminalAccessoryKey;
}
@@ -41,19 +67,28 @@ const ITEMS: readonly AccessoryItem[] = [
{
id: "ArrowLeft",
label: "←",
+ symbol: "arrow.left",
accessibilityLabel: "Arrow left",
key: "ArrowLeft",
},
- { id: "ArrowUp", label: "↑", accessibilityLabel: "Arrow up", key: "ArrowUp" },
+ {
+ id: "ArrowUp",
+ label: "↑",
+ symbol: "arrow.up",
+ accessibilityLabel: "Arrow up",
+ key: "ArrowUp",
+ },
{
id: "ArrowDown",
label: "↓",
+ symbol: "arrow.down",
accessibilityLabel: "Arrow down",
key: "ArrowDown",
},
{
id: "ArrowRight",
label: "→",
+ symbol: "arrow.right",
accessibilityLabel: "Arrow right",
key: "ArrowRight",
},
@@ -62,34 +97,69 @@ const ITEMS: readonly AccessoryItem[] = [
{ id: "-", label: "-", accessibilityLabel: "Minus", key: "-" },
{ id: "/", label: "/", accessibilityLabel: "Slash", key: "/" },
{ id: "|", label: "|", accessibilityLabel: "Pipe", key: "|" },
- { id: "paste", icon: "Copy", accessibilityLabel: "Paste" },
+ {
+ id: "paste",
+ icon: "Copy",
+ symbol: "doc.on.clipboard",
+ accessibilityLabel: "Paste",
+ },
];
+function KeyGlyph({ item, color }: { item: AccessoryItem; color: string }) {
+ if (item.icon !== undefined || (IS_IOS && item.symbol !== undefined)) {
+ return (
+
+ );
+ }
+ return (
+
+ {item.label}
+
+ );
+}
+
export function TerminalAccessoryBar({
ctrlActive,
onToggleCtrl,
onKey,
onPaste,
onKeyboard,
+ keyboardVisible = false,
onMenu,
+ menuActions,
testID,
}: TerminalAccessoryBarProps) {
const { tokens } = useTheme();
+ const menuGlyph = (
+
+ );
return (
{ITEMS.map((item) => {
const isCtrl = item.id === "ctrl";
@@ -102,60 +172,106 @@ export function TerminalAccessoryBar({
accessibilityState={isCtrl ? { selected: ctrlActive } : undefined}
testID={`terminal-key-${item.id}`}
onPress={() => {
+ haptic("selection");
if (isCtrl) onToggleCtrl();
else if (item.id === "paste") onPaste();
else if (item.key) onKey(item.key);
}}
- className={cn(
- "h-9 min-w-9 items-center justify-center rounded-md border px-2.5 active:bg-state-active",
- active
- ? "border-foreground bg-foreground"
- : "border-border bg-background",
- )}
+ style={({ pressed }) => [
+ styles.key,
+ {
+ backgroundColor: active ? tokens.primary : tokens.secondary,
+ opacity: pressed ? 0.6 : 1,
+ },
+ ]}
>
- {item.icon ? (
-
- ) : (
-
- {item.label}
-
- )}
+
);
})}
- {onMenu ? (
+ {menuActions ? (
+ // An icon-only trigger: the menu host is the accessible element
+ // (label, role, testID); the glyph view inside is not.
+
+ {menuGlyph}
+
+ ) : onMenu ? (
[
+ styles.barButton,
+ { opacity: pressed ? 0.5 : 1 },
+ ]}
>
-
+ {menuGlyph}
) : null}
{onKeyboard ? (
{
+ haptic("selection");
+ onKeyboard();
+ }}
+ style={({ pressed }) => [
+ styles.barButton,
+ { opacity: pressed ? 0.5 : 1 },
+ ]}
>
-
+
) : null}
);
}
+
+const styles = StyleSheet.create({
+ bar: {
+ flexDirection: "row",
+ alignItems: "center",
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+ scroll: { flex: 1 },
+ keys: {
+ paddingHorizontal: 8,
+ paddingVertical: 6,
+ gap: 6,
+ },
+ key: {
+ height: KEY_HEIGHT,
+ minWidth: KEY_MIN_WIDTH,
+ paddingHorizontal: 10,
+ borderRadius: KEY_RADIUS,
+ borderCurve: "continuous",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ barButton: {
+ width: BAR_BUTTON_WIDTH,
+ height: KEY_HEIGHT + 12,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/src/screens/terminal/TerminalScreen.tsx b/apps/mobile/src/screens/terminal/TerminalScreen.tsx
index 08623e41b8..bbb7f08452 100644
--- a/apps/mobile/src/screens/terminal/TerminalScreen.tsx
+++ b/apps/mobile/src/screens/terminal/TerminalScreen.tsx
@@ -1,7 +1,7 @@
import type { TerminalSession } from "@bb/server-contract";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useState } from "react";
-import { Pressable, View } from "react-native";
+import { Alert, Pressable, View } from "react-native";
import {
useCloseTerminal,
useCreateTerminal,
@@ -12,27 +12,33 @@ import {
import { useTheme } from "@/theme";
import { useProfiles } from "@/app-shell/ProfilesProvider";
import {
- EmptyStatePanel,
+ confirmDestructive,
Icon,
KeyboardPaddingView,
ListRow,
Separator,
+ sfSymbolFor,
Sheet,
Text,
toast,
useSheet,
- type IconName,
+ type NativeMenuAction,
type SheetController,
} from "@/ui";
import { ConnectionBanner } from "../shell/ConnectionBanner";
import { threadTerminalHref } from "../shell/hrefs";
+import { ScreenTitle } from "../shell/ScreenTitle";
import { SheetNameForm } from "../sidebar/SheetNameForm";
import { TerminalTabContent } from "./TerminalTabContent";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
/**
* `/threads/[id]/terminal/[terminalId]`: one terminal full screen (any
- * orientation). Header: the session title (tap → rename), a "…" menu with
- * rename / restart / new terminal / close.
+ * orientation). Header: the session title and a "…" menu with rename /
+ * restart / new terminal / close — a native toolbar menu on iOS (rename
+ * through the system prompt, close through the destructive confirmation),
+ * a sheet with the same rows on Android.
*/
export function TerminalScreen() {
// The route can be restored before a profile is active (cold start on the
@@ -47,10 +53,12 @@ export function TerminalScreen() {
<>
- No active server.
+
+ No active server.
+
>
);
@@ -77,8 +85,9 @@ function ConnectedTerminalScreen({
const closeTerminal = useCloseTerminal();
const createTerminal = useCreateTerminal();
const renameTerminal = useRenameTerminal();
- // One sheet with two views (menu / rename): presenting a second modal
- // while the first dismisses leaves an empty backdrop on iOS.
+ // Android: one sheet with two views (menu / rename) — presenting a second
+ // modal while the first dismisses leaves an empty backdrop. iOS never
+ // mounts it (native menu + system prompt).
const sheet = useSheet();
const [sheetView, setSheetView] = useState<"menu" | "rename" | null>(null);
const openMenu = useCallback(() => {
@@ -123,15 +132,57 @@ function ConnectedTerminalScreen({
},
);
}, [closeTerminal, router, terminalId]);
+ const rename = useCallback(
+ (title: string) => {
+ if (!session) return;
+ renameTerminal.mutate({ terminalId: session.id, title });
+ },
+ [renameTerminal, session],
+ );
+ const promptRename = useCallback(() => {
+ if (!session) return;
+ if (process.env.EXPO_OS === "ios") {
+ // The system text-field alert, prefilled with the current title.
+ Alert.prompt(
+ "Rename terminal",
+ undefined,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Rename",
+ onPress: (title?: string) => {
+ const next = title?.trim() ?? "";
+ if (next.length > 0 && next !== session.title) rename(next);
+ },
+ },
+ ],
+ "plain-text",
+ session.title,
+ );
+ return;
+ }
+ setSheetView("rename");
+ }, [rename, session]);
const running = session?.status === "running";
- const actions: TerminalMenuAction[] = [
+ const confirmClose = useCallback(() => {
+ confirmDestructive({
+ title: running ? "Close this terminal?" : "Remove this terminal?",
+ message: running
+ ? "The shell and anything running in it will be killed."
+ : undefined,
+ actionLabel: running ? "Close" : "Remove",
+ onConfirm: handleClose,
+ });
+ }, [handleClose, running]);
+
+ const actions: NativeMenuAction[] = [
{
key: "rename",
label: "Rename",
icon: "Edit",
disabled: !session,
- onPress: () => setSheetView("rename"),
+ onPress: promptRename,
},
{
key: "restart",
@@ -161,7 +212,7 @@ function ConnectedTerminalScreen({
disabled: !session || closeTerminal.isPending,
onPress: () => {
sheet.dismiss();
- handleClose();
+ confirmClose();
},
},
];
@@ -169,24 +220,61 @@ function ConnectedTerminalScreen({
return (
<>
(
-
-
-
- ),
- }}
+ options={
+ IS_IOS
+ ? {
+ orientation: "all",
+ // Nothing scrolls under the bar (the xterm canvas fills the
+ // screen), so it stays opaque in the canvas color.
+ headerTransparent: false,
+ headerStyle: { backgroundColor: tokens.surfaceRaisedSolid },
+ }
+ : {
+ orientation: "all",
+ headerRight: () => (
+
+
+
+ ),
+ }
+ }
/>
-
-
+ {session?.title ?? "Terminal"}
+ {IS_IOS ? (
+
+
+ {actions.map((action) => (
+
+ {action.label}
+
+ ))}
+
+
+ ) : null}
+
+
- {
- if (!open) setSheetView(null);
- }}
- >
- {
- if (!session) return;
- renameTerminal.mutate(
- { terminalId: session.id, title },
- { onSettled: () => sheet.dismiss() },
- );
+ {IS_IOS ? null : (
+ {
+ if (!open) setSheetView(null);
}}
- sheet={sheet}
- />
-
+ >
+ {
+ if (!session) return;
+ renameTerminal.mutate(
+ { terminalId: session.id, title },
+ { onSettled: () => sheet.dismiss() },
+ );
+ }}
+ sheet={sheet}
+ />
+
+ )}
>
);
}
-interface TerminalMenuAction {
- key: string;
- label: string;
- icon: IconName;
- destructive?: boolean;
- disabled?: boolean;
- onPress: () => void;
-}
-
interface TerminalActionsSheetBodyProps {
view: "menu" | "rename" | null;
session: TerminalSession | null;
- actions: readonly TerminalMenuAction[];
+ actions: readonly NativeMenuAction[];
renamePending: boolean;
onRename: (title: string) => void;
sheet: SheetController;
}
+/** Android: the "…" sheet's rows, or the rename form once Rename was picked. */
function TerminalActionsSheetBody({
view,
session,
diff --git a/apps/mobile/src/screens/terminal/TerminalSessionsList.tsx b/apps/mobile/src/screens/terminal/TerminalSessionsList.tsx
index ee26da5b27..ab18eef865 100644
--- a/apps/mobile/src/screens/terminal/TerminalSessionsList.tsx
+++ b/apps/mobile/src/screens/terminal/TerminalSessionsList.tsx
@@ -9,7 +9,14 @@ import {
useTerminals,
} from "@/data/terminals";
import type { TerminalQueryScope } from "@/lib/query/query-keys";
-import { Button, EmptyStatePanel, ListRow, Skeleton, Text } from "@/ui";
+import {
+ Button,
+ GroupedRow,
+ GroupedSection,
+ Skeleton,
+ Text,
+ type GroupedSurface,
+} from "@/ui";
/**
* The scope's terminal sessions with a "Start terminal" action: the body of
@@ -21,6 +28,8 @@ interface TerminalSessionsListProps {
listScope: TerminalQueryScope;
createScope: TerminalCreateScope;
onOpenTerminal: (terminalId: string) => void;
+ /** What the session cards sit on: the grouped page (full screen) or the panel's raised surface. */
+ surface?: GroupedSurface;
testID?: string;
}
@@ -29,8 +38,10 @@ export function TerminalSessionsList(props: TerminalSessionsListProps) {
const { connection } = useProfiles();
if (!connection) {
return (
-
- No active server.
+
+
+ No active server.
+
);
}
@@ -41,6 +52,7 @@ function ConnectedTerminalSessionsList({
listScope,
createScope,
onOpenTerminal,
+ surface = "grouped",
testID = "terminal-sessions",
}: TerminalSessionsListProps) {
const terminalsQuery = useTerminals(listScope);
@@ -58,7 +70,7 @@ function ConnectedTerminalSessionsList({
};
return (
-
+
{terminalsQuery.isLoading && !terminalsQuery.data ? (
-
-
+
+
) : terminalsQuery.error && !terminalsQuery.data ? (
-
-
+
+
Failed to load terminals.
-
+
{terminalsQuery.error.message}
-
+
) : sessions.length === 0 ? (
- No terminals
+
+ No terminals
+
) : (
-
- {sessions.map((session, index) => {
+
+ {sessions.map((session) => {
const row = describeTerminalSessionRow(session);
return (
-
- {index > 0 ? (
-
- ) : null}
- onOpenTerminal(session.id)}
- testID={`terminal-session-row-${session.id}`}
- />
-
+ onOpenTerminal(session.id)}
+ testID={`terminal-session-row-${session.id}`}
+ />
);
})}
-
+
)}
);
diff --git a/apps/mobile/src/screens/terminal/TerminalTabContent.tsx b/apps/mobile/src/screens/terminal/TerminalTabContent.tsx
index f54f78f776..2582348eaf 100644
--- a/apps/mobile/src/screens/terminal/TerminalTabContent.tsx
+++ b/apps/mobile/src/screens/terminal/TerminalTabContent.tsx
@@ -1,8 +1,8 @@
import type { TerminalSession } from "@bb/server-contract";
import { useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
-import { useCallback, useRef, useState } from "react";
-import { View } from "react-native";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Keyboard, StyleSheet, View } from "react-native";
import { e2eModeEnabled } from "@/app-shell/e2e";
import { useProfileClient, useProfiles } from "@/app-shell/ProfilesProvider";
import {
@@ -12,16 +12,21 @@ import {
useFetchTerminalOutput,
useTerminalSession,
} from "@/data/terminals";
-import { Button, EmptyStatePanel, Spinner, Text, toast } from "@/ui";
+import { useTheme } from "@/theme";
+import { Button, Spinner, Text, toast, type NativeMenuAction } from "@/ui";
import { TerminalAccessoryBar } from "./TerminalAccessoryBar";
import { TerminalView, type TerminalViewHandle } from "./TerminalView";
import type { TerminalAccessoryKey } from "./terminal-bridge";
import { useTerminalTitleSync } from "./use-terminal-title-sync";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
/**
* One terminal session as tab / screen content: the attached xterm view, the
* accessory key bar, and the not-running states. Usable inside the thread
- * panel's Terminal tab or full screen (`TerminalScreen`).
+ * panel's Terminal tab or full screen (`TerminalScreen`). The chrome sits on
+ * the raised solid surface the xterm canvas is painted with, so the page and
+ * its frame read as one.
*/
interface TerminalTabContentProps {
@@ -31,8 +36,10 @@ interface TerminalTabContentProps {
onRestart?: () => void;
onStartNew?: () => void;
restartPending?: boolean;
- /** Adds a "…" key to the accessory bar (the full-screen route's menu). */
+ /** Android: adds a "…" key that opens the full-screen route's actions sheet. */
onMenu?: () => void;
+ /** iOS: the full-screen route's actions as a native menu on the "…" key. */
+ menuActions?: readonly NativeMenuAction[];
/** False for a retained-but-hidden terminal (inactive panel tab / closed sheet). */
visible?: boolean;
testID?: string;
@@ -43,13 +50,17 @@ export function TerminalTabContent(props: TerminalTabContentProps) {
// outlive its screen (profile switch, sign-out); the session hooks below
// need an active connection.
const { connection } = useProfiles();
+ const { tokens } = useTheme();
if (!connection) {
return (
- No active server.
+
+ No active server.
+
);
}
@@ -63,16 +74,19 @@ function ConnectedTerminalTabContent({
onStartNew,
restartPending = false,
onMenu,
+ menuActions,
visible = true,
testID = "terminal-tab",
}: TerminalTabContentProps) {
+ const { tokens } = useTheme();
const sessionQuery = useTerminalSession(terminalId);
const session = sessionQuery.data;
if (sessionQuery.isLoading && !session) {
return (
@@ -81,19 +95,28 @@ function ConnectedTerminalTabContent({
}
if (!session) {
return (
-
-
-
+
+
+
{sessionQuery.error
? "Could not load this terminal."
: "This terminal no longer exists."}
{sessionQuery.error ? (
-
+
{sessionQuery.error.message}
) : null}
-
+
{onStartNew ? (
Start new terminal
@@ -111,6 +134,7 @@ function ConnectedTerminalTabContent({
onStartNew={onStartNew}
restartPending={restartPending}
onMenu={onMenu}
+ menuActions={menuActions}
visible={visible}
testID={testID}
/>
@@ -124,6 +148,7 @@ interface AttachedTerminalProps {
onStartNew?: () => void;
restartPending: boolean;
onMenu?: () => void;
+ menuActions?: readonly NativeMenuAction[];
visible: boolean;
testID: string;
}
@@ -135,9 +160,11 @@ function AttachedTerminal({
onStartNew,
restartPending,
onMenu,
+ menuActions,
visible,
testID,
}: AttachedTerminalProps) {
+ const { tokens } = useTheme();
const { serverUrl } = useProfileClient();
const queryClient = useQueryClient();
const fetchOutput = useFetchTerminalOutput();
@@ -150,6 +177,25 @@ function AttachedTerminal({
const [hadLiveSession] = useState(session.status !== "exited");
const handleTitleChange = useTerminalTitleSync(session);
+ // The WebView owns the keyboard; the accessory bar's keyboard key toggles
+ // it, so track whether one is up (any keyboard: the notifications are
+ // app-wide).
+ const [keyboardVisible, setKeyboardVisible] = useState(false);
+ useEffect(() => {
+ const show = Keyboard.addListener(
+ IS_IOS ? "keyboardWillShow" : "keyboardDidShow",
+ () => setKeyboardVisible(true),
+ );
+ const hide = Keyboard.addListener(
+ IS_IOS ? "keyboardWillHide" : "keyboardDidHide",
+ () => setKeyboardVisible(false),
+ );
+ return () => {
+ show.remove();
+ hide.remove();
+ };
+ }, []);
+
const handleSessionChange = useCallback(
(next: TerminalSession) => {
if (next.status === "exited")
@@ -177,13 +223,19 @@ function AttachedTerminal({
() => toast.error("Could not read the clipboard"),
);
}, []);
- const focusTerminal = useCallback(() => viewRef.current?.focus(), []);
+ const toggleKeyboard = useCallback(() => {
+ if (keyboardVisible) viewRef.current?.blur();
+ else viewRef.current?.focus();
+ }, [keyboardVisible]);
const notice = terminalSessionStatusNotice(session);
const showView = hadLiveSession;
return (
-
+
{showView ? (
) : null}
{notice !== null ? (
-
+
{notice}
{!showView ? (
-
+
Its output is no longer available.
) : null}
-
+
{onRestart ? (
setCtrlActive((value) => !value)}
onKey={handleKey}
onPaste={handlePaste}
- onKeyboard={focusTerminal}
+ onKeyboard={toggleKeyboard}
+ keyboardVisible={keyboardVisible}
onMenu={onMenu}
+ menuActions={menuActions}
testID="terminal-accessory-bar"
/>
) : null}
@@ -275,3 +335,12 @@ function lastNonEmptyLine(lines: readonly string[]): string {
}
return "";
}
+
+const styles = StyleSheet.create({
+ fill: { flex: 1 },
+ statusCard: {
+ borderTopWidth: StyleSheet.hairlineWidth,
+ padding: 16,
+ gap: 12,
+ },
+});
diff --git a/apps/mobile/src/screens/terminal/TerminalView.tsx b/apps/mobile/src/screens/terminal/TerminalView.tsx
index 44ad2257ce..6b412f4da4 100644
--- a/apps/mobile/src/screens/terminal/TerminalView.tsx
+++ b/apps/mobile/src/screens/terminal/TerminalView.tsx
@@ -334,7 +334,7 @@ export const TerminalView = forwardRef(
Could not load the terminal page.
-
+
{page.error.message}
diff --git a/apps/mobile/src/screens/terminal/ThreadTerminalsScreen.tsx b/apps/mobile/src/screens/terminal/ThreadTerminalsScreen.tsx
index 36f5551dfd..2a02605993 100644
--- a/apps/mobile/src/screens/terminal/ThreadTerminalsScreen.tsx
+++ b/apps/mobile/src/screens/terminal/ThreadTerminalsScreen.tsx
@@ -1,6 +1,6 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback } from "react";
-import { View } from "react-native";
+import { ScrollView, View } from "react-native";
import { useProfiles } from "@/app-shell/ProfilesProvider";
import { EmptyStatePanel } from "@/ui";
import { Screen } from "../shell/Screen";
@@ -9,7 +9,8 @@ import { TerminalSessionsList } from "./TerminalSessionsList";
/**
* `/threads/[id]/terminal`: the thread's terminals (list + Start), the route
- * behind the panel's Terminal tab for deep links and full-screen use.
+ * behind the panel's Terminal tab for deep links and full-screen use. A
+ * grouped page: the session cards sit on the grouped background.
*/
export function ThreadTerminalsScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
@@ -31,12 +32,21 @@ export function ThreadTerminalsScreen() {
No active server.
) : (
-
+
+
+
+
+
)}
>
diff --git a/apps/mobile/src/screens/terminal/panel-contents.tsx b/apps/mobile/src/screens/terminal/panel-contents.tsx
index 9a63dec2a7..3fb1ed5c25 100644
--- a/apps/mobile/src/screens/terminal/panel-contents.tsx
+++ b/apps/mobile/src/screens/terminal/panel-contents.tsx
@@ -9,7 +9,7 @@ import {
useTerminalSession,
} from "@/data/terminals";
import { useTheme } from "@/theme";
-import { EmptyStatePanel, Icon, KeyboardPaddingView, Text } from "@/ui";
+import { Icon, KeyboardPaddingView, Text } from "@/ui";
// Leaf imports: the panel barrel pulls in the registration manifest, which
// imports this module (see the panel README).
import { usePanel } from "../panel/PanelProvider";
@@ -46,10 +46,10 @@ export function TerminalLauncherContent({ scope }: PanelLauncherContentProps) {
);
if (listScope === null || createScope === null) {
return (
-
-
+
+
{terminalScopeUnavailableMessage(scope)}
-
+
);
}
@@ -58,6 +58,7 @@ export function TerminalLauncherContent({ scope }: PanelLauncherContentProps) {
listScope={listScope}
createScope={createScope}
onOpenTerminal={onOpenTerminal}
+ surface="raised"
testID="panel-terminal-launcher"
/>
);
@@ -71,8 +72,10 @@ export function TerminalPanelTabContent(
const { connection } = useProfiles();
if (!connection) {
return (
-
- No active server.
+
+
+ No active server.
+
);
}
@@ -167,6 +170,11 @@ interface TerminalToolbarProps {
onClose: () => void;
}
+/**
+ * The panel terminal's bar: the session title (tap → full screen) and the
+ * tinted restart / new / close glyphs, on the same raised surface as the
+ * xterm canvas under a hairline.
+ */
function TerminalToolbar({
title,
onExpand,
@@ -176,21 +184,34 @@ function TerminalToolbar({
}: TerminalToolbarProps) {
const { tokens } = useTheme();
return (
-
+
[styles.title, { opacity: pressed ? 0.6 : 1 }]}
testID="panel-terminal-title"
>
-
-
+
+
{title}
{onExpand ? (
-
+
) : null}
[styles.button, { opacity: pressed ? 0.5 : 1 }]}
testID={testID}
>
-
+
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
+ toolbar: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 4,
+ paddingLeft: 12,
+ paddingRight: 8,
+ paddingVertical: 6,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ title: {
+ minWidth: 0,
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ paddingVertical: 4,
+ },
+ button: {
+ width: 32,
+ height: 32,
+ alignItems: "center",
+ justifyContent: "center",
+ },
});
diff --git a/apps/mobile/src/screens/terminal/terminal-theme.ts b/apps/mobile/src/screens/terminal/terminal-theme.ts
index f1732203e9..e0b101a844 100644
--- a/apps/mobile/src/screens/terminal/terminal-theme.ts
+++ b/apps/mobile/src/screens/terminal/terminal-theme.ts
@@ -3,18 +3,21 @@ import type { TerminalPageTheme } from "./terminal-bridge";
/**
* xterm theme from the native tokens (the web's
- * `buildTerminalThemeFromCssColors`: the canvas and cursor cutout are the
- * sidebar surface, selection is `muted`, ANSI 0-15 are the palette's
- * `--ansi-*`).
+ * `buildTerminalThemeFromCssColors`): the canvas and cursor cutout are the
+ * raised solid surface — the workspace panel's sheet color, which the
+ * terminal chrome (toolbar, accessory bar, status card) is painted with too,
+ * so the page and its frame read as one — selection is `muted`, ANSI 0-15
+ * are the palette's `--ansi-*`. Strings only: the theme is serialized to the
+ * WebView page.
*/
export function buildTerminalThemeFromTokens(
tokens: NativeThemeTokens,
): TerminalPageTheme {
return {
- background: tokens.sidebar,
+ background: tokens.surfaceRaisedSolid,
foreground: tokens.foreground,
cursor: tokens.foreground,
- cursorAccent: tokens.sidebar,
+ cursorAccent: tokens.surfaceRaisedSolid,
selectionBackground: tokens.muted,
black: tokens.ansi0,
red: tokens.ansi1,
diff --git a/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx b/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx
index 3dabb3c147..ed4c3c3932 100644
--- a/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx
+++ b/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx
@@ -1,15 +1,34 @@
-import { Pressable, View } from "react-native";
+import type { ThreadResponse } from "@bb/server-contract";
+import { Stack } from "expo-router";
+import { Pressable, useWindowDimensions, View } from "react-native";
import { useTheme } from "@/theme";
-import { cn, Icon, Text } from "@/ui";
+import { cn, Icon, sfSymbolFor, Text } from "@/ui";
import { PanelToggleButton } from "../panel/PanelToggleButton";
+import { useThreadActions } from "./actions/use-thread-actions";
import type { ThreadStatusPill } from "./thread-detail-header-model";
+const IS_IOS = process.env.EXPO_OS === "ios";
+/**
+ * Room the bar items take on one side: the iOS 26 glass group holding the
+ * panel and menu buttons (about 110pt) plus its margins. The back button
+ * side is narrower, but the title is centered, so the wider side bounds
+ * both.
+ */
+const BAR_ITEMS_INSET = 131;
+/** The minimal back button plus its margins. */
+const BACK_ITEM_INSET = 68;
+const TITLE_MIN_WIDTH = 120;
+
/**
* The thread screen's native header pieces. There is one header only: the
* title (tap to rename) with a status subtitle while the thread needs
- * attention, has an error, or waits on a host, and two buttons on the right — the workspace panel
- * and the "…" menu. Everything else the old two-layer header carried
- * (environment line, child roll-up, git action) lives in the menu sheet.
+ * attention, has an error, or waits on a host, and two items on the right —
+ * the workspace panel and the "…" menu. On iOS those are native bar items
+ * (`Stack.Toolbar`: a selected-state button and a pull-down `UIMenu` built
+ * from the thread action model); on Android they stay `headerRight`
+ * Pressables that open the bottom sheet. Everything else the old two-layer
+ * header carried (environment line, child roll-up, git action) lives in
+ * the menu.
*/
interface ThreadHeaderTitleProps {
@@ -44,6 +63,15 @@ export function ThreadHeaderTitle({
onPressTitle,
}: ThreadHeaderTitleProps) {
const { tokens } = useTheme();
+ const { width } = useWindowDimensions();
+ // UIKit centers a custom title view in the bar and sizes it from our
+ // layout, so it never shrinks to the room between the bar items: bound it
+ // by the wider side (the two-item right group) on both sides, or a long
+ // title runs under the buttons.
+ const maxWidth = Math.max(
+ TITLE_MIN_WIDTH,
+ width - BACK_ITEM_INSET - BAR_ITEMS_INSET,
+ );
const subtitle = headerSubtitle(statusPill, childPillLabel);
const subtitleColor =
statusPill.tone === "error"
@@ -59,11 +87,13 @@ export function ThreadHeaderTitle({
disabled={!onPressTitle}
onPress={onPressTitle ?? undefined}
hitSlop={8}
- className="max-w-[240px] items-center"
+ className="items-center"
+ style={{ maxWidth }}
testID="thread-detail-header"
>
{subtitle ? (
);
}
+
+export interface ThreadHeaderGitAction {
+ label: string;
+ pending: boolean;
+ onPress: () => void;
+}
+
+interface ThreadHeaderToolbarProps {
+ /** Undefined while loading: the items render disabled. */
+ thread: ThreadResponse | undefined;
+ panelActive: boolean;
+ onOpenPanel: () => void;
+ /** The primary git action (Commit / Squash merge); null hides the row. */
+ gitAction: ThreadHeaderGitAction | null;
+ /** Menu heading: "project · host · worktree · branch" (+ child roll-up). */
+ menuTitle: string | null;
+ onDeleted: () => void;
+ onHandoffToNewThread: () => void;
+ onNewThreadInWorktree: (() => void) | null;
+ /** Opens the rename prompt. */
+ onRename: () => void;
+}
+
+const PANEL_SYMBOL =
+ sfSymbolFor("PanelBottom") ?? "rectangle.bottomthird.inset.filled";
+
+/**
+ * iOS header items: the workspace-panel button (selected while the panel is
+ * up) and the "…" menu. Renders nothing elsewhere; Android keeps
+ * `ThreadHeaderActions`. Every `Stack.Toolbar.*` element is a direct child
+ * of the one `Stack.Toolbar` (expo-router converts the tree into native
+ * `UIBarButtonItem`s, so the pieces cannot be split into components).
+ */
+export function ThreadHeaderToolbar(props: ThreadHeaderToolbarProps) {
+ if (!IS_IOS) return null;
+ if (props.thread === undefined) {
+ return (
+
+
+
+
+ );
+ }
+ return ;
+}
+
+function ThreadHeaderToolbarReady({
+ thread,
+ panelActive,
+ onOpenPanel,
+ gitAction,
+ menuTitle,
+ onDeleted,
+ onHandoffToNewThread,
+ onNewThreadInWorktree,
+ onRename,
+}: ThreadHeaderToolbarProps & { thread: ThreadResponse }) {
+ const model = useThreadActions({
+ thread,
+ onDeleted,
+ onHandoffToNewThread,
+ onNewThreadInWorktree,
+ onRename,
+ });
+ return (
+
+
+
+ {gitAction ? (
+
+ {gitAction.label}
+
+ ) : null}
+ {model.actions.map((action) =>
+ action.key === "move" ? null : (
+
+ {action.label}
+
+ ),
+ )}
+
+ {model.sectionChoices.map((choice) => (
+
+ {choice.label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/screens/thread/ThreadDetailScreen.tsx b/apps/mobile/src/screens/thread/ThreadDetailScreen.tsx
index de94e9ad55..0b9357a44e 100644
--- a/apps/mobile/src/screens/thread/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/screens/thread/ThreadDetailScreen.tsx
@@ -7,8 +7,16 @@ import {
useLocalSearchParams,
useRouter,
} from "expo-router";
-import { useCallback, useEffect, useMemo, useRef } from "react";
-import { View } from "react-native";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { ScrollView, View, type LayoutChangeEvent } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useProfiles } from "@/app-shell";
import type { ComposerHandle } from "@/composer";
import {
@@ -31,6 +39,7 @@ import { appendPendingStopRow } from "@/data/thread-runtime";
import {
getThreadDisplayTitle,
useMarkThreadRead,
+ useRenameThread,
useThread,
useThreadReadTracking,
} from "@/data/threads";
@@ -40,8 +49,10 @@ import {
COMPOSER_KEYBOARD_GAP,
KeyboardPaddingView,
OverlayBounds,
+ promptName,
Skeleton,
Text,
+ useLiquidGlass,
useSheet,
} from "@/ui";
import { ThreadWorkspacePanelProvider, usePanel } from "../panel";
@@ -58,7 +69,11 @@ import { MergeBasePickerSheet } from "./context/MergeBasePickerSheet";
import { useThreadContextChips } from "./context/use-thread-context-chips";
import { ThreadPromptArea } from "./prompt-area/ThreadPromptArea";
import { useFollowUpComposer } from "./prompt-area/use-follow-up-composer";
-import { ThreadHeaderActions, ThreadHeaderTitle } from "./ThreadDetailHeader";
+import {
+ ThreadHeaderActions,
+ ThreadHeaderTitle,
+ ThreadHeaderToolbar,
+} from "./ThreadDetailHeader";
import {
describeThreadEnvironment,
describeThreadStatusPill,
@@ -81,6 +96,8 @@ import { useThreadUnreadDividerState } from "./use-thread-unread-divider-state";
*/
const SIDE_CHAT_PLUGIN_ID = "side-chat";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
const EMPTY_QUEUED_MESSAGES: ThreadQueuedMessage[] = [];
const EMPTY_CHILD_SOURCES: ChildThreadPendingAttentionSource[] = [];
@@ -88,15 +105,42 @@ function isNotFoundError(error: unknown): boolean {
return error instanceof BbHttpError && error.status === 404;
}
+/**
+ * The non-list states (loading, error, empty) in place of the timeline: a
+ * scroll view so they inset under the translucent header the way the list
+ * does (`contentInsetAdjustmentBehavior`), and still fill the region.
+ */
+function TimelinePlaceholder({
+ children,
+ testID,
+}: {
+ children: ReactNode;
+ testID?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
function TimelineSkeleton() {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
}
@@ -197,6 +241,22 @@ function ThreadDetailBody({ threadId }: { threadId: string }) {
);
const listRef = useRef(null);
+ // Liquid Glass (iOS 26): the prompt area floats over the timeline (the
+ // composer is a glass card) and the rows scroll under it. The host
+ // reports its height; the rows and the jump pill clear the part of it
+ // above the list's bottom edge (the home-indicator padding sits below
+ // that edge, outside the list's frame, so the scroll view's own
+ // safe-area inset stays zero and `scrollToEnd` lands exactly).
+ const glass = useLiquidGlass();
+ const insets = useSafeAreaInsets();
+ const promptBottomPadding = Math.max(insets.bottom, 8);
+ const [promptAreaHeight, setPromptAreaHeight] = useState(0);
+ const handlePromptAreaLayout = useCallback((event: LayoutChangeEvent) => {
+ setPromptAreaHeight(event.nativeEvent.layout.height);
+ }, []);
+ const promptOverlap = glass
+ ? Math.max(0, promptAreaHeight - promptBottomPadding)
+ : 0;
// The workspace panel (Info / Diff / Files / Terminal + synced file tabs):
// the header button presents it.
const panel = usePanel();
@@ -219,17 +279,30 @@ function ThreadDetailBody({ threadId }: { threadId: string }) {
mergeBaseBranch: contextChips.workspace.mergeBaseBranch,
});
const gitSheet = useSheet();
- // Header "…" menu (rename, pin, read state, move, links, archive, delete).
+ // Header "…" menu (rename, pin, read state, move, links, archive, delete):
+ // a native menu on iOS, the bottom sheet on Android.
const threadActions = useThreadActionsSheet();
const presentThreadMenu = threadActions.present;
const openThreadMenu = useCallback(
() => presentThreadMenu("menu"),
[presentThreadMenu],
);
- const openRename = useCallback(
- () => presentThreadMenu("rename"),
- [presentThreadMenu],
- );
+ // Rename: the system prompt on iOS, the sheet's form elsewhere.
+ const renameThread = useRenameThread();
+ const openRename = useCallback(() => {
+ if (thread === undefined) return;
+ const currentTitle = getThreadDisplayTitle(thread);
+ const prompted = promptName({
+ title: "Rename thread",
+ initialValue: currentTitle,
+ submitLabel: "Rename",
+ onSubmit: (nextTitle) => {
+ if (nextTitle === currentTitle) return;
+ renameThread.mutate({ id: thread.id, title: nextTitle });
+ },
+ });
+ if (!prompted) presentThreadMenu("rename");
+ }, [presentThreadMenu, renameThread, thread]);
const handleDeleted = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace("/");
@@ -286,7 +359,8 @@ function ThreadDetailBody({ threadId }: { threadId: string }) {
host: bootstrap.data?.host ?? null,
projectName: projectName ?? null,
});
- // The "…" menu's first rows: what the old second header row carried.
+ // The Android sheet's first rows: what the old second header row carried
+ // (on iOS the panel is a bar button and the git action the menu's first row).
const menuLeadingActions: ThreadMenuAction[] = [
{
key: "workspace",
@@ -368,7 +442,7 @@ function ThreadDetailBody({ threadId }: { threadId: string }) {
return (
<>
-
+
{notFound
@@ -376,7 +450,7 @@ function ThreadDetailBody({ threadId }: { threadId: string }) {
: "Could not load this thread."}
{!notFound ? (
-
+
{threadError.message}
) : null}
@@ -394,11 +468,20 @@ function ThreadDetailBody({ threadId }: { threadId: string }) {
Retry
) : null}
-
+
>
);
}
+ const headerTitle = () => (
+
+ );
+
return (
- (
-
- ),
- headerRight: () => (
-
- ),
- }}
- />
+ {IS_IOS ? (
+ <>
+
+ 0 ? menuDetail : null}
+ onDeleted={handleDeleted}
+ onHandoffToNewThread={contextChips.handoffToNewThread}
+ onNewThreadInWorktree={contextChips.newThreadInWorktree}
+ onRename={openRename}
+ />
+ >
+ ) : (
+ (
+
+ ),
+ }}
+ />
+ )}
{turnLoaders}
- {(timelineLoading && entries.length === 0) || !threadReady ? (
-
+ {/* Floating prompt area: the timeline's frame ends at the glass
+ card's bottom edge; only the card's own height is padded into
+ the rows (above). In flow, the body is the region itself. */}
+
+ {(timelineLoading && entries.length === 0) || !threadReady ? (
-
- ) : timelineError && entries.length === 0 ? (
-
-
-
- Failed to load the timeline.
-
-
- {timelineError.message}
-
-
- void refetchLatestTimeline()}
- >
- Retry
-
-
- ) : entries.length === 0 && !showWorkingIndicator ? (
-
- No messages yet.
-
- ) : (
-
- )}
+ ) : timelineError && entries.length === 0 ? (
+
+
+
+ Failed to load the timeline.
+
+
+ {timelineError.message}
+
+
+ void refetchLatestTimeline()}
+ >
+ Retry
+
+
+ ) : entries.length === 0 && !showWorkingIndicator ? (
+
+ No messages yet.
+
+ ) : (
+
+ )}
+
- {thread ? (
+ {thread && !IS_IOS ? (
void;
}
-function runAction(
- item: MessageActionItem,
- target: TimelineMessageActionsTarget,
- handlers: TimelineMessageActionHandlers,
- onCopy: (text: string) => void,
-): void {
- switch (item.key) {
- case "copy":
- onCopy(target.text);
- return;
- case "quote-paragraph":
- if (target.paragraph !== null) {
- handlers.quoteIntoComposer?.(target.paragraph);
- }
- return;
- case "add-to-chat":
- handlers.quoteIntoComposer?.(target.text);
- return;
- case "edit":
- handlers.editMessage?.(buildEditMessageRequest(target));
- return;
- case "fork":
- handlers.forkFromMessage?.({ sourceSeqEnd: target.sourceSeqEnd });
- return;
- case "send-to-main":
- handlers.sendToMainThread?.({ messageText: target.text });
- return;
- }
-}
-
/**
* The long-press menu for a conversation message (web MessageActionBar as a
* bottom sheet): copy, quote paragraph / add to chat, edit, fork, send to
* main thread — each present only when the host supplied its handler and
- * the message qualifies.
+ * the message qualifies. Both platforms: the timeline rows are recycled
+ * FlashList cells, so they keep a plain long-press instead of hosting a
+ * per-row native context menu (which would pin each cell's size to its
+ * first SwiftUI measurement).
*/
export function MessageActionSheet({
controller,
@@ -73,7 +45,7 @@ export function MessageActionSheet({
key: item.key,
label: item.label,
icon: item.icon,
- onPress: () => runAction(item, target, handlers, onCopy),
+ onPress: () => runMessageAction(item, target, handlers, onCopy),
}));
}, [handlers, onCopy, target]);
return ;
diff --git a/apps/mobile/src/screens/thread/actions/ThreadActionsSheet.tsx b/apps/mobile/src/screens/thread/actions/ThreadActionsSheet.tsx
index e2d92fe5db..3922018be5 100644
--- a/apps/mobile/src/screens/thread/actions/ThreadActionsSheet.tsx
+++ b/apps/mobile/src/screens/thread/actions/ThreadActionsSheet.tsx
@@ -1,25 +1,5 @@
-import { isThreadRead } from "@bb/client-core";
import type { ThreadResponse } from "@bb/server-contract";
-import * as Clipboard from "expo-clipboard";
import { useCallback, useMemo, useState, type ReactNode } from "react";
-import { Linking } from "react-native";
-import { useProfileClient } from "@/app-shell/ProfilesProvider";
-import { useSidebarBootstrap } from "@/data/sidebar";
-import {
- getThreadDisplayTitle,
- useArchiveThread,
- useDeleteThread,
- useMarkThreadRead,
- useMarkThreadUnread,
- useMoveThreadToSection,
- usePinThread,
- useRenameThread,
- useThreadChildSummary,
- useUnarchiveThread,
- useUnpinThread,
-} from "@/data/threads";
-import { describeError } from "@/lib/describe-error";
-import { shareThreadLink } from "@/lib/share";
import { useTheme } from "@/theme";
import {
Icon,
@@ -28,31 +8,23 @@ import {
Sheet,
Spinner,
Text,
- toast,
useSheet,
- type IconName,
type SheetController,
} from "@/ui";
-import { CenteredRow, CheckRow, SheetHeader } from "../../shell/sheet-rows";
+import { CheckRow, SheetHeader } from "../../shell/sheet-rows";
import { SheetNameForm } from "../../sidebar/SheetNameForm";
-import { buildThreadWebUrl } from "./thread-links";
+import { useThreadActions, type ThreadMenuAction } from "./use-thread-actions";
/**
- * The thread header's "…" menu and its follow-up forms (rename, move to
- * section, delete confirmation) as one bottom sheet whose content follows
- * a small state machine — the same shape as the sidebar's long-press menu
- * (web ThreadActionsMenu), plus Copy link / Open in web.
+ * The thread header's "…" menu as one bottom sheet whose content follows a
+ * small state machine — the menu, the rename form, the move-to-section
+ * list (the same shape as the sidebar's long-press menu). Delete confirms
+ * through the system alert. This is the Android path; iOS renders the same
+ * model (`useThreadActions`) as the native header menu in
+ * `ThreadHeaderToolbar`.
*/
-type SheetState =
- | { view: "menu" }
- | { view: "rename" }
- | { view: "move" }
- | {
- view: "delete";
- /** Null while the child summary loads. */
- childThreadCount: number | null;
- };
+type SheetState = { view: "menu" } | { view: "rename" } | { view: "move" };
interface ThreadActionsSheetController {
sheet: SheetController;
@@ -80,21 +52,9 @@ export function useThreadActionsSheet(): ThreadActionsSheetController {
);
}
-export interface ThreadMenuAction {
- key: string;
- label: string;
- icon: IconName;
- destructive?: boolean;
- disabled?: boolean;
- /** Replaces the icon with a spinner (action in flight). */
- pending?: boolean;
- onPress: () => void;
- testID?: string;
-}
-
-type MenuAction = ThreadMenuAction;
+export type { ThreadMenuAction } from "./use-thread-actions";
-function MenuRows({ actions }: { actions: readonly MenuAction[] }) {
+function MenuRows({ actions }: { actions: readonly ThreadMenuAction[] }) {
const { tokens } = useTheme();
return (
<>
@@ -127,8 +87,6 @@ function MenuRows({ actions }: { actions: readonly MenuAction[] }) {
);
}
-const ARCHIVE_UNDO_TOAST_DURATION_MS = 8000;
-
interface ThreadActionsSheetProps {
controller: ThreadActionsSheetController;
thread: ThreadResponse;
@@ -158,234 +116,49 @@ export function ThreadActionsSheet({
leadingActions = EMPTY_LEADING_ACTIONS,
headerDetail = null,
}: ThreadActionsSheetProps) {
- const { tokens } = useTheme();
- const { serverUrl } = useProfileClient();
const { sheet, state, setState, dismiss } = controller;
- const bootstrap = useSidebarBootstrap();
- const sections = bootstrap.data?.sections ?? [];
-
- const renameThread = useRenameThread();
- const moveThread = useMoveThreadToSection();
- const pinThread = usePinThread();
- const unpinThread = useUnpinThread();
- const archiveThread = useArchiveThread();
- const unarchiveThread = useUnarchiveThread();
- const deleteThread = useDeleteThread();
- const childSummary = useThreadChildSummary();
- const markRead = useMarkThreadRead();
- const markUnread = useMarkThreadUnread();
-
- const title = getThreadDisplayTitle(thread);
- const webUrl = buildThreadWebUrl({
- serverUrl,
- projectId: thread.projectId,
- threadId: thread.id,
- });
-
- const unarchiveMany = useCallback(
- (threadIds: readonly string[]) => {
- for (const id of threadIds) unarchiveThread.mutate({ id });
- },
- [unarchiveThread],
+ const openRename = useCallback(
+ () => setState({ view: "rename" }),
+ [setState],
);
-
- const archiveWithUndo = useCallback(() => {
- archiveThread.mutate(
- { id: thread.id },
- {
- onSuccess: (response) => {
- const count = response.archivedThreadIds.length;
- const toastId = `thread-archived-${thread.id}`;
- toast.success(
- count > 1
- ? `Archived ${title} and ${count - 1} child ${count - 1 === 1 ? "thread" : "threads"}`
- : `Archived ${title}`,
- {
- id: toastId,
- duration: ARCHIVE_UNDO_TOAST_DURATION_MS,
- action: {
- label: "Undo",
- onClick: () => {
- toast.dismiss(toastId);
- unarchiveMany(response.archivedThreadIds);
- },
- },
- },
- );
- },
- },
- );
- }, [archiveThread, thread.id, title, unarchiveMany]);
-
- const requestDelete = useCallback(() => {
- setState({ view: "delete", childThreadCount: null });
- childSummary.mutateAsync(thread.id).then(
- (summary) => {
- setState({
- view: "delete",
- childThreadCount: summary.nonDeletedChildCount,
- });
- },
- (error: unknown) => {
- toast.error("Could not check child threads", {
- description: describeError(error),
- });
- dismiss();
- },
- );
- }, [childSummary, dismiss, setState, thread.id]);
-
- const copyLink = useCallback(() => {
- void Clipboard.setStringAsync(webUrl)
- .then(() => toast.success("Link copied"))
- .catch(() => toast.error("Could not copy link"));
- }, [webUrl]);
-
- const shareLink = useCallback(() => {
- shareThreadLink({ title, url: webUrl }).catch(() => {
- toast.error("Could not open the share sheet");
- });
- }, [title, webUrl]);
-
- const openInWeb = useCallback(() => {
- Linking.openURL(webUrl).catch(() => {
- toast.error("Could not open the link");
- });
- }, [webUrl]);
+ const openMove = useCallback(() => setState({ view: "move" }), [setState]);
+ const model = useThreadActions({
+ thread,
+ onDeleted,
+ onHandoffToNewThread,
+ onNewThreadInWorktree,
+ onRename: openRename,
+ onMove: openMove,
+ onBeforeAction: dismiss,
+ });
const renderContent = (): ReactNode => {
if (!state) return null;
switch (state.view) {
- case "menu": {
- const isRead = isThreadRead(thread);
- const isPinned = thread.pinnedAt !== null;
- const isArchived = thread.archivedAt !== null;
- const menu: MenuAction[] = [
- {
- key: "handoff",
- label: "Handoff to new thread",
- icon: "MessageSquarePlus",
- onPress: () => {
- dismiss();
- onHandoffToNewThread();
- },
- },
- ...(onNewThreadInWorktree
- ? [
- {
- key: "new-thread-in-worktree",
- label: "New thread in this worktree",
- icon: "FolderGit" as const,
- onPress: () => {
- dismiss();
- onNewThreadInWorktree();
- },
- },
- ]
- : []),
- {
- key: "rename",
- label: "Rename",
- icon: "Edit",
- onPress: () => setState({ view: "rename" }),
- },
- {
- key: isPinned ? "unpin" : "pin",
- label: isPinned ? "Unpin" : "Pin",
- icon: isPinned ? "PinOff" : "Pin",
- onPress: () => {
- dismiss();
- if (isPinned) unpinThread.mutate({ id: thread.id });
- else pinThread.mutate({ id: thread.id });
- },
- },
- {
- key: isRead ? "mark-unread" : "mark-read",
- label: isRead ? "Mark unread" : "Mark read",
- icon: isRead ? "Mail" : "MailOpen",
- onPress: () => {
- dismiss();
- if (isRead) markUnread.mutate(thread.id);
- else markRead.mutate(thread.id);
- },
- },
- {
- key: "move",
- label: "Move to section",
- icon: "Layers",
- onPress: () => setState({ view: "move" }),
- },
- {
- key: "copy-link",
- label: "Copy link",
- icon: "Copy",
- onPress: () => {
- dismiss();
- copyLink();
- },
- },
- {
- key: "share-link",
- label: "Share link",
- icon: "ArrowUpRight",
- onPress: () => {
- dismiss();
- shareLink();
- },
- },
- {
- key: "open-in-web",
- label: "Open in web",
- icon: "ExternalLink",
- onPress: () => {
- dismiss();
- openInWeb();
- },
- },
- {
- key: isArchived ? "unarchive" : "archive",
- label: isArchived ? "Unarchive" : "Archive",
- icon: isArchived ? "ArchiveRestore" : "Archive",
- onPress: () => {
- dismiss();
- if (isArchived) unarchiveThread.mutate({ id: thread.id });
- else archiveWithUndo();
- },
- },
- {
- key: "delete",
- label: "Delete",
- icon: "Trash2",
- destructive: true,
- onPress: requestDelete,
- },
- ];
+ case "menu":
return (
<>
-
+
{leadingActions.length > 0 ? (
<>
>
) : null}
-
+
>
);
- }
case "rename":
return (
{
- renameThread.mutate(
- { id: thread.id, title: nextTitle },
- { onSettled: dismiss },
- );
+ model.rename(nextTitle);
+ dismiss();
}}
onCancel={dismiss}
testID="thread-rename"
@@ -394,92 +167,24 @@ export function ThreadActionsSheet({
case "move":
return (
<>
-
- {sections.map((section) => (
+
+ {model.sectionChoices.map((choice) => (
{
- dismiss();
- if (thread.sectionId !== section.id) {
- moveThread.mutate({ id: thread.id, sectionId: section.id });
- }
- }}
- testID={`thread-move-${section.id}`}
+ key={choice.key}
+ label={choice.label}
+ icon={choice.icon}
+ checked={choice.selected}
+ onPress={choice.onPress}
+ testID={choice.testID}
/>
))}
- {
- dismiss();
- if (thread.sectionId !== null) {
- moveThread.mutate({ id: thread.id, sectionId: null });
- }
- }}
- testID="thread-move-none"
- />
- {sections.length === 0 ? (
+ {!model.hasSections ? (
Create sections from the sidebar display options.
) : null}
>
);
- case "delete": {
- const { childThreadCount } = state;
- const message =
- childThreadCount === null
- ? "Checking child threads…"
- : [
- childThreadCount > 0
- ? `${childThreadCount} child ${childThreadCount === 1 ? "thread" : "threads"} will be deleted.`
- : null,
- "This action cannot be undone.",
- ]
- .filter((part): part is string => part !== null)
- .join(" ");
- const pending = childThreadCount === null || deleteThread.isPending;
- return (
- <>
-
-
- }
- destructive
- disabled={pending}
- onPress={() => {
- if (childThreadCount === null) return;
- deleteThread.mutate(
- {
- id: thread.id,
- childThreadsConfirmed: childThreadCount > 0,
- },
- {
- onSuccess: () => {
- toast.success("Thread deleted");
- onDeleted();
- },
- onSettled: dismiss,
- },
- );
- }}
- testID="thread-delete-confirm"
- />
-
-
- >
- );
- }
}
};
diff --git a/apps/mobile/src/screens/thread/actions/message-actions-model.ts b/apps/mobile/src/screens/thread/actions/message-actions-model.ts
index 14bfd5003f..2380fd9c43 100644
--- a/apps/mobile/src/screens/thread/actions/message-actions-model.ts
+++ b/apps/mobile/src/screens/thread/actions/message-actions-model.ts
@@ -139,6 +139,40 @@ export function buildMessageActionItems(
return items;
}
+/**
+ * Dispatches one chosen action to the host handlers (shared by the sheet
+ * and the native context menu).
+ */
+export function runMessageAction(
+ item: MessageActionItem,
+ target: TimelineMessageActionsTarget,
+ handlers: TimelineMessageActionHandlers,
+ onCopy: (text: string) => void,
+): void {
+ switch (item.key) {
+ case "copy":
+ onCopy(target.text);
+ return;
+ case "quote-paragraph":
+ if (target.paragraph !== null) {
+ handlers.quoteIntoComposer?.(target.paragraph);
+ }
+ return;
+ case "add-to-chat":
+ handlers.quoteIntoComposer?.(target.text);
+ return;
+ case "edit":
+ handlers.editMessage?.(buildEditMessageRequest(target));
+ return;
+ case "fork":
+ handlers.forkFromMessage?.({ sourceSeqEnd: target.sourceSeqEnd });
+ return;
+ case "send-to-main":
+ handlers.sendToMainThread?.({ messageText: target.text });
+ return;
+ }
+}
+
/**
* Web `canEditMessage`: only the person's own plain message requests that
* were accepted, not grouped into a batch, and carry no image URLs.
diff --git a/apps/mobile/src/screens/thread/actions/use-thread-actions.ts b/apps/mobile/src/screens/thread/actions/use-thread-actions.ts
new file mode 100644
index 0000000000..1b29c5911a
--- /dev/null
+++ b/apps/mobile/src/screens/thread/actions/use-thread-actions.ts
@@ -0,0 +1,384 @@
+import { isThreadRead } from "@bb/client-core";
+import type { ThreadResponse } from "@bb/server-contract";
+import * as Clipboard from "expo-clipboard";
+import { useCallback, useMemo } from "react";
+import { Linking } from "react-native";
+import { useProfileClient } from "@/app-shell/ProfilesProvider";
+import { useSidebarBootstrap } from "@/data/sidebar";
+import {
+ getThreadDisplayTitle,
+ useArchiveThread,
+ useDeleteThread,
+ useMarkThreadRead,
+ useMarkThreadUnread,
+ useMoveThreadToSection,
+ usePinThread,
+ useRenameThread,
+ useThreadChildSummary,
+ useUnarchiveThread,
+ useUnpinThread,
+} from "@/data/threads";
+import { describeError } from "@/lib/describe-error";
+import { shareThreadLink } from "@/lib/share";
+import { confirmDestructive, toast, type IconName, type SFSymbol } from "@/ui";
+import { buildThreadWebUrl } from "./thread-links";
+
+/**
+ * The thread "…" action model (web ThreadActionsMenu plus Copy link / Open
+ * in web), shared by its two renderings: the native header menu on iOS
+ * (`ThreadHeaderToolbar`) and the bottom sheet on Android
+ * (`ThreadActionsSheet`). Labels, keys, icons, order and the mutations
+ * live here once; the renderings only decide how Rename and Move present.
+ */
+
+export interface ThreadMenuAction {
+ key: string;
+ label: string;
+ /** The Android glyph (and the SF Symbol through the icon map). */
+ icon: IconName;
+ /** iOS: the exact symbol the header menu shows instead of the mapped one. */
+ symbol?: SFSymbol;
+ destructive?: boolean;
+ disabled?: boolean;
+ /** Replaces the icon with a spinner (action in flight). */
+ pending?: boolean;
+ onPress: () => void;
+ testID?: string;
+}
+
+export interface ThreadSectionChoice {
+ key: string;
+ label: string;
+ icon: IconName;
+ selected: boolean;
+ onPress: () => void;
+ testID: string;
+}
+
+export interface ThreadActionsModel {
+ title: string;
+ /** Menu rows in order (handoff … delete); Move is a row only with `onMove`. */
+ actions: ThreadMenuAction[];
+ /** "Move to section" choices: the sidebar sections, then Unorganized. */
+ sectionChoices: ThreadSectionChoice[];
+ /** False when the sidebar has no sections yet (the choice list is Unorganized only). */
+ hasSections: boolean;
+ /** Rename mutation for the platform's rename UI. */
+ rename: (title: string) => void;
+ renamePending: boolean;
+}
+
+interface UseThreadActionsOptions {
+ thread: ThreadResponse;
+ /** Called after the thread was deleted (leave the screen). */
+ onDeleted: () => void;
+ /** "Handoff to new thread": compose seeded with a mention of this thread. */
+ onHandoffToNewThread: () => void;
+ /** "New thread in this worktree"; null when the thread has no reusable worktree. */
+ onNewThreadInWorktree: (() => void) | null;
+ /** Opens the platform's rename UI (native prompt / sheet form). */
+ onRename: () => void;
+ /**
+ * Opens a separate Move UI. When omitted the menu has no Move row and the
+ * caller renders `sectionChoices` itself (the iOS submenu).
+ */
+ onMove?: () => void;
+ /** Runs before every action's effect (the sheet dismisses itself). */
+ onBeforeAction?: () => void;
+}
+
+const ARCHIVE_UNDO_TOAST_DURATION_MS = 8000;
+
+export function useThreadActions({
+ thread,
+ onDeleted,
+ onHandoffToNewThread,
+ onNewThreadInWorktree,
+ onRename,
+ onMove,
+ onBeforeAction,
+}: UseThreadActionsOptions): ThreadActionsModel {
+ const { serverUrl } = useProfileClient();
+ const bootstrap = useSidebarBootstrap();
+ const sections = bootstrap.data?.sections;
+
+ const renameThread = useRenameThread();
+ const moveThread = useMoveThreadToSection();
+ const pinThread = usePinThread();
+ const unpinThread = useUnpinThread();
+ const archiveThread = useArchiveThread();
+ const unarchiveThread = useUnarchiveThread();
+ const deleteThread = useDeleteThread();
+ const childSummary = useThreadChildSummary();
+ const markRead = useMarkThreadRead();
+ const markUnread = useMarkThreadUnread();
+
+ const title = getThreadDisplayTitle(thread);
+ const threadId = thread.id;
+ const webUrl = buildThreadWebUrl({
+ serverUrl,
+ projectId: thread.projectId,
+ threadId,
+ });
+
+ const unarchiveMany = useCallback(
+ (threadIds: readonly string[]) => {
+ for (const id of threadIds) unarchiveThread.mutate({ id });
+ },
+ [unarchiveThread],
+ );
+
+ const archiveWithUndo = useCallback(() => {
+ archiveThread.mutate(
+ { id: threadId },
+ {
+ onSuccess: (response) => {
+ const count = response.archivedThreadIds.length;
+ const toastId = `thread-archived-${threadId}`;
+ toast.success(
+ count > 1
+ ? `Archived ${title} and ${count - 1} child ${count - 1 === 1 ? "thread" : "threads"}`
+ : `Archived ${title}`,
+ {
+ id: toastId,
+ duration: ARCHIVE_UNDO_TOAST_DURATION_MS,
+ action: {
+ label: "Undo",
+ onClick: () => {
+ toast.dismiss(toastId);
+ unarchiveMany(response.archivedThreadIds);
+ },
+ },
+ },
+ );
+ },
+ },
+ );
+ }, [archiveThread, threadId, title, unarchiveMany]);
+
+ // Delete: the child-thread roll-up decides the confirmation copy, then the
+ // system alert confirms (web ThreadActionsMenu's delete dialog).
+ const requestDelete = useCallback(() => {
+ childSummary.mutateAsync(threadId).then(
+ (summary) => {
+ const childThreadCount = summary.nonDeletedChildCount;
+ const message = [
+ childThreadCount > 0
+ ? `${childThreadCount} child ${childThreadCount === 1 ? "thread" : "threads"} will be deleted.`
+ : null,
+ "This action cannot be undone.",
+ ]
+ .filter((part): part is string => part !== null)
+ .join(" ");
+ confirmDestructive({
+ title: `Delete ${title}?`,
+ message,
+ actionLabel: "Delete",
+ onConfirm: () => {
+ deleteThread.mutate(
+ { id: threadId, childThreadsConfirmed: childThreadCount > 0 },
+ {
+ onSuccess: () => {
+ toast.success("Thread deleted");
+ onDeleted();
+ },
+ },
+ );
+ },
+ });
+ },
+ (error: unknown) => {
+ toast.error("Could not check child threads", {
+ description: describeError(error),
+ });
+ },
+ );
+ }, [childSummary, deleteThread, onDeleted, threadId, title]);
+
+ const copyLink = useCallback(() => {
+ void Clipboard.setStringAsync(webUrl)
+ .then(() => toast.success("Link copied"))
+ .catch(() => toast.error("Could not copy link"));
+ }, [webUrl]);
+
+ const shareLink = useCallback(() => {
+ shareThreadLink({ title, url: webUrl }).catch(() => {
+ toast.error("Could not open the share sheet");
+ });
+ }, [title, webUrl]);
+
+ const openInWeb = useCallback(() => {
+ Linking.openURL(webUrl).catch(() => {
+ toast.error("Could not open the link");
+ });
+ }, [webUrl]);
+
+ const rename = useCallback(
+ (nextTitle: string) => {
+ renameThread.mutate({ id: threadId, title: nextTitle });
+ },
+ [renameThread, threadId],
+ );
+
+ const isRead = isThreadRead(thread);
+ const isPinned = thread.pinnedAt !== null;
+ const isArchived = thread.archivedAt !== null;
+ const sectionId = thread.sectionId;
+
+ const actions = useMemo((): ThreadMenuAction[] => {
+ const run = (effect: () => void) => () => {
+ onBeforeAction?.();
+ effect();
+ };
+ return [
+ {
+ key: "handoff",
+ label: "Handoff to new thread",
+ icon: "MessageSquarePlus",
+ symbol: "square.and.pencil",
+ onPress: run(onHandoffToNewThread),
+ },
+ ...(onNewThreadInWorktree
+ ? [
+ {
+ key: "new-thread-in-worktree",
+ label: "New thread in this worktree",
+ icon: "FolderGit" as const,
+ symbol: "folder.badge.plus" as const,
+ onPress: run(onNewThreadInWorktree),
+ },
+ ]
+ : []),
+ {
+ key: "rename",
+ label: "Rename",
+ icon: "Edit",
+ // The rename UI takes over the sheet / presents its own alert; the
+ // sheet must not dismiss first.
+ onPress: onRename,
+ },
+ {
+ key: isPinned ? "unpin" : "pin",
+ label: isPinned ? "Unpin" : "Pin",
+ icon: isPinned ? "PinOff" : "Pin",
+ onPress: run(() => {
+ if (isPinned) unpinThread.mutate({ id: threadId });
+ else pinThread.mutate({ id: threadId });
+ }),
+ },
+ {
+ key: isRead ? "mark-unread" : "mark-read",
+ label: isRead ? "Mark unread" : "Mark read",
+ icon: isRead ? "Mail" : "MailOpen",
+ symbol: isRead ? "envelope.badge" : "envelope.open",
+ onPress: run(() => {
+ if (isRead) markUnread.mutate(threadId);
+ else markRead.mutate(threadId);
+ }),
+ },
+ ...(onMove
+ ? [
+ {
+ key: "move",
+ label: "Move to section",
+ icon: "Layers" as const,
+ onPress: onMove,
+ },
+ ]
+ : []),
+ {
+ key: "copy-link",
+ label: "Copy link",
+ icon: "Copy",
+ symbol: "link",
+ onPress: run(copyLink),
+ },
+ {
+ key: "share-link",
+ label: "Share link",
+ icon: "ArrowUpRight",
+ symbol: "square.and.arrow.up",
+ onPress: run(shareLink),
+ },
+ {
+ key: "open-in-web",
+ label: "Open in web",
+ icon: "ExternalLink",
+ symbol: "safari",
+ onPress: run(openInWeb),
+ },
+ {
+ key: isArchived ? "unarchive" : "archive",
+ label: isArchived ? "Unarchive" : "Archive",
+ icon: isArchived ? "ArchiveRestore" : "Archive",
+ onPress: run(() => {
+ if (isArchived) unarchiveThread.mutate({ id: threadId });
+ else archiveWithUndo();
+ }),
+ },
+ {
+ key: "delete",
+ label: "Delete",
+ icon: "Trash2",
+ destructive: true,
+ onPress: run(requestDelete),
+ },
+ ];
+ }, [
+ archiveWithUndo,
+ copyLink,
+ isArchived,
+ isPinned,
+ isRead,
+ markRead,
+ markUnread,
+ onBeforeAction,
+ onHandoffToNewThread,
+ onMove,
+ onNewThreadInWorktree,
+ onRename,
+ openInWeb,
+ pinThread,
+ requestDelete,
+ shareLink,
+ threadId,
+ unarchiveThread,
+ unpinThread,
+ ]);
+
+ const sectionChoices = useMemo((): ThreadSectionChoice[] => {
+ const choose = (nextSectionId: string | null) => () => {
+ onBeforeAction?.();
+ if (sectionId !== nextSectionId) {
+ moveThread.mutate({ id: threadId, sectionId: nextSectionId });
+ }
+ };
+ return [
+ ...(sections ?? []).map((section) => ({
+ key: section.id,
+ label: section.name,
+ icon: "Layers" as const,
+ selected: sectionId === section.id,
+ onPress: choose(section.id),
+ testID: `thread-move-${section.id}`,
+ })),
+ {
+ key: "none",
+ label: "Unorganized",
+ icon: "Circle" as const,
+ selected: sectionId === null,
+ onPress: choose(null),
+ testID: "thread-move-none",
+ },
+ ];
+ }, [moveThread, onBeforeAction, sectionId, sections, threadId]);
+
+ return {
+ title,
+ actions,
+ sectionChoices,
+ hasSections: (sections?.length ?? 0) > 0,
+ rename,
+ renamePending: renameThread.isPending,
+ };
+}
diff --git a/apps/mobile/src/screens/thread/cards/PromptChip.tsx b/apps/mobile/src/screens/thread/cards/PromptChip.tsx
index 8765e8be31..0b0575ea5f 100644
--- a/apps/mobile/src/screens/thread/cards/PromptChip.tsx
+++ b/apps/mobile/src/screens/thread/cards/PromptChip.tsx
@@ -1,25 +1,48 @@
import type { ReactNode } from "react";
-import { Pressable, View } from "react-native";
+import { Pressable, View, type ViewStyle } from "react-native";
+import { haptic } from "@/lib/haptics";
import { usePickerSheetMaxHeight } from "@/screens/pickers";
import { useTheme } from "@/theme";
import {
cn,
+ GlassSurface,
Icon,
+ NativeMenu,
Sheet,
ShimmerIcon,
Spinner,
Text,
+ useLiquidGlass,
useSheet,
type IconName,
+ type NativeMenuAction,
type SheetHandle,
} from "@/ui";
+const IS_IOS = process.env.EXPO_OS === "ios";
+const CHIP_HEIGHT = 36;
+/**
+ * The capsule: on iOS 26 the shape is the Liquid Glass itself (the chips
+ * float over the timeline, so they refract it); elsewhere the fill and
+ * border below are painted into the same shape.
+ */
+const CHIP_SHAPE: ViewStyle = {
+ height: CHIP_HEIGHT,
+ borderRadius: CHIP_HEIGHT / 2,
+ borderCurve: "continuous",
+ overflow: "hidden",
+ flexDirection: "row",
+ alignItems: "center",
+};
+
export interface PromptChipAction {
label: string;
onPress: () => void;
pending: boolean;
/** Trailing glyph; defaults to the dismiss X. */
icon?: IconName;
+ /** Ends something (exit plan mode, clear goal): red in the native menu. */
+ destructive?: boolean;
testID?: string;
}
@@ -61,12 +84,24 @@ type PromptChipProps = PromptChipBaseProps &
);
const DEFAULT_LABEL_MAX_WIDTH = 180;
+const GLYPH_SIZE = 14;
+/** iOS 17+: the live glyph pulses (the SF Symbol effect); older iOS shows it still. */
+const LIVE_EFFECT = { effect: "pulse", repeat: -1 } as const;
/**
- * One chip in the prompt-stack row: glyph (shimmering while live), label,
- * muted detail, optional trailing action. A tap opens a bottom sheet with
- * the body the web card shows expanded, or runs `onPress` for chips that
- * navigate (the related-thread chip).
+ * One chip in the prompt-stack row: glyph (pulsing on iOS / shimmering
+ * elsewhere while live), label, muted detail, optional trailing action. A
+ * tap on the text opens a bottom sheet with the body the web card shows
+ * expanded, or runs `onPress` for chips that navigate (the related-thread
+ * chip). On iOS the trailing action — an icon-only segment, the one shape a
+ * `NativeMenu` may wrap — opens a one-item native menu (destructive when it
+ * ends a mode), so an "exit" is never a single accidental tap.
+ *
+ * The capsule is a `GlassSurface`: Liquid Glass on iOS 26 (the chip floats
+ * over the timeline), the raised fill with the pill border everywhere else.
+ * Glass is translucent over whatever scrolls under it, so with glass the
+ * detail segment and the trailing glyph drop their muted tone for the full
+ * foreground; the warning / destructive tints stay as they are.
*/
export function PromptChip({
icon,
@@ -83,38 +118,91 @@ export function PromptChip({
onPress,
}: PromptChipProps) {
const { tokens } = useTheme();
+ const glass = useLiquidGlass();
const sheet = useSheet();
const maxHeight = usePickerSheetMaxHeight();
+ const open = () => {
+ haptic("selection");
+ if (onPress) onPress();
+ else sheet.present();
+ };
+ /** The secondary tone: muted on a solid capsule, full foreground on glass. */
+ const secondaryColor = glass ? tokens.foreground : tokens.mutedForeground;
+ const chipFallbackStyle: ViewStyle = {
+ backgroundColor: tokens.surfaceRaisedSolid,
+ borderWidth: 1,
+ borderColor: tokens.pillSurfaceBorder,
+ };
+ const glyph =
+ leading ??
+ (live ? (
+ IS_IOS ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ ));
+ const actionGlyph = action ? (
+ action.pending ? (
+
+ ) : (
+
+ )
+ ) : null;
+ const menuActions: NativeMenuAction[] = action
+ ? [
+ {
+ key: "action",
+ label: action.label,
+ icon: action.icon ?? "X",
+ destructive: action.destructive,
+ disabled: action.pending,
+ onPress: () => {
+ haptic(action.destructive ? "warning" : "selection");
+ action.onPress();
+ },
+ },
+ ]
+ : [];
return (
<>
-
- {leading ??
- (live ? (
-
- ) : (
-
- ))}
+ {glyph}
{detail ? (
-
+ // Footnote (foreground) on glass, the muted caption on a fill.
+
{detail}
) : null}
- {action ? (
+ {action && IS_IOS ? (
+ // The menu host is the accessible element (label, role, state,
+ // testID); the glyph segment inside it is sized explicitly because
+ // the host measures its content rather than the chip.
+
+
+ {actionGlyph}
+
+
+ ) : action ? (
- {action.pending ? (
-
- ) : (
-
- )}
+ {actionGlyph}
) : null}
-
+
{children === undefined ? null : (
{durationToCompactString(elapsed)};
+ return (
+
+ {durationToCompactString(elapsed)}
+
+ );
}
function workflowAgentProgressLabel(
@@ -101,7 +105,11 @@ function WorkflowSheetSection({
{name}
- {progress ? {progress} : null}
+ {progress ? (
+
+ {progress}
+
+ ) : null}
{workflow.workflowName ? (
@@ -265,6 +273,7 @@ export function ThreadPromptModeChip({
label: "Exit plan mode",
onPress: onExitPlanMode,
pending: isExitPending,
+ destructive: true,
testID: "thread-chip-plan-exit",
}
: null
@@ -302,6 +311,7 @@ export function ThreadGoalChip({
label: "Clear goal",
onPress: onClearGoal,
pending: isClearPending,
+ destructive: true,
testID: "thread-chip-goal-clear",
}
: null
@@ -315,11 +325,13 @@ export function ThreadGoalChip({
- {formatGoalTokenUsage(goal)}
+
+ {formatGoalTokenUsage(goal)}
+
-
+
{formatGoalDuration(goal.timeUsedSeconds)}
@@ -534,7 +546,7 @@ export function ThreadModelFallbackChip({
iconColor={tokens.warningText}
label="Fallback"
action={{
- label: "Dismiss model fallback",
+ label: "Dismiss",
onPress: () => setDismissedSourceSeq(fallback.sourceSeq),
pending: false,
testID: "thread-chip-model-fallback-dismiss",
diff --git a/apps/mobile/src/screens/thread/context/MergeBasePickerSheet.tsx b/apps/mobile/src/screens/thread/context/MergeBasePickerSheet.tsx
index a47bafd46a..fc68173cf9 100644
--- a/apps/mobile/src/screens/thread/context/MergeBasePickerSheet.tsx
+++ b/apps/mobile/src/screens/thread/context/MergeBasePickerSheet.tsx
@@ -4,15 +4,8 @@ import {
getMergeBaseBranchCandidateGroups,
useEnvironmentMergeBaseBranches,
} from "@/data/environments";
-import { useTheme } from "@/theme";
-import {
- Icon,
- ListRow,
- Sheet,
- Spinner,
- Text,
- type SheetController,
-} from "@/ui";
+import { haptic } from "@/lib/haptics";
+import { ListRow, Sheet, Spinner, Text, type SheetController } from "@/ui";
import { SheetInput } from "../../pickers/SheetInput";
import { usePickerSheetMaxHeight } from "../../pickers/OptionSheet";
@@ -38,7 +31,6 @@ export function MergeBasePickerSheet({
onSelect,
stackBehavior,
}: MergeBasePickerSheetProps) {
- const { tokens } = useTheme();
const maxHeight = usePickerSheetMaxHeight();
const [query, setQuery] = useState("");
const branches = useEnvironmentMergeBaseBranches(environmentId, {
@@ -52,6 +44,7 @@ export function MergeBasePickerSheet({
remoteMergeBaseBranchOptions: branches.data?.remoteBranches ?? [],
});
const pick = (branch: string) => {
+ haptic("selection");
controller.dismiss();
onSelect(branch);
};
@@ -61,11 +54,6 @@ export function MergeBasePickerSheet({
title={branch}
leading="GitBranch"
selected={branch === mergeBaseBranch}
- trailing={
- branch === mergeBaseBranch ? (
-
- ) : null
- }
onPress={() => pick(branch)}
testID={`merge-base-option-${branch}`}
/>
diff --git a/apps/mobile/src/screens/thread/context/ThreadContextChips.tsx b/apps/mobile/src/screens/thread/context/ThreadContextChips.tsx
index afd419fe97..f9c238cf8b 100644
--- a/apps/mobile/src/screens/thread/context/ThreadContextChips.tsx
+++ b/apps/mobile/src/screens/thread/context/ThreadContextChips.tsx
@@ -12,6 +12,7 @@ import {
toChangeTally,
} from "@/data/environments";
import type { ChildThreadPendingAttention } from "@/data/interactions";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
import { Button, Icon, ListRow, Text } from "@/ui";
import { PromptChip } from "../cards/PromptChip";
@@ -201,7 +202,7 @@ export function ThreadChangesChip({
>
{(sheet) => (
<>
-
+
{formatChangeSummary(tally)}
@@ -230,7 +232,9 @@ export function ThreadChangesChip({
@@ -259,7 +263,7 @@ export function ThreadChangesChip({
* Pull request (web pull request row): state + checks glyphs, the number,
* and the state as detail when it is not simply open. The sheet carries
* the attention label, Open on GitHub, and Mark ready / the merge methods /
- * Convert to draft (the web split-button menu).
+ * Convert to draft (the web split-button menu) as rows on both platforms.
*/
export function ThreadPullRequestChip({
layout,
@@ -362,6 +366,9 @@ function PullRequestSheetBody({
) : null}
{pullRequestActions && action?.kind === "merge" ? (
+ // The web split button as rows: the merge methods, then Convert to
+ // draft. Text rows, so they are sheet rows rather than a native
+ // menu on a "Merge" button (see `NativeMenu`).
{PULL_REQUEST_MERGE_ACTIONS.map((merge) => (
pullRequestActions.onMerge(merge.method)}
+ onPress={() => {
+ haptic("impact-medium");
+ pullRequestActions.onMerge(merge.method);
+ }}
testID={`thread-chip-pull-request-merge-${merge.method}`}
/>
))}
@@ -377,7 +387,10 @@ function PullRequestSheetBody({
leading="GitPullRequestDraft"
title="Convert to draft"
disabled={pullRequestActions.isPending}
- onPress={pullRequestActions.onConvertToDraft}
+ onPress={() => {
+ haptic("selection");
+ pullRequestActions.onConvertToDraft();
+ }}
testID="thread-chip-pull-request-draft"
/>
diff --git a/apps/mobile/src/screens/thread/interactions/InteractionBannerShell.tsx b/apps/mobile/src/screens/thread/interactions/InteractionBannerShell.tsx
index b839007590..4487aedab0 100644
--- a/apps/mobile/src/screens/thread/interactions/InteractionBannerShell.tsx
+++ b/apps/mobile/src/screens/thread/interactions/InteractionBannerShell.tsx
@@ -1,10 +1,13 @@
import { useRouter } from "expo-router";
import type { ReactNode } from "react";
import { Pressable, View } from "react-native";
+import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated";
import { useTheme } from "@/theme";
import { Icon, Text } from "@/ui";
import { threadHref } from "../../shell/hrefs";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
export interface InteractionSourceThread {
threadId: string;
title: string;
@@ -23,11 +26,15 @@ interface InteractionBannerShellProps {
testID?: string;
}
+/** Card corners: continuous 12pt (the grouped inset-card look). */
+const CARD_STYLE = { borderRadius: 12, borderCurve: "continuous" } as const;
+
/**
* Frame shared by every pending-interaction banner (mirrors the web
* `BannerShell` in ThreadPendingInteractionBanner.tsx): recessed card, an
* optional "From child thread" link, title, body, right-aligned footer
- * actions, and the inline mutation error.
+ * actions, and the inline mutation error. It rises in and drops out as
+ * the interaction arrives / resolves.
*/
export function InteractionBannerShell({
title,
@@ -41,8 +48,22 @@ export function InteractionBannerShell({
const router = useRouter();
const { tokens } = useTheme();
return (
-
@@ -66,7 +87,9 @@ export function InteractionBannerShell({
) : null}
{title ? (
@@ -91,12 +114,13 @@ export function InteractionBannerShell({
{errorMessage ? (
{errorMessage}
) : null}
-
+
);
}
diff --git a/apps/mobile/src/screens/thread/interactions/PendingInteractionBanner.tsx b/apps/mobile/src/screens/thread/interactions/PendingInteractionBanner.tsx
index 19ae809962..8c5b643126 100644
--- a/apps/mobile/src/screens/thread/interactions/PendingInteractionBanner.tsx
+++ b/apps/mobile/src/screens/thread/interactions/PendingInteractionBanner.tsx
@@ -19,7 +19,7 @@ import {
type UserQuestionPendingInteractionPayload,
} from "@bb/domain";
import { useCallback, useMemo } from "react";
-import { ScrollView, View } from "react-native";
+import { ScrollView, View, type StyleProp, type ViewStyle } from "react-native";
import {
approvalDecisionButtonVariant,
approvalResolutionDecision,
@@ -54,6 +54,32 @@ import { QuestionForm } from "./QuestionForm";
import { SecretRequestForm } from "./SecretRequestForm";
const DETAIL_SCROLL_MAX_HEIGHT = 220;
+const IS_IOS = process.env.EXPO_OS === "ios";
+/** Inner detail cards (command, plan, tool use): continuous 10pt corners. */
+const DETAIL_CARD_STYLE = {
+ borderRadius: 10,
+ borderCurve: "continuous",
+} as const;
+
+/**
+ * iOS decision buttons: the safest yes filled, the session-long yes plain,
+ * Deny red-tinted — never a second filled button competing with the first.
+ */
+function iosDecisionButtonProps(decision: PendingInteractionApprovalDecision): {
+ variant: "default" | "ghost" | "outline";
+ tint?: "destructive";
+} {
+ switch (decision) {
+ case "allow_once":
+ return { variant: "default" };
+ case "allow_for_session":
+ return { variant: "ghost" };
+ case "deny":
+ return { variant: "outline", tint: "destructive" };
+ default:
+ return assertNever(decision);
+ }
+}
interface PendingInteractionBannerProps {
interaction: PendingInteraction;
@@ -231,7 +257,9 @@ function ApprovalInteractionBanner({
{subject.command !== null ? (
-
+
) : subject.plan !== null ? (
-
+
0 ? (
) : null}
@@ -331,6 +366,7 @@ function ToolUseAskCard({ ask }: { ask: PendingInteractionToolUseAsk }) {
return (
@@ -372,15 +408,17 @@ function ToolUseAskCard({ ask }: { ask: PendingInteractionToolUseAsk }) {
function ApprovalDetailList({
className,
+ style,
lines,
mono = false,
}: {
className: string;
+ style?: StyleProp;
lines: readonly string[];
mono?: boolean;
}) {
return (
-
+
{lines.map((line) => (
void;
testID?: string;
}
+/**
+ * One choice. iOS: a grouped-list cell (17pt label, footnote description,
+ * tinted check mark when chosen, hairline separators) — the system's
+ * single / multi-choice table look. Elsewhere: the web's radio / checkbox
+ * square in a highlighted row.
+ */
function QuestionOptionRow({
checked,
label,
description,
multiSelect,
disabled,
+ separated = false,
onSelect,
testID,
}: QuestionOptionRowProps) {
const { tokens } = useTheme();
+ const select = () => {
+ haptic("selection");
+ onSelect();
+ };
+ if (IS_IOS) {
+ return (
+
+
+ {label}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {checked ? (
+
+ ) : (
+ // Reserve the check's column so labels do not shift on selection.
+
+ )}
+ {separated ? (
+
+ ) : null}
+
+ );
+ }
return (
@@ -134,9 +184,12 @@ function QuestionTabs({
accessibilityRole="button"
accessibilityState={{ selected: isActive }}
accessibilityLabel={question.prompt}
- onPress={() => onSelect(index)}
+ onPress={() => {
+ haptic("selection");
+ onSelect(index);
+ }}
className={cn(
- "h-7 justify-center rounded-md px-2 active:bg-state-hover",
+ "h-7 justify-center rounded-full px-2.5 active:bg-state-hover",
isActive && "bg-muted",
)}
testID={`question-tab-${index}`}
@@ -156,7 +209,7 @@ function QuestionTabs({
);
})}
-
+
{currentIndex + 1} of {questions.length}
@@ -181,10 +234,23 @@ function QuestionInputBlock({
const options = question.options;
return (
-
+
{question.prompt}
-
+
{options.map((option, index) => {
const checked = state.selected.includes(option.value);
return (
@@ -195,6 +261,7 @@ function QuestionInputBlock({
description={option.description}
multiSelect={question.multiSelect}
disabled={disabled}
+ separated={index > 0}
onSelect={() => onToggleOption(option.value)}
testID={`question-option-${index}`}
/>
@@ -210,6 +277,7 @@ function QuestionInputBlock({
label={OTHER_OPTION_LABEL}
multiSelect={question.multiSelect}
disabled={disabled}
+ separated={options.length > 0}
onSelect={onSelectOther}
testID="question-option-other"
/>
@@ -223,7 +291,8 @@ function QuestionInputBlock({
autoCapitalize="sentences"
onChangeText={onFreeTextChange}
placeholder="Type your own answer…"
- className="mt-2 max-h-40 bg-surface-raised"
+ className={cn("mt-2 max-h-40", !IS_IOS && "bg-surface-raised")}
+ grouped={IS_IOS}
testID="question-free-text"
/>
) : null}
diff --git a/apps/mobile/src/screens/thread/interactions/SecretRequestForm.tsx b/apps/mobile/src/screens/thread/interactions/SecretRequestForm.tsx
index aae2857006..f9b29bf4e1 100644
--- a/apps/mobile/src/screens/thread/interactions/SecretRequestForm.tsx
+++ b/apps/mobile/src/screens/thread/interactions/SecretRequestForm.tsx
@@ -5,8 +5,11 @@ import {
buildSecretRequestResponse,
type SecretRequestFormResult,
} from "@/data/interactions";
+import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme";
-import { Button, Icon, Input, Text } from "@/ui";
+import { Button, cn, Icon, Input, Text } from "@/ui";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
interface SecretRequestFormProps {
/** Resets the fields when a different interaction takes over. */
@@ -94,7 +97,10 @@ export function SecretRequestForm({
setValues((current) => ({ ...current, [field.name]: text }))
}
accessibilityLabel={field.name}
- className="flex-1 bg-card pr-11"
+ // iOS: the filled grouped field on the recessed banner;
+ // Android: the bordered field on the card color.
+ className={cn("flex-1 pr-11", !IS_IOS && "bg-card")}
+ grouped={IS_IOS}
testID={`secret-field-${field.name}`}
/>
+ onPress={() => {
+ haptic("selection");
setRevealed((current) => ({
...current,
[field.name]: !current[field.name],
- }))
- }
- className="absolute right-1 h-8 w-8 items-center justify-center rounded-md active:bg-state-hover"
+ }));
+ }}
+ className="absolute right-1 h-8 w-8 items-center justify-center rounded-full active:opacity-60"
hitSlop={6}
>
@@ -125,6 +132,7 @@ export function SecretRequestForm({
{formError ? (
{formError}
diff --git a/apps/mobile/src/screens/thread/prompt-area/ThreadPromptArea.tsx b/apps/mobile/src/screens/thread/prompt-area/ThreadPromptArea.tsx
index 08276c871e..f424bc1ccb 100644
--- a/apps/mobile/src/screens/thread/prompt-area/ThreadPromptArea.tsx
+++ b/apps/mobile/src/screens/thread/prompt-area/ThreadPromptArea.tsx
@@ -12,7 +12,14 @@ import type {
TimelineWorkflowWorkRow,
} from "@bb/server-contract";
import { useMemo, type RefObject } from "react";
-import { ScrollView, useWindowDimensions, View } from "react-native";
+import {
+ ScrollView,
+ StyleSheet,
+ useWindowDimensions,
+ View,
+ type ViewProps,
+ type ViewStyle,
+} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Composer, type ComposerAction, type ComposerHandle } from "@/composer";
import type { ChildThreadPendingAttention } from "@/data/interactions";
@@ -51,10 +58,27 @@ interface ThreadPromptAreaProps {
contextChips: ThreadContextChipsProps;
/** "Handoff to new thread" (compose seeded with a `@thread:` mention). */
onHandoffToNewThread: () => void;
+ /**
+ * Liquid Glass: float over the bottom of the timeline (absolute, no page
+ * fill, no seam) instead of docking under it — the composer and the chips
+ * are glass capsules; the banner (a form) and the queued list (cards) sit
+ * on raised panels. The screen pads the timeline for the height
+ * `onLayout` reports.
+ */
+ floating?: boolean;
+ onLayout?: ViewProps["onLayout"];
}
/** Share of the window the stack + composer may take before the stack scrolls. */
const MAX_PROMPT_AREA_WINDOW_FRACTION = 0.6;
+const IS_IOS = process.env.EXPO_OS === "ios";
+/** The floating host: pinned to the bottom of the overlay bounds. */
+const FLOATING_HOST_STYLE: ViewStyle = {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: 0,
+};
/**
* The bottom of the thread screen (port of apps/app ThreadDetailPromptArea):
@@ -87,8 +111,11 @@ export function ThreadPromptArea({
contextWindowUsage,
contextChips,
onHandoffToNewThread,
+ floating = false,
+ onLayout,
}: ThreadPromptAreaProps) {
const insets = useSafeAreaInsets();
+ const { tokens } = useTheme();
const { height: windowHeight } = useWindowDimensions();
const cancelPlan = useCancelThreadPlan();
const clearGoal = useClearThreadGoal();
@@ -125,23 +152,52 @@ export function ThreadPromptArea({
);
const showBanner = pendingInteraction !== null && !composer.hidden;
+ // Floating host: the banner (a form) and the queued list (a card list)
+ // lose the page fill behind them, so each sits on a raised panel. The
+ // chips are not on it: each is its own glass capsule over the timeline,
+ // like the composer below them.
+ const floatingPanelStyle: ViewStyle | undefined = floating
+ ? {
+ backgroundColor: tokens.surfaceRaisedSolid,
+ borderRadius: 18,
+ borderCurve: "continuous",
+ overflow: "hidden",
+ boxShadow: "0 8px 24px rgba(0, 0, 0, 0.18)",
+ }
+ : undefined;
+ const showQueue = !composer.hidden && queuedMessages.length > 0;
// Skip the stack's bottom gap when nothing renders in it.
- const stackHasContent =
- hasThreadPromptChips(stackChips) ||
- (!composer.hidden && queuedMessages.length > 0);
+ const stackHasContent = hasThreadPromptChips(stackChips) || showQueue;
return (
{showBanner ? (
- {!composer.hidden && queuedMessages.length > 0 ? (
-
+ {showQueue ? (
+ // The queued list is the one stack item that keeps a panel
+ // when floating: its card surface is translucent.
+
+
+
) : null}
{composer.hidden || thread === undefined ? null : (
@@ -246,7 +308,12 @@ function EditModeHeader({
testID="thread-composer-edit-header"
>
-
+
{kind === "queued-message"
? "Editing queued message"
: "Editing sent message"}
diff --git a/apps/mobile/src/screens/thread/queue/QueuedMessagesList.tsx b/apps/mobile/src/screens/thread/queue/QueuedMessagesList.tsx
index 84e93eee85..920aabe033 100644
--- a/apps/mobile/src/screens/thread/queue/QueuedMessagesList.tsx
+++ b/apps/mobile/src/screens/thread/queue/QueuedMessagesList.tsx
@@ -1,22 +1,29 @@
import type { ThreadQueuedMessage } from "@bb/domain";
import { useCallback, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
+import Animated, {
+ FadeIn,
+ FadeOut,
+ LinearTransition,
+} from "react-native-reanimated";
import {
useDeleteThreadQueuedMessage,
useReorderThreadQueuedMessage,
useSendThreadQueuedMessage,
useSetThreadQueuedMessageGroupBoundary,
} from "@/data/thread-runtime";
+import { haptic } from "@/lib/haptics";
import { getMutationErrorMessage } from "@/lib/query/mutation-errors";
import { useTheme } from "@/theme";
import {
ActionSheet,
cn,
Icon,
+ NativeMenu,
Spinner,
Text,
useSheet,
- type ActionSheetAction,
+ type NativeMenuAction,
} from "@/ui";
import {
buildQueuedMessageRowModels,
@@ -26,6 +33,12 @@ import {
type QueuedMessageRowModel,
} from "./queued-messages-list-model";
+const IS_IOS = process.env.EXPO_OS === "ios";
+/** Card corners: continuous 12pt (the grouped inset-card look). */
+const CARD_STYLE = { borderRadius: 12, borderCurve: "continuous" } as const;
+const ROW_EXIT_MS = 160;
+const ROW_ENTER_MS = 200;
+
export interface QueuedMessageEditRequest {
queuedMessage: ThreadQueuedMessage;
queuedMessageIndex: number;
@@ -61,7 +74,9 @@ interface RowProps {
sendDisabled: boolean;
actionDisabled: boolean;
onSendNow: () => void;
- onEdit: () => void;
+ /** The secondary actions (Edit, move, group, Delete). */
+ menuActions: readonly NativeMenuAction[];
+ /** Android: presents the shared action sheet for this row. */
onOpenMenu: () => void;
}
@@ -73,14 +88,27 @@ function QueuedMessageRow({
sendDisabled,
actionDisabled,
onSendNow,
- onEdit,
+ menuActions,
onOpenMenu,
}: RowProps) {
const { tokens } = useTheme();
const busy = processingAction !== null;
const ordinal = row.index + 1;
+ const sendInert = busy || actionDisabled || sendDisabled;
+ const menuInert = busy || actionDisabled;
+ const menuGlyph = (
+
+ );
return (
- 0 && "border-t border-border-hairline",
@@ -100,7 +128,11 @@ function QueuedMessageRow({
{busy ? (
) : (
-
+
{ordinal}
)}
@@ -137,56 +169,71 @@ function QueuedMessageRow({
{
+ haptic("impact-medium");
+ onSendNow();
}}
+ className="h-9 w-9 items-center justify-center rounded-full active:opacity-60"
+ style={{ opacity: sendInert ? 0.4 : 1 }}
hitSlop={4}
testID="queued-message-send-now"
>
-
-
-
-
-
-
-
+
+ {IS_IOS ? (
+ // An icon-only trigger: the menu host is the accessible element
+ // (label, role, state, testID); the glyph view inside is not.
+
+
+ {menuGlyph}
+
+
+ ) : (
+
+ {menuGlyph}
+
+ )}
) : null}
-
+
);
}
/**
* Messages queued behind the running turn, listed under the composer
* (mirrors apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
- * without drag). Per row: Send now, Edit (handed to the composer through
- * `onEdit`), and a "…" sheet with Move up / Move down, the group toggle
- * ("send together with the messages above" / "send separately"), and
- * Delete. The lead group that sends as one turn is tinted and closed by a
- * dashed divider. Mutations are optimistic (see `@/data/thread-runtime`);
- * the last error shows inline under the list.
+ * without drag). Per row: one Send now button and a "…" menu (a native
+ * menu on iOS, the action sheet on Android) with Edit (handed to the
+ * composer through `onEdit`), Move up / Move down, the group toggle ("send
+ * together with the messages above" / "send separately"), and Delete. The
+ * lead group that sends as one turn is tinted and closed by a dashed
+ * divider. Mutations are optimistic (see `@/data/thread-runtime`); the
+ * last error shows inline under the list. Rows fade out as they leave and
+ * the card reflows.
*/
export function QueuedMessagesList({
threadId,
@@ -255,61 +302,99 @@ export function QueuedMessagesList({
})
: null;
+ const edit = useCallback(
+ (row: QueuedMessageRowModel) => {
+ const queuedMessage = byId.get(row.id);
+ if (queuedMessage) {
+ onEdit({ queuedMessage, queuedMessageIndex: row.index });
+ }
+ },
+ [byId, onEdit],
+ );
+
+ // The "…" menu for one row (the same items feed the native menu and the
+ // Android sheet).
+ const menuActionsFor = useCallback(
+ (row: QueuedMessageRowModel): NativeMenuAction[] => {
+ const actions: NativeMenuAction[] = [
+ {
+ key: "edit",
+ label: "Edit",
+ icon: "Edit",
+ onPress: () => edit(row),
+ },
+ ];
+ if (row.moveUp) {
+ const request = row.moveUp;
+ actions.push({
+ key: "move-up",
+ label: "Move up",
+ icon: "ArrowUp",
+ onPress: () => {
+ haptic("selection");
+ reorder.mutate({ id: threadId, ...request });
+ },
+ });
+ }
+ if (row.moveDown) {
+ const request = row.moveDown;
+ actions.push({
+ key: "move-down",
+ label: "Move down",
+ icon: "ArrowDown",
+ onPress: () => {
+ haptic("selection");
+ reorder.mutate({ id: threadId, ...request });
+ },
+ });
+ }
+ if (row.groupToggle) {
+ const toggle = row.groupToggle;
+ actions.push({
+ key: "group-toggle",
+ label: queuedMessageGroupToggleLabel(toggle),
+ icon: "Layers",
+ onPress: () => {
+ haptic("selection");
+ setGroupBoundary.mutate({ id: threadId, ...toggle.request });
+ },
+ });
+ }
+ actions.push({
+ key: "delete",
+ label: "Delete",
+ icon: "Trash2",
+ destructive: true,
+ onPress: () => {
+ haptic("warning");
+ deleteMessage.mutate({ id: threadId, queuedMessageId: row.id });
+ },
+ });
+ return actions;
+ },
+ [deleteMessage, edit, reorder, setGroupBoundary, threadId],
+ );
+
const menuRow = menuRowId
? (rows.find((row) => row.id === menuRowId) ?? null)
: null;
- const menuActions = useMemo(() => {
- if (!menuRow) return [];
- const actions: ActionSheetAction[] = [];
- if (menuRow.moveUp) {
- const request = menuRow.moveUp;
- actions.push({
- key: "move-up",
- label: "Move up",
- icon: "ArrowUp",
- onPress: () => reorder.mutate({ id: threadId, ...request }),
- });
- }
- if (menuRow.moveDown) {
- const request = menuRow.moveDown;
- actions.push({
- key: "move-down",
- label: "Move down",
- icon: "ArrowDown",
- onPress: () => reorder.mutate({ id: threadId, ...request }),
- });
- }
- if (menuRow.groupToggle) {
- const toggle = menuRow.groupToggle;
- actions.push({
- key: "group-toggle",
- label: queuedMessageGroupToggleLabel(toggle),
- icon: "Layers",
- onPress: () =>
- setGroupBoundary.mutate({ id: threadId, ...toggle.request }),
- });
- }
- actions.push({
- key: "delete",
- label: "Delete",
- icon: "Trash2",
- destructive: true,
- onPress: () =>
- deleteMessage.mutate({ id: threadId, queuedMessageId: menuRow.id }),
- });
- return actions;
- }, [deleteMessage, menuRow, reorder, setGroupBoundary, threadId]);
+ const sheetActions = useMemo(
+ () => (menuRow ? menuActionsFor(menuRow) : []),
+ [menuActionsFor, menuRow],
+ );
if (rows.length === 0) return null;
return (
-
-
+
{rows.length === 1
? "1 queued message"
: `${rows.length} queued messages`}
@@ -334,12 +419,7 @@ export function QueuedMessagesList({
mode: "auto",
})
}
- onEdit={() => {
- const queuedMessage = byId.get(row.id);
- if (queuedMessage) {
- onEdit({ queuedMessage, queuedMessageIndex: row.index });
- }
- }}
+ menuActions={IS_IOS ? menuActionsFor(row) : []}
onOpenMenu={() => {
setMenuRowId(row.id);
menu.present();
@@ -355,15 +435,17 @@ export function QueuedMessagesList({
{errorMessage}
) : null}
- setMenuRowId(null)}
- />
-
+ {IS_IOS ? null : (
+ setMenuRowId(null)}
+ />
+ )}
+
);
}
diff --git a/apps/mobile/src/screens/thread/timeline/FallbackTimelineRow.tsx b/apps/mobile/src/screens/thread/timeline/FallbackTimelineRow.tsx
index 0d77810c28..6909239602 100644
--- a/apps/mobile/src/screens/thread/timeline/FallbackTimelineRow.tsx
+++ b/apps/mobile/src/screens/thread/timeline/FallbackTimelineRow.tsx
@@ -121,6 +121,7 @@ export function FallbackTimelineRow({
diff --git a/apps/mobile/src/screens/thread/timeline/TimelineList.tsx b/apps/mobile/src/screens/thread/timeline/TimelineList.tsx
index 6d3dabb418..042a0d7f34 100644
--- a/apps/mobile/src/screens/thread/timeline/TimelineList.tsx
+++ b/apps/mobile/src/screens/thread/timeline/TimelineList.tsx
@@ -21,11 +21,10 @@ import {
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
+import { withAlpha } from "@/markdown/colors";
import { useTheme } from "@/theme";
import { Button, Icon, Spinner, Text } from "@/ui";
-import {
- type TimelineListEntry,
-} from "./list-entries";
+import { type TimelineListEntry } from "./list-entries";
import { getTimelineRowRenderer } from "./renderers";
// Registers the row renderers (side effect) before the first cell renders.
import "./renderers/index";
@@ -64,6 +63,13 @@ interface TimelineListProps {
footer?: ReactElement | null;
/** Extra space under the footer (bottom bar height). */
bottomInset: number;
+ /**
+ * Height of a bar floating over the bottom of the list (the Liquid Glass
+ * prompt area). `bottomInset` must already clear it; this lifts the
+ * jump-to-latest pill and the scroll indicator above it. 0 (the default)
+ * when the bar is docked under the list.
+ */
+ bottomOverlay?: number;
testID?: string;
}
@@ -168,6 +174,7 @@ export const TimelineList = forwardRef(
onLoadOlderRows,
footer,
bottomInset,
+ bottomOverlay = 0,
testID,
},
ref,
@@ -307,11 +314,9 @@ export const TimelineList = forwardRef(
scrollToEndNow(true);
}, [scrollToEndNow]);
- useImperativeHandle(
- ref,
- () => ({ scrollToEnd: jumpToLatest }),
- [jumpToLatest],
- );
+ useImperativeHandle(ref, () => ({ scrollToEnd: jumpToLatest }), [
+ jumpToLatest,
+ ]);
const renderItem = useCallback(
({ item: entry }: ListRenderItemInfo) => {
@@ -390,29 +395,43 @@ export const TimelineList = forwardRef(
ListHeaderComponent={header}
ListFooterComponent={footerNode}
keyboardShouldPersistTaps="handled"
+ // Not "interactive": the composer is positioned by
+ // KeyboardPaddingView, which only follows keyboard frame
+ // notifications, and iOS posts none while the keyboard is dragged.
keyboardDismissMode="on-drag"
+ // First scrollable of the route: insets under a transparent /
+ // blurred native header and above the home indicator.
+ contentInsetAdjustmentBehavior="automatic"
+ // The indicator stops where the floating prompt area begins.
+ scrollIndicatorInsets={
+ bottomOverlay > 0 ? { bottom: bottomOverlay } : undefined
+ }
testID={testID}
/>
{showJumpToLatest ? (
-
+
Jump to latest
diff --git a/apps/mobile/src/screens/thread/timeline/TimelineTitleView.tsx b/apps/mobile/src/screens/thread/timeline/TimelineTitleView.tsx
index aabd2a67f9..71df497336 100644
--- a/apps/mobile/src/screens/thread/timeline/TimelineTitleView.tsx
+++ b/apps/mobile/src/screens/thread/timeline/TimelineTitleView.tsx
@@ -96,7 +96,7 @@ function LiveDurationText({
// row entry.
if (elapsed <= 1_000) return null;
return (
-
+
{durationToCompactString(elapsed)}
);
@@ -123,7 +123,12 @@ function renderDecoration(
: base;
if (decoration.completedAt !== null) {
return (
-
+
{durationToCompactString(
decoration.completedAt - decoration.startedAt,
)}
@@ -147,7 +152,7 @@ function renderDecoration(
return (
{durationText ? (
-
+
{durationText}
) : null}
@@ -173,7 +178,7 @@ function renderDecoration(
}
if (parts.length === 0) return null;
return (
-
+
{parts.join(", ")}
);
@@ -187,7 +192,7 @@ function renderDecoration(
});
if (text.length === 0) return null;
return (
-
+
{text}
);
@@ -195,12 +200,12 @@ function renderDecoration(
return (
{decoration.added > 0 ? (
-
+
+{decoration.added}
) : null}
{decoration.removed > 0 ? (
-
+
-{decoration.removed}
) : null}
diff --git a/apps/mobile/src/screens/thread/timeline/host/TimelineRowHostProvider.tsx b/apps/mobile/src/screens/thread/timeline/host/TimelineRowHostProvider.tsx
index 675432c471..c96989887c 100644
--- a/apps/mobile/src/screens/thread/timeline/host/TimelineRowHostProvider.tsx
+++ b/apps/mobile/src/screens/thread/timeline/host/TimelineRowHostProvider.tsx
@@ -12,6 +12,7 @@ import {
} from "react";
import { useProfileClient } from "@/app-shell/ProfilesProvider";
import { usePluginList } from "@/data/plugins";
+import { haptic } from "@/lib/haptics";
import {
useSenderThreadMetadataById,
type SenderThreadMetadata,
@@ -106,6 +107,7 @@ interface TimelineRowHostProviderProps {
function copyMessageTextToClipboard(text: string): void {
void Clipboard.setStringAsync(text)
.then(() => {
+ haptic("success");
toast.success("Copied");
})
.catch(() => {
diff --git a/apps/mobile/src/screens/thread/timeline/renderers/conversation/AssistantMessageRow.tsx b/apps/mobile/src/screens/thread/timeline/renderers/conversation/AssistantMessageRow.tsx
index 8ce82549f7..ace983a2a9 100644
--- a/apps/mobile/src/screens/thread/timeline/renderers/conversation/AssistantMessageRow.tsx
+++ b/apps/mobile/src/screens/thread/timeline/renderers/conversation/AssistantMessageRow.tsx
@@ -18,7 +18,11 @@ import {
useConversationMarkdownHandlers,
} from "./conversation-shared";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
const EMPTY_MENTIONS: readonly PromptTextMention[] = [];
+/** Conversation prose: the 17pt body on iOS, the web timeline size elsewhere. */
+const PROSE_TEXT_SIZE = IS_IOS ? "base" : "sm";
interface AssistantMessageRowProps {
row: Extract;
@@ -30,8 +34,10 @@ interface AssistantMessageRowProps {
* Agent prose (web `AssistantConversationMessage`): the full markdown body
* at the top of the prominence ramp — never dimmed, never collapsed — with
* `@thread:` pills, images through the host-files route (lightbox on tap),
- * and the attachment strip. Long-press opens the message actions; a
- * long-press on one paragraph also offers to quote just that block.
+ * and the attachment strip. Long-press opens the message action sheet on
+ * both platforms (a per-row SwiftUI context-menu host would pin the
+ * recycled cell's size to its first measurement); a long-press on one
+ * paragraph also offers to quote just that block.
*/
export function AssistantMessageRow({
row,
@@ -114,6 +120,7 @@ export function AssistantMessageRow({
{hasText ? (
void;
}
+/** Conversation prose: the 17pt body on iOS, the web timeline size elsewhere. */
+const PROSE_TEXT_SIZE = IS_IOS ? "base" : "sm";
/** Web `max-h-[15lh]` on the timeline body type. */
const COLLAPSED_BODY_MAX_HEIGHT =
- USER_MESSAGE_COLLAPSED_MAX_LINES * nativeTypography.sm.lineHeight;
+ USER_MESSAGE_COLLAPSED_MAX_LINES *
+ nativeTypography[PROSE_TEXT_SIZE].lineHeight;
/** The authored bubble leaves this much room on its left (web max-w-[70%]). */
const BUBBLE_LEFT_INSET_PX = 40;
+/** iOS sent-message bubble: continuous corners, tinted, no outline. */
+const BUBBLE_RADIUS = 18;
+const BUBBLE_TINT_ALPHA = 0.12;
+/** iOS bubble width cap (the sent-message idiom leaves the left side free). */
+const BUBBLE_MAX_WIDTH = "85%";
/**
* The person's own message (web `UserConversationMessage`): a right-aligned
* bubble with the markdown body (mentions as pills), the attachment strip,
* and the steer label above it. Long bodies clamp at fifteen lines / the
- * char cap with a Show more toggle; long-press opens the message actions.
+ * char cap with a Show more toggle; long-press opens the message action
+ * sheet on both platforms (a per-row SwiftUI context-menu host would pin
+ * the recycled cell's size to its first measurement).
*/
export function AuthoredUserMessage({
row,
@@ -50,6 +63,7 @@ export function AuthoredUserMessage({
expanded,
onToggle,
}: AuthoredUserMessageProps) {
+ const { tokens } = useTheme();
const { presentMessageActions } = useTimelineRowHost();
const { onThreadPress, onFilePress, resolveThreadMention, serverHostname } =
useConversationMarkdownHandlers();
@@ -73,22 +87,20 @@ export function AuthoredUserMessage({
);
const messageText = row.text.trim();
const editable = canEditUserMessage(row);
- const onLongPress = useCallback(
- () =>
- presentMessageActions({
- rowId: row.id,
- role: "user",
- text: row.text,
- sourceSeqStart: row.sourceSeqStart,
- sourceSeqEnd: row.sourceSeqEnd,
- paragraph: null,
- editable,
- mentions: row.mentions,
- attachments: row.attachments,
- }),
+ const actionsTarget = useMemo(
+ () => ({
+ rowId: row.id,
+ role: "user" as const,
+ text: row.text,
+ sourceSeqStart: row.sourceSeqStart,
+ sourceSeqEnd: row.sourceSeqEnd,
+ paragraph: null,
+ editable,
+ mentions: row.mentions,
+ attachments: row.attachments,
+ }),
[
editable,
- presentMessageActions,
row.attachments,
row.id,
row.mentions,
@@ -97,6 +109,10 @@ export function AuthoredUserMessage({
row.text,
],
);
+ const onLongPress = useCallback(
+ () => presentMessageActions(actionsTarget),
+ [actionsTarget, presentMessageActions],
+ );
// Collapsed bodies clamp to a fixed height; whether that clamp hides
// anything is a layout fact (blocks have margins), measured off the
@@ -136,7 +152,21 @@ export function AuthoredUserMessage({
accessible={false}
onLongPress={onLongPress}
delayLongPress={LONG_PRESS_DELAY_MS}
- className="max-w-full rounded-xl border border-border-seam bg-surface-recessed px-3.5 py-2.5 active:opacity-90"
+ className={
+ IS_IOS
+ ? "px-3.5 py-2.5 active:opacity-90"
+ : "max-w-full rounded-xl border border-border-seam bg-surface-recessed px-3.5 py-2.5 active:opacity-90"
+ }
+ style={
+ IS_IOS
+ ? {
+ maxWidth: BUBBLE_MAX_WIDTH,
+ borderRadius: BUBBLE_RADIUS,
+ borderCurve: "continuous",
+ backgroundColor: withAlpha(tokens.primary, BUBBLE_TINT_ALPHA),
+ }
+ : undefined
+ }
testID="conversation-user-bubble"
>
{body.prefixText !== null ? (
@@ -156,6 +186,7 @@ export function AuthoredUserMessage({
{body.parseAsMarkdown ? (
) : (
- {body.content}
+
+ {body.content}
+
)}
diff --git a/apps/mobile/src/screens/thread/timeline/renderers/conversation/ConversationAttachments.tsx b/apps/mobile/src/screens/thread/timeline/renderers/conversation/ConversationAttachments.tsx
index a3fb7888dd..90b5488ea0 100644
--- a/apps/mobile/src/screens/thread/timeline/renderers/conversation/ConversationAttachments.tsx
+++ b/apps/mobile/src/screens/thread/timeline/renderers/conversation/ConversationAttachments.tsx
@@ -57,11 +57,12 @@ export function ConversationAttachments({
? "border-surface-selected-border bg-surface-raised"
: "border-border bg-surface-recessed",
)}
- style={
+ style={[
align === "end"
? { height: 80, width: 120 }
- : { height: 64, width: 96 }
- }
+ : { height: 64, width: 96 },
+ { borderRadius: 10, borderCurve: "continuous" },
+ ]}
testID="conversation-attachment-image"
>
{
+ const previous = applied.current;
+ applied.current = { rowKey, expanded };
+ const target = expanded ? 1 : 0;
+ if (previous.rowKey !== rowKey) {
+ turn.set(target);
+ return;
+ }
+ if (previous.expanded === expanded) return;
+ turn.set(
+ withTiming(target, {
+ duration: CHEVRON_TURN_MS,
+ easing: Easing.out(Easing.quad),
+ }),
+ );
+ }, [expanded, rowKey, turn]);
+ const rotation = useAnimatedStyle(() => ({
+ transform: [{ rotate: `${turn.get() * 90}deg` }],
+ }));
+ return (
+
+
+
+ );
+}
+
interface ExpandableRowHeaderProps {
+ /**
+ * Identity of the row (the list item key): the disclosure chevron snaps
+ * rather than animates when a recycled cell moves to another row.
+ */
+ rowKey: string;
title: TimelineTitle;
/** Replaces the generic title renderer for a specialized header. */
titleContent?: ReactNode;
@@ -82,9 +142,11 @@ interface ExpandableRowHeaderProps {
* One-line row header (web `ExpandableTimelineRow` summary / `TimelineStaticRow`):
* optional leading glyph, the title (segments + decorations), and — for
* expandable rows — a disclosure chevron at the trailing edge, the touch-
- * friendly place for it. Tapping anywhere on the line toggles.
+ * friendly place for it. Tapping anywhere on the line toggles (with the
+ * selection haptic).
*/
export function ExpandableRowHeader({
+ rowKey,
title,
titleContent,
leadingIcon,
@@ -101,7 +163,12 @@ export function ExpandableRowHeader({
testID,
}: ExpandableRowHeaderProps) {
const { tokens } = useTheme();
- const handlePress = expandable ? onToggle : onPress;
+ const handlePress = expandable
+ ? () => {
+ haptic("selection");
+ onToggle();
+ }
+ : onPress;
const pressable = handlePress !== undefined;
const iconColor = leadingIconColor ?? tokens.mutedForeground;
return (
@@ -141,9 +208,9 @@ export function ExpandableRowHeader({
{expandable ? (
-
) : (
diff --git a/apps/mobile/src/screens/thread/timeline/renderers/summaries/SummaryRow.tsx b/apps/mobile/src/screens/thread/timeline/renderers/summaries/SummaryRow.tsx
index d281623608..2b07a55895 100644
--- a/apps/mobile/src/screens/thread/timeline/renderers/summaries/SummaryRow.tsx
+++ b/apps/mobile/src/screens/thread/timeline/renderers/summaries/SummaryRow.tsx
@@ -20,6 +20,7 @@ export function SummaryRow({
return (
{body.text}
@@ -74,6 +74,7 @@ export function SystemRow({
return (
@@ -72,6 +75,7 @@ export const ToolCallDetailBlock = memo(function ToolCallDetailBlock({
variant="mono"
style={headerTextStyle}
numberOfLines={headerExpanded ? undefined : HEADER_COLLAPSED_LINES}
+ selectable
testID="timeline-tool-args"
>
@@ -127,8 +131,10 @@ export const ToolCallDetailBlock = memo(function ToolCallDetailBlock({
{line.length === 0 ? " " : line}
diff --git a/apps/mobile/src/screens/thread/timeline/renderers/work/WorkRowShell.tsx b/apps/mobile/src/screens/thread/timeline/renderers/work/WorkRowShell.tsx
index 8ec998af1c..555891c897 100644
--- a/apps/mobile/src/screens/thread/timeline/renderers/work/WorkRowShell.tsx
+++ b/apps/mobile/src/screens/thread/timeline/renderers/work/WorkRowShell.tsx
@@ -102,6 +102,7 @@ export function WorkRowShell({
return (
void;
- onLongPress: (row: SidebarThreadRow) => void;
- onUnarchive: (thread: ThreadListEntry) => void;
- pending: boolean;
-}) {
- const { tokens } = useTheme();
- const noop = useCallback(() => undefined, []);
- return (
-
-
-
-
- onUnarchive(row.thread)}
- className="mr-2 h-10 w-10 items-center justify-center rounded-md active:bg-state-hover"
- style={{ opacity: pending ? 0.5 : 1 }}
- testID={`unarchive-${row.thread.id}`}
- >
-
-
-
- );
-}
-
function ArchivedBody({
initialProjectId,
}: {
@@ -89,11 +41,9 @@ function ArchivedBody({
}) {
const insets = useSafeAreaInsets();
const { tokens } = useTheme();
- const actions = useSidebarActions();
const bootstrap = useSidebarBootstrap();
const [projectId, setProjectId] = useState(initialProjectId);
const archived = useArchivedThreads(projectId ? { projectId } : {});
- const unarchive = useUnarchiveThread();
const filterSheet = useSheet();
const bootstrapData = bootstrap.data;
@@ -119,52 +69,25 @@ function ArchivedBody({
[archived.data],
);
- const onPress = useCallback(
- (row: SidebarThreadRow) => actions.openThread(row.thread),
- [actions],
- );
- const onLongPress = useCallback(
- (row: SidebarThreadRow) => actions.openThreadMenu(row.thread),
- [actions],
- );
- const onUnarchive = useCallback(
- (thread: ThreadListEntry) => {
- unarchive.mutate(
- { id: thread.id },
- {
- onSuccess: () =>
- toast.success(`Unarchived ${getThreadDisplayTitle(thread)}`),
- },
- );
- },
- [unarchive],
- );
- const pendingIds = unarchive.variables?.id;
+ const noop = useCallback(() => undefined, []);
+ const selectProject = useCallback((next: string | null) => {
+ haptic("selection");
+ setProjectId(next);
+ }, []);
const renderItem = useCallback(
({ item }: ListRenderItemInfo) => (
-
),
- [
- onLongPress,
- onPress,
- onUnarchive,
- pendingIds,
- projectId,
- projectNamesById,
- unarchive.isPending,
- ],
+ [noop, projectId, projectNamesById],
);
const filterLabel = projectId
@@ -176,62 +99,104 @@ function ArchivedBody({
key: "all",
label: "All projects",
icon: "Layers",
- onPress: () => setProjectId(null),
+ checked: projectId === null,
+ onPress: () => selectProject(null),
},
- ...projects.map(
- (project): ActionSheetAction => ({
- key: project.id,
- label: project.name,
- icon: "Folder",
- onPress: () => setProjectId(project.id),
- }),
- ),
+ ...projects.map((project): ActionSheetAction => ({
+ key: project.id,
+ label: project.name,
+ icon: "Folder",
+ checked: projectId === project.id,
+ onPress: () => selectProject(project.id),
+ })),
];
+ const banner = ;
+
return (
<>
- (
-
+
+ selectProject(null)}
>
-
-
+ {projects.map((project) => (
+ selectProject(project.id)}
>
- {filterLabel}
-
-
-
- ),
- }}
- />
+ {project.name}
+
+ ))}
+
+
+ ) : (
+ (
+
+
+
+ {filterLabel}
+
+
+
+ ),
+ }}
+ />
+ )}
{archived.isLoading ? (
-
- {[0, 1, 2, 3].map((index) => (
-
- ))}
-
+
+ {banner}
+
+ {[0, 1, 2, 3].map((index) => (
+
+ ))}
+
+
) : archived.isError ? (
-
+
+ {banner}
Could not load archived threads.
-
+
{archived.error.message}
@@ -242,17 +207,13 @@ function ArchivedBody({
>
Retry
-
+
) : (
{
@@ -260,6 +221,7 @@ function ArchivedBody({
void archived.fetchNextPage();
}
}}
+ ListHeaderComponent={banner}
ListEmptyComponent={
@@ -276,18 +238,21 @@ function ArchivedBody({
) : null
}
+ contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingBottom: insets.bottom + 24,
- paddingTop: 8,
+ paddingTop: 4,
}}
testID="archived-thread-list"
/>
)}
-
+ {IS_IOS ? null : (
+
+ )}
>
);
}
@@ -296,12 +261,16 @@ function keyExtractor(row: SidebarThreadRow): string {
return row.key;
}
-/** `/settings/archived`: paginated archived threads, filter by project, unarchive. */
+/**
+ * `/settings/archived`: paginated archived threads under a large title,
+ * filtered by project from the header menu; rows unarchive from the
+ * context menu, the swipe actions, or (Android) the long-press sheet.
+ */
export function ArchivedThreadsScreen() {
const { connection } = useProfiles();
const { projectId } = useLocalSearchParams<{ projectId?: string }>();
return (
-
+
{connection ? (
diff --git a/apps/mobile/src/screens/threads/ThreadSearchResults.tsx b/apps/mobile/src/screens/threads/ThreadSearchResults.tsx
new file mode 100644
index 0000000000..e120edbbc8
--- /dev/null
+++ b/apps/mobile/src/screens/threads/ThreadSearchResults.tsx
@@ -0,0 +1,255 @@
+import type { ThreadListEntry } from "@bb/domain";
+import type { ThreadSearchResult } from "@bb/server-contract";
+import { FlashList, type ListRenderItemInfo } from "@shopify/flash-list";
+import { useCallback, useMemo, type ReactElement } from "react";
+import { View, type StyleProp, type ViewStyle } from "react-native";
+import {
+ THREAD_SEARCH_MIN_NON_WHITESPACE_CHARS,
+ useRecentThreads,
+ useSidebarModel,
+ useSidebarPreferences,
+ useThreadSearch,
+} from "@/data/sidebar";
+import { EmptyState, Spinner, Text } from "@/ui";
+import {
+ flatThreadRow,
+ projectSubtitle,
+ SidebarThreadRowView,
+ type SidebarRowSubtitle,
+ type SidebarThreadRow,
+} from "../sidebar";
+
+type SearchListRow =
+ | { type: "label"; key: string; label: string }
+ | {
+ type: "thread";
+ key: string;
+ row: SidebarThreadRow;
+ /**
+ * The best non-title match snippet (message text), else the project
+ * name. Built with the row so its identity holds across renders and
+ * `memo(SidebarThreadRowView)` keeps the row.
+ */
+ subtitle: SidebarRowSubtitle | null;
+ };
+
+const DISABLE_MAINTAIN_POSITION = { disabled: true };
+
+function snippetFor(result: ThreadSearchResult): string | null {
+ const match = result.matches.find(
+ (candidate) =>
+ candidate.sourceKind !== "title" &&
+ candidate.sourceKind !== "title_fallback",
+ );
+ return match?.text.trim() || null;
+}
+
+function threadListRow(
+ key: string,
+ thread: ThreadListEntry,
+ snippet: string | null,
+ projectNamesById: ReadonlyMap,
+): SearchListRow {
+ return {
+ type: "thread",
+ key,
+ row: flatThreadRow(thread),
+ subtitle:
+ snippet !== null
+ ? { kind: "snippet", text: snippet }
+ : projectSubtitle(projectNamesById.get(thread.projectId) ?? null),
+ };
+}
+
+function buildRows(args: {
+ results: {
+ active: ThreadSearchResult[];
+ archived: ThreadSearchResult[];
+ } | null;
+ recent: ThreadListEntry[];
+ projectNamesById: ReadonlyMap;
+}): SearchListRow[] {
+ const rows: SearchListRow[] = [];
+ if (args.results) {
+ if (args.results.active.length > 0) {
+ rows.push({ type: "label", key: "label:active", label: "Threads" });
+ for (const result of args.results.active) {
+ rows.push(
+ threadListRow(
+ `active:${result.thread.id}`,
+ result.thread,
+ snippetFor(result),
+ args.projectNamesById,
+ ),
+ );
+ }
+ }
+ if (args.results.archived.length > 0) {
+ rows.push({ type: "label", key: "label:archived", label: "Archived" });
+ for (const result of args.results.archived) {
+ rows.push(
+ threadListRow(
+ `archived:${result.thread.id}`,
+ result.thread,
+ snippetFor(result),
+ args.projectNamesById,
+ ),
+ );
+ }
+ }
+ return rows;
+ }
+ if (args.recent.length > 0) {
+ rows.push({ type: "label", key: "label:recent", label: "Recent" });
+ for (const thread of args.recent) {
+ rows.push(
+ threadListRow(
+ `recent:${thread.id}`,
+ thread,
+ null,
+ args.projectNamesById,
+ ),
+ );
+ }
+ }
+ return rows;
+}
+
+interface ThreadSearchResultsProps {
+ /** The live query; recent threads show while it is empty. */
+ query: string;
+ /** Scrolls ahead of the status line and the rows (the connection banner). */
+ ListHeaderComponent?: ReactElement | null;
+ contentContainerStyle?: StyleProp;
+ testID?: string;
+}
+
+/**
+ * Search results as a list: Recent (empty query) / Threads / Archived
+ * sections of thread rows, with the debounced full-text search's hint,
+ * progress and error lines at the top. Rendered on home under the header
+ * search bar and on the `/threads/search` route; needs the enclosing
+ * `SidebarActionsProvider` for the row actions.
+ */
+export function ThreadSearchResults({
+ query,
+ ListHeaderComponent,
+ contentContainerStyle,
+ testID = "thread-search-list",
+}: ThreadSearchResultsProps) {
+ const search = useThreadSearch(query);
+ const recent = useRecentThreads();
+ const [preferences] = useSidebarPreferences();
+ const { model } = useSidebarModel({
+ organize: preferences.organize,
+ sort: preferences.sort,
+ });
+
+ const projectNamesById = model.projectNamesById;
+ const rows = useMemo(
+ () =>
+ buildRows({
+ results:
+ search.hasSearchableQuery && search.data
+ ? {
+ active: search.data.active.results,
+ archived: search.data.archived.results,
+ }
+ : null,
+ recent: search.hasSearchableQuery ? [] : recent.threads,
+ projectNamesById,
+ }),
+ [projectNamesById, recent.threads, search.data, search.hasSearchableQuery],
+ );
+
+ const noop = useCallback(() => undefined, []);
+
+ const renderItem = useCallback(
+ ({ item }: ListRenderItemInfo) => {
+ if (item.type === "label") {
+ return (
+
+ {item.label}
+
+ );
+ }
+ return (
+
+ );
+ },
+ [noop],
+ );
+
+ const trimmed = query.trim();
+ const showHint =
+ trimmed.length > 0 && !search.hasSearchableQuery && !search.isDebouncing;
+ const busy = search.isFetching || search.isDebouncing;
+ const noResults =
+ search.hasSearchableQuery &&
+ !search.isLoading &&
+ !search.isDebouncing &&
+ search.data !== undefined &&
+ rows.length === 0;
+
+ const header = (
+ <>
+ {ListHeaderComponent}
+ {showHint ? (
+
+ ) : null}
+ {busy && rows.length === 0 ? (
+
+
+
+ ) : null}
+ {search.isError ? (
+
+ ) : null}
+ {noResults ? (
+
+
+ No threads match “{search.debouncedQuery}”.
+
+
+ ) : null}
+ >
+ );
+
+ return (
+
+ );
+}
+
+function keyExtractor(row: SearchListRow): string {
+ return row.key;
+}
+
+function getItemType(row: SearchListRow): string {
+ return row.type;
+}
diff --git a/apps/mobile/src/screens/threads/ThreadSearchScreen.tsx b/apps/mobile/src/screens/threads/ThreadSearchScreen.tsx
index f668d8f785..1095fee2da 100644
--- a/apps/mobile/src/screens/threads/ThreadSearchScreen.tsx
+++ b/apps/mobile/src/screens/threads/ThreadSearchScreen.tsx
@@ -1,249 +1,75 @@
-import type { ThreadListEntry } from "@bb/domain";
-import type { ThreadSearchResult } from "@bb/server-contract";
-import { FlashList, type ListRenderItemInfo } from "@shopify/flash-list";
-import { useCallback, useMemo, useState } from "react";
+import { Stack } from "expo-router";
+import { useState } from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useProfiles } from "@/app-shell";
-import {
- THREAD_SEARCH_MIN_NON_WHITESPACE_CHARS,
- useRecentThreads,
- useSidebarModel,
- useSidebarPreferences,
- useThreadSearch,
-} from "@/data/sidebar";
import { useTheme } from "@/theme";
-import { EmptyState, Icon, Input, Spinner, Text } from "@/ui";
+import { Icon, Input, Text } from "@/ui";
+import { ConnectionBanner } from "../shell/ConnectionBanner";
import { Screen } from "../shell/Screen";
-import {
- flatThreadRow,
- SidebarActionsProvider,
- projectSubtitle,
- SidebarThreadRowView,
- type SidebarRowSubtitle,
- useSidebarActions,
- type SidebarThreadRow,
-} from "../sidebar";
+import { SidebarActionsProvider } from "../sidebar";
+import { ThreadSearchResults } from "./ThreadSearchResults";
-type SearchListRow =
- | { type: "label"; key: string; label: string }
- | {
- type: "thread";
- key: string;
- row: SidebarThreadRow;
- /** Best non-title match snippet (message text), if any. */
- snippet: string | null;
- };
-
-const DISABLE_MAINTAIN_POSITION = { disabled: true };
-
-function snippetFor(result: ThreadSearchResult): string | null {
- const match = result.matches.find(
- (candidate) =>
- candidate.sourceKind !== "title" &&
- candidate.sourceKind !== "title_fallback",
- );
- return match?.text.trim() || null;
-}
-
-function buildRows(args: {
- results: {
- active: ThreadSearchResult[];
- archived: ThreadSearchResult[];
- } | null;
- recent: ThreadListEntry[];
-}): SearchListRow[] {
- const rows: SearchListRow[] = [];
- if (args.results) {
- if (args.results.active.length > 0) {
- rows.push({ type: "label", key: "label:active", label: "Threads" });
- for (const result of args.results.active) {
- rows.push({
- type: "thread",
- key: `active:${result.thread.id}`,
- row: flatThreadRow(result.thread),
- snippet: snippetFor(result),
- });
- }
- }
- if (args.results.archived.length > 0) {
- rows.push({ type: "label", key: "label:archived", label: "Archived" });
- for (const result of args.results.archived) {
- rows.push({
- type: "thread",
- key: `archived:${result.thread.id}`,
- row: flatThreadRow(result.thread),
- snippet: snippetFor(result),
- });
- }
- }
- return rows;
- }
- if (args.recent.length > 0) {
- rows.push({ type: "label", key: "label:recent", label: "Recent" });
- for (const thread of args.recent) {
- rows.push({
- type: "thread",
- key: `recent:${thread.id}`,
- row: flatThreadRow(thread),
- snippet: null,
- });
- }
- }
- return rows;
-}
+const IS_IOS = process.env.EXPO_OS === "ios";
function SearchBody() {
const insets = useSafeAreaInsets();
const { tokens } = useTheme();
- const actions = useSidebarActions();
const [query, setQuery] = useState("");
- const search = useThreadSearch(query);
- const recent = useRecentThreads();
- const [preferences] = useSidebarPreferences();
- const { model } = useSidebarModel({
- organize: preferences.organize,
- sort: preferences.sort,
- });
-
- const rows = useMemo(
- () =>
- buildRows({
- results:
- search.hasSearchableQuery && search.data
- ? {
- active: search.data.active.results,
- archived: search.data.archived.results,
- }
- : null,
- recent: search.hasSearchableQuery ? [] : recent.threads,
- }),
- [recent.threads, search.data, search.hasSearchableQuery],
- );
-
- const onPress = useCallback(
- (row: SidebarThreadRow) => actions.openThread(row.thread),
- [actions],
- );
- const onLongPress = useCallback(
- (row: SidebarThreadRow) => actions.openThreadMenu(row.thread),
- [actions],
- );
- const noop = useCallback(() => undefined, []);
- const projectNamesById = model.projectNamesById;
-
- const renderItem = useCallback(
- ({ item }: ListRenderItemInfo) => {
- if (item.type === "label") {
- return (
-
- {item.label}
-
- );
- }
- const subtitle: SidebarRowSubtitle | null =
- item.snippet !== null && item.snippet !== undefined
- ? { kind: "snippet", text: item.snippet }
- : projectSubtitle(
- projectNamesById.get(item.row.thread.projectId) ?? null,
- );
- return (
-
- );
- },
- [noop, onLongPress, onPress, projectNamesById],
- );
-
- const trimmed = query.trim();
- const showHint =
- trimmed.length > 0 && !search.hasSearchableQuery && !search.isDebouncing;
- const noResults =
- search.hasSearchableQuery &&
- !search.isLoading &&
- !search.isDebouncing &&
- search.data !== undefined &&
- rows.length === 0;
-
return (
<>
-
-
-
-
-
-
-
- {search.isFetching || search.isDebouncing ? : null}
-
- {showHint ? (
-
- ) : null}
- {search.isError ? (
- setQuery(event.nativeEvent.text)}
+ onCancelButtonPress={() => setQuery("")}
/>
- ) : null}
- {noResults ? (
-
-
- No threads match “{search.debouncedQuery}”.
-
+ ) : (
+
+
+
+
+
+
+
- ) : null}
- }
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
- testID="thread-search-list"
/>
>
);
}
-function keyExtractor(row: SearchListRow): string {
- return row.key;
-}
-
-function getItemType(row: SearchListRow): string {
- return row.type;
-}
-
-/** `/threads/search`: debounced full-text search, recent threads while empty. */
+/**
+ * `/threads/search`: debounced full-text search, recent threads while
+ * empty. On iOS home searches in place from its header search bar, so this
+ * route mostly serves deep links and uses the same native search bar;
+ * Android reaches it from the home header's search button.
+ */
export function ThreadSearchScreen() {
const { connection } = useProfiles();
return (
-
+
{connection ? (
diff --git a/apps/mobile/src/theme/font-platform.ios.ts b/apps/mobile/src/theme/font-platform.ios.ts
new file mode 100644
index 0000000000..e70956a768
--- /dev/null
+++ b/apps/mobile/src/theme/font-platform.ios.ts
@@ -0,0 +1,33 @@
+import type { FontWeightName, FontWeightValue } from "./fonts";
+
+/**
+ * iOS font families and weights consumed by `fonts.ts` (Metro picks this
+ * file over `font-platform.ts` on iOS; keep the exports identical).
+ */
+
+/**
+ * Leaving `fontFamily` unset selects the system font (SF Pro), which keeps
+ * Dynamic Type metrics and switches Text/Display optical sizes at 20pt.
+ * Weight and italics come from `fontWeight` / `fontStyle`.
+ */
+export const SANS_FAMILIES: Record = {
+ regular: undefined,
+ medium: undefined,
+ semibold: undefined,
+ bold: undefined,
+};
+
+/** SF Pro ships every weight, so each name maps to its exact value. */
+export const SANS_WEIGHTS: Record = {
+ regular: "400",
+ medium: "500",
+ semibold: "600",
+ bold: "700",
+};
+
+/**
+ * Menlo is the monospace face third-party apps can address by name (SF Mono
+ * is not). Its 0.6em advance is what `DiffHunkView` sizes gutters with. It
+ * ships Regular/Bold (+ italics) only, so 500/600 snap to the nearest face.
+ */
+export const MONO_FAMILY: string = "Menlo";
diff --git a/apps/mobile/src/theme/font-platform.ts b/apps/mobile/src/theme/font-platform.ts
new file mode 100644
index 0000000000..e52b4cf347
--- /dev/null
+++ b/apps/mobile/src/theme/font-platform.ts
@@ -0,0 +1,40 @@
+import type { FontWeightName, FontWeightValue } from "./fonts";
+
+/**
+ * Platform font families and weights consumed by `fonts.ts`.
+ *
+ * This is the Android (and node / vitest) default; Metro picks
+ * `font-platform.ios.ts` on iOS. Both files must export the same names with
+ * compatible types — `fonts.test.ts` guards that so the iOS pick cannot drift
+ * from the default. Kept free of react-native imports so the font resolver
+ * stays testable in node (see vitest.config.ts).
+ */
+
+/**
+ * Android's UI families per weight. Below API 28 React Native can only pick
+ * the NORMAL or BOLD face of a family (`ReactFontManager.TypefaceStyle`
+ * maps any `fontWeight` under 700 to NORMAL), so `medium` names the real
+ * `sans-serif-medium` family instead of relying on weight synthesis; the
+ * other weights stay on the generic family.
+ */
+export const SANS_FAMILIES: Record = {
+ regular: "sans-serif",
+ medium: "sans-serif-medium",
+ semibold: "sans-serif",
+ bold: "sans-serif",
+};
+
+/**
+ * Numeric weights paired with the families above. `semibold` travels as 700:
+ * Roboto ships no 600 face (API 28+ already snaps 600 to bold), and pre-28
+ * devices would render it as regular otherwise.
+ */
+export const SANS_WEIGHTS: Record = {
+ regular: "400",
+ medium: "500",
+ semibold: "700",
+ bold: "700",
+};
+
+/** Android's built-in monospace family. */
+export const MONO_FAMILY: string = "monospace";
diff --git a/apps/mobile/src/theme/fonts.test.ts b/apps/mobile/src/theme/fonts.test.ts
index ec2ad1417c..7d840eb648 100644
--- a/apps/mobile/src/theme/fonts.test.ts
+++ b/apps/mobile/src/theme/fonts.test.ts
@@ -1,12 +1,88 @@
import { describe, expect, it } from "vitest";
-import { FONT_FAMILIES, resolveFont } from "./fonts";
+import * as defaultPlatform from "./font-platform";
+import * as iosPlatform from "./font-platform.ios";
+import {
+ FONT_FAMILIES,
+ FONT_WEIGHT_VALUES,
+ type FontWeightName,
+ resolveFont,
+ resolveItalicFont,
+} from "./fonts";
+
+const WEIGHTS: readonly FontWeightName[] = [
+ "regular",
+ "medium",
+ "semibold",
+ "bold",
+];
+
+describe("font platform modules", () => {
+ it("export the same names so Metro's .ios pick cannot drift from the default", () => {
+ expect(Object.keys(iosPlatform).sort()).toEqual(
+ Object.keys(defaultPlatform).sort(),
+ );
+ for (const weight of WEIGHTS) {
+ expect(typeof iosPlatform.SANS_WEIGHTS[weight]).toBe("string");
+ expect(typeof defaultPlatform.SANS_WEIGHTS[weight]).toBe("string");
+ }
+ });
+
+ it("default (Android / node) names a real medium family and sends semibold as bold", () => {
+ // Below API 28 React Native maps any fontWeight < 700 on a family to its
+ // NORMAL face, so medium needs its own family and 600 must be 700.
+ expect(defaultPlatform.SANS_FAMILIES).toEqual({
+ regular: "sans-serif",
+ medium: "sans-serif-medium",
+ semibold: "sans-serif",
+ bold: "sans-serif",
+ });
+ expect(defaultPlatform.SANS_WEIGHTS).toEqual({
+ regular: "400",
+ medium: "500",
+ semibold: "700",
+ bold: "700",
+ });
+ expect(defaultPlatform.MONO_FAMILY).toBe("monospace");
+ });
+
+ it("iOS leaves sans unset for the system font with exact weights, and uses Menlo for mono", () => {
+ for (const weight of WEIGHTS) {
+ expect(iosPlatform.SANS_FAMILIES[weight]).toBeUndefined();
+ }
+ expect(iosPlatform.SANS_WEIGHTS).toEqual({
+ regular: "400",
+ medium: "500",
+ semibold: "600",
+ bold: "700",
+ });
+ expect(iosPlatform.MONO_FAMILY).toBe("Menlo");
+ });
+
+ it("derives the font tables from the platform module", () => {
+ for (const weight of WEIGHTS) {
+ expect(FONT_FAMILIES.sans[weight]).toBe(
+ defaultPlatform.SANS_FAMILIES[weight],
+ );
+ expect(FONT_FAMILIES.mono[weight]).toBe(defaultPlatform.MONO_FAMILY);
+ expect(FONT_WEIGHT_VALUES[weight]).toBe(
+ defaultPlatform.SANS_WEIGHTS[weight],
+ );
+ }
+ });
+});
describe("resolveFont", () => {
- it("defaults to regular Inter", () => {
- expect(resolveFont({})).toEqual({
+ it("defaults to the regular sans face with no italic", () => {
+ const font = resolveFont({});
+ expect(font).toEqual({
fontFamily: FONT_FAMILIES.sans.regular,
fontWeight: "400",
});
+ expect(font).not.toHaveProperty("fontStyle");
+ });
+
+ it("always carries the fontFamily key so it overrides a class family", () => {
+ expect(Object.keys(resolveFont({}))).toContain("fontFamily");
});
it("derives weight and family from web-style utility classes", () => {
@@ -18,20 +94,26 @@ describe("resolveFont", () => {
resolveFont({ className: "font-mono text-xs font-semibold" }),
).toEqual({
fontFamily: FONT_FAMILIES.mono.semibold,
- fontWeight: "600",
+ fontWeight: FONT_WEIGHT_VALUES.semibold,
});
- expect(resolveFont({ className: "font-bold" }).fontFamily).toBe(
- FONT_FAMILIES.sans.bold,
- );
+ expect(resolveFont({ className: "font-bold" }).fontWeight).toBe("700");
+ });
+
+ it("mono always resolves to a concrete family name", () => {
+ // DiffHunkView / file previews size gutters from `fonts.mono.*`.
+ for (const weight of WEIGHTS) {
+ expect(typeof resolveFont({ mono: true, weight }).fontFamily).toBe(
+ "string",
+ );
+ }
});
it("does not match class prefixes loosely", () => {
- expect(resolveFont({ className: "font-mono-medium" }).fontFamily).toBe(
- FONT_FAMILIES.sans.regular,
- );
- expect(resolveFont({ className: "font-boldish" }).fontFamily).toBe(
- FONT_FAMILIES.sans.regular,
- );
+ expect(resolveFont({ className: "font-mono-medium" })).toEqual({
+ fontFamily: FONT_FAMILIES.sans.regular,
+ fontWeight: "400",
+ });
+ expect(resolveFont({ className: "font-boldish" }).fontWeight).toBe("400");
});
it("lets explicit props override classes", () => {
@@ -54,3 +136,20 @@ describe("resolveFont", () => {
);
});
});
+
+describe("resolveItalicFont", () => {
+ it("keeps the sans family and exact weight and adds fontStyle italic", () => {
+ for (const weight of WEIGHTS) {
+ const font = resolveItalicFont(weight);
+ expect(font.fontFamily).toBe(FONT_FAMILIES.sans[weight]);
+ expect(font.fontStyle).toBe("italic");
+ expect(font.fontWeight).toBe(resolveFont({ weight }).fontWeight);
+ }
+ });
+
+ it("never switches to the mono family", () => {
+ expect(resolveItalicFont("semibold").fontFamily).not.toBe(
+ FONT_FAMILIES.mono.semibold,
+ );
+ });
+});
diff --git a/apps/mobile/src/theme/fonts.ts b/apps/mobile/src/theme/fonts.ts
index d71c745bed..cf74fc639c 100644
--- a/apps/mobile/src/theme/fonts.ts
+++ b/apps/mobile/src/theme/fonts.ts
@@ -1,60 +1,44 @@
+import { MONO_FAMILY, SANS_FAMILIES, SANS_WEIGHTS } from "./font-platform";
+
/**
- * Font family tokens. Expo Google Fonts register each weight under its own
- * family name, so a (family, weight) pair maps to one of these names. Keep
- * this list in sync with `useAppFonts` (which loads them) and the
- * `--font-sans-*` / `--font-mono-*` entries in `global.css`.
+ * Font family tokens. The app renders in the platform's own faces: the system
+ * font on iOS (SF Pro, selected by leaving `fontFamily` unset so UIKit keeps
+ * its metrics and optical sizes), `sans-serif` on Android, and Menlo /
+ * `monospace` for code. Weight always travels as a numeric `fontWeight` next
+ * to the family, so one `(kind, weight)` pair renders correctly on both
+ * platforms (Android names the `sans-serif-medium` family for 500 and sends
+ * 600 as 700, since pre-API-28 devices cannot pick other weights).
+ *
+ * The per-platform families and weights live in `font-platform.ts`
+ * (Android / node default) and `font-platform.ios.ts` (picked by Metro on
+ * iOS). `global.css` only keeps the `font-sans` / `font-mono` class names
+ * resolvable; `` (src/ui/Text.tsx) re-resolves the family through
+ * `resolveFont` as an inline style, and that is what renders.
*/
export type FontFamilyKind = "sans" | "mono";
export type FontWeightName = "regular" | "medium" | "semibold" | "bold";
+export type FontWeightValue = "400" | "500" | "600" | "700";
-export const FONT_FAMILIES: Record<
- FontFamilyKind,
- Record
-> = {
- sans: {
- regular: "Inter_400Regular",
- medium: "Inter_500Medium",
- semibold: "Inter_600SemiBold",
- bold: "Inter_700Bold",
- },
+export interface FontFamilies {
+ /** `undefined` means the platform UI font (SF Pro on iOS). */
+ sans: Record;
+ /** Always a concrete family name: consumers size gutters from it. */
+ mono: Record;
+}
+
+export const FONT_FAMILIES: FontFamilies = {
+ sans: SANS_FAMILIES,
mono: {
- regular: "FiraCode_400Regular",
- medium: "FiraCode_500Medium",
- semibold: "FiraCode_600SemiBold",
- bold: "FiraCode_700Bold",
+ regular: MONO_FAMILY,
+ medium: MONO_FAMILY,
+ semibold: MONO_FAMILY,
+ bold: MONO_FAMILY,
},
};
-/**
- * Italic faces (markdown emphasis). Only the weights prose needs are loaded;
- * `resolveItalicFont` picks the nearest one. Kept apart from FONT_FAMILIES so
- * the (family, weight) contract above stays exhaustive for `resolveFont`.
- */
-export const ITALIC_FONT_FAMILIES: Record<
- Extract,
- string
-> = {
- regular: "Inter_400Regular_Italic",
- semibold: "Inter_600SemiBold_Italic",
-};
-
-/** Italic Inter for `weight` (regular/medium → regular italic, else semibold). */
-export function resolveItalicFont(weight: FontWeightName): ResolvedFont {
- return weight === "regular" || weight === "medium"
- ? { fontFamily: ITALIC_FONT_FAMILIES.regular, fontWeight: "400" }
- : { fontFamily: ITALIC_FONT_FAMILIES.semibold, fontWeight: "600" };
-}
-
/** Numeric weight to pair with the family so iOS/Android never fake-bold. */
-export const FONT_WEIGHT_VALUES: Record<
- FontWeightName,
- "400" | "500" | "600" | "700"
-> = {
- regular: "400",
- medium: "500",
- semibold: "600",
- bold: "700",
-};
+export const FONT_WEIGHT_VALUES: Record =
+ SANS_WEIGHTS;
const CLASS_WEIGHTS: readonly { token: string; weight: FontWeightName }[] = [
{ token: "font-bold", weight: "bold" },
@@ -63,17 +47,32 @@ const CLASS_WEIGHTS: readonly { token: string; weight: FontWeightName }[] = [
{ token: "font-normal", weight: "regular" },
];
+/**
+ * Spreadable RN `TextStyle` subset. `fontFamily` is present but `undefined`
+ * for the iOS system font; the explicit key still overrides a family set by a
+ * `font-sans` class when the style array is flattened.
+ */
export interface ResolvedFont {
- fontFamily: string;
- fontWeight: "400" | "500" | "600" | "700";
+ fontFamily?: string;
+ fontWeight: FontWeightValue;
+ /** Only set by `resolveItalicFont`; system faces ship their own italics. */
+ fontStyle?: "italic";
+}
+
+/** Italic sans at `weight` (markdown emphasis, thinking text). */
+export function resolveItalicFont(weight: FontWeightName): ResolvedFont {
+ return {
+ fontFamily: FONT_FAMILIES.sans[weight],
+ fontWeight: FONT_WEIGHT_VALUES[weight],
+ fontStyle: "italic",
+ };
}
/**
* Picks the concrete font for a Text. Explicit props win; otherwise the
* web-style utility classes (`font-medium`, `font-mono`, …) in `className`
- * decide, so class strings ported from the web app select the right file
- * on both platforms (Android cannot derive a weight from a single-face
- * family, so `fontWeight` alone would render regular).
+ * decide, so class strings ported from the web app select the right family
+ * and weight on both platforms.
*/
export function resolveFont(options: {
className?: string;
diff --git a/apps/mobile/src/theme/generate-native-theme.test.ts b/apps/mobile/src/theme/generate-native-theme.test.ts
index 4571c02dde..22749b6b34 100644
--- a/apps/mobile/src/theme/generate-native-theme.test.ts
+++ b/apps/mobile/src/theme/generate-native-theme.test.ts
@@ -26,6 +26,18 @@ import {
const MODES = ["light", "dark"] as const;
const toOklch = converter("oklch");
+/** A minimal mobile layer for the synthetic-source tests below. */
+const MINIMAL_MOBILE_CSS = `
+ @theme { --radius-2xl: 16px; --radius-full: 9999px; }
+`;
+const MINIMAL_THEME_CSS = `
+ :root, .light { --canvas: #fff; --ink: #000; --radius: 8px; }
+ .dark { --canvas: #000; --ink: #fff; }
+ @theme inline { --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px; }
+`;
+const emptyPalettes = (): Map<(typeof BUILTIN_THEME_IDS)[number], string> =>
+ new Map(BUILTIN_THEME_IDS.map((id) => [id, ""]));
+
function lightness(color: string): number {
const parsed = parse(color);
if (!parsed) throw new Error(`not a color: ${color}`);
@@ -73,13 +85,31 @@ describe("generate-native-theme", () => {
}
});
- it("keeps the default anchors: white light canvas, dark canvas below ink", () => {
- expect(nativeThemes.default.light.canvas).toBe("#ffffff");
- expect(nativeThemes.default.light.background).toBe("#ffffff");
- expect(lightness(nativeThemes.default.light.ink)).toBeLessThan(0.5);
- expect(lightness(nativeThemes.default.dark.canvas)).toBeLessThan(
- lightness(nativeThemes.default.dark.ink),
- );
+ it("re-tunes the default palette to the iOS system anchors and tint", () => {
+ const { light, dark } = nativeThemes.default;
+ // systemBackground / label / systemBlue / systemRed per mode.
+ expect(light.canvas).toBe("#ffffff");
+ expect(light.background).toBe("#ffffff");
+ expect(light.ink).toBe("#000000");
+ expect(light.primary).toBe("#007aff");
+ expect(light.primaryForeground).toBe("#ffffff");
+ expect(light.destructive).toBe("#ff3b30");
+ expect(dark.canvas).toBe("#000000");
+ expect(dark.ink).toBe("#ffffff");
+ expect(dark.primary).toBe("#0a84ff");
+ expect(dark.primaryForeground).toBe("#ffffff");
+ expect(dark.destructive).toBe("#ff453a");
+ expect(lightness(light.ink)).toBeLessThan(0.5);
+ expect(lightness(dark.canvas)).toBeLessThan(lightness(dark.ink));
+ });
+
+ it("lets palettes override the mobile layer's anchors and tint", () => {
+ // nord.ts sets these itself; it cascades after mobile-overrides.css.
+ expect(nativeThemes.nord.light.primary).toBe("#5e81ac");
+ expect(nativeThemes.nord.light.canvas).toBe("#eceff4");
+ expect(nativeThemes.nord.dark.primary).toBe("#88c0d0");
+ expect(nativeThemes.nord.dark.canvas).toBe("#2e3440");
+ expect(nativeThemes.dracula.dark.ink).toBe("#f8f8f2");
});
for (const id of BUILTIN_THEME_IDS) {
@@ -116,10 +146,96 @@ describe("generate-native-theme", () => {
tokens.stateHover.startsWith(`rgba(${inkRgb?.join(", ")}, `),
).toBe(true);
});
+
+ it("keeps one grouped surface flush with the canvas and lifts the other toward the ink", () => {
+ // Inset grouped lists: light tints the page behind white cells,
+ // dark keeps the page black and lifts the cells (systemGray6).
+ const [flat, lifted] =
+ mode === "light"
+ ? [tokens.surfaceGroupedCell, tokens.surfaceGrouped]
+ : [tokens.surfaceGrouped, tokens.surfaceGroupedCell];
+ expect(flat).toBe(tokens.canvas);
+ expect(lifted).not.toBe(tokens.canvas);
+ const [low, high] = [
+ lightness(tokens.canvas),
+ lightness(tokens.ink),
+ ].sort((a, b) => a - b);
+ expect(lightness(lifted)).toBeGreaterThan(low);
+ expect(lightness(lifted)).toBeLessThan(high);
+ });
});
}
}
+ it("cascades theme.css → mobile overrides → palette", () => {
+ const model = buildNativeThemeModel({
+ themeCss: `
+ :root, .light { --canvas: #fff; --ink: #333; --primary: #111111; --radius: 8px; }
+ .dark { --canvas: #000; --ink: #ccc; --primary: #eeeeee; }
+ @theme inline { --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px; }
+ `,
+ mobileCss: `
+ :root, .light {
+ --ink: #000;
+ --primary: #007aff;
+ --grouped: color-mix(in oklab, var(--ink) 4%, var(--canvas));
+ }
+ .dark { --ink: #fff; --primary: #0a84ff; --grouped: var(--canvas); }
+ ${MINIMAL_MOBILE_CSS}
+ `,
+ paletteCss: new Map(
+ BUILTIN_THEME_IDS.map((id) => [
+ id,
+ id === "nord"
+ ? `:root, .light { --canvas: #eceff4; --ink: #2e3440; --primary: #5e81ac; }
+ .dark { --canvas: #2e3440; --ink: #d8dee9; --primary: #88c0d0; }`
+ : "",
+ ]),
+ ),
+ });
+ const base = model.themes.get("default");
+ const nord = model.themes.get("nord");
+ // The mobile layer wins over theme.css for the default palette…
+ expect(base?.light.ink).toBe("#000000");
+ expect(base?.light.primary).toBe("#007aff");
+ expect(base?.dark.primary).toBe("#0a84ff");
+ expect(base?.dark.ink).toBe("#ffffff");
+ // …and loses to a palette, whose anchors re-derive the mobile-only token.
+ expect(nord?.light.primary).toBe("#5e81ac");
+ expect(nord?.dark.primary).toBe("#88c0d0");
+ expect(nord?.dark.grouped).toBe("#2e3440");
+ expect(nord?.light.grouped).not.toBe(base?.light.grouped);
+ expect(model.mobileOnlyTokens).toEqual(["grouped"]);
+ });
+
+ it("rejects a `:root` mobile override without a `.dark` twin", () => {
+ // `:root` reaches dark mode too: a root-only `--primary` would silently
+ // replace theme.css's dark value.
+ expect(() =>
+ buildNativeThemeModel({
+ themeCss: MINIMAL_THEME_CSS,
+ mobileCss: `:root, .light { --primary: #007aff; } ${MINIMAL_MOBILE_CSS}`,
+ paletteCss: emptyPalettes(),
+ }),
+ ).toThrow(/sets --primary under `:root`/);
+ // `.dark`-only re-tunes are allowed: light keeps the web value.
+ const model = buildNativeThemeModel({
+ themeCss: MINIMAL_THEME_CSS,
+ mobileCss: `.dark { --ink: #cccccc; } ${MINIMAL_MOBILE_CSS}`,
+ paletteCss: emptyPalettes(),
+ });
+ expect(model.themes.get("default")?.light.ink).toBe("#000000");
+ expect(model.themes.get("default")?.dark.ink).toBe("#cccccc");
+ // …but a mobile-only token still needs both modes to exist at all.
+ expect(() =>
+ buildNativeThemeModel({
+ themeCss: MINIMAL_THEME_CSS,
+ mobileCss: `.dark { --grouped: #111111; } ${MINIMAL_MOBILE_CSS}`,
+ paletteCss: emptyPalettes(),
+ }),
+ ).toThrow(/one mode only: --grouped/);
+ });
+
it("resolves color-mix like Chrome: premultiplied alpha and carried hues", () => {
const model = buildNativeThemeModel({
themeCss: `
@@ -160,7 +276,8 @@ describe("generate-native-theme", () => {
}
}
`,
- paletteCss: new Map(BUILTIN_THEME_IDS.map((id) => [id, ""])),
+ mobileCss: MINIMAL_MOBILE_CSS,
+ paletteCss: emptyPalettes(),
});
const light = model.themes.get("default")?.light;
const dark = model.themes.get("default")?.dark;
@@ -176,12 +293,47 @@ describe("generate-native-theme", () => {
expect(dark?.border).toBe("#4f5460");
expect(dark?.hover).toBe("rgba(236, 239, 244, 0.138)");
expect(dark?.successForeground).toBe("#cbd9c0");
- expect(model.radii).toEqual({ base: 8, sm: 4, md: 6, lg: 8, xl: 12 });
+ expect(model.radii).toEqual({
+ base: 8,
+ sm: 4,
+ md: 6,
+ lg: 8,
+ xl: 12,
+ xl2: 16,
+ full: 9999,
+ });
+ // No mobile `@theme` text overrides: the coarse-pointer value stands.
expect(model.typography).toEqual([
["sm", { fontSize: 15, lineHeight: 22 }],
]);
});
+ it("layers the mobile @theme type ramp over the web's touch scale", () => {
+ const model = buildNativeThemeModel({
+ themeCss: `
+ ${MINIMAL_THEME_CSS}
+ @theme { --text-sm: 0.8125rem; --text-sm--line-height: 1.25rem; }
+ @media (max-width: 767px) and (pointer: coarse) {
+ :root { --text-sm: 0.9375rem; --text-sm--line-height: 1.375rem; }
+ }
+ `,
+ mobileCss: `
+ @theme {
+ --text-sm: 15px;
+ --text-sm--line-height: 20px;
+ --text-3xl: 34px;
+ --text-3xl--line-height: 41px;
+ }
+ ${MINIMAL_MOBILE_CSS}
+ `,
+ paletteCss: emptyPalettes(),
+ });
+ expect(model.typography).toEqual([
+ ["sm", { fontSize: 15, lineHeight: 20 }],
+ ["3xl", { fontSize: 34, lineHeight: 41 }],
+ ]);
+ });
+
it("rejects a token that only the dark block defines", () => {
// `:root` declarations reach dark mode too, so a light-block token can
// only go missing the other way round: declared under `.dark` alone.
@@ -192,7 +344,8 @@ describe("generate-native-theme", () => {
.dark { --canvas: #000; --ink: #fff; --only-dark: #123456; }
@theme inline { --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px; }
`,
- paletteCss: new Map(BUILTIN_THEME_IDS.map((id) => [id, ""])),
+ mobileCss: MINIMAL_MOBILE_CSS,
+ paletteCss: emptyPalettes(),
}),
).toThrow(/one mode only: --only-dark/);
});
@@ -200,11 +353,8 @@ describe("generate-native-theme", () => {
it("rejects a palette that sets a token theme.css does not define", () => {
expect(() =>
buildNativeThemeModel({
- themeCss: `
- :root, .light { --canvas: #fff; --ink: #000; --radius: 8px; }
- .dark { --canvas: #000; --ink: #fff; }
- @theme inline { --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px; }
- `,
+ themeCss: MINIMAL_THEME_CSS,
+ mobileCss: MINIMAL_MOBILE_CSS,
paletteCss: new Map(
BUILTIN_THEME_IDS.map((id) => [
id,
@@ -229,12 +379,26 @@ describe("generate-native-theme", () => {
expect(source.endsWith("\n")).toBe(true);
});
- it("exposes the touch type scale and radii used by the web app", () => {
- expect(nativeTypography.sm).toEqual({ fontSize: 15, lineHeight: 22 });
- expect(nativeTypography.base).toEqual({ fontSize: 16, lineHeight: 24 });
- expect(nativeTypography["2xs"].fontSize).toBeLessThan(
- nativeTypography.xs.fontSize,
- );
- expect(nativeRadii).toEqual({ base: 8, sm: 4, md: 6, lg: 8, xl: 12 });
+ it("exposes the Apple text-style ramp and the web radii plus 2xl/full", () => {
+ // caption2, footnote, subheadline, body, title3, title2, title1, largeTitle.
+ expect(nativeTypography).toEqual({
+ "2xs": { fontSize: 11, lineHeight: 13 },
+ xs: { fontSize: 13, lineHeight: 18 },
+ sm: { fontSize: 15, lineHeight: 20 },
+ base: { fontSize: 17, lineHeight: 22 },
+ lg: { fontSize: 20, lineHeight: 25 },
+ xl: { fontSize: 22, lineHeight: 28 },
+ "2xl": { fontSize: 28, lineHeight: 34 },
+ "3xl": { fontSize: 34, lineHeight: 41 },
+ });
+ expect(nativeRadii).toEqual({
+ base: 8,
+ sm: 4,
+ md: 6,
+ lg: 8,
+ xl: 12,
+ xl2: 16,
+ full: 9999,
+ });
});
});
diff --git a/apps/mobile/src/theme/index.ts b/apps/mobile/src/theme/index.ts
index 5292ddcba9..56cfdfa463 100644
--- a/apps/mobile/src/theme/index.ts
+++ b/apps/mobile/src/theme/index.ts
@@ -1,8 +1,8 @@
-// `useAppFonts` is intentionally not re-exported: importing its module keeps
-// the splash screen up until the hook hides it, so import it explicitly from
-// "@/theme/useAppFonts" in the root layout only.
+// Fonts are the platform system faces (see fonts.ts / font-platform*.ts);
+// nothing is loaded at runtime, so the root layout gates the splash on
+// `useAppBoot` alone.
export { ThemeProvider, useTheme, type Theme } from "./ThemeProvider";
-export { resolveFont, resolveItalicFont } from "./fonts";
+export { resolveFont, resolveItalicFont, type ResolvedFont } from "./fonts";
export { type ThemeModePreference } from "./theme-preference";
export { scrimBaseColor } from "./scrim";
export { nativeTypography, type NativeThemeTokens } from "./theme.native";
diff --git a/apps/mobile/src/theme/mobile-overrides.css b/apps/mobile/src/theme/mobile-overrides.css
new file mode 100644
index 0000000000..8e5965091c
--- /dev/null
+++ b/apps/mobile/src/theme/mobile-overrides.css
@@ -0,0 +1,145 @@
+/*
+ * Mobile-only theme override layer.
+ *
+ * scripts/generate-native-theme.ts replays this file AFTER
+ * apps/app/src/components/ui/theme.css and BEFORE every built-in palette's
+ * CSS (apps/app/src/lib/themes/*.ts). It re-tunes the DEFAULT palette to the
+ * iOS system look; a palette that sets its own anchors and literals (Nord,
+ * Dracula, …) still wins because it cascades last. The web app never loads
+ * this file, and only these parts of it are read:
+ *
+ * - custom properties inside `:root`, `.light`, and `.dark` rules (colors);
+ * - the `@theme` block (`--text-*` type ramp, `--radius-2xl`, `--radius-full`).
+ *
+ * Rules:
+ * - Every neutral derives from `--canvas`/`--ink` via `color-mix()` so custom
+ * palettes keep tinting the surfaces this layer touches. Use the token names
+ * theme.css already has; new names are allowed (they become mobile-only
+ * tokens, listed in the generated header and pinned in theme-vars.test.ts).
+ * - `:root` reaches dark mode too, so a token set under `:root, .light` must
+ * also get a `.dark` value or it would replace theme.css's dark value (the
+ * generator refuses that). `.dark`-only re-tunes are fine: light keeps the
+ * web value. A NEW token must exist in both modes.
+ * - Percentages mean different things per mix: an opaque mix
+ * (`…, var(--canvas)`) shifts oklch/oklab LIGHTNESS, a translucent mix
+ * (`…, transparent`) sets sRGB ALPHA. Near black the two diverge: white at
+ * 22.7% lightness over black is #1c1c1c (systemGray6), the same surface as
+ * white at 11% alpha. Comments below quote the resulting default hex.
+ * - Keep the invariants generate-native-theme.test.ts guards:
+ * card == popover == background == canvas; sidebar < secondary/accent/muted
+ * < border <= input (lightness distance from the canvas); stateActive alpha
+ * > stateHover alpha.
+ */
+
+:root,
+.light {
+ /* Anchors: systemBackground / label. */
+ --ink: oklch(0 0 0);
+ /* systemBlue tint; the ring and selection tints derive from it. */
+ --primary: #007aff;
+ --primary-foreground: #ffffff;
+ /* Text tiers (black over white): muted #666666 (5.7:1), readback #747474
+ * (4.7:1), subtle #8c8c8c (3.3:1 — the secondaryLabel tier; hints and
+ * chevrons only, never body copy). */
+ --muted-foreground: color-mix(in oklch, var(--ink) 49%, var(--canvas));
+ --readback-foreground: color-mix(in oklch, var(--ink) 44%, var(--canvas));
+ --subtle-foreground: color-mix(in oklch, var(--ink) 36%, var(--canvas));
+ /* Separator (#c7c7c7): one value for every line. */
+ --border: color-mix(in oklch, var(--ink) 17%, var(--canvas));
+ --border-hairline: color-mix(in oklch, var(--ink) 17%, var(--canvas));
+ --border-seam: var(--border);
+ --sidebar-border: color-mix(in oklch, var(--ink) 17%, var(--canvas));
+ /* systemGroupedBackground-class sunken step (#f2f2f2). */
+ --surface-recessed: color-mix(in oklab, var(--ink) 5%, transparent);
+ --surface-recessed-solid: color-mix(in oklab, var(--ink) 4%, var(--canvas));
+ /* Inset grouped lists: the page behind the cards and the cards themselves
+ * (systemGroupedBackground / secondarySystemGroupedBackground). */
+ --surface-grouped: color-mix(in oklab, var(--ink) 4%, var(--canvas));
+ --surface-grouped-cell: var(--canvas);
+ /* systemRed / systemGreen / systemOrange / systemYellow. */
+ --destructive: #ff3b30;
+ --destructive-text: #d70015;
+ --success: #34c759;
+ --warning: #ff9500;
+ /* systemOrange darkened to clear AA on white (4.6:1). */
+ --warning-text: #b84d00;
+ --attention: #ffcc00;
+}
+
+.dark {
+ /* Anchors: systemBackground / label. */
+ --canvas: oklch(0 0 0);
+ --ink: oklch(1 0 0);
+ --primary: #0a84ff;
+ --primary-foreground: #ffffff;
+ /* Text tiers (white over black): muted #8c8c8c (6.3:1, secondaryLabel),
+ * readback #808080 (5.3:1), subtle #777777 (4.7:1). */
+ --muted-foreground: color-mix(in oklch, var(--ink) 64%, var(--canvas));
+ --readback-foreground: color-mix(in oklch, var(--ink) 60%, var(--canvas));
+ --subtle-foreground: color-mix(in oklch, var(--ink) 57%, var(--canvas));
+ /* Lift ramp over pure black. Opaque steps are lightness shares:
+ * 22.7% → #1c1c1c (systemGray6: sidebar, raised, grouped cells),
+ * 29% → #2b2b2b (systemGray5: secondary/accent fills),
+ * 32% → #333333 (muted), 34% → #383838 (opaqueSeparator), 40% → #484848. */
+ --sidebar: color-mix(in oklch, var(--ink) 22.7%, var(--canvas));
+ --sidebar-accent: color-mix(in oklch, var(--ink) 29%, var(--canvas));
+ --secondary: color-mix(in oklch, var(--ink) 29%, var(--canvas));
+ --accent: color-mix(in oklch, var(--ink) 29%, var(--canvas));
+ --muted: color-mix(in oklch, var(--ink) 32%, var(--canvas));
+ --border: color-mix(in oklch, var(--ink) 34%, var(--canvas));
+ --border-hairline: color-mix(in oklch, var(--ink) 34%, var(--canvas));
+ --border-seam: var(--border);
+ --sidebar-border: color-mix(in oklch, var(--ink) 34%, var(--canvas));
+ --input: color-mix(in oklch, var(--ink) 40%, var(--canvas));
+ /* Translucent twins use alpha: 11% white over black is #1c1c1c. */
+ --surface-raised: color-mix(in oklab, var(--ink) 11%, transparent);
+ --surface-raised-solid: color-mix(in oklab, var(--ink) 22.7%, var(--canvas));
+ --surface-recessed: color-mix(in oklab, var(--ink) 11%, transparent);
+ --surface-recessed-solid: color-mix(
+ in oklab,
+ var(--ink) 22.7%,
+ var(--canvas)
+ );
+ --surface-recessed-soft-solid: color-mix(
+ in oklch,
+ var(--ink) 20%,
+ var(--canvas)
+ );
+ /* Inset grouped lists: black page, systemGray6 cells. */
+ --surface-grouped: var(--canvas);
+ --surface-grouped-cell: color-mix(in oklab, var(--ink) 22.7%, var(--canvas));
+ /* systemRed / systemGreen / systemOrange / systemYellow (dark variants). */
+ --destructive: #ff453a;
+ --destructive-text: #ff6961;
+ --success: #30d158;
+ --warning: #ff9f0a;
+ --warning-text: #ff9f0a;
+ --attention: #ffd60a;
+}
+
+/*
+ * Apple text-style ramp (font size / line height in px): caption2, footnote,
+ * subheadline, body, title3, title2, title1, largeTitle. Emitted as
+ * nativeTypography; global.css mirrors it as `--text-*` ratios. The extra
+ * radii are Tailwind's `rounded-2xl` / `rounded-full` as numbers.
+ */
+@theme {
+ --text-2xs: 11px;
+ --text-2xs--line-height: 13px;
+ --text-xs: 13px;
+ --text-xs--line-height: 18px;
+ --text-sm: 15px;
+ --text-sm--line-height: 20px;
+ --text-base: 17px;
+ --text-base--line-height: 22px;
+ --text-lg: 20px;
+ --text-lg--line-height: 25px;
+ --text-xl: 22px;
+ --text-xl--line-height: 28px;
+ --text-2xl: 28px;
+ --text-2xl--line-height: 34px;
+ --text-3xl: 34px;
+ --text-3xl--line-height: 41px;
+ --radius-2xl: 16px;
+ --radius-full: 9999px;
+}
diff --git a/apps/mobile/src/theme/scrim.test.ts b/apps/mobile/src/theme/scrim.test.ts
index 17e087769c..d14fa1e74b 100644
--- a/apps/mobile/src/theme/scrim.test.ts
+++ b/apps/mobile/src/theme/scrim.test.ts
@@ -14,14 +14,27 @@ function luminance(color: string): number {
}
describe("scrimBaseColor", () => {
- it("darkens the background in every palette and mode", () => {
+ it("darkens the page and its lifted surfaces in every palette and mode", () => {
for (const [palette, modes] of Object.entries(nativeThemes)) {
for (const mode of ["light", "dark"] as const) {
const tokens = modes[mode];
const scrim = scrimBaseColor(mode, tokens);
- const before = luminance(blendOver(tokens.background, scrim, 0));
- const after = luminance(blendOver(tokens.background, scrim, 0.35));
- expect(after, `${palette}/${mode}`).toBeLessThan(before);
+ // Mobile-only exception: the default dark canvas is iOS
+ // systemBackground (#000000, set in mobile-overrides.css), which no
+ // scrim can darken. What matters there is that the scrim still dims
+ // the lifted surfaces drawn on it, so the grouped cell (never black)
+ // must get darker and the page must never get lighter.
+ for (const key of ["background", "surfaceGroupedCell"] as const) {
+ const before = luminance(blendOver(tokens[key], scrim, 0));
+ const after = luminance(blendOver(tokens[key], scrim, 0.35));
+ const label = `${palette}/${mode}/${key}`;
+ if (before === 0) {
+ expect(key, label).toBe("background");
+ expect(after, label).toBe(0);
+ } else {
+ expect(after, label).toBeLessThan(before);
+ }
+ }
}
}
});
diff --git a/apps/mobile/src/theme/theme-vars.test.ts b/apps/mobile/src/theme/theme-vars.test.ts
index 7bae127803..2a040bb041 100644
--- a/apps/mobile/src/theme/theme-vars.test.ts
+++ b/apps/mobile/src/theme/theme-vars.test.ts
@@ -12,6 +12,40 @@ const WEB_THEME_CSS = readFileSync(
join(MOBILE_ROOT, "..", "app", "src", "components", "ui", "theme.css"),
"utf8",
);
+const MOBILE_OVERRIDES_CSS = readFileSync(
+ join(HERE, "mobile-overrides.css"),
+ "utf8",
+);
+
+/**
+ * Tokens that exist only in src/theme/mobile-overrides.css, with no web
+ * counterpart (the generator lists them in theme.native.ts's header). Adding
+ * one is deliberate: declare it in both modes of the override file, map it in
+ * global.css (`--color-: var(--)`), and add it here and to
+ * MOBILE_ONLY_COLOR_UTILITIES. If theme.css later grows the same name, drop
+ * it from this set — the web then owns it.
+ */
+const MOBILE_ONLY_TOKENS = new Set(["surface-grouped", "surface-grouped-cell"]);
+
+/**
+ * `--color-*` utilities global.css exposes beyond the web `@theme inline`
+ * list: the anchors, the sidebar search match, the pill chrome, the shadow
+ * color (all theme.css tokens the web has no class for) and the mobile-only
+ * grouped-list surfaces.
+ */
+const MOBILE_ONLY_COLOR_UTILITIES = new Set([
+ "canvas",
+ "ink",
+ "pill-foreground",
+ "pill-icon",
+ "pill-surface-border",
+ "pill-surface-selected-border",
+ "sidebar-search-match",
+ "sidebar-search-match-border",
+ "shadow-color",
+ "surface-grouped",
+ "surface-grouped-cell",
+]);
/** `--color-x: var(--y)` pairs inside every `@theme inline` block. */
function colorMappings(css: string): Map {
@@ -36,13 +70,23 @@ function declaredVars(css: string): Set {
describe("theme vars", () => {
const tokens = nativeThemes.default.light;
- it("maps every generated token key back to a theme.css custom property", () => {
+ it("maps every generated token key to a theme.css property or a documented mobile-only one", () => {
const webVars = declaredVars(WEB_THEME_CSS);
- for (const key of Object.keys(tokens)) {
- const cssVar = tokenKeyToCssVar(key);
- expect(cssVar.startsWith("--")).toBe(true);
- expect(webVars.has(cssVar.slice(2)), `${key} → ${cssVar}`).toBe(true);
+ const mobileVars = declaredVars(MOBILE_OVERRIDES_CSS);
+ for (const name of MOBILE_ONLY_TOKENS) {
+ expect(mobileVars.has(name), `--${name} in mobile-overrides.css`).toBe(
+ true,
+ );
+ expect(
+ webVars.has(name),
+ `--${name} is now in theme.css; drop it from MOBILE_ONLY_TOKENS`,
+ ).toBe(false);
}
+ const generatedMobileOnly = Object.keys(tokens)
+ .map((key) => tokenKeyToCssVar(key).slice(2))
+ .filter((name) => !webVars.has(name))
+ .sort();
+ expect(generatedMobileOnly).toEqual([...MOBILE_ONLY_TOKENS].sort());
});
it("handles the digit-bearing ansi names", () => {
@@ -77,6 +121,9 @@ describe("theme vars", () => {
`--color-${utility} → --${cssVar}`,
).toBe(true);
}
+ // Anything beyond the web list is deliberate and documented above.
+ const extra = [...mobile.keys()].filter((utility) => !web.has(utility));
+ expect(extra.sort()).toEqual([...MOBILE_ONLY_COLOR_UTILITIES].sort());
});
it("global.css radii and type scale match the generated native values", () => {
@@ -89,6 +136,8 @@ describe("theme vars", () => {
expect(px("radius-md")).toBe(nativeRadii.md);
expect(px("radius-lg")).toBe(nativeRadii.lg);
expect(px("radius-xl")).toBe(nativeRadii.xl);
+ expect(px("radius-2xl")).toBe(nativeRadii.xl2);
+ expect(px("radius-full")).toBe(nativeRadii.full);
// Line heights are `calc(lineHeight / fontSize)` ratios (see the comment
// in global.css: a px value inside Tailwind's `var(--tw-leading, …)`
// fallback is treated as an em multiplier by react-native-css).
diff --git a/apps/mobile/src/theme/theme.native.ts b/apps/mobile/src/theme/theme.native.ts
index d184e20f15..149d8ff48b 100644
--- a/apps/mobile/src/theme/theme.native.ts
+++ b/apps/mobile/src/theme/theme.native.ts
@@ -1,15 +1,23 @@
/**
* GENERATED FILE — run pnpm --filter @bb/mobile theme:generate
*
- * Source: apps/app/src/components/ui/theme.css and the built-in palettes in
- * apps/app/src/lib/themes/*.ts, replayed through the web cascade per palette
- * and mode by apps/mobile/scripts/generate-native-theme.ts.
+ * Source: apps/app/src/components/ui/theme.css, then the mobile-only override
+ * layer apps/mobile/src/theme/mobile-overrides.css, then the built-in palettes
+ * in apps/app/src/lib/themes/*.ts, replayed through the web cascade per
+ * palette and mode by apps/mobile/scripts/generate-native-theme.ts. The
+ * mobile layer re-tunes the default palette to the iOS system look; palettes
+ * cascade after it, so their anchors and literals still win.
*
* `var()` is substituted textually; `color-mix(in oklch|oklab, …)` is
* evaluated like Chrome (premultiplied alpha, shorter hue arc, converted
* near-achromatic operands lose their hue). Opaque results are `#rrggbb`,
- * translucent ones `rgba(r, g, b, a)`. Typography uses the coarse-pointer
- * (touch) sizes as the base scale, in CSS pixels.
+ * translucent ones `rgba(r, g, b, a)`. Typography is the Apple text-style
+ * ramp from the mobile layer's `@theme` block, in CSS pixels.
+ *
+ * Tokens only the mobile layer defines (no web utility class; global.css
+ * maps them by hand):
+ * --surface-grouped
+ * --surface-grouped-cell
*
* Tokens deliberately left out (edit the generator to add them):
* --bb-sidebar-row-height: dimension (1.75rem)
@@ -151,6 +159,8 @@ export interface NativeThemeTokens {
surfaceAttention: string;
surfaceDestructive: string;
surfaceDestructiveBorder: string;
+ surfaceGrouped: string;
+ surfaceGroupedCell: string;
surfaceRaised: string;
surfaceRaisedSolid: string;
surfaceRecessed: string;
@@ -209,10 +219,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#eff1f5",
attention: "#fe640b",
background: "#eff1f5",
- border: "#d7d8e0",
- borderHairline: "#d6d7df",
- borderSeam: "#dfe0e7",
- borderSeamVertical: "#dfe0e7",
+ border: "#d2d3dc",
+ borderHairline: "#d2d3dc",
+ borderSeam: "#d2d3dc",
+ borderSeamVertical: "#d2d3dc",
canvas: "#eff1f5",
card: "#eff1f5",
cardForeground: "#4c4f69",
@@ -229,7 +239,7 @@ export const nativeThemes: Record = {
mutedForeground: "#5c5f77",
pillForeground: "#4c4f69",
pillIcon: "#4c4f69",
- pillSurfaceBorder: "#d7d8e0",
+ pillSurfaceBorder: "#d2d3dc",
pillSurfaceSelectedBorder: "#cecfd9",
popover: "#eff1f5",
popoverForeground: "#4c4f69",
@@ -245,7 +255,7 @@ export const nativeThemes: Record = {
sidebar: "#ecedf2",
sidebarAccent: "#e2e3e9",
sidebarAccentForeground: "#4c4f69",
- sidebarBorder: "#d7d8e0",
+ sidebarBorder: "#d2d3dc",
sidebarForeground: "#4c4f69",
sidebarRing: "#8839ef",
sidebarSearchMatch: "#ece4d1",
@@ -258,11 +268,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(254, 100, 11, 0.14)",
surfaceDestructive: "rgba(210, 15, 57, 0.06)",
surfaceDestructiveBorder: "rgba(210, 15, 57, 0.25)",
+ surfaceGrouped: "#e8eaef",
+ surfaceGroupedCell: "#eff1f5",
surfaceRaised: "rgba(76, 79, 105, 0.025)",
surfaceRaisedSolid: "#ebedf1",
- surfaceRecessed: "rgba(76, 79, 105, 0.06)",
+ surfaceRecessed: "rgba(76, 79, 105, 0.05)",
surfaceRecessedSoftSolid: "#e8e9ef",
- surfaceRecessedSolid: "#e4e6ec",
+ surfaceRecessedSolid: "#e8eaef",
surfaceScrim: "rgba(239, 241, 245, 0.92)",
surfaceSelected: "rgba(136, 57, 239, 0.16)",
surfaceSelectedBorder: "rgba(136, 57, 239, 0.35)",
@@ -272,7 +284,7 @@ export const nativeThemes: Record = {
warningText: "#b8730a",
},
dark: {
- accent: "#313244",
+ accent: "#4b4d62",
accentForeground: "#cdd6f4",
ansi0: "#45475a",
ansi1: "#f38ba8",
@@ -308,10 +320,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#11111b",
attention: "#fab387",
background: "#1e1e2e",
- border: "#3c3d50",
- borderHairline: "#3e4053",
- borderSeam: "#2e2f41",
- borderSeamVertical: "#2e2f41",
+ border: "#54566b",
+ borderHairline: "#54566b",
+ borderSeam: "#54566b",
+ borderSeamVertical: "#54566b",
canvas: "#1e1e2e",
card: "#1e1e2e",
cardForeground: "#cdd6f4",
@@ -323,12 +335,12 @@ export const nativeThemes: Record = {
fileAccent: "#89b4fa",
foreground: "#cdd6f4",
ink: "#cdd6f4",
- input: "#515368",
- muted: "#36374a",
+ input: "#5e6177",
+ muted: "#505267",
mutedForeground: "#bac2de",
pillForeground: "#cdd6f4",
pillIcon: "#cdd6f4",
- pillSurfaceBorder: "#3c3d50",
+ pillSurfaceBorder: "#54566b",
pillSurfaceSelectedBorder: "#45475b",
popover: "#1e1e2e",
popoverForeground: "#cdd6f4",
@@ -338,13 +350,13 @@ export const nativeThemes: Record = {
readbackForeground: "#a6adc8",
resourceSourceShelfCardHoverBorder: "#4d4f64",
ring: "#cba6f7",
- secondary: "#313244",
+ secondary: "#4b4d62",
secondaryForeground: "#cdd6f4",
shadowColor: "rgba(0, 0, 0, 0.4)",
- sidebar: "#242535",
- sidebarAccent: "#303143",
+ sidebar: "#414256",
+ sidebarAccent: "#4b4d62",
sidebarAccentForeground: "#cdd6f4",
- sidebarBorder: "#3a3b4e",
+ sidebarBorder: "#54566b",
sidebarForeground: "#cdd6f4",
sidebarRing: "#cba6f7",
sidebarSearchMatch: "#3f3a3a",
@@ -357,11 +369,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(250, 179, 135, 0.12)",
surfaceDestructive: "rgba(243, 139, 168, 0.08)",
surfaceDestructiveBorder: "rgba(243, 139, 168, 0.3)",
- surfaceRaised: "rgba(205, 214, 244, 0.025)",
- surfaceRaisedSolid: "#222232",
- surfaceRecessed: "rgba(205, 214, 244, 0.06)",
- surfaceRecessedSoftSolid: "#242435",
- surfaceRecessedSolid: "#272738",
+ surfaceGrouped: "#1e1e2e",
+ surfaceGroupedCell: "#414256",
+ surfaceRaised: "rgba(205, 214, 244, 0.11)",
+ surfaceRaisedSolid: "#414256",
+ surfaceRecessed: "rgba(205, 214, 244, 0.11)",
+ surfaceRecessedSoftSolid: "#3d3e51",
+ surfaceRecessedSolid: "#414256",
surfaceScrim: "rgba(30, 30, 46, 0.92)",
surfaceSelected: "rgba(203, 166, 247, 0.12)",
surfaceSelectedBorder: "rgba(203, 166, 247, 0.35)",
@@ -373,8 +387,8 @@ export const nativeThemes: Record = {
},
default: {
light: {
- accent: "#ededed",
- accentForeground: "#333333",
+ accent: "#e4e4e4",
+ accentForeground: "#000000",
ansi0: "#000000",
ansi1: "#a11616",
ansi10: "#197c52",
@@ -407,73 +421,75 @@ export const nativeThemes: Record = {
ansiBgFg7: "#ffffff",
ansiBgFg8: "#ffffff",
ansiBgFg9: "#ffffff",
- attention: "#dc9e12",
+ attention: "#ffcc00",
background: "#ffffff",
- border: "#dfdfdf",
- borderHairline: "#dedede",
- borderSeam: "#e9e9e9",
- borderSeamVertical: "#e9e9e9",
+ border: "#c7c7c7",
+ borderHairline: "#c7c7c7",
+ borderSeam: "#c7c7c7",
+ borderSeamVertical: "#c7c7c7",
canvas: "#ffffff",
card: "#ffffff",
- cardForeground: "#333333",
- destructive: "#a5000f",
+ cardForeground: "#000000",
+ destructive: "#ff3b30",
destructiveForeground: "#ffffff",
- destructiveText: "#a5000f",
+ destructiveText: "#d70015",
diffAdded: "#005c32",
diffRemoved: "#8d0000",
fileAccent: "#4075aa",
- foreground: "#333333",
- ink: "#333333",
- input: "#bdbdbd",
- muted: "#e6e6e6",
- mutedForeground: "#525252",
- pillForeground: "#333333",
- pillIcon: "#333333",
- pillSurfaceBorder: "#dfdfdf",
- pillSurfaceSelectedBorder: "#d4d4d4",
+ foreground: "#000000",
+ ink: "#000000",
+ input: "#a0a0a0",
+ muted: "#dbdbdb",
+ mutedForeground: "#666666",
+ pillForeground: "#000000",
+ pillIcon: "#000000",
+ pillSurfaceBorder: "#c7c7c7",
+ pillSurfaceSelectedBorder: "#c0c0c0",
popover: "#ffffff",
- popoverForeground: "#333333",
+ popoverForeground: "#000000",
prMerged: "#7847d0",
- primary: "#262626",
+ primary: "#007aff",
primaryForeground: "#ffffff",
- readbackForeground: "#5b5b5b",
- resourceSourceShelfCardHoverBorder: "#c9c9c9",
- ring: "#262626",
- secondary: "#ededed",
- secondaryForeground: "#333333",
+ readbackForeground: "#747474",
+ resourceSourceShelfCardHoverBorder: "#b1b1b1",
+ ring: "#007aff",
+ secondary: "#e4e4e4",
+ secondaryForeground: "#000000",
shadowColor: "rgba(51, 51, 51, 0.1)",
- sidebar: "#fafafa",
- sidebarAccent: "#ededed",
- sidebarAccentForeground: "#333333",
- sidebarBorder: "#dfdfdf",
- sidebarForeground: "#333333",
- sidebarRing: "#262626",
+ sidebar: "#f8f8f8",
+ sidebarAccent: "#e4e4e4",
+ sidebarAccentForeground: "#000000",
+ sidebarBorder: "#c7c7c7",
+ sidebarForeground: "#000000",
+ sidebarRing: "#007aff",
sidebarSearchMatch: "#f8eed8",
sidebarSearchMatchBorder: "#f3e3be",
- stateActive: "rgba(51, 51, 51, 0.118)",
- stateHover: "rgba(51, 51, 51, 0.059)",
- subtleForeground: "#636363",
- success: "#3bb974",
- successForeground: "#7a5a34",
- surfaceAttention: "rgba(220, 158, 18, 0.14)",
- surfaceDestructive: "rgba(165, 0, 15, 0.06)",
- surfaceDestructiveBorder: "rgba(165, 0, 15, 0.25)",
- surfaceRaised: "rgba(51, 51, 51, 0.025)",
- surfaceRaisedSolid: "#f9f9f9",
- surfaceRecessed: "rgba(51, 51, 51, 0.06)",
- surfaceRecessedSoftSolid: "#f5f5f5",
- surfaceRecessedSolid: "#f1f1f1",
+ stateActive: "rgba(0, 0, 0, 0.118)",
+ stateHover: "rgba(0, 0, 0, 0.059)",
+ subtleForeground: "#8c8c8c",
+ success: "#34c759",
+ successForeground: "#522900",
+ surfaceAttention: "rgba(255, 204, 0, 0.14)",
+ surfaceDestructive: "rgba(255, 59, 48, 0.06)",
+ surfaceDestructiveBorder: "rgba(255, 59, 48, 0.25)",
+ surfaceGrouped: "#f2f2f2",
+ surfaceGroupedCell: "#ffffff",
+ surfaceRaised: "rgba(0, 0, 0, 0.025)",
+ surfaceRaisedSolid: "#f7f7f7",
+ surfaceRecessed: "rgba(0, 0, 0, 0.05)",
+ surfaceRecessedSoftSolid: "#f1f1f1",
+ surfaceRecessedSolid: "#f2f2f2",
surfaceScrim: "rgba(255, 255, 255, 0.92)",
- surfaceSelected: "rgba(38, 38, 38, 0.16)",
- surfaceSelectedBorder: "rgba(38, 38, 38, 0.35)",
+ surfaceSelected: "rgba(0, 122, 255, 0.16)",
+ surfaceSelectedBorder: "rgba(0, 122, 255, 0.35)",
timelineAccent: "#4075aa",
- versionUpgrade: "#3a3a3a",
- warning: "#eb7c33",
- warningText: "#b0540e",
+ versionUpgrade: "#000000",
+ warning: "#ff9500",
+ warningText: "#b84d00",
},
dark: {
- accent: "#282828",
- accentForeground: "#c1c1c1",
+ accent: "#2b2b2b",
+ accentForeground: "#ffffff",
ansi0: "#858585",
ansi1: "#d85e5e",
ansi10: "#23d18b",
@@ -506,69 +522,71 @@ export const nativeThemes: Record = {
ansiBgFg7: "#000000",
ansiBgFg8: "#000000",
ansiBgFg9: "#000000",
- attention: "#f0b135",
- background: "#151515",
- border: "#313131",
- borderHairline: "#343434",
- borderSeam: "#252525",
- borderSeamVertical: "#252525",
- canvas: "#151515",
- card: "#151515",
- cardForeground: "#c1c1c1",
- destructive: "#cc323d",
+ attention: "#ffd60a",
+ background: "#000000",
+ border: "#383838",
+ borderHairline: "#383838",
+ borderSeam: "#383838",
+ borderSeamVertical: "#383838",
+ canvas: "#000000",
+ card: "#000000",
+ cardForeground: "#ffffff",
+ destructive: "#ff453a",
destructiveForeground: "#ffffff",
- destructiveText: "#e06062",
+ destructiveText: "#ff6961",
diffAdded: "#00d594",
diffRemoved: "#ff696d",
fileAccent: "#79a9db",
- foreground: "#c1c1c1",
- ink: "#c1c1c1",
- input: "#464646",
- muted: "#2c2c2c",
- mutedForeground: "#b7b7b7",
- pillForeground: "#c1c1c1",
- pillIcon: "#c1c1c1",
- pillSurfaceBorder: "#313131",
- pillSurfaceSelectedBorder: "#3a3a3a",
- popover: "#151515",
- popoverForeground: "#c1c1c1",
+ foreground: "#ffffff",
+ ink: "#ffffff",
+ input: "#484848",
+ muted: "#333333",
+ mutedForeground: "#8c8c8c",
+ pillForeground: "#ffffff",
+ pillIcon: "#ffffff",
+ pillSurfaceBorder: "#383838",
+ pillSurfaceSelectedBorder: "#222222",
+ popover: "#000000",
+ popoverForeground: "#ffffff",
prMerged: "#a27dfa",
- primary: "#c4c4c4",
- primaryForeground: "#1a1a1a",
- readbackForeground: "#a3a3a3",
- resourceSourceShelfCardHoverBorder: "#424242",
- ring: "#c4c4c4",
- secondary: "#282828",
- secondaryForeground: "#c1c1c1",
+ primary: "#0a84ff",
+ primaryForeground: "#ffffff",
+ readbackForeground: "#808080",
+ resourceSourceShelfCardHoverBorder: "#2e2e2e",
+ ring: "#0a84ff",
+ secondary: "#2b2b2b",
+ secondaryForeground: "#ffffff",
shadowColor: "rgba(0, 0, 0, 0.4)",
- sidebar: "#1b1b1b",
- sidebarAccent: "#262626",
- sidebarAccentForeground: "#c1c1c1",
- sidebarBorder: "#2f2f2f",
- sidebarForeground: "#c1c1c1",
- sidebarRing: "#c4c4c4",
- sidebarSearchMatch: "#373123",
- sidebarSearchMatchBorder: "#50452b",
- stateActive: "rgba(193, 193, 193, 0.225)",
- stateHover: "rgba(193, 193, 193, 0.138)",
- subtleForeground: "#989898",
- success: "#4bc680",
- successForeground: "#d3b088",
- surfaceAttention: "rgba(240, 177, 53, 0.12)",
- surfaceDestructive: "rgba(204, 50, 61, 0.08)",
- surfaceDestructiveBorder: "rgba(204, 50, 61, 0.3)",
- surfaceRaised: "rgba(193, 193, 193, 0.025)",
- surfaceRaisedSolid: "#181818",
- surfaceRecessed: "rgba(193, 193, 193, 0.06)",
- surfaceRecessedSoftSolid: "#1b1b1b",
- surfaceRecessedSolid: "#1d1d1d",
- surfaceScrim: "rgba(21, 21, 21, 0.92)",
- surfaceSelected: "rgba(196, 196, 196, 0.12)",
- surfaceSelectedBorder: "rgba(196, 196, 196, 0.35)",
+ sidebar: "#1c1c1c",
+ sidebarAccent: "#2b2b2b",
+ sidebarAccentForeground: "#ffffff",
+ sidebarBorder: "#383838",
+ sidebarForeground: "#ffffff",
+ sidebarRing: "#0a84ff",
+ sidebarSearchMatch: "#120d02",
+ sidebarSearchMatchBorder: "#2e2409",
+ stateActive: "rgba(255, 255, 255, 0.225)",
+ stateHover: "rgba(255, 255, 255, 0.138)",
+ subtleForeground: "#777777",
+ success: "#30d158",
+ successForeground: "#ffcf98",
+ surfaceAttention: "rgba(255, 214, 10, 0.12)",
+ surfaceDestructive: "rgba(255, 69, 58, 0.08)",
+ surfaceDestructiveBorder: "rgba(255, 69, 58, 0.3)",
+ surfaceGrouped: "#000000",
+ surfaceGroupedCell: "#1c1c1c",
+ surfaceRaised: "rgba(255, 255, 255, 0.11)",
+ surfaceRaisedSolid: "#1c1c1c",
+ surfaceRecessed: "rgba(255, 255, 255, 0.11)",
+ surfaceRecessedSoftSolid: "#161616",
+ surfaceRecessedSolid: "#1c1c1c",
+ surfaceScrim: "rgba(0, 0, 0, 0.92)",
+ surfaceSelected: "rgba(10, 132, 255, 0.12)",
+ surfaceSelectedBorder: "rgba(10, 132, 255, 0.35)",
timelineAccent: "#79a9db",
- versionUpgrade: "#b9b9b9",
- warning: "#fc8c45",
- warningText: "#fc8c45",
+ versionUpgrade: "#f2f2f2",
+ warning: "#ff9f0a",
+ warningText: "#ff9f0a",
},
},
dracula: {
@@ -609,10 +627,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#000000",
attention: "#9a7d00",
background: "#f8f8f2",
- border: "#d6d7de",
- borderHairline: "#d4d6dd",
- borderSeam: "#e0e1e8",
- borderSeamVertical: "#e0e1e8",
+ border: "#cfd1d8",
+ borderHairline: "#cfd1d8",
+ borderSeam: "#cfd1d8",
+ borderSeamVertical: "#cfd1d8",
canvas: "#f8f8f2",
card: "#f8f8f2",
cardForeground: "#282a36",
@@ -629,7 +647,7 @@ export const nativeThemes: Record = {
mutedForeground: "#5f616c",
pillForeground: "#282a36",
pillIcon: "#282a36",
- pillSurfaceBorder: "#d6d7de",
+ pillSurfaceBorder: "#cfd1d8",
pillSurfaceSelectedBorder: "#caccd3",
popover: "#f8f8f2",
popoverForeground: "#282a36",
@@ -645,7 +663,7 @@ export const nativeThemes: Record = {
sidebar: "#f1f2f8",
sidebarAccent: "#e4e5eb",
sidebarAccentForeground: "#282a36",
- sidebarBorder: "#d6d7de",
+ sidebarBorder: "#cfd1d8",
sidebarForeground: "#282a36",
sidebarRing: "#7d5bbe",
sidebarSearchMatch: "#f3e9ce",
@@ -658,11 +676,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(154, 125, 0, 0.14)",
surfaceDestructive: "rgba(196, 49, 75, 0.06)",
surfaceDestructiveBorder: "rgba(196, 49, 75, 0.25)",
+ surfaceGrouped: "#efefea",
+ surfaceGroupedCell: "#f8f8f2",
surfaceRaised: "rgba(40, 42, 54, 0.025)",
surfaceRaisedSolid: "#f2f2ed",
- surfaceRecessed: "rgba(40, 42, 54, 0.06)",
+ surfaceRecessed: "rgba(40, 42, 54, 0.05)",
surfaceRecessedSoftSolid: "#eceef4",
- surfaceRecessedSolid: "#eaeae6",
+ surfaceRecessedSolid: "#efefea",
surfaceScrim: "rgba(248, 248, 242, 0.92)",
surfaceSelected: "rgba(125, 91, 190, 0.16)",
surfaceSelectedBorder: "rgba(125, 91, 190, 0.35)",
@@ -672,7 +692,7 @@ export const nativeThemes: Record = {
warningText: "#8f5a22",
},
dark: {
- accent: "#3f414d",
+ accent: "#5d5f6a",
accentForeground: "#f8f8f2",
ansi0: "#21222c",
ansi1: "#ff5555",
@@ -708,10 +728,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#000000",
attention: "#f1fa8c",
background: "#282a36",
- border: "#4b4d58",
- borderHairline: "#4e505b",
- borderSeam: "#3b3d49",
- borderSeamVertical: "#3b3d49",
+ border: "#676974",
+ borderHairline: "#676974",
+ borderSeam: "#676974",
+ borderSeamVertical: "#676974",
canvas: "#282a36",
card: "#282a36",
cardForeground: "#f8f8f2",
@@ -723,12 +743,12 @@ export const nativeThemes: Record = {
fileAccent: "#8be9fd",
foreground: "#f8f8f2",
ink: "#f8f8f2",
- input: "#646671",
- muted: "#444652",
+ input: "#73757f",
+ muted: "#636570",
mutedForeground: "#b2b4bc",
pillForeground: "#f8f8f2",
pillIcon: "#f8f8f2",
- pillSurfaceBorder: "#4b4d58",
+ pillSurfaceBorder: "#676974",
pillSurfaceSelectedBorder: "#565863",
popover: "#282a36",
popoverForeground: "#f8f8f2",
@@ -738,13 +758,13 @@ export const nativeThemes: Record = {
readbackForeground: "#a5a7b0",
resourceSourceShelfCardHoverBorder: "#5f616c",
ring: "#bd93f9",
- secondary: "#3f414d",
+ secondary: "#5d5f6a",
secondaryForeground: "#f8f8f2",
shadowColor: "rgba(0, 0, 0, 0.4)",
- sidebar: "#2f313d",
- sidebarAccent: "#3d3f4b",
+ sidebar: "#51535e",
+ sidebarAccent: "#5d5f6a",
sidebarAccentForeground: "#f8f8f2",
- sidebarBorder: "#484a56",
+ sidebarBorder: "#676974",
sidebarForeground: "#f8f8f2",
sidebarRing: "#bd93f9",
sidebarSearchMatch: "#484440",
@@ -757,11 +777,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(241, 250, 140, 0.12)",
surfaceDestructive: "rgba(255, 85, 85, 0.08)",
surfaceDestructiveBorder: "rgba(255, 85, 85, 0.3)",
- surfaceRaised: "rgba(248, 248, 242, 0.025)",
- surfaceRaisedSolid: "#2c2e3a",
- surfaceRecessed: "rgba(248, 248, 242, 0.06)",
- surfaceRecessedSoftSolid: "#2f313d",
- surfaceRecessedSolid: "#323440",
+ surfaceGrouped: "#282a36",
+ surfaceGroupedCell: "#51535d",
+ surfaceRaised: "rgba(248, 248, 242, 0.11)",
+ surfaceRaisedSolid: "#51535d",
+ surfaceRecessed: "rgba(248, 248, 242, 0.11)",
+ surfaceRecessedSoftSolid: "#4c4e59",
+ surfaceRecessedSolid: "#51535d",
surfaceScrim: "rgba(40, 42, 54, 0.92)",
surfaceSelected: "rgba(189, 147, 249, 0.12)",
surfaceSelectedBorder: "rgba(189, 147, 249, 0.35)",
@@ -809,10 +831,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#000000",
attention: "#b57614",
background: "#fbf1c7",
- border: "#ddd5b1",
- borderHairline: "#dcd3b0",
- borderSeam: "#e7deb8",
- borderSeamVertical: "#e7deb8",
+ border: "#d7cfac",
+ borderHairline: "#d7cfac",
+ borderSeam: "#d7cfac",
+ borderSeamVertical: "#d7cfac",
canvas: "#fbf1c7",
card: "#fbf1c7",
cardForeground: "#3c3836",
@@ -829,7 +851,7 @@ export const nativeThemes: Record = {
mutedForeground: "#6e6b5e",
pillForeground: "#3c3836",
pillIcon: "#3c3836",
- pillSurfaceBorder: "#ddd5b1",
+ pillSurfaceBorder: "#d7cfac",
pillSurfaceSelectedBorder: "#d2caa9",
popover: "#fbf1c7",
popoverForeground: "#3c3836",
@@ -845,7 +867,7 @@ export const nativeThemes: Record = {
sidebar: "#f6edc3",
sidebarAccent: "#eae1ba",
sidebarAccentForeground: "#3c3836",
- sidebarBorder: "#ddd5b1",
+ sidebarBorder: "#d7cfac",
sidebarForeground: "#3c3836",
sidebarRing: "#076678",
sidebarSearchMatch: "#f5e4ad",
@@ -858,11 +880,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(181, 118, 20, 0.14)",
surfaceDestructive: "rgba(204, 36, 29, 0.06)",
surfaceDestructiveBorder: "rgba(204, 36, 29, 0.25)",
+ surfaceGrouped: "#f3e9c1",
+ surfaceGroupedCell: "#fbf1c7",
surfaceRaised: "rgba(60, 56, 54, 0.025)",
surfaceRaisedSolid: "#f6ecc3",
- surfaceRecessed: "rgba(60, 56, 54, 0.06)",
+ surfaceRecessed: "rgba(60, 56, 54, 0.05)",
surfaceRecessedSoftSolid: "#f2e8c0",
- surfaceRecessedSolid: "#eee5be",
+ surfaceRecessedSolid: "#f3e9c1",
surfaceScrim: "rgba(251, 241, 199, 0.92)",
surfaceSelected: "rgba(7, 102, 120, 0.16)",
surfaceSelectedBorder: "rgba(7, 102, 120, 0.35)",
@@ -872,7 +896,7 @@ export const nativeThemes: Record = {
warningText: "#af3a03",
},
dark: {
- accent: "#3e3c38",
+ accent: "#5a574d",
accentForeground: "#ebdbb2",
ansi0: "#3c3836",
ansi1: "#cc241d",
@@ -908,10 +932,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#000000",
attention: "#fabd2f",
background: "#282828",
- border: "#494740",
- borderHairline: "#4c4942",
- borderSeam: "#3a3936",
- borderSeamVertical: "#3a3936",
+ border: "#645f53",
+ borderHairline: "#645f53",
+ borderSeam: "#645f53",
+ borderSeamVertical: "#645f53",
canvas: "#282828",
card: "#282828",
cardForeground: "#ebdbb2",
@@ -923,12 +947,12 @@ export const nativeThemes: Record = {
fileAccent: "#83a598",
foreground: "#ebdbb2",
ink: "#ebdbb2",
- input: "#615d51",
- muted: "#43413c",
+ input: "#6f6a5b",
+ muted: "#605c51",
mutedForeground: "#aba085",
pillForeground: "#ebdbb2",
pillIcon: "#ebdbb2",
- pillSurfaceBorder: "#494740",
+ pillSurfaceBorder: "#645f53",
pillSurfaceSelectedBorder: "#535048",
popover: "#282828",
popoverForeground: "#ebdbb2",
@@ -938,13 +962,13 @@ export const nativeThemes: Record = {
readbackForeground: "#9f957d",
resourceSourceShelfCardHoverBorder: "#5c584e",
ring: "#83a598",
- secondary: "#3e3c38",
+ secondary: "#5a574d",
secondaryForeground: "#ebdbb2",
shadowColor: "rgba(0, 0, 0, 0.4)",
- sidebar: "#2f2f2d",
- sidebarAccent: "#3c3b37",
+ sidebar: "#4f4c44",
+ sidebarAccent: "#5a574d",
sidebarAccentForeground: "#ebdbb2",
- sidebarBorder: "#47443f",
+ sidebarBorder: "#645f53",
sidebarForeground: "#ebdbb2",
sidebarRing: "#83a598",
sidebarSearchMatch: "#494233",
@@ -957,11 +981,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(250, 189, 47, 0.12)",
surfaceDestructive: "rgba(251, 73, 52, 0.08)",
surfaceDestructiveBorder: "rgba(251, 73, 52, 0.3)",
- surfaceRaised: "rgba(235, 219, 178, 0.025)",
- surfaceRaisedSolid: "#2c2c2b",
- surfaceRecessed: "rgba(235, 219, 178, 0.06)",
- surfaceRecessedSoftSolid: "#2f2e2d",
- surfaceRecessedSolid: "#32312f",
+ surfaceGrouped: "#282828",
+ surfaceGroupedCell: "#4f4c44",
+ surfaceRaised: "rgba(235, 219, 178, 0.11)",
+ surfaceRaisedSolid: "#4f4c44",
+ surfaceRecessed: "rgba(235, 219, 178, 0.11)",
+ surfaceRecessedSoftSolid: "#4a4841",
+ surfaceRecessedSolid: "#4f4c44",
surfaceScrim: "rgba(40, 40, 40, 0.92)",
surfaceSelected: "rgba(131, 165, 152, 0.12)",
surfaceSelectedBorder: "rgba(131, 165, 152, 0.35)",
@@ -1009,10 +1035,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#000000",
attention: "#ebcb8b",
background: "#eceff4",
- border: "#cfd2d9",
- borderHairline: "#cdd1d7",
- borderSeam: "#d8dbe1",
- borderSeamVertical: "#d8dbe1",
+ border: "#c9ccd3",
+ borderHairline: "#c9ccd3",
+ borderSeam: "#c9ccd3",
+ borderSeamVertical: "#c9ccd3",
canvas: "#eceff4",
card: "#eceff4",
cardForeground: "#2e3440",
@@ -1029,7 +1055,7 @@ export const nativeThemes: Record = {
mutedForeground: "#616772",
pillForeground: "#2e3440",
pillIcon: "#2e3440",
- pillSurfaceBorder: "#cfd2d9",
+ pillSurfaceBorder: "#c9ccd3",
pillSurfaceSelectedBorder: "#c4c8cf",
popover: "#eceff4",
popoverForeground: "#2e3440",
@@ -1045,7 +1071,7 @@ export const nativeThemes: Record = {
sidebar: "#e8eaf0",
sidebarAccent: "#dbdee4",
sidebarAccentForeground: "#2e3440",
- sidebarBorder: "#cfd2d9",
+ sidebarBorder: "#c9ccd3",
sidebarForeground: "#2e3440",
sidebarRing: "#5e81ac",
sidebarSearchMatch: "#eae3d0",
@@ -1058,11 +1084,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(235, 203, 139, 0.14)",
surfaceDestructive: "rgba(191, 97, 106, 0.06)",
surfaceDestructiveBorder: "rgba(191, 97, 106, 0.25)",
+ surfaceGrouped: "#e4e7ec",
+ surfaceGroupedCell: "#eceff4",
surfaceRaised: "rgba(46, 52, 64, 0.025)",
surfaceRaisedSolid: "#e7eaef",
- surfaceRecessed: "rgba(46, 52, 64, 0.06)",
+ surfaceRecessed: "rgba(46, 52, 64, 0.05)",
surfaceRecessedSoftSolid: "#e3e6ec",
- surfaceRecessedSolid: "#dfe3e8",
+ surfaceRecessedSolid: "#e4e7ec",
surfaceScrim: "rgba(236, 239, 244, 0.92)",
surfaceSelected: "rgba(94, 129, 172, 0.16)",
surfaceSelectedBorder: "rgba(94, 129, 172, 0.35)",
@@ -1072,7 +1100,7 @@ export const nativeThemes: Record = {
warningText: "#99543a",
},
dark: {
- accent: "#414854",
+ accent: "#5b616d",
accentForeground: "#d8dee9",
ansi0: "#3b4252",
ansi1: "#bf616a",
@@ -1108,10 +1136,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#000000",
attention: "#ebcb8b",
background: "#2e3440",
- border: "#4b525e",
- borderHairline: "#4e5460",
- borderSeam: "#3e4451",
- borderSeamVertical: "#3e4451",
+ border: "#636975",
+ borderHairline: "#636975",
+ borderSeam: "#636975",
+ borderSeamVertical: "#636975",
canvas: "#2e3440",
card: "#2e3440",
cardForeground: "#d8dee9",
@@ -1123,12 +1151,12 @@ export const nativeThemes: Record = {
fileAccent: "#88c0d0",
foreground: "#d8dee9",
ink: "#d8dee9",
- input: "#616773",
- muted: "#464c58",
+ input: "#6d737f",
+ muted: "#606672",
mutedForeground: "#a1a7b3",
pillForeground: "#d8dee9",
pillIcon: "#d8dee9",
- pillSurfaceBorder: "#4b525e",
+ pillSurfaceBorder: "#636975",
pillSurfaceSelectedBorder: "#555b67",
popover: "#2e3440",
popoverForeground: "#d8dee9",
@@ -1138,13 +1166,13 @@ export const nativeThemes: Record = {
readbackForeground: "#969ca8",
resourceSourceShelfCardHoverBorder: "#5c626f",
ring: "#88c0d0",
- secondary: "#414854",
+ secondary: "#5b616d",
secondaryForeground: "#d8dee9",
shadowColor: "rgba(0, 0, 0, 0.4)",
- sidebar: "#343a46",
- sidebarAccent: "#404652",
+ sidebar: "#515763",
+ sidebarAccent: "#5b616d",
sidebarAccentForeground: "#d8dee9",
- sidebarBorder: "#494f5c",
+ sidebarBorder: "#636975",
sidebarForeground: "#d8dee9",
sidebarRing: "#88c0d0",
sidebarSearchMatch: "#4e4d48",
@@ -1157,11 +1185,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(235, 203, 139, 0.12)",
surfaceDestructive: "rgba(191, 97, 106, 0.08)",
surfaceDestructiveBorder: "rgba(191, 97, 106, 0.3)",
- surfaceRaised: "rgba(216, 222, 233, 0.025)",
- surfaceRaisedSolid: "#323844",
- surfaceRecessed: "rgba(216, 222, 233, 0.06)",
- surfaceRecessedSoftSolid: "#343a46",
- surfaceRecessedSolid: "#373d49",
+ surfaceGrouped: "#2e3440",
+ surfaceGroupedCell: "#515763",
+ surfaceRaised: "rgba(216, 222, 233, 0.11)",
+ surfaceRaisedSolid: "#515763",
+ surfaceRecessed: "rgba(216, 222, 233, 0.11)",
+ surfaceRecessedSoftSolid: "#4c525f",
+ surfaceRecessedSolid: "#515763",
surfaceScrim: "rgba(46, 52, 64, 0.92)",
surfaceSelected: "rgba(136, 192, 208, 0.12)",
surfaceSelectedBorder: "rgba(136, 192, 208, 0.35)",
@@ -1209,10 +1239,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#ffffff",
attention: "#b58900",
background: "#fdf6e3",
- border: "#d9dac3",
- borderHairline: "#d7d8c2",
- borderSeam: "#e5e3cd",
- borderSeamVertical: "#e5e3cd",
+ border: "#d1d4bd",
+ borderHairline: "#d1d4bd",
+ borderSeam: "#d1d4bd",
+ borderSeamVertical: "#d1d4bd",
canvas: "#fdf6e3",
card: "#fdf6e3",
cardForeground: "#073642",
@@ -1229,7 +1259,7 @@ export const nativeThemes: Record = {
mutedForeground: "#496e67",
pillForeground: "#073642",
pillIcon: "#073642",
- pillSurfaceBorder: "#d9dac3",
+ pillSurfaceBorder: "#d1d4bd",
pillSurfaceSelectedBorder: "#cbcfb9",
popover: "#fdf6e3",
popoverForeground: "#073642",
@@ -1245,7 +1275,7 @@ export const nativeThemes: Record = {
sidebar: "#f7f1de",
sidebarAccent: "#e9e6d1",
sidebarAccentForeground: "#073642",
- sidebarBorder: "#d9dac3",
+ sidebarBorder: "#d1d4bd",
sidebarForeground: "#073642",
sidebarRing: "#268bd2",
sidebarSearchMatch: "#f6e7c2",
@@ -1258,11 +1288,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(181, 137, 0, 0.14)",
surfaceDestructive: "rgba(220, 50, 47, 0.06)",
surfaceDestructiveBorder: "rgba(220, 50, 47, 0.25)",
+ surfaceGrouped: "#f2eedc",
+ surfaceGroupedCell: "#fdf6e3",
surfaceRaised: "rgba(7, 54, 66, 0.025)",
surfaceRaisedSolid: "#f6f1df",
- surfaceRecessed: "rgba(7, 54, 66, 0.06)",
+ surfaceRecessed: "rgba(7, 54, 66, 0.05)",
surfaceRecessedSoftSolid: "#f2edd9",
- surfaceRecessedSolid: "#ede9d9",
+ surfaceRecessedSolid: "#f2eedc",
surfaceScrim: "rgba(253, 246, 227, 0.92)",
surfaceSelected: "rgba(38, 139, 210, 0.16)",
surfaceSelectedBorder: "rgba(38, 139, 210, 0.35)",
@@ -1272,7 +1304,7 @@ export const nativeThemes: Record = {
warningText: "#a53c12",
},
dark: {
- accent: "#153943",
+ accent: "#2d4b54",
accentForeground: "#93a1a1",
ansi0: "#073642",
ansi1: "#dc322f",
@@ -1308,10 +1340,10 @@ export const nativeThemes: Record = {
ansiBgFg9: "#ffffff",
attention: "#b58900",
background: "#002b36",
- border: "#1f404a",
- borderHairline: "#21424c",
- borderSeam: "#123741",
- borderSeamVertical: "#123741",
+ border: "#34505a",
+ borderHairline: "#34505a",
+ borderSeam: "#34505a",
+ borderSeamVertical: "#34505a",
canvas: "#002b36",
card: "#002b36",
cardForeground: "#93a1a1",
@@ -1323,12 +1355,12 @@ export const nativeThemes: Record = {
fileAccent: "#2aa198",
foreground: "#93a1a1",
ink: "#93a1a1",
- input: "#324f58",
- muted: "#1a3c46",
+ input: "#3c5760",
+ muted: "#314e57",
mutedForeground: "#677b82",
pillForeground: "#93a1a1",
pillIcon: "#93a1a1",
- pillSurfaceBorder: "#1f404a",
+ pillSurfaceBorder: "#34505a",
pillSurfaceSelectedBorder: "#274650",
popover: "#002b36",
popoverForeground: "#93a1a1",
@@ -1338,13 +1370,13 @@ export const nativeThemes: Record = {
readbackForeground: "#5e747b",
resourceSourceShelfCardHoverBorder: "#2e4c55",
ring: "#268bd2",
- secondary: "#153943",
+ secondary: "#2d4b54",
secondaryForeground: "#93a1a1",
shadowColor: "rgba(0, 0, 0, 0.4)",
- sidebar: "#06303a",
- sidebarAccent: "#143842",
+ sidebar: "#24444d",
+ sidebarAccent: "#2d4b54",
sidebarAccentForeground: "#93a1a1",
- sidebarBorder: "#1d3f49",
+ sidebarBorder: "#34505a",
sidebarForeground: "#93a1a1",
sidebarRing: "#268bd2",
sidebarSearchMatch: "#304641",
@@ -1357,11 +1389,13 @@ export const nativeThemes: Record = {
surfaceAttention: "rgba(181, 137, 0, 0.12)",
surfaceDestructive: "rgba(220, 50, 47, 0.08)",
surfaceDestructiveBorder: "rgba(220, 50, 47, 0.3)",
- surfaceRaised: "rgba(147, 161, 161, 0.025)",
- surfaceRaisedSolid: "#042e38",
- surfaceRecessed: "rgba(147, 161, 161, 0.06)",
- surfaceRecessedSoftSolid: "#062f3a",
- surfaceRecessedSolid: "#09313c",
+ surfaceGrouped: "#002b36",
+ surfaceGroupedCell: "#24444d",
+ surfaceRaised: "rgba(147, 161, 161, 0.11)",
+ surfaceRaisedSolid: "#24444d",
+ surfaceRecessed: "rgba(147, 161, 161, 0.11)",
+ surfaceRecessedSoftSolid: "#20414b",
+ surfaceRecessedSolid: "#24444d",
surfaceScrim: "rgba(0, 43, 54, 0.92)",
surfaceSelected: "rgba(38, 139, 210, 0.12)",
surfaceSelectedBorder: "rgba(38, 139, 210, 0.35)",
@@ -1373,13 +1407,18 @@ export const nativeThemes: Record = {
},
};
-/** `--radius` and the Tailwind `--radius-*` steps, in CSS pixels. */
+/**
+ * `--radius` and the Tailwind `--radius-*` steps, in CSS pixels. `xl2` is
+ * `rounded-2xl`; `full` is `rounded-full` (pills, circles).
+ */
export const nativeRadii = {
base: 8,
sm: 4,
md: 6,
lg: 8,
xl: 12,
+ xl2: 16,
+ full: 9999,
};
export interface NativeTextStyle {
@@ -1388,26 +1427,42 @@ export interface NativeTextStyle {
}
/**
- * The `--text-*` scale theme.css overrides, using the coarse-pointer (touch)
- * values as the base, in CSS pixels. Sizes theme.css does not override keep
- * Tailwind's defaults.
+ * The `--text-*` scale: theme.css's coarse-pointer (touch) values with the
+ * mobile layer's Apple text-style ramp on top (caption2 → largeTitle), in
+ * CSS pixels. Mirrored as ratios in global.css (theme-vars.test.ts).
*/
export const nativeTypography = {
"2xs": {
fontSize: 11,
- lineHeight: 15,
+ lineHeight: 13,
},
xs: {
- fontSize: 14,
- lineHeight: 20,
+ fontSize: 13,
+ lineHeight: 18,
},
sm: {
fontSize: 15,
- lineHeight: 22,
+ lineHeight: 20,
},
base: {
- fontSize: 16,
- lineHeight: 24,
+ fontSize: 17,
+ lineHeight: 22,
+ },
+ lg: {
+ fontSize: 20,
+ lineHeight: 25,
+ },
+ xl: {
+ fontSize: 22,
+ lineHeight: 28,
+ },
+ "2xl": {
+ fontSize: 28,
+ lineHeight: 34,
+ },
+ "3xl": {
+ fontSize: 34,
+ lineHeight: 41,
},
} satisfies Record;
diff --git a/apps/mobile/src/theme/useAppFonts.ts b/apps/mobile/src/theme/useAppFonts.ts
deleted file mode 100644
index c6af140d15..0000000000
--- a/apps/mobile/src/theme/useAppFonts.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import {
- FiraCode_400Regular,
- FiraCode_500Medium,
- FiraCode_600SemiBold,
- FiraCode_700Bold,
-} from "@expo-google-fonts/fira-code";
-import {
- Inter_400Regular,
- Inter_400Regular_Italic,
- Inter_500Medium,
- Inter_600SemiBold,
- Inter_600SemiBold_Italic,
- Inter_700Bold,
-} from "@expo-google-fonts/inter";
-import { useFonts } from "expo-font";
-import * as SplashScreen from "expo-splash-screen";
-import { FONT_FAMILIES, ITALIC_FONT_FAMILIES } from "./fonts";
-
-// Keep the native splash up until the fonts (and whatever else the root
-// layout awaits) are ready; the root layout hides it after its own gates.
-void SplashScreen.preventAutoHideAsync().catch(() => undefined);
-
-const FONT_SOURCES = {
- [FONT_FAMILIES.sans.regular]: Inter_400Regular,
- [FONT_FAMILIES.sans.medium]: Inter_500Medium,
- [FONT_FAMILIES.sans.semibold]: Inter_600SemiBold,
- [FONT_FAMILIES.sans.bold]: Inter_700Bold,
- [ITALIC_FONT_FAMILIES.regular]: Inter_400Regular_Italic,
- [ITALIC_FONT_FAMILIES.semibold]: Inter_600SemiBold_Italic,
- [FONT_FAMILIES.mono.regular]: FiraCode_400Regular,
- [FONT_FAMILIES.mono.medium]: FiraCode_500Medium,
- [FONT_FAMILIES.mono.semibold]: FiraCode_600SemiBold,
- [FONT_FAMILIES.mono.bold]: FiraCode_700Bold,
-} as const;
-
-/**
- * Loads Inter and Fira Code (the web app's `--font-sans` / `--font-mono`).
- * Returns `ready` once loaded, or after a load error (the app then renders
- * with system fonts rather than staying on the splash forever).
- */
-export function useAppFonts(): { ready: boolean; error: Error | null } {
- const [loaded, error] = useFonts(FONT_SOURCES);
- const ready = loaded || error !== null;
- return { ready, error };
-}
diff --git a/apps/mobile/src/ui/ActionSheet.tsx b/apps/mobile/src/ui/ActionSheet.tsx
index 0462d72baf..8df240610f 100644
--- a/apps/mobile/src/ui/ActionSheet.tsx
+++ b/apps/mobile/src/ui/ActionSheet.tsx
@@ -1,18 +1,27 @@
-import { View } from "react-native";
+import { Fragment } from "react";
+import { Pressable, View } from "react-native";
import { haptic } from "@/lib/haptics";
import { useTheme } from "@/theme/ThemeProvider";
+import { cn } from "./cn";
+import { GROUPED_CARD_RADIUS } from "./Grouped";
import { Icon, type IconName } from "./Icon";
-import { ListRow } from "./ListRow";
-import { Separator } from "./Separator";
+import { ListRow, LIST_ROW_ICON_SIZE } from "./ListRow";
+import { Separator, SEPARATOR_INSET } from "./Separator";
import { Sheet, type SheetController, type SheetProps } from "./Sheet";
import { Text } from "./Text";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
export interface ActionSheetAction {
key: string;
label: string;
+ /** Secondary line under the label (the fallback sheet only; native menus drop it). */
+ subtitle?: string;
icon?: IconName;
destructive?: boolean;
disabled?: boolean;
+ /** Current choice in a single-select menu (check mark). Omit for commands. */
+ checked?: boolean;
/** Runs after the sheet starts dismissing. */
onPress: () => void;
}
@@ -27,9 +36,15 @@ export interface ActionSheetProps {
stackBehavior?: SheetProps["stackBehavior"];
}
+/** Separator inset past a leading glyph to the label column. */
+const ACTION_SEPARATOR_INSET = SEPARATOR_INSET + LIST_ROW_ICON_SIZE + 12;
+
/**
- * A list of actions in a bottom sheet (long-press menus, "…" menus). Present
- * it through `useSheet()`:
+ * A list of actions in a bottom sheet, styled like the system action
+ * sheet: a card of 17pt rows (SF glyphs, destructive in red) and a separate
+ * Cancel card. On iOS this is the long-list / Android fallback — short,
+ * button-anchored menus use `NativeMenu`, confirmations `confirmDestructive`.
+ * Present it through `useSheet()`:
*
* const menu = useSheet();
* … onLongPress={menu.present}
@@ -44,58 +59,92 @@ export function ActionSheet({
}: ActionSheetProps) {
const { tokens } = useTheme();
const hasHeader = Boolean(title || message);
+ const hasIcons = actions.some((action) => action.icon);
+ const card = {
+ borderRadius: GROUPED_CARD_RADIUS,
+ borderCurve: "continuous" as const,
+ };
return (
- {hasHeader ? (
-
- {title ? (
-
- {title}
-
+
+
+ {hasHeader ? (
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {message ? (
+
+ {message}
+
+ ) : null}
+
) : null}
- {message ? {message} : null}
-
- ) : null}
- {hasHeader ? : null}
- {actions.map((action) => (
- (
+
+ {index > 0 || hasHeader ? (
+
+ ) : null}
+
+ ) : undefined
}
+ destructive={action.destructive}
+ disabled={action.disabled}
+ selected={action.checked === true}
+ onPress={() => {
+ // A destructive row is a confirmation step: warn physically.
+ if (action.destructive) haptic("warning");
+ controller.dismiss();
+ action.onPress();
+ }}
+ testID={`action-sheet-${action.key}`}
/>
- ) : undefined
- }
- destructive={action.destructive}
- disabled={action.disabled}
- onPress={() => {
- // A destructive row is a confirmation step: warn physically.
- if (action.destructive) haptic("warning");
- controller.dismiss();
- action.onPress();
- }}
- testID={`action-sheet-${action.key}`}
- />
- ))}
-
-
+
+ ))}
+
+
+
+ Cancel
+
+
+
);
}
diff --git a/apps/mobile/src/ui/Button.tsx b/apps/mobile/src/ui/Button.tsx
index e5f5c54087..55df203d66 100644
--- a/apps/mobile/src/ui/Button.tsx
+++ b/apps/mobile/src/ui/Button.tsx
@@ -1,7 +1,8 @@
import { cva, type VariantProps } from "class-variance-authority";
-import type { ReactNode } from "react";
+import { useState, type ReactNode } from "react";
import { Pressable, View, type PressableProps } from "react-native";
import { haptic, hapticKindForButton, type ButtonHaptic } from "@/lib/haptics";
+import { withAlpha } from "@/markdown/colors";
import { useTheme } from "@/theme/ThemeProvider";
import type { NativeThemeTokens } from "@/theme/theme.native";
import { cn } from "./cn";
@@ -9,12 +10,23 @@ import { Icon, type IconName } from "./Icon";
import { Spinner } from "./Spinner";
import { Text } from "./Text";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
/*
- * Mirrors packages/shared-ui/src/components/ui/button.tsx (variant and size
- * names), with the coarse-pointer heights as the base: default 40, sm 36,
- * lg 48, icon 40×40. `active:` replaces web `hover:`.
+ * Variant and size names mirror packages/shared-ui/src/components/ui/button.tsx
+ * so call sites port unchanged. Android renders the web shapes (below);
+ * iOS maps the same names onto the system button styles:
+ *
+ * default → filled (primary capsule, white label)
+ * destructive → filled (destructive capsule)
+ * outline · secondary → tinted (primary at 15%, primary label)
+ * ghost · link → plain (primary label, no fill)
+ *
+ * Pressing dims the whole button to 60% (UIKit highlight) instead of
+ * swapping the fill; `pressed` (toggle) adds a tint fill to plain/tinted.
*/
-const buttonVariants = cva(
+
+const androidButtonVariants = cva(
"flex-row items-center justify-center gap-2 rounded-md",
{
variants: {
@@ -49,7 +61,7 @@ const buttonVariants = cva(
},
);
-const buttonTextVariants = cva("font-medium", {
+const androidTextVariants = cva("font-medium", {
variants: {
variant: {
default: "text-background",
@@ -73,18 +85,74 @@ const buttonTextVariants = cva("font-medium", {
});
export type ButtonVariant = NonNullable<
- VariantProps["variant"]
+ VariantProps["variant"]
>;
export type ButtonSize = NonNullable<
- VariantProps["size"]
+ VariantProps["size"]
>;
+type IosAppearance = "filled" | "filledDestructive" | "tinted" | "plain";
+
+const IOS_APPEARANCE: Record = {
+ default: "filled",
+ destructive: "filledDestructive",
+ outline: "tinted",
+ secondary: "tinted",
+ ghost: "plain",
+ link: "plain",
+};
+
+const iosButtonVariants = cva(
+ "flex-row items-center justify-center gap-2 rounded-full",
+ {
+ variants: {
+ appearance: {
+ filled: "bg-primary",
+ filledDestructive: "bg-destructive",
+ tinted: "",
+ plain: "",
+ },
+ size: {
+ default: "h-11 px-5",
+ sm: "h-9 px-3.5",
+ lg: "h-12 px-6",
+ icon: "h-11 w-11",
+ },
+ },
+ defaultVariants: {
+ appearance: "filled",
+ size: "default",
+ },
+ },
+);
+
+const iosTextVariants = cva("", {
+ variants: {
+ appearance: {
+ filled: "font-semibold text-primary-foreground",
+ filledDestructive: "font-semibold text-destructive-foreground",
+ tinted: "font-semibold text-primary",
+ plain: "text-primary",
+ },
+ size: {
+ default: "text-base",
+ sm: "text-sm",
+ lg: "text-base",
+ icon: "text-base",
+ },
+ },
+ defaultVariants: {
+ appearance: "filled",
+ size: "default",
+ },
+});
+
export type { ButtonHaptic };
export interface ButtonProps
extends
Omit,
- Omit, "pressed"> {
+ Omit, "pressed"> {
/** A string renders as themed text; any other node renders as-is. */
children?: ReactNode;
/** Leading glyph (from ICON_MAP); trailing when `iconPosition="right"`. */
@@ -94,13 +162,20 @@ export interface ButtonProps
loading?: boolean;
/** Toggle-style pressed state (web `aria-pressed`). */
pressed?: boolean;
+ /**
+ * iOS only: the color the tinted / plain appearances (`outline`,
+ * `secondary`, `ghost`, `link`) use — the primary tint (default) or the
+ * destructive red (a "Deny" / "Remove" that must not read as the primary
+ * action). Android keeps its variant look.
+ */
+ tint?: "primary" | "destructive";
/** Fire haptic feedback on press. */
haptic?: ButtonHaptic | boolean;
onPress?: () => void;
className?: string;
}
-const TEXT_TOKEN: Record = {
+const ANDROID_TEXT_TOKEN: Record = {
default: "background",
destructive: "destructiveForeground",
outline: "foreground",
@@ -109,13 +184,33 @@ const TEXT_TOKEN: Record = {
link: "primary",
};
-const ICON_SIZE: Record = {
+const IOS_TEXT_TOKEN: Record = {
+ filled: "primaryForeground",
+ filledDestructive: "destructiveForeground",
+ tinted: "primary",
+ plain: "primary",
+};
+
+const ANDROID_ICON_SIZE: Record = {
default: 18,
sm: 16,
lg: 20,
icon: 20,
};
+const IOS_ICON_SIZE: Record = {
+ default: 20,
+ sm: 16,
+ lg: 20,
+ icon: 22,
+};
+
+/** Tint fill alphas for the iOS `tinted` appearance (rest / toggled). */
+const TINT_ALPHA = 0.15;
+const TINT_ALPHA_PRESSED = 0.28;
+/** UIKit highlight: the whole control dims while the finger is down. */
+const PRESS_OPACITY = 0.6;
+
export function Button({
variant: variantProp,
size: sizeProp,
@@ -124,9 +219,12 @@ export function Button({
iconPosition = "left",
loading = false,
pressed = false,
+ tint = "primary",
haptic: hapticProp = false,
disabled,
onPress,
+ onPressIn,
+ onPressOut,
className,
accessibilityRole = "button",
...props
@@ -134,14 +232,45 @@ export function Button({
const variant = variantProp ?? "default";
const size = sizeProp ?? "default";
const { tokens } = useTheme();
+ const [pressing, setPressing] = useState(false);
const isDisabled = disabled || loading;
- const contentColor = tokens[TEXT_TOKEN[variant]];
+ const appearance = IOS_APPEARANCE[variant];
+ // The tinted / plain appearances take the destructive red when asked.
+ const iosTintable = appearance === "tinted" || appearance === "plain";
+ const iosTintColor =
+ tint === "destructive" ? tokens.destructive : tokens.primary;
+ const contentColor = IS_IOS
+ ? iosTintable && tint === "destructive"
+ ? tokens.destructiveText
+ : tokens[IOS_TEXT_TOKEN[appearance]]
+ : tokens[ANDROID_TEXT_TOKEN[variant]];
const glyph = loading ? (
) : icon ? (
-
+
) : null;
+ const iosStyle = IS_IOS
+ ? [
+ { borderCurve: "continuous" as const },
+ appearance === "tinted"
+ ? {
+ backgroundColor: withAlpha(
+ iosTintColor,
+ pressed ? TINT_ALPHA_PRESSED : TINT_ALPHA,
+ ),
+ }
+ : appearance === "plain" && pressed
+ ? { backgroundColor: withAlpha(iosTintColor, TINT_ALPHA) }
+ : null,
+ pressing ? { opacity: PRESS_OPACITY } : null,
+ ]
+ : undefined;
+
return (
{
+ if (IS_IOS) setPressing(true);
+ onPressIn?.(event);
+ }}
+ onPressOut={(event) => {
+ if (IS_IOS) setPressing(false);
+ onPressOut?.(event);
+ }}
className={cn(
- buttonVariants({ variant, size, pressed }),
+ IS_IOS
+ ? iosButtonVariants({ appearance, size })
+ : androidButtonVariants({ variant, size, pressed }),
isDisabled && "opacity-50",
className,
)}
+ style={iosStyle}
{...props}
>
{iconPosition === "left" ? glyph : null}
{typeof children === "string" ? (
{children}
diff --git a/apps/mobile/src/ui/GlassSurface.ios.tsx b/apps/mobile/src/ui/GlassSurface.ios.tsx
new file mode 100644
index 0000000000..918c5cbe20
--- /dev/null
+++ b/apps/mobile/src/ui/GlassSurface.ios.tsx
@@ -0,0 +1,74 @@
+import {
+ GlassView,
+ isGlassEffectAPIAvailable,
+ isLiquidGlassAvailable,
+} from "expo-glass-effect";
+import Animated from "react-native-reanimated";
+import type { GlassSurfaceProps } from "./glass-surface-types";
+
+/**
+ * The glass view is the animated node itself (not a child of one): a
+ * Reanimated layout transition animates the frame of the view it is set on
+ * while that view's children snap to their final layout, so glass nested
+ * under an animating wrapper would jump to its final size and poke out of
+ * the growing (or shrinking) wrapper.
+ */
+const AnimatedGlassView = Animated.createAnimatedComponent(GlassView);
+
+let liquidGlass: boolean | null = null;
+
+/**
+ * iOS 26+ with the `UIGlassEffect` API actually present (some 26 betas
+ * advertise Liquid Glass but crash on the effect initialiser — expo's
+ * `isGlassEffectAPIAvailable` covers that). Resolved once: the answer
+ * cannot change while the app runs.
+ */
+export function useLiquidGlass(): boolean {
+ if (liquidGlass === null) {
+ liquidGlass = isLiquidGlassAvailable() && isGlassEffectAPIAvailable();
+ }
+ return liquidGlass;
+}
+
+/**
+ * iOS: Liquid Glass when the OS renders it — expo-glass-effect's `GlassView`
+ * with `style`'s radius and continuous corners shaping the effect and the
+ * children mounted inside it (the native view puts them in the effect's
+ * content view). Without Liquid Glass the surface is the same view the
+ * default module renders: `style` plus `fallbackStyle`.
+ */
+export function GlassSurface({
+ style,
+ fallbackStyle,
+ glassStyle = "regular",
+ tintColor,
+ interactive = false,
+ layout,
+ children,
+ ...rest
+}: GlassSurfaceProps) {
+ if (!useLiquidGlass()) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
+
+export type {
+ GlassSurfaceLayout,
+ GlassSurfaceProps,
+} from "./glass-surface-types";
diff --git a/apps/mobile/src/ui/GlassSurface.tsx b/apps/mobile/src/ui/GlassSurface.tsx
new file mode 100644
index 0000000000..773a282855
--- /dev/null
+++ b/apps/mobile/src/ui/GlassSurface.tsx
@@ -0,0 +1,40 @@
+import Animated from "react-native-reanimated";
+import type { GlassSurfaceProps } from "./glass-surface-types";
+
+/**
+ * Whether the running OS renders Liquid Glass (iOS 26+ with the
+ * `UIGlassEffect` API present). Always `false` here; `GlassSurface.ios.tsx`
+ * answers for iOS. Hosts branch on it to float a glass bar over scrolling
+ * content instead of docking an opaque one under it.
+ */
+export function useLiquidGlass(): boolean {
+ return false;
+}
+
+/**
+ * Android / default: a plain surface with the fallback fill and border
+ * (`GlassSurface.ios.tsx` renders expo-glass-effect's `GlassView` on iOS 26+).
+ * The `layout` transition still applies so a host animates the same way on
+ * every platform.
+ */
+export function GlassSurface({
+ style,
+ fallbackStyle,
+ glassStyle: _glassStyle,
+ tintColor: _tintColor,
+ interactive: _interactive,
+ layout,
+ children,
+ ...rest
+}: GlassSurfaceProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export type {
+ GlassSurfaceLayout,
+ GlassSurfaceProps,
+} from "./glass-surface-types";
diff --git a/apps/mobile/src/ui/Grouped.tsx b/apps/mobile/src/ui/Grouped.tsx
new file mode 100644
index 0000000000..4a7d06f282
--- /dev/null
+++ b/apps/mobile/src/ui/Grouped.tsx
@@ -0,0 +1,367 @@
+import { Children, Fragment, isValidElement, type ReactNode } from "react";
+import { Pressable, View } from "react-native";
+import { useTheme } from "@/theme/ThemeProvider";
+import { cn } from "./cn";
+import { Icon, isIconName, type IconName } from "./Icon";
+import {
+ DisclosureChevron,
+ LIST_ROW_ICON_SIZE,
+ SelectedCheck,
+} from "./ListRow";
+import { Separator } from "./Separator";
+import type { SFSymbol } from "./sf-symbol-map";
+import { Text } from "./Text";
+
+const IS_IOS = process.env.EXPO_OS === "ios";
+
+/*
+ * iOS inset-grouped list (UITableView `.insetGrouped` / SwiftUI `List`):
+ * a footnote header, a rounded card of 44pt rows on the grouped-cell color
+ * with hairline separators inset to the text column, and a footnote footer.
+ * Screens that host these sit on `bg-surface-grouped`. Everything is token
+ * driven (strings), so custom palettes keep their anchors.
+ */
+
+/** Card corner radius. */
+export const GROUPED_CARD_RADIUS = 10;
+/** Horizontal padding inside a row (the text column starts here with no leading). */
+export const GROUPED_ROW_PADDING_X = 16;
+/** Gap between a leading glyph/badge and the text column. */
+const GROUPED_ROW_GAP = 12;
+/** `IconBadge` default size (iOS Settings). */
+export const ICON_BADGE_SIZE = 29;
+
+export interface IconBadgeProps {
+ icon: IconName;
+ /** iOS only: the exact symbol to draw (a `.fill` variant); `name` stays the Android glyph. */
+ symbol?: SFSymbol;
+ /** Badge fill: a theme token or a fixed brand color string. */
+ color: string;
+ /** Square size; the corner radius and glyph scale with it (default 29). */
+ size?: number;
+ /** Glyph color; white like iOS Settings unless the fill needs otherwise. */
+ glyphColor?: string;
+ accessibilityLabel?: string;
+}
+
+/** Tinted rounded square with a white glyph, the iOS Settings row badge. */
+export function IconBadge({
+ icon,
+ symbol,
+ color,
+ size = ICON_BADGE_SIZE,
+ glyphColor = "#ffffff",
+ accessibilityLabel,
+}: IconBadgeProps) {
+ const scale = size / ICON_BADGE_SIZE;
+ return (
+
+
+
+ );
+}
+
+export interface GroupedRowProps {
+ title: string;
+ /** Secondary line under the title (footnote, muted). */
+ subtitle?: string;
+ /** Right-aligned current value (17pt, muted), before the trailing slot. */
+ value?: string;
+ /** Paints `value` in the warning / destructive text color (offline, fallback). */
+ valueTone?: "default" | "warning" | "destructive";
+ /** An icon name renders a 20px glyph; any node renders as-is. */
+ leading?: IconName | ReactNode;
+ /** Color of a `leading` icon name: the label color (default) or the tint. */
+ leadingTone?: "foreground" | "primary";
+ /** Tinted square badge (iOS Settings); wins over `leading`. `symbol` is the iOS `.fill` variant. */
+ badge?: { icon: IconName; symbol?: SFSymbol; color: string };
+ /**
+ * `"chevron"` = disclosure (pushes a screen / opens a picker);
+ * `"checkmark"` = current choice; any node (a `Switch`) renders as-is.
+ */
+ trailing?: "chevron" | "checkmark" | ReactNode;
+ onPress?: () => void;
+ onLongPress?: () => void;
+ destructive?: boolean;
+ disabled?: boolean;
+ /** Lets the user select/copy the title and value (data rows). */
+ selectable?: boolean;
+ /** Lines before the title truncates (default 1). */
+ titleLines?: number;
+ className?: string;
+ testID?: string;
+ accessibilityLabel?: string;
+ accessibilityHint?: string;
+}
+
+/**
+ * One inset-grouped cell: 44pt min, 17pt label, optional value + trailing
+ * glyph; pressing highlights with `state-active`. Without `onPress` /
+ * `onLongPress` the row is a plain `View`, so a control in `trailing` keeps
+ * its own accessibility element. Put it (or a wrapper that forwards
+ * `leading`/`badge`) inside `GroupedSection`, which draws the separators.
+ */
+export function GroupedRow({
+ title,
+ subtitle,
+ value,
+ valueTone = "default",
+ leading,
+ leadingTone = "foreground",
+ badge,
+ trailing,
+ onPress,
+ onLongPress,
+ destructive = false,
+ disabled = false,
+ selectable = false,
+ titleLines = 1,
+ className,
+ testID,
+ accessibilityLabel,
+ accessibilityHint,
+}: GroupedRowProps) {
+ const { tokens } = useTheme();
+ const interactive = Boolean(onPress || onLongPress);
+ const titleColor = destructive ? tokens.destructiveText : tokens.foreground;
+ const valueColor =
+ valueTone === "warning"
+ ? tokens.warningText
+ : valueTone === "destructive"
+ ? tokens.destructiveText
+ : tokens.mutedForeground;
+ const leadingColor = destructive
+ ? tokens.destructiveText
+ : leadingTone === "primary"
+ ? tokens.primary
+ : tokens.foreground;
+ const leadingNode = badge ? (
+
+ ) : isIconName(leading) ? (
+
+ ) : (
+ leading
+ );
+ const trailingNode =
+ trailing === "chevron" ? (
+
+ ) : trailing === "checkmark" ? (
+
+ ) : (
+ trailing
+ );
+ const layoutClassName = cn(
+ "min-h-[44px] flex-row items-center gap-3 px-4 py-2.5",
+ disabled && "opacity-50",
+ className,
+ );
+ const content = (
+ <>
+ {leadingNode}
+
+
+ {title}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ {value ? (
+
+ {value}
+
+ ) : null}
+ {trailingNode}
+ >
+ );
+ if (!interactive) {
+ // A static row (a label beside a Switch / Button, a data row) is a plain
+ // container so its texts and its control stay separate accessibility
+ // elements. A `Pressable` is `accessible` by default: it would fold the
+ // row into one dimmed "button" that hides the control from VoiceOver
+ // and from Maestro (the Switch's `testID` becomes unreachable).
+ return (
+
+ {content}
+
+ );
+ }
+ return (
+
+ {content}
+
+ );
+}
+
+export interface GroupedSectionProps {
+ /** Footnote header above the card (sentence case on iOS). */
+ title?: string;
+ /** Footnote below the card (help copy, warnings as a node with a tone). */
+ footer?: string | ReactNode;
+ /** Right-hand slot on the header line (a refresh button, a picker). */
+ action?: ReactNode;
+ /** Rows. Arrays are fine; do not wrap rows in a Fragment (separators go between direct children). */
+ children: ReactNode;
+ /**
+ * Separator inset: `"text"` (default) lines separators up with each
+ * row's text column (past a `leading` glyph or `badge`); a number is an
+ * exact px inset for rows with custom leading content.
+ */
+ separatorInset?: number | "text";
+ /**
+ * What the card sits on. `"grouped"` (default): the grouped page color,
+ * cells take `surface-grouped-cell`. `"raised"`: a raised solid host — a
+ * sheet, the workspace panel — whose dark color coincides with the grouped
+ * cell (#1c1c1c): cells then take the translucent raised twin in dark mode
+ * and the grouped cell color in light, where it still lifts off the host.
+ */
+ surface?: GroupedSurface;
+ /** Rendered below the header line, above the card (legacy descriptions). */
+ description?: string;
+ className?: string;
+ testID?: string;
+}
+
+export type GroupedSurface = "grouped" | "raised";
+
+/** Where the text column of a row starts, read off its `leading`/`badge` props. */
+function rowTextInset(child: ReactNode): number {
+ if (!isValidElement<{ badge?: unknown; leading?: unknown }>(child)) {
+ return GROUPED_ROW_PADDING_X;
+ }
+ if (child.props.badge) {
+ return GROUPED_ROW_PADDING_X + ICON_BADGE_SIZE + GROUPED_ROW_GAP;
+ }
+ if (child.props.leading !== undefined && child.props.leading !== null) {
+ return GROUPED_ROW_PADDING_X + LIST_ROW_ICON_SIZE + GROUPED_ROW_GAP;
+ }
+ return GROUPED_ROW_PADDING_X;
+}
+
+/**
+ * The inset card: header, rows separated by hairlines inset to the text
+ * column, footer. `null`/`false` children are skipped, so conditional rows
+ * need no wrapper.
+ */
+export function GroupedSection({
+ title,
+ footer,
+ action,
+ children,
+ separatorInset = "text",
+ surface = "grouped",
+ description,
+ className,
+ testID,
+}: GroupedSectionProps) {
+ const { tokens, mode } = useTheme();
+ const cardColor =
+ surface === "raised" && mode === "dark"
+ ? tokens.surfaceRaised
+ : tokens.surfaceGroupedCell;
+ // `toArray` drops null/boolean children and keys the rest.
+ const rows = Children.toArray(children);
+ return (
+
+ {title || action ? (
+
+ {title ? (
+
+ {title}
+
+ ) : (
+
+ )}
+ {action}
+
+ ) : null}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {rows.map((row, index) => (
+
+ {index > 0 ? (
+
+ ) : null}
+ {row}
+
+ ))}
+
+ {footer ? (
+ typeof footer === "string" ? (
+
+ {footer}
+
+ ) : (
+ {footer}
+ )
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/ui/HugeIcon.tsx b/apps/mobile/src/ui/HugeIcon.tsx
new file mode 100644
index 0000000000..64310d86dd
--- /dev/null
+++ b/apps/mobile/src/ui/HugeIcon.tsx
@@ -0,0 +1,76 @@
+import type { SFSymbolEffect } from "expo-image";
+import { HugeiconsIcon } from "@hugeicons/react-native";
+import type { ImageStyle, StyleProp, ViewStyle } from "react-native";
+import { useTheme } from "@/theme/ThemeProvider";
+import { ICON_MAP, type IconName } from "./icon-map";
+import type { SFSymbol, SFSymbolWeight } from "./sf-symbol-map";
+
+/** theme.css `--icon-stroke-width`. */
+export const ICON_STROKE_WIDTH = 1.75;
+/** Touch base size (web `size-5` under `pointer: coarse`). */
+export const ICON_SIZE_DEFAULT = 20;
+
+/**
+ * Layout/transform/opacity styles both renderers accept: the Hugeicons svg
+ * takes a `ViewStyle`, the expo-image symbol an `ImageStyle` (no
+ * `overflow: "scroll"`).
+ */
+export type IconStyle = ViewStyle & ImageStyle;
+
+export interface IconProps {
+ name: IconName;
+ /** Pixel size; defaults to 20 (16 fits inline text and compact buttons). */
+ size?: number;
+ /** Any RN color string; defaults to the current `foreground` token. */
+ color?: string;
+ /** Hugeicons stroke width (Android, and unmapped names on iOS). */
+ strokeWidth?: number;
+ /**
+ * SF Symbol weight (iOS, mapped names only); defaults to medium. The
+ * Hugeicons renderer has a fixed stroke and ignores it.
+ */
+ weight?: SFSymbolWeight;
+ /**
+ * iOS only: render this exact symbol instead of the map's outline variant
+ * (`arrow.up.circle.fill`, `paperplane.fill`). `name` stays the Android
+ * glyph and the accessibility vocabulary.
+ */
+ symbol?: SFSymbol;
+ /** iOS 17+ only: an expo-image symbol effect (`"pulse"`, `{ effect, repeat }`). */
+ effect?: SFSymbolEffect;
+ style?: StyleProp;
+ accessibilityLabel?: string;
+}
+
+/**
+ * The Hugeicons renderer. `Icon.tsx` (Android / default) is this component;
+ * `Icon.ios.tsx` renders SF Symbols for mapped names and falls back to it.
+ * It lives in its own module because a `./Icon` import from inside
+ * `Icon.ios.tsx` resolves to `Icon.ios.tsx` itself under Metro's platform
+ * resolution.
+ */
+export function HugeIcon({
+ name,
+ size = ICON_SIZE_DEFAULT,
+ color,
+ strokeWidth = ICON_STROKE_WIDTH,
+ style,
+ accessibilityLabel,
+}: IconProps) {
+ const { tokens } = useTheme();
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/ui/Icon.ios.tsx b/apps/mobile/src/ui/Icon.ios.tsx
new file mode 100644
index 0000000000..57b98fb537
--- /dev/null
+++ b/apps/mobile/src/ui/Icon.ios.tsx
@@ -0,0 +1,70 @@
+import { Image } from "expo-image";
+import { useTheme } from "@/theme/ThemeProvider";
+import { HugeIcon, ICON_SIZE_DEFAULT, type IconProps } from "./HugeIcon";
+import {
+ SF_SYMBOL_WEIGHT,
+ SF_SYMBOL_WEIGHTS,
+ sfSymbolFor,
+} from "./sf-symbol-map";
+
+/**
+ * iOS icon: the SF Symbol mapped to `name` through expo-image's `sf:` source
+ * (`tintColor` must stay a string token, never `Color.ios.*`), or the
+ * Hugeicons glyph for names without a symbol (brand marks). Metro resolves
+ * `./Icon` to this file on iOS; `Icon.tsx` is the Android / default sibling.
+ */
+export function Icon({
+ name,
+ size = ICON_SIZE_DEFAULT,
+ color,
+ strokeWidth,
+ weight = SF_SYMBOL_WEIGHT,
+ symbol: symbolOverride,
+ effect,
+ style,
+ accessibilityLabel,
+}: IconProps) {
+ const { tokens } = useTheme();
+ const symbol = symbolOverride ?? sfSymbolFor(name);
+ if (symbol === undefined) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+export { HugeIcon, type IconProps } from "./HugeIcon";
+export { ICON_NAMES, isIconName, type IconName } from "./icon-map";
diff --git a/apps/mobile/src/ui/Icon.tsx b/apps/mobile/src/ui/Icon.tsx
index 10e19fe73c..7f3b7f4a5f 100644
--- a/apps/mobile/src/ui/Icon.tsx
+++ b/apps/mobile/src/ui/Icon.tsx
@@ -1,48 +1,15 @@
-import { HugeiconsIcon } from "@hugeicons/react-native";
-import type { StyleProp, ViewStyle } from "react-native";
-import { useTheme } from "@/theme/ThemeProvider";
-import { ICON_MAP, type IconName } from "./icon-map";
-
-/** theme.css `--icon-stroke-width`. */
-const ICON_STROKE_WIDTH = 1.75;
-/** Touch base size (web `size-5` under `pointer: coarse`). */
-const ICON_SIZE_DEFAULT = 20;
-
-export interface IconProps {
- name: IconName;
- /** Pixel size; defaults to 20 (16 fits inline text and compact buttons). */
- size?: number;
- /** Any RN color string; defaults to the current `foreground` token. */
- color?: string;
- strokeWidth?: number;
- style?: StyleProp;
- accessibilityLabel?: string;
-}
-
-export function Icon({
- name,
- size = ICON_SIZE_DEFAULT,
- color,
- strokeWidth = ICON_STROKE_WIDTH,
- style,
- accessibilityLabel,
-}: IconProps) {
- const { tokens } = useTheme();
- return (
-
- );
+import { HugeIcon, type IconProps } from "./HugeIcon";
+
+/**
+ * Android / default platform icon: the Hugeicons glyph for `name`. Metro
+ * picks the sibling `Icon.ios.tsx` on iOS, which renders the SF Symbol from
+ * `sf-symbol-map.ts` when one exists and otherwise falls back to `HugeIcon`.
+ * Both modules export the same surface so `@/ui` re-exports resolve on
+ * every platform.
+ */
+export function Icon(props: IconProps) {
+ return ;
}
+export { HugeIcon, type IconProps } from "./HugeIcon";
export { ICON_NAMES, isIconName, type IconName } from "./icon-map";
diff --git a/apps/mobile/src/ui/Input.tsx b/apps/mobile/src/ui/Input.tsx
index e15f3af5c1..8f719aae0c 100644
--- a/apps/mobile/src/ui/Input.tsx
+++ b/apps/mobile/src/ui/Input.tsx
@@ -1,44 +1,123 @@
import { forwardRef } from "react";
-import { TextInput, type TextInputProps } from "react-native";
+import {
+ StyleSheet,
+ TextInput,
+ type StyleProp,
+ type TextInputProps,
+ type TextStyle,
+} from "react-native";
import { resolveFont } from "@/theme/fonts";
import { useTheme } from "@/theme/ThemeProvider";
import { cn } from "./cn";
-export interface InputProps extends TextInputProps {
+const IS_IOS = process.env.EXPO_OS === "ios";
+
+/** iOS text-field corner radius (grouped cells and search fields). */
+export const INPUT_RADIUS = 10;
+
+export interface InputFieldOptions {
/** Paints the destructive border (validation error). */
invalid?: boolean;
- /** Fira Code (URLs, codes, paths). */
+ /** Mono face (URLs, codes, paths). */
mono?: boolean;
+ /**
+ * The field sits inside a grouped card (`GroupedSection`): it takes the
+ * cell color instead of the `muted` fill so it reads as part of the cell.
+ */
+ grouped?: boolean;
+ editable?: boolean;
className?: string;
}
+export interface InputFieldProps {
+ className: string;
+ style: StyleProp;
+ placeholderTextColor: string;
+ selectionColor: string;
+ cursorColor: string;
+ keyboardAppearance: "light" | "dark";
+ clearButtonMode?: TextInputProps["clearButtonMode"];
+}
+
/**
- * Single-line text field. Mirrors packages/shared-ui input.tsx with the
- * coarse-pointer height (40) and `text-base` (16px, which also stops iOS
- * Safari-style zoom-on-focus semantics from mattering here).
+ * The props every text field in the app shares, so `Input`, `TextArea` and
+ * the sheet-hosted `SheetInput` render identically. iOS: the grouped field
+ * look — filled (`muted` or the grouped cell), no border, radius 10 with
+ * continuous corners, 17pt system font, tertiary placeholder, a clear
+ * button while editing; invalid = destructive hairline. Android: the
+ * bordered web field (`border-input`, focus ring).
*/
+export function useInputFieldProps({
+ invalid = false,
+ mono,
+ grouped = false,
+ editable = true,
+ className,
+}: InputFieldOptions): InputFieldProps {
+ const { tokens, mode } = useTheme();
+ const font = resolveFont({ className, mono });
+ if (IS_IOS) {
+ return {
+ className: cn(
+ "w-full px-3 text-base text-foreground",
+ grouped ? "bg-surface-grouped-cell" : "bg-muted",
+ !editable && "opacity-50",
+ className,
+ ),
+ style: [
+ font,
+ { borderRadius: INPUT_RADIUS, borderCurve: "continuous" },
+ invalid
+ ? {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: tokens.destructive,
+ }
+ : null,
+ ],
+ placeholderTextColor: tokens.subtleForeground,
+ selectionColor: tokens.primary,
+ cursorColor: tokens.primary,
+ keyboardAppearance: mode,
+ clearButtonMode: "while-editing",
+ };
+ }
+ return {
+ className: cn(
+ "w-full rounded-md border border-input bg-transparent px-3 text-base text-foreground focus:border-ring",
+ invalid && "border-destructive",
+ !editable && "opacity-50",
+ className,
+ ),
+ style: font,
+ placeholderTextColor: tokens.mutedForeground,
+ selectionColor: tokens.primary,
+ cursorColor: tokens.primary,
+ keyboardAppearance: mode,
+ };
+}
+
+export interface InputProps extends TextInputProps, InputFieldOptions {}
+
+/** Single-line text field: 44pt on iOS, 40 on Android. */
export const Input = forwardRef(function Input(
- { invalid = false, editable = true, mono, className, style, ...props },
+ { invalid, editable = true, mono, grouped, className, style, ...props },
ref,
) {
- const { tokens } = useTheme();
- const font = resolveFont({ className, mono });
+ const field = useInputFieldProps({
+ invalid,
+ mono,
+ grouped,
+ editable,
+ className: cn(IS_IOS ? "h-11" : "h-10", className),
+ });
return (
);
diff --git a/apps/mobile/src/ui/ListRow.tsx b/apps/mobile/src/ui/ListRow.tsx
index a81e709eb0..91d35cf50d 100644
--- a/apps/mobile/src/ui/ListRow.tsx
+++ b/apps/mobile/src/ui/ListRow.tsx
@@ -5,15 +5,28 @@ import { cn } from "./cn";
import { Icon, isIconName, type IconName } from "./Icon";
import { Text } from "./Text";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
+/** Disclosure chevron: 14pt semibold SF glyph on iOS, 18px Hugeicons stroke elsewhere. */
+export const LIST_ROW_CHEVRON_SIZE = IS_IOS ? 14 : 18;
+/** Leading glyph size in rows. */
+export const LIST_ROW_ICON_SIZE = 20;
+
export interface ListRowProps {
title: string;
subtitle?: string;
/** An icon name renders a 20px glyph; any node renders as-is. */
leading?: IconName | ReactNode;
- /** `"chevron"` renders the disclosure glyph; any node renders as-is. */
+ /** Color of a `leading` icon name: the label color (default) or the tint. */
+ leadingTone?: "foreground" | "primary";
+ /**
+ * `"chevron"` renders the disclosure glyph; any node renders as-is. When
+ * omitted (or null) a `selected` row shows a tinted check mark instead.
+ */
trailing?: "chevron" | ReactNode;
onPress?: () => void;
onLongPress?: () => void;
+ /** Current choice in a single-select list: trailing check mark in `primary`. */
selected?: boolean;
destructive?: boolean;
disabled?: boolean;
@@ -24,15 +37,39 @@ export interface ListRowProps {
testID?: string;
}
+/** Trailing check mark for the selected row of a single-choice list. */
+export function SelectedCheck() {
+ const { tokens } = useTheme();
+ return (
+
+ );
+}
+
+/** Disclosure chevron for rows that push a screen or open a sheet. */
+export function DisclosureChevron() {
+ const { tokens } = useTheme();
+ return (
+
+ );
+}
+
/**
- * Touch list row (min 44px): leading glyph, title/subtitle, trailing slot.
- * Pressed and selected states use the web `state-hover` / `surface-selected`
- * fills. Long-press is where context menus live on mobile.
+ * Touch list row (min 44pt): leading glyph, 17pt title / 15pt secondary
+ * subtitle, trailing slot. Pressing fills the row with `state-active`
+ * (system highlight); `selected` is shown as a check mark, not a fill, so
+ * picker rows read like iOS table cells. Long-press is where the Android
+ * context menu lives (iOS rows get native menus from their screens).
*/
export function ListRow({
title,
subtitle,
leading,
+ leadingTone = "foreground",
trailing,
onPress,
onLongPress,
@@ -47,47 +84,75 @@ export function ListRow({
const { tokens } = useTheme();
const interactive = Boolean(onPress || onLongPress);
const titleColor = destructive ? tokens.destructiveText : tokens.foreground;
- return (
-
+ const leadingColor = destructive
+ ? tokens.destructiveText
+ : leadingTone === "primary"
+ ? tokens.primary
+ : tokens.foreground;
+ const trailingNode =
+ trailing === "chevron" ? (
+
+ ) : trailing === undefined || trailing === null ? (
+ selected ? (
+
+ ) : null
+ ) : (
+ trailing
+ );
+ const layoutClassName = cn(
+ "min-h-[44px] flex-row items-center gap-3 px-4 py-2",
+ disabled && "opacity-50",
+ className,
+ );
+ const content = (
+ <>
{isIconName(leading) ? (
-
+
) : (
leading
)}
{title}
{subtitle ? (
-
+
{subtitle}
) : null}
- {trailing === "chevron" ? (
-
- ) : (
- trailing
+ {trailingNode}
+ >
+ );
+ if (!interactive) {
+ // A static row is a plain container so a control in `trailing` keeps its
+ // own accessibility element (a `Pressable` is `accessible` by default and
+ // would fold it into one dimmed "button"; see GroupedRow).
+ return (
+
+ {content}
+
+ );
+ }
+ return (
+
+ {content}
);
}
diff --git a/apps/mobile/src/ui/NativeMenu.ios.tsx b/apps/mobile/src/ui/NativeMenu.ios.tsx
new file mode 100644
index 0000000000..ad6a2d9344
--- /dev/null
+++ b/apps/mobile/src/ui/NativeMenu.ios.tsx
@@ -0,0 +1,107 @@
+import { MenuView, type MenuAction } from "@expo/ui/community/menu";
+import { View } from "react-native";
+import { sfSymbolFor } from "./sf-symbol-map";
+import type { NativeMenuAction, NativeMenuProps } from "./native-menu-shared";
+
+function toMenuAction(action: NativeMenuAction): MenuAction {
+ const symbol =
+ action.symbol ?? (action.icon ? sfSymbolFor(action.icon) : undefined);
+ const disabled = action.disabled === true;
+ return {
+ id: action.key,
+ // The native item has no subtitle line: a disabled item folds its
+ // subtitle (the reason it is unavailable) into the title so the
+ // explanation survives; enabled items drop their description.
+ title:
+ disabled && action.subtitle
+ ? `${action.label} — ${action.subtitle}`
+ : action.label,
+ // Brand marks (Discord, Github) have no symbol; the item renders text-only.
+ ...(symbol ? { image: symbol } : {}),
+ // `state` switches the item to a toggle row; leave it off for commands.
+ ...(action.checked === undefined
+ ? {}
+ : { state: action.checked ? ("on" as const) : ("off" as const) }),
+ attributes: {
+ destructive: action.destructive === true,
+ disabled,
+ },
+ ...(action.items && action.items.length > 0
+ ? {
+ subactions: action.items.map(toMenuAction),
+ displayInline: action.inline === true,
+ }
+ : {}),
+ };
+}
+
+/** Every leaf item, so a submenu pick resolves to its own handler. */
+function findAction(
+ actions: readonly NativeMenuAction[],
+ key: string,
+): NativeMenuAction | undefined {
+ for (const action of actions) {
+ if (action.key === key) return action;
+ const nested = action.items ? findAction(action.items, key) : undefined;
+ if (nested) return nested;
+ }
+ return undefined;
+}
+
+/**
+ * iOS: a native pull-down menu (tap) or context menu (`longPress`) anchored
+ * to the trigger, built from `@expo/ui`'s `MenuView`.
+ *
+ * The rule (see `NativeMenuProps`): the trigger is an icon-only button. The
+ * SwiftUI menu host drops the wrapped React Native subtree from the
+ * accessibility tree, so an `accessible` wrapper is the one element
+ * VoiceOver and Maestro see — it carries the label, the button role, the
+ * disabled state and the `testID`; activating it (a tap at its centre)
+ * reaches the native menu underneath. The menu items themselves are native
+ * and stay accessible. Icons come from the SF Symbol map; the native menu
+ * has no `subtitle` line, so only a disabled item keeps it (folded into the
+ * title as the reason it is unavailable). Metro picks this file on iOS;
+ * `NativeMenu.tsx` is the fallback.
+ */
+export function NativeMenu({
+ title,
+ actions,
+ longPress = false,
+ disabled = false,
+ children,
+ style,
+ testID,
+ accessibilityLabel,
+}: NativeMenuProps) {
+ return (
+
+ {disabled ? (
+ children
+ ) : (
+ {
+ findAction(actions, nativeEvent.event)?.onPress();
+ }}
+ shouldOpenOnLongPress={longPress}
+ >
+ {children}
+
+ )}
+
+ );
+}
+
+export {
+ flattenNativeMenuActions,
+ type NativeMenuAction,
+ type NativeMenuProps,
+} from "./native-menu-shared";
diff --git a/apps/mobile/src/ui/NativeMenu.tsx b/apps/mobile/src/ui/NativeMenu.tsx
new file mode 100644
index 0000000000..9c047ec7a9
--- /dev/null
+++ b/apps/mobile/src/ui/NativeMenu.tsx
@@ -0,0 +1,61 @@
+import { Pressable } from "react-native";
+import { ActionSheet } from "./ActionSheet";
+import {
+ flattenNativeMenuActions,
+ type NativeMenuProps,
+} from "./native-menu-shared";
+import { useSheet } from "./Sheet";
+
+/**
+ * Android / default: the trigger is wrapped in a Pressable that presents an
+ * `ActionSheet` with the same actions (`NativeMenu.ios.tsx` renders a
+ * native `MenuView`). The same rule applies on both platforms: the trigger
+ * is an icon-only button and the wrapper is the accessible element, named
+ * by `accessibilityLabel`. Because the wrapper owns the gesture, a trigger
+ * that is itself a Pressable with the same gesture claims the touch first
+ * — text-bearing rows keep their own `Pressable` + `ActionSheet` instead.
+ */
+export function NativeMenu({
+ title,
+ actions,
+ onOpen,
+ longPress = false,
+ disabled = false,
+ children,
+ style,
+ testID,
+ accessibilityLabel,
+}: NativeMenuProps) {
+ const sheet = useSheet();
+ const open = () => {
+ onOpen?.();
+ sheet.present();
+ };
+ return (
+ <>
+
+ {children}
+
+
+ >
+ );
+}
+
+export {
+ flattenNativeMenuActions,
+ type NativeMenuAction,
+ type NativeMenuProps,
+} from "./native-menu-shared";
diff --git a/apps/mobile/src/ui/README.md b/apps/mobile/src/ui/README.md
index cc1b99d35e..8704802aea 100644
--- a/apps/mobile/src/ui/README.md
+++ b/apps/mobile/src/ui/README.md
@@ -12,12 +12,16 @@ import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
-import { useAppFonts } from "@/theme/useAppFonts"; // module keeps the splash up until the layout hides it
+import { useAppBoot } from "@/app-shell";
import { ThemeProvider } from "@/theme";
import { SheetProvider, Toaster } from "@/ui";
+// Fonts are the platform system faces (nothing to load), so the splash is
+// gated on boot alone.
+void SplashScreen.preventAutoHideAsync().catch(() => undefined);
+
export default function RootLayout() {
- const { ready } = useAppFonts(); // true once Inter/Fira Code load
+ const { ready } = useAppBoot();
useEffect(() => {
if (ready) void SplashScreen.hideAsync().catch(() => undefined);
}, [ready]);
@@ -56,36 +60,104 @@ inside it.
(`bg-background`, `text-foreground`, `border-border`, `bg-sidebar-accent`,
`text-destructive-text`, `bg-surface-selected`, …), plus opacity modifiers
(`bg-foreground/90`) and `active:` / `focus:` (Pressable / TextInput) in
- place of web `hover:` / `focus-visible:`. Radii: `rounded-sm|md|lg|xl` =
- 4/6/8/12. Type scale: `text-2xs|xs|sm|base` = 11/14/15/16 (touch values).
-- Fonts: `` sets `fontFamily` + `fontWeight` from `weight`/`mono` or
- from `font-medium|semibold|bold|mono` classes (Expo Google Fonts register
- one family per weight). Outside `` use `font-sans-medium`,
- `font-mono-semibold`, … or `theme.fonts.sans.medium` with a matching
- `fontWeight`.
+ place of web `hover:` / `focus-visible:`. Radii: `rounded-sm|md|lg|xl|2xl|full`
+ = 4/6/8/12/16/9999 (`theme.radii.sm|md|lg|xl|xl2|full`).
+- Type scale: the Apple text-style ramp, `text-2xs|xs|sm|base|lg|xl|2xl|3xl` =
+ 11/13, 13/18, 15/20, 17/22, 20/25, 22/28, 28/34, 34/41 (size/line height:
+ caption2, footnote, subheadline, body, title3, title2, title1, largeTitle).
+ `nativeTypography` (from `@/theme`) carries the same numbers for inline
+ styles. Large titles in navigation headers come from the native `Stack`,
+ not from `Text`.
+- Where values come from: `apps/app/src/components/ui/theme.css`, then
+ `src/theme/mobile-overrides.css` layered on top (the iOS system look for the
+ default palette: black/white anchors, systemBlue `--primary`, system status
+ colors, separator-class borders, `#1c1c1c`-class dark surfaces), then each
+ built-in palette last — so Nord/Dracula/… keep their own anchors and every
+ override derives from `--canvas`/`--ink`. Mobile-only values go in the
+ override file, never in theme.css; afterwards run
+ `pnpm --filter @bb/mobile theme:generate` and update the pinned tests
+ (`generate-native-theme.test.ts`, `theme-vars.test.ts`).
+- Grouped lists (iOS inset style): `bg-surface-grouped` is the page behind
+ the cards and `bg-surface-grouped-cell` the cards (`tokens.surfaceGrouped` /
+ `tokens.surfaceGroupedCell`). Light: tinted page, white cells; dark: black
+ page, lifted cells. These two tokens exist only on mobile.
+- Fonts: the platform system faces — SF Pro on iOS (`fontFamily: undefined`
+ plus a numeric `fontWeight`; italics via `fontStyle: "italic"`),
+ `sans-serif` on Android; mono is `Menlo` on iOS and `monospace` on Android
+ (`src/theme/font-platform.ts` + `font-platform.ios.ts`, chosen by Metro).
+ Nothing is downloaded or bundled, so there is no font load gate. ``
+ sets `fontFamily` + `fontWeight` from `weight`/`mono` or from
+ `font-medium|semibold|bold|mono` classes via `resolveFont`; outside ``
+ spread `resolveFont(...)` / `resolveItalicFont(weight)` or use
+ `theme.fonts.mono.regular` (always a string) with a matching `fontWeight`.
+ `global.css` keeps `font-sans` / `font-mono` resolvable (`"System"` /
+ `monospace`) only so the class names work; CSS cannot select a family per
+ platform, so ``'s inline style is what actually renders. There are no
+ per-weight `font-sans-*` / `font-mono-*` utilities.
- Do not use `leading-*` utilities (NativeWind emits em multipliers); the
`text-*` sizes already carry the web line heights.
- `dark:` variants are unnecessary — swap happens through variables.
+## Platform rule
+
+iOS is the design target; Android must not crash. Primitives branch on
+`process.env.EXPO_OS === "ios"` (a module-level `IS_IOS`) for the iOS look
+and keep the previous Material/web look as the default. iOS-only modules
+(`@expo/ui/community/menu`, `sf:` sources) live in `*.ios.tsx` siblings under
+`src/` with a same-named default twin (`Icon.ios.tsx` / `Icon.tsx`,
+`NativeMenu.ios.tsx` / `NativeMenu.tsx`) — never under `app/`, where
+expo-router would register them as routes. `platform-neutrality.test.ts`
+scans for `@expo/ui/swift-ui`, `Color.ios`, `Alert.prompt(` and `sf:` and
+fails unless the occurrence is in such a sibling or within 40 lines of a
+`Platform.select` / `Platform.OS === "ios"` / `EXPO_OS === "ios"` guard.
+Colors are always token strings (`tokens.primary`, `withAlpha(...)`); there
+is no `Color.ios` palette layer. `expo-glass-effect` (the Liquid Glass
+native view) is imported only in `GlassSurface.ios.tsx`; the same test fails
+an import anywhere else.
+
+### Liquid Glass (iOS 26)
+
+`useLiquidGlass()` is `true` only on iOS 26+ with the `UIGlassEffect` API
+present (always `false` on Android and older iOS). A host that docks a bar
+under scrolling content branches on it: with glass the bar floats over the
+content as a transparent overlay at the bottom of the `OverlayBounds`
+region, the content scrolls under it (`contentContainerStyle.paddingBottom`
+and `scrollIndicatorInsets` clear the bar's measured height), and the bar's
+surface is a `GlassSurface`. Keep the scroll view's frame ending at the
+bar's bottom edge (pad the home-indicator inset outside it): RN's
+`scrollToEnd` ignores the scroll view's own safe-area inset, so a frame
+that ran under the home indicator would land short of its end. The home
+dock and the thread prompt area do this; the composer card is the glass, and
+so is each prompt chip above it (`PromptChip`): chips float as their own
+capsules rather than on a solid bar, and only content that needs a backing
+(the pending-interaction form, the queued-message cards) keeps a raised panel.
+
## Primitives
-| Component | Props (beyond RN passthrough) | Notes |
-| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ------- | ------------------------------------------------------------------- | ---------------------- |
-| `Text` | `variant` body·bodyLarge·title·heading·label·caption·sectionLabel·chrome·mono; `tone` default·foreground·muted·subtle·readback·primary·destructive·warning·success·inverse; `weight`; `mono`; `className` | Themed RN Text. |
-| `Button` | `variant` default·secondary·outline·ghost·destructive·link; `size` sm·default·lg·icon; `icon` (IconName), `iconPosition`; `loading`; `pressed` (toggle); `haptic` (`true`/light/medium/heavy/selection); `onPress`; string or node children; `className` | Heights 36/40/48, icon 40×40. |
-| `Badge` | `variant` default·secondary·destructive·outline | |
-| `Pill` | `variant` secondary·destructive·outline·emphasis; `size` default·sm | Truncates one line. |
-| `Input` | `invalid`, `mono`, all `TextInputProps` | h-10, focus ring via `focus:border-ring`. |
-| `TextArea` | `invalid`, `mono` | multiline, min-h 60. |
-| `Switch` | `checked`, `onCheckedChange`, `size` default·sm, `disabled` | Native switch, token colors. |
-| `Skeleton` | `className` (size it) | Reanimated pulse. |
-| `Spinner` | `size`, `color` | ActivityIndicator. |
-| `EmptyState` / `EmptyStatePanel` | `message`, `icon` / children | Inline hint / dashed panel. |
-| `ListRow` | `title`, `subtitle`, `leading` (IconName or node), `trailing` (`"chevron"` or node), `onPress`, `onLongPress`, `selected`, `destructive`, `disabled`, `titleLines` | 44px min touch row. |
-| `Separator` | `orientation`, `inset` | 1px `bg-border`. |
-| `Icon` | `name` (IconName), `size` (default 20), `color` (default foreground token), `strokeWidth` (1.75), `accessibilityLabel` | Same names/glyphs as shared-ui `ICON_MAP`; `isIconName()` guard. |
-| `Sheet` | `controller` (from `useSheet()`); `title`, `layout` view·scroll·custom, `snapPoints`, `enableDynamicSizing`, `maxDynamicContentSize`, `onDismiss`, `onOpenChange`, `deferContent` | @gorhom/bottom-sheet modal; children realized two frames after present, retained afterwards. `useSheet()` → `SheetController {present, dismiss}` (stable; call from handlers). Also `SheetScrollView`, `SheetFlatList`, `SheetTextInput`. |
-| `ActionSheet` | `controller`; `title`, `message`, `actions: {key,label,icon?,destructive?,disabled?,onPress}[]` | Long-press menus: `const menu = useSheet(); ; onLongPress={menu.present}`. |
-| `toast` / `Toaster` | `toast.success | error | info | warning | message(msg, {description, duration, action})`, `toast.dismiss(id)` | sonner-native, themed. |
+| Component | Props (beyond RN passthrough) | Notes |
+| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `Text` | `variant` body(15)·bodyLarge(17)·title(22/700)·heading(17/600)·headline(17/600)·label(15/500)·caption(13 muted)·footnote(13)·sectionLabel·chrome(11 muted)·largeTitle(34/700)·mono; `tone` default·foreground·muted·subtle·readback·primary·destructive·warning·success·inverse; `weight`; `mono`; `numeric` (tabular figures); `className` | Themed RN Text on the Apple ramp. `sectionLabel` is sentence-case footnote on iOS, the uppercase overline on Android. |
+| `Button` | `variant` default·secondary·outline·ghost·destructive·link; `size` sm·default·lg·icon; `icon` (IconName), `iconPosition`; `loading`; `pressed` (toggle); `tint` primary·destructive (iOS: the tinted/plain appearances in the destructive red, e.g. a Deny); `haptic` (`true`/light/medium/heavy/selection); `onPress`; string or node children; `className` | iOS maps the names onto system styles: default → filled primary capsule (44pt), destructive → filled red, outline/secondary → tinted (primary 15%), ghost/link → plain primary text; pressing dims to 60%. Android keeps the web shapes (36/40/48, icon 40×40) and ignores `tint`. |
+| `Badge` | `variant` default·secondary·destructive·outline | |
+| `Pill` | `variant` secondary·destructive·outline·emphasis; `size` default·sm | Truncates one line. |
+| `Input` | `invalid`, `mono`, `grouped` (cell fill inside a `GroupedSection`), all `TextInputProps` | iOS: 44pt filled field (`muted`), radius 10 continuous, no border (invalid = destructive hairline), clear button while editing, keyboard follows the theme mode. Android: h-10 bordered, `focus:border-ring`. `useInputFieldProps()` exposes the shared appearance for other inputs. |
+| `TextArea` | `invalid`, `mono`, `grouped` | multiline, min-h 60, same appearance as `Input`. |
+| `Switch` | `checked`, `onCheckedChange`, `size` default·sm, `disabled` | iOS: untinted system switch on the default palette, `primary` on-track for other palettes; `size` ignored. Android: token-tinted, `sm` scales to 0.8. |
+| `Skeleton` | `className` (size it) | Reanimated pulse. |
+| `Spinner` | `size`, `color` | ActivityIndicator. |
+| `EmptyState` / `EmptyStatePanel` | `message`, `icon` / children | Inline hint / dashed panel. |
+| `ListRow` | `title`, `subtitle`, `leading` (IconName or node), `leadingTone` foreground·primary, `trailing` (`"chevron"` or node), `onPress`, `onLongPress`, `selected`, `destructive`, `disabled`, `titleLines` | 44pt row, 17pt title / 15pt muted subtitle, pressed = `state-active`. `selected` renders a tinted check mark (no fill) when `trailing` is omitted. Also exports `DisclosureChevron` and `SelectedCheck`. |
+| `GroupedSection` | `title`, `footer` (string or node), `action`, `separatorInset` (`"text"` or px), `description`, `testID`, children | iOS inset-grouped card (`bg-surface-grouped-cell`, radius 10 continuous) with a footnote header/footer and hairline separators between direct children, inset past each row's `leading`/`badge` to its text column. Host screens use `bg-surface-grouped`. |
+| `GroupedRow` | `title`, `subtitle`, `value`, `leading` / `leadingTone`, `badge: {icon, color}`, `trailing` `"chevron"`·`"checkmark"`·node, `onPress`, `onLongPress`, `destructive`, `disabled`, `selectable`, `titleLines`, `testID`, `accessibilityLabel`/`Hint` | One grouped cell: 44pt min, 17pt label, 17pt muted value on the right, SF chevron 14pt semibold. Put a `Switch` in `trailing` for toggle rows. |
+| `IconBadge` | `icon`, `color` (token string), `size` (29), `glyphColor` (white) | Tinted rounded square (radius 7 continuous) with a white glyph — the iOS Settings row badge. |
+| `Separator` | `orientation`, `inset` (`true` = 16, or px) | Hairline in `border-hairline`. |
+| `GlassSurface` | `style` (shape + padding: `borderRadius`, `borderCurve`), `fallbackStyle` (fill + border without glass), `glassStyle` regular·clear, `tintColor` (token string), `interactive` (touch highlight, off by default), `layout` (Reanimated transition), `ViewProps` | A Liquid Glass surface on iOS 26+ (`expo-glass-effect` `GlassView`, children inside the effect, the radius shapes the glass; the view itself is the animated node so a `layout` transition grows the glass with the card) and the plain `style` + `fallbackStyle` view everywhere else — the composer card. Pair with `useLiquidGlass()` in the host to float the surface over scrolling content. |
+| `Icon` | `name` (IconName), `size` (default 20), `color` (default foreground token), `strokeWidth` (Hugeicons), `weight` (SF Symbols), `symbol` (iOS: an exact SF Symbol, e.g. a `.fill` variant, instead of the mapped one), `effect` (iOS 17+: expo-image `sfEffect`, e.g. `{ effect: "pulse", repeat: -1 }`), `accessibilityLabel` | Same names as shared-ui `ICON_MAP`; iOS renders the mapped SF Symbol (`sf-symbol-map.ts`, `sfSymbolFor(name)` for header/menu items), falling back to Hugeicons for brand marks; `isIconName()` guard. `symbol`/`effect` are ignored by the Hugeicons renderer, so `name` stays the Android glyph. |
+| `Sheet` | `controller` (from `useSheet()`); `title`, `layout` view·scroll·custom, `surface` raised·grouped, `snapPoints`, `enableDynamicSizing`, `maxDynamicContentSize`, `onDismiss`, `onOpenChange`, `deferContent`, `stackBehavior` | @gorhom/bottom-sheet modal in the UIKit sheet look (top radius 38 on iOS / 12 on Android, continuous, no outline, 36×5 grabber at 30% foreground, `surface-raised-solid` background or the grouped page color); children realized two frames after present, retained afterwards. `useSheet()` → `SheetController {present, dismiss}` (stable; call from handlers). Also `SheetScrollView`, `SheetFlatList`, `SheetTextInput`. |
+| `ActionSheet` | `controller`; `title`, `message`, `actions: {key,label,subtitle?,icon?,destructive?,disabled?,checked?,onPress}[]` | The long-list / Android fallback menu: a grouped card of 17pt rows (SF glyphs, destructive in red, warning haptic, `checked` = tinted check mark) and a separate tinted Cancel card. testIDs `action-sheet-` / `action-sheet-cancel`. `const menu = useSheet(); ; onLongPress={menu.present}`. |
+| `NativeMenu` | `title`, `actions: NativeMenuAction[]` (`ActionSheetAction` + `symbol` (iOS: exact SF Symbol), `items` (nested: a submenu, or an inline section with `inline`)), `accessibilityLabel`, `longPress`, `onOpen` (fallback only), `disabled`, `style`, `testID`, children = an icon-only trigger; `flattenNativeMenuActions()` | **Rule: wraps only an icon-only button with an explicit `accessibilityLabel` (+ `testID`).** The iOS menu host removes the wrapped RN subtree from the accessibility tree (verified with Maestro hierarchy dumps), so the host wrapper is the single element VoiceOver / Maestro see; anything showing text (rows, option pills, chips, value rows) is a `Pressable` + `ActionSheet` / `OptionSheet` on both platforms. iOS: `@expo/ui` `MenuView` — a pull-down menu on tap or a context menu on long-press, icons via `symbol ?? sfSymbolFor(icon)`, `checked` as a check mark, destructive in red (`subtitle` is dropped), `items` as submenus / inline sections; the items are native and accessible but carry no testID — Maestro taps them by label. Android: the wrapper is a Pressable presenting an `ActionSheet` with nested `items` flattened (`action-sheet-` ids). |
+| `confirmDestructive(options)` | `{ title, message?, actionLabel, cancelLabel?, onConfirm, onCancel? }` | Warning haptic + `Alert.alert` with Cancel and a destructive button; replaces one-red-row confirmation sheets on both platforms. |
+| `promptName(options)` | `{ title, message?, initialValue, submitLabel, onSubmit(name), onCancel? }` → `boolean` | Single-field name prompt. iOS (`name-prompt.ios.ts`) shows `Alert.prompt` and returns `true`; elsewhere returns `false` so the caller presents its own sheet form (`SheetNameForm`). |
+| `toast` / `Toaster` | `toast.success \| error \| info \| warning \| message \| loading(msg, {description, duration, action, id})`, `toast.dismiss(id)` | sonner-native, themed: SF status glyphs via `Icon`, raised surface, radius 14 continuous, hairline only in light mode, system font. |
Gallery: `app/dev/ui.tsx` renders everything (route `/dev/ui`).
diff --git a/apps/mobile/src/ui/Separator.tsx b/apps/mobile/src/ui/Separator.tsx
index bf119dfc90..91597de637 100644
--- a/apps/mobile/src/ui/Separator.tsx
+++ b/apps/mobile/src/ui/Separator.tsx
@@ -1,31 +1,41 @@
-import { View } from "react-native";
+import { StyleSheet, View } from "react-native";
+import { useTheme } from "@/theme/ThemeProvider";
import { cn } from "./cn";
+/** Left inset that lines a separator up with a row's text column (px-4). */
+export const SEPARATOR_INSET = 16;
+
export interface SeparatorProps {
orientation?: "horizontal" | "vertical";
- /** Left inset in px for list separators that align with row content. */
- inset?: number;
+ /**
+ * Left inset so list separators start at the row content: `true` = the
+ * row padding (16), a number = exact px (e.g. past a leading glyph).
+ */
+ inset?: number | boolean;
className?: string;
}
+/** One-pixel (hairline) rule in `border-hairline`. */
export function Separator({
orientation = "horizontal",
inset = 0,
className,
}: SeparatorProps) {
+ const { tokens } = useTheme();
+ const insetPx =
+ inset === true ? SEPARATOR_INSET : inset === false ? 0 : inset;
+ const horizontal = orientation === "horizontal";
return (
);
}
diff --git a/apps/mobile/src/ui/Sheet.tsx b/apps/mobile/src/ui/Sheet.tsx
index 52f47dc36e..5afebc79d5 100644
--- a/apps/mobile/src/ui/Sheet.tsx
+++ b/apps/mobile/src/ui/Sheet.tsx
@@ -21,11 +21,22 @@ import {
} from "react";
import { Keyboard, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { withAlpha } from "@/markdown/colors";
import { useTheme } from "@/theme/ThemeProvider";
import { scrimBaseColor } from "@/theme/scrim";
+import { cn } from "./cn";
import { Text } from "./Text";
import { useDeferredRealization } from "./useDeferredRealization";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
+/** Top corner radius: the UIKit sheet radius on iOS, the Material one elsewhere. */
+export const SHEET_CORNER_RADIUS = IS_IOS ? 38 : 12;
+/** Grabber metrics (UISheetPresentationController). */
+const GRABBER_WIDTH = 36;
+const GRABBER_HEIGHT = 5;
+const GRABBER_ALPHA = 0.3;
+
/** Imperative handle a mounted `` registers with its controller. */
export interface SheetHandle {
present: () => void;
@@ -71,6 +82,8 @@ export const SheetPresenceContext = createContext<{
onPresenceChange: (open: boolean) => void;
} | null>(null);
+export type SheetSurface = "raised" | "grouped";
+
export interface SheetProps extends Pick<
BottomSheetModalProps,
| "snapPoints"
@@ -83,7 +96,7 @@ export interface SheetProps extends Pick<
> {
controller: SheetController;
children: ReactNode;
- /** Optional header title (semibold, with a bottom hairline). */
+ /** Optional centered title row (headline). */
title?: string;
/**
* `view` (default) sizes to content; `scroll` puts children in a
@@ -91,6 +104,12 @@ export interface SheetProps extends Pick<
* BottomSheetFlatList/SectionList bodies.
*/
layout?: "view" | "scroll" | "custom";
+ /**
+ * `raised` (default): the lifted surface, rows sit directly on it.
+ * `grouped`: the grouped page color, for bodies made of inset cards
+ * (`ActionSheet`, `GroupedSection`) that need to stand out from it.
+ */
+ surface?: SheetSurface;
/** Called when the sheet finishes presenting/dismissing (index ≥ 0 = open). */
onOpenChange?: (open: boolean) => void;
/**
@@ -101,16 +120,18 @@ export interface SheetProps extends Pick<
}
/**
- * Bottom sheet built on @gorhom/bottom-sheet. Content is realized two frames
- * after presenting so the slide-in starts on an empty body, and retained
- * afterwards (the web persistent drawer contract). Requires ``
- * up the tree.
+ * Bottom sheet built on @gorhom/bottom-sheet, styled like a UIKit sheet:
+ * large continuous top corners, no outline, a translucent grabber, the
+ * raised surface color. Content is realized two frames after presenting so
+ * the slide-in starts on an empty body, and retained afterwards (the web
+ * persistent drawer contract). Requires `` up the tree.
*/
export function Sheet({
controller,
children,
title,
layout = "view",
+ surface = "raised",
snapPoints,
enableDynamicSizing,
maxDynamicContentSize,
@@ -122,7 +143,7 @@ export function Sheet({
deferContent = true,
}: SheetProps) {
const modalRef = useRef(null);
- const { tokens, radii, mode } = useTheme();
+ const { tokens, mode } = useTheme();
const scrimColor = scrimBaseColor(mode, tokens);
const insets = useSafeAreaInsets();
const [presented, setPresented] = useState(false);
@@ -167,27 +188,36 @@ export function Sheet({
);
const dynamic = enableDynamicSizing ?? snapPoints === undefined;
+ const surfaceColor =
+ surface === "grouped" ? tokens.surfaceGrouped : tokens.surfaceRaisedSolid;
const backgroundStyle = useMemo(
() => ({
- backgroundColor: tokens.popover,
- borderTopLeftRadius: radii.xl,
- borderTopRightRadius: radii.xl,
- borderWidth: 1,
- borderColor: tokens.border,
+ backgroundColor: surfaceColor,
+ borderTopLeftRadius: SHEET_CORNER_RADIUS,
+ borderTopRightRadius: SHEET_CORNER_RADIUS,
+ borderCurve: "continuous" as const,
}),
- [tokens, radii],
+ [surfaceColor],
);
const handleIndicatorStyle = useMemo(
- () => ({ backgroundColor: tokens.input, width: 36 }),
+ () => ({
+ backgroundColor: withAlpha(tokens.foreground, GRABBER_ALPHA),
+ width: GRABBER_WIDTH,
+ height: GRABBER_HEIGHT,
+ borderRadius: GRABBER_HEIGHT / 2,
+ }),
[tokens],
);
const header = title ? (
-
+
{title}
diff --git a/apps/mobile/src/ui/Switch.tsx b/apps/mobile/src/ui/Switch.tsx
index 1e3862b5bc..6f4a6f0ead 100644
--- a/apps/mobile/src/ui/Switch.tsx
+++ b/apps/mobile/src/ui/Switch.tsx
@@ -4,20 +4,27 @@ import {
} from "react-native";
import { useTheme } from "@/theme/ThemeProvider";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
export interface SwitchProps extends Omit<
RNSwitchProps,
"value" | "onValueChange" | "style"
> {
checked: boolean;
onCheckedChange?: (checked: boolean) => void;
- /** `sm` scales the native control down (web default is the small one). */
+ /**
+ * Accepted for call-site compatibility. iOS switches have one size, so it
+ * is ignored there; Android scales the control down for `sm`.
+ */
size?: "default" | "sm";
className?: string;
}
/**
- * Themed native switch: checked track = `foreground`, unchecked = `muted`,
- * thumb = `background` (packages/shared-ui switch.tsx colors).
+ * Native switch. iOS with the default palette is left untinted (the system
+ * green track); other palettes tint the on-track with `primary` so Nord /
+ * Dracula keep their accent. Android keeps the token-tinted Material look
+ * (checked track = `foreground`, unchecked = `muted`, thumb = `background`).
*/
export function Switch({
checked,
@@ -27,17 +34,25 @@ export function Switch({
className,
...props
}: SwitchProps) {
- const { tokens } = useTheme();
+ const { tokens, palette } = useTheme();
+ const colors = IS_IOS
+ ? palette === "default"
+ ? {}
+ : { trackColor: { true: tokens.primary } }
+ : {
+ trackColor: { false: tokens.muted, true: tokens.foreground },
+ thumbColor: tokens.background,
+ };
return (
);
diff --git a/apps/mobile/src/ui/Text.tsx b/apps/mobile/src/ui/Text.tsx
index df09a80e52..cfb0469fb3 100644
--- a/apps/mobile/src/ui/Text.tsx
+++ b/apps/mobile/src/ui/Text.tsx
@@ -1,32 +1,54 @@
import { cva, type VariantProps } from "class-variance-authority";
-import { Text as RNText, type TextProps as RNTextProps } from "react-native";
+import {
+ Text as RNText,
+ type TextProps as RNTextProps,
+ type TextStyle,
+} from "react-native";
import { resolveFont, type FontWeightName } from "@/theme/fonts";
import { cn } from "./cn";
+const IS_IOS = process.env.EXPO_OS === "ios";
+
/**
- * Typography roles. Sizes are the touch scale from theme.css (`--text-*`
- * under `pointer: coarse`): 2xs 11, xs 14, sm 15, base 16 (see global.css).
+ * Grouped-list section header. iOS writes it in sentence case, footnote
+ * size, secondary color (no tracking); Android keeps the Material-style
+ * uppercase overline. Both literals stay in source so Tailwind's scanner
+ * emits the classes for either platform.
+ */
+const SECTION_LABEL_CLASS = IS_IOS
+ ? "text-xs text-muted-foreground"
+ : "text-xs font-medium uppercase tracking-wide text-subtle-foreground/75";
+
+/**
+ * Typography roles on the Apple text-style ramp (`--text-*` in global.css):
+ * 2xs 11 caption2 · xs 13 footnote · sm 15 subheadline · base 17 body ·
+ * lg 20 title3 · xl 22 title2 · 2xl 28 title1 · 3xl 34 largeTitle.
*/
const textVariants = cva("font-sans text-foreground", {
variants: {
variant: {
- /** Default UI copy (web `text-sm`). */
+ /** Dense UI copy: rows in sheets, chrome, secondary lines (15). */
body: "text-sm",
- /** Composer/input copy and long-form reading (web `text-base`). */
+ /** Conversation prose, row titles, input copy (17). */
bodyLarge: "text-base",
- /** Screen and sheet titles. */
- title: "text-lg font-semibold",
- /** Card and section headings. */
+ /** Screen and sheet titles (22/700, title2). */
+ title: "text-xl font-bold",
+ /** Card and section headings (17/600, headline). */
heading: "text-base font-semibold",
- /** Form labels, row titles, button copy. */
+ /** Emphasized row title (17/600, headline). */
+ headline: "text-base font-semibold",
+ /** Form labels, pill copy, button copy (15/500). */
label: "text-sm font-medium",
- /** Secondary line under a title. */
+ /** Secondary line under a title (13, footnote, muted). */
caption: "text-xs text-muted-foreground",
- /** Chrome section labels (web `text-xs subtle-foreground/75`). */
- sectionLabel:
- "text-xs font-medium uppercase tracking-wide text-subtle-foreground/75",
- /** Count chips, ids, unread divider (web `text-2xs`). */
+ /** Footnote in the foreground color (grouped footers set their tone). */
+ footnote: "text-xs",
+ /** Grouped section header. */
+ sectionLabel: SECTION_LABEL_CLASS,
+ /** Count chips, ids, unread divider (11, caption2). */
chrome: "text-2xs text-muted-foreground",
+ /** In-body large title (34/700); navigation headers use the native Stack. */
+ largeTitle: "text-3xl font-bold",
/** Code, paths, ids. */
mono: "font-mono text-sm",
},
@@ -54,30 +76,42 @@ export type TextVariant = NonNullable<
>;
export type TextTone = NonNullable["tone"]>;
+const TABULAR_NUMS: TextStyle = { fontVariant: ["tabular-nums"] };
+
export interface TextProps
extends RNTextProps, VariantProps {
/** Overrides the weight implied by `variant`/`className`. */
weight?: FontWeightName;
- /** Forces Fira Code (or Inter when false) regardless of `className`. */
+ /** Forces the mono face (or sans when false) regardless of `className`. */
mono?: boolean;
+ /** Tabular figures for counters, timers, sizes, line numbers. */
+ numeric?: boolean;
className?: string;
}
/**
- * Themed text. Always sets `fontFamily` + `fontWeight` together (Expo Google
- * Fonts register one family per weight), deriving them from `weight`/`mono`
- * or from web-style `font-medium|semibold|bold` / `font-mono` classes.
+ * Themed text. Always sets `fontFamily` + `fontWeight` together (the system
+ * face on iOS, `sans-serif` on Android; see src/theme/fonts.ts), deriving
+ * them from `weight`/`mono` or from web-style `font-medium|semibold|bold` /
+ * `font-mono` classes.
*/
export function Text({
variant,
tone,
weight,
mono,
+ numeric = false,
className,
style,
...props
}: TextProps) {
const merged = cn(textVariants({ variant, tone }), className);
const font = resolveFont({ className: merged, weight, mono });
- return ;
+ return (
+
+ );
}
diff --git a/apps/mobile/src/ui/TextArea.tsx b/apps/mobile/src/ui/TextArea.tsx
index ef7e5a2e80..511ad02607 100644
--- a/apps/mobile/src/ui/TextArea.tsx
+++ b/apps/mobile/src/ui/TextArea.tsx
@@ -1,38 +1,32 @@
import { forwardRef } from "react";
import { TextInput, type TextInputProps } from "react-native";
-import { resolveFont } from "@/theme/fonts";
-import { useTheme } from "@/theme/ThemeProvider";
import { cn } from "./cn";
+import { useInputFieldProps, type InputFieldOptions } from "./Input";
-export interface TextAreaProps extends TextInputProps {
- invalid?: boolean;
- mono?: boolean;
- className?: string;
-}
+export interface TextAreaProps extends TextInputProps, InputFieldOptions {}
-/** Multi-line text field. Mirrors packages/shared-ui textarea.tsx. */
+/** Multi-line text field with the `Input` appearance (min height 60). */
export const TextArea = forwardRef(function TextArea(
- { invalid = false, editable = true, mono, className, style, ...props },
+ { invalid, editable = true, mono, grouped, className, style, ...props },
ref,
) {
- const { tokens } = useTheme();
- const font = resolveFont({ className, mono });
+ const field = useInputFieldProps({
+ invalid,
+ mono,
+ grouped,
+ editable,
+ className: cn("min-h-[60px] py-2.5", className),
+ });
return (
);
diff --git a/apps/mobile/src/ui/Toast.tsx b/apps/mobile/src/ui/Toast.tsx
index d0d4cb3b36..95f134a06c 100644
--- a/apps/mobile/src/ui/Toast.tsx
+++ b/apps/mobile/src/ui/Toast.tsx
@@ -1,5 +1,7 @@
import { useMemo } from "react";
+import { StyleSheet } from "react-native";
import { toast as sonnerToast, Toaster as SonnerToaster } from "sonner-native";
+import { resolveFont } from "@/theme/fonts";
import { useTheme } from "@/theme/ThemeProvider";
import { Icon } from "./Icon";
@@ -62,17 +64,33 @@ export const toast = {
dismiss: (id?: ToastId) => sonnerToast.dismiss(id),
};
-/** Themed sonner-native host. Place once, after the navigator. */
+const TOAST_RADIUS = 14;
+const ICON_SIZE = 20;
+
+/**
+ * Themed sonner-native host. Place once, after the navigator. Status glyphs
+ * are SF Symbols on iOS (through `Icon`), the card is the raised surface
+ * with continuous corners, a hairline in light mode only, and the system
+ * font.
+ */
export function Toaster() {
- const { tokens, mode, radii, fonts } = useTheme();
+ const { tokens, mode, radii } = useTheme();
const icons = useMemo(
() => ({
- success: ,
- error: ,
+ success: (
+
+ ),
+ error: (
+
+ ),
warning: (
-
+
),
- info: ,
+ info: ,
}),
[tokens],
);
@@ -86,28 +104,30 @@ export function Toaster() {
icons={icons}
toastOptions={{
style: {
- backgroundColor: tokens.popover,
- borderColor: tokens.border,
- borderWidth: 1,
- borderRadius: radii.lg,
+ backgroundColor: tokens.surfaceRaisedSolid,
+ borderRadius: TOAST_RADIUS,
+ borderCurve: "continuous",
+ borderWidth: mode === "dark" ? 0 : StyleSheet.hairlineWidth,
+ borderColor: tokens.borderHairline,
+ boxShadow: `0 6px 20px ${tokens.shadowColor}`,
},
titleStyle: {
+ ...resolveFont({ weight: "semibold" }),
color: tokens.foreground,
- fontFamily: fonts.sans.medium,
fontSize: 15,
},
descriptionStyle: {
+ ...resolveFont({}),
color: tokens.mutedForeground,
- fontFamily: fonts.sans.regular,
- fontSize: 14,
+ fontSize: 13,
},
actionButtonStyle: {
- backgroundColor: tokens.foreground,
- borderRadius: radii.md,
+ backgroundColor: tokens.primary,
+ borderRadius: radii.full,
},
actionButtonTextStyle: {
- color: tokens.background,
- fontFamily: fonts.sans.medium,
+ ...resolveFont({ weight: "semibold" }),
+ color: tokens.primaryForeground,
},
}}
/>
diff --git a/apps/mobile/src/ui/confirm.ts b/apps/mobile/src/ui/confirm.ts
new file mode 100644
index 0000000000..c8ed969865
--- /dev/null
+++ b/apps/mobile/src/ui/confirm.ts
@@ -0,0 +1,40 @@
+import { Alert } from "react-native";
+import { haptic } from "@/lib/haptics";
+
+export interface ConfirmDestructiveOptions {
+ title: string;
+ message?: string;
+ /** The red button ("Delete", "Remove machine"). */
+ actionLabel: string;
+ /** Defaults to "Cancel". */
+ cancelLabel?: string;
+ onConfirm: () => void;
+ /** Runs when the user cancels or dismisses the dialog. */
+ onCancel?: () => void;
+}
+
+/**
+ * System confirmation for a destructive action: a native alert with Cancel
+ * and a destructive button, preceded by the warning haptic. Replaces the
+ * "sheet with one red row" pattern; works identically on Android.
+ */
+export function confirmDestructive({
+ title,
+ message,
+ actionLabel,
+ cancelLabel = "Cancel",
+ onConfirm,
+ onCancel,
+}: ConfirmDestructiveOptions): void {
+ haptic("warning");
+ Alert.alert(
+ title,
+ message,
+ [
+ { text: cancelLabel, style: "cancel", onPress: onCancel },
+ { text: actionLabel, style: "destructive", onPress: onConfirm },
+ ],
+ // Android: the back button / outside tap dismisses like Cancel.
+ { cancelable: true, onDismiss: onCancel },
+ );
+}
diff --git a/apps/mobile/src/ui/glass-surface-types.ts b/apps/mobile/src/ui/glass-surface-types.ts
new file mode 100644
index 0000000000..7ea4a51a13
--- /dev/null
+++ b/apps/mobile/src/ui/glass-surface-types.ts
@@ -0,0 +1,33 @@
+import type { ComponentProps } from "react";
+import type { StyleProp, ViewProps, ViewStyle } from "react-native";
+import type Animated from "react-native-reanimated";
+
+/** A Reanimated layout transition (`LinearTransition`, …) for the surface. */
+export type GlassSurfaceLayout = ComponentProps["layout"];
+
+export interface GlassSurfaceProps extends ViewProps {
+ /**
+ * Applied in every mode: the shape (`borderRadius`, `borderCurve`) and the
+ * content padding. With Liquid Glass the radius shapes the glass itself.
+ * Keep margins on a wrapper: the glass must reach the surface's edges.
+ */
+ style?: StyleProp;
+ /**
+ * The fill and border the surface shows when Liquid Glass is unavailable
+ * (older iOS, Android, reduce-transparency fallbacks). Never applied to
+ * glass, which must stay transparent to refract the content under it.
+ */
+ fallbackStyle?: StyleProp;
+ /** `regular` (the default material) or `clear` (for media-heavy backdrops). */
+ glassStyle?: "regular" | "clear";
+ /** A tint mixed into the glass (a token string). */
+ tintColor?: string;
+ /**
+ * Whether the glass reacts to touches (the iOS 26 button highlight and
+ * scale). Off by default: a surface that hosts a text field should not
+ * wobble under caret taps.
+ */
+ interactive?: boolean;
+ /** Layout transition for size changes (a pill growing into a card). */
+ layout?: GlassSurfaceLayout;
+}
diff --git a/apps/mobile/src/ui/index.ts b/apps/mobile/src/ui/index.ts
index da976391cb..5d6c1e60fb 100644
--- a/apps/mobile/src/ui/index.ts
+++ b/apps/mobile/src/ui/index.ts
@@ -1,21 +1,71 @@
export { ActionSheet, type ActionSheetAction } from "./ActionSheet";
export { Badge } from "./Badge";
-export { Button } from "./Button";
+export {
+ Button,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+} from "./Button";
export { cn } from "./cn";
+export { confirmDestructive, type ConfirmDestructiveOptions } from "./confirm";
export { EmptyState, EmptyStatePanel } from "./EmptyState";
+export {
+ GlassSurface,
+ useLiquidGlass,
+ type GlassSurfaceLayout,
+ type GlassSurfaceProps,
+} from "./GlassSurface";
+export {
+ GROUPED_CARD_RADIUS,
+ GROUPED_ROW_PADDING_X,
+ GroupedRow,
+ GroupedSection,
+ ICON_BADGE_SIZE,
+ IconBadge,
+ type GroupedRowProps,
+ type GroupedSectionProps,
+ type GroupedSurface,
+ type IconBadgeProps,
+} from "./Grouped";
export { Icon, ICON_NAMES, isIconName, type IconName } from "./Icon";
-export { Input, type InputProps } from "./Input";
+export {
+ Input,
+ INPUT_RADIUS,
+ useInputFieldProps,
+ type InputFieldOptions,
+ type InputProps,
+} from "./Input";
+export {
+ flattenNativeMenuActions,
+ NativeMenu,
+ type NativeMenuAction,
+ type NativeMenuProps,
+} from "./NativeMenu";
+export {
+ sfSymbolFor,
+ type SFSymbol,
+ type SFSymbolWeight,
+} from "./sf-symbol-map";
export {
COMPOSER_KEYBOARD_GAP,
KeyboardPaddingView,
} from "./KeyboardPaddingView";
-export { ListRow } from "./ListRow";
+export {
+ DisclosureChevron,
+ ListRow,
+ LIST_ROW_CHEVRON_SIZE,
+ LIST_ROW_ICON_SIZE,
+ SelectedCheck,
+ type ListRowProps,
+} from "./ListRow";
export { LONG_PRESS_DELAY_MS } from "./long-press";
+export { promptName, type NamePromptOptions } from "./name-prompt";
export { OverlayBounds, useOverlayBounds } from "./OverlayBounds";
export { Pill } from "./Pill";
-export { Separator } from "./Separator";
+export { Separator, SEPARATOR_INSET, type SeparatorProps } from "./Separator";
export {
Sheet,
+ SHEET_CORNER_RADIUS,
SheetFlatList,
SheetPresenceContext,
SheetProvider,
@@ -24,12 +74,14 @@ export {
useSheet,
type SheetController,
type SheetHandle,
+ type SheetProps,
+ type SheetSurface,
} from "./Sheet";
export { ShimmerIcon } from "./ShimmerIcon";
export { ShimmerText } from "./ShimmerText";
export { Skeleton } from "./Skeleton";
export { Spinner } from "./Spinner";
-export { Switch } from "./Switch";
-export { Text } from "./Text";
-export { TextArea } from "./TextArea";
+export { Switch, type SwitchProps } from "./Switch";
+export { Text, type TextProps, type TextTone, type TextVariant } from "./Text";
+export { TextArea, type TextAreaProps } from "./TextArea";
export { toast, Toaster } from "./Toast";
diff --git a/apps/mobile/src/ui/name-prompt-types.ts b/apps/mobile/src/ui/name-prompt-types.ts
new file mode 100644
index 0000000000..977acbc6b5
--- /dev/null
+++ b/apps/mobile/src/ui/name-prompt-types.ts
@@ -0,0 +1,14 @@
+// Shared by name-prompt.ts (Android / default) and name-prompt.ios.ts; a separate
+// module because "./name-prompt" resolves to the .ios sibling on iOS.
+
+export interface NamePromptOptions {
+ title: string;
+ message?: string;
+ initialValue: string;
+ /** The confirming button ("Rename", "Create"). */
+ submitLabel: string;
+ /** Receives the trimmed, non-empty name. */
+ onSubmit: (name: string) => void;
+ /** Runs when the prompt is cancelled or submitted empty. */
+ onCancel?: () => void;
+}
diff --git a/apps/mobile/src/ui/name-prompt.ios.ts b/apps/mobile/src/ui/name-prompt.ios.ts
new file mode 100644
index 0000000000..3ae2067962
--- /dev/null
+++ b/apps/mobile/src/ui/name-prompt.ios.ts
@@ -0,0 +1,37 @@
+import { Alert } from "react-native";
+import type { NamePromptOptions } from "./name-prompt-types";
+
+/**
+ * iOS: the system alert with a text field (Cancel + the submit button), the
+ * native home of "rename this". Metro picks this file on iOS;
+ * `name-prompt.ts` is the fallback that asks the caller for a sheet form.
+ */
+export function promptName({
+ title,
+ message,
+ initialValue,
+ submitLabel,
+ onSubmit,
+ onCancel,
+}: NamePromptOptions): boolean {
+ Alert.prompt(
+ title,
+ message,
+ [
+ { text: "Cancel", style: "cancel", onPress: () => onCancel?.() },
+ {
+ text: submitLabel,
+ onPress: (value?: string) => {
+ const name = value?.trim();
+ if (name) onSubmit(name);
+ else onCancel?.();
+ },
+ },
+ ],
+ "plain-text",
+ initialValue,
+ );
+ return true;
+}
+
+export type { NamePromptOptions } from "./name-prompt-types";
diff --git a/apps/mobile/src/ui/name-prompt.ts b/apps/mobile/src/ui/name-prompt.ts
new file mode 100644
index 0000000000..fc08b91c87
--- /dev/null
+++ b/apps/mobile/src/ui/name-prompt.ts
@@ -0,0 +1,13 @@
+import type { NamePromptOptions } from "./name-prompt-types";
+
+/**
+ * Single-field name prompt (rename a thread, new section). iOS shows the
+ * system alert with a text field (`name-prompt.ios.ts`); other platforms
+ * have no native equivalent, so this returns `false` and the caller
+ * presents its sheet form instead.
+ */
+export function promptName(_options: NamePromptOptions): boolean {
+ return false;
+}
+
+export type { NamePromptOptions } from "./name-prompt-types";
diff --git a/apps/mobile/src/ui/native-menu-shared.ts b/apps/mobile/src/ui/native-menu-shared.ts
new file mode 100644
index 0000000000..52ebc8fca9
--- /dev/null
+++ b/apps/mobile/src/ui/native-menu-shared.ts
@@ -0,0 +1,77 @@
+import type { ReactNode } from "react";
+import type { StyleProp, ViewStyle } from "react-native";
+import type { ActionSheetAction } from "./ActionSheet";
+import type { SFSymbol } from "./sf-symbol-map";
+
+// Shared by NativeMenu.tsx (Android / default) and NativeMenu.ios.tsx. Lives in
+// its own module because Metro resolves "./NativeMenu" to the .ios sibling on
+// iOS, so the platform files must never import each other by basename.
+
+/**
+ * One menu item: the `ActionSheet` action shape (`key`, `label`, `icon?`,
+ * `destructive?`, `disabled?`, `checked?`, `subtitle?`, `onPress`), so
+ * existing action arrays pass straight through, plus the native-menu
+ * extras: an explicit SF Symbol (`.fill` variants the icon map does not
+ * carry) and nested `items` (a submenu, or an inline titled section with
+ * `inline`). The Android sheet ignores `symbol` and flattens `items`.
+ */
+export interface NativeMenuAction extends ActionSheetAction {
+ /** iOS only: overrides the symbol derived from `icon`. */
+ symbol?: SFSymbol;
+ /** Nested items: a submenu, or an inline section when `inline` is set. */
+ items?: readonly NativeMenuAction[];
+ /** Render `items` inline under this item's label as a section header. */
+ inline?: boolean;
+}
+
+/** The sheet has no submenus: every nested item becomes a top-level row. */
+export function flattenNativeMenuActions(
+ actions: readonly NativeMenuAction[],
+): ActionSheetAction[] {
+ const rows: ActionSheetAction[] = [];
+ for (const action of actions) {
+ if (action.items && action.items.length > 0) {
+ rows.push(...flattenNativeMenuActions(action.items));
+ continue;
+ }
+ rows.push(action);
+ }
+ return rows;
+}
+
+/**
+ * The rule: a native menu wraps only an icon-only button. On iOS the menu
+ * host removes the wrapped React Native subtree from the accessibility
+ * tree (verified with Maestro hierarchy dumps): VoiceOver and XCUITest see
+ * one element — the host — with the host's own label, and nothing inside
+ * it. Anything that shows text (list rows, option pills, chips, value
+ * rows) is a `Pressable` that presents an `ActionSheet` / `OptionSheet` on
+ * both platforms instead.
+ */
+export interface NativeMenuProps {
+ /** Menu heading (iOS draws it as the first section's title). */
+ title?: string;
+ actions: readonly NativeMenuAction[];
+ /**
+ * Fires as the menu opens on the fallback path only (haptics, lazy
+ * data). The native iOS menu exposes no open hook and adds its own
+ * haptic.
+ */
+ onOpen?: () => void;
+ /** Open on long-press (context menu) instead of tap. */
+ longPress?: boolean;
+ /** Renders the trigger inert. */
+ disabled?: boolean;
+ /** The trigger: a glyph in a sized `View`, never text. */
+ children: ReactNode;
+ /**
+ * Names the trigger for VoiceOver and Maestro — the host is the single
+ * accessible element; the glyph inside it is not one. Pass it on every
+ * menu (it falls back to `title` only so older call sites keep a name).
+ */
+ accessibilityLabel?: string;
+ /** The host wrapper's style. */
+ style?: StyleProp;
+ /** Lands on the host, the one element the accessibility tree keeps. */
+ testID?: string;
+}
diff --git a/apps/mobile/src/ui/platform-neutrality.test.ts b/apps/mobile/src/ui/platform-neutrality.test.ts
new file mode 100644
index 0000000000..ee89eb5592
--- /dev/null
+++ b/apps/mobile/src/ui/platform-neutrality.test.ts
@@ -0,0 +1,201 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+/*
+ * Android must not crash: every iOS-only API is either in a `*.ios.ts(x)`
+ * sibling under src/ (Metro picks it per platform; never under app/, where
+ * expo-router would register it as a route) or sits right after a platform
+ * guard. Mirrors the source-scanning style of icon-map.test.ts /
+ * sf-symbol-map.test.ts.
+ */
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const MOBILE_ROOT = join(HERE, "..", "..");
+const SRC_ROOT = join(MOBILE_ROOT, "src");
+const APP_ROOT = join(MOBILE_ROOT, "app");
+
+/** Files that exist to host the iOS-only API (the adapters themselves). */
+const ALLOWED_FILES = new Set([
+ "src/ui/Icon.ios.tsx",
+ "src/ui/sf-symbol-map.ts",
+ "src/ui/platform-neutrality.test.ts",
+]);
+
+/** iOS-only surfaces that render nothing or throw on Android. */
+const IOS_ONLY_PATTERNS: readonly { label: string; regex: RegExp }[] = [
+ { label: "@expo/ui/swift-ui import", regex: /@expo\/ui\/swift-ui/ },
+ { label: "Color.ios palette", regex: /\bColor\.ios\b/ },
+ { label: "Alert.prompt", regex: /\bAlert\.prompt\(/ },
+ { label: "sf: image source", regex: /["'`]sf:/ },
+];
+
+/**
+ * The Liquid Glass native view: its module is iOS-only, so the import may
+ * live only in a `*.ios.tsx` sibling (no guard window — a default-platform
+ * bundle must never resolve it).
+ */
+const GLASS_IMPORT_REGEX = /["']expo-glass-effect["']/;
+
+/** A platform check that makes the following lines iOS-only. */
+const GUARD_REGEX =
+ /Platform\.select\s*\(|Platform\.OS\s*[!=]==?\s*["']ios["']|process\.env\.EXPO_OS\s*[!=]==?\s*["']ios["']/;
+
+/** How far above an occurrence a guard still counts. */
+const GUARD_WINDOW_LINES = 40;
+
+function listSourceFiles(dir: string, out: string[]): string[] {
+ for (const entry of readdirSync(dir)) {
+ if (entry === "node_modules") continue;
+ const path = join(dir, entry);
+ if (statSync(path).isDirectory()) listSourceFiles(path, out);
+ else if (/\.tsx?$/.test(entry)) out.push(path);
+ }
+ return out;
+}
+
+function isIosSibling(relPath: string): boolean {
+ return /\.ios\.tsx?$/.test(relPath) && relPath.startsWith("src/");
+}
+
+function isCommentLine(line: string): boolean {
+ const trimmed = line.trimStart();
+ return (
+ trimmed.startsWith("//") ||
+ trimmed.startsWith("*") ||
+ trimmed.startsWith("/*")
+ );
+}
+
+interface Occurrence {
+ file: string;
+ line: number;
+ label: string;
+}
+
+/** Occurrences of the iOS-only patterns that are not protected. */
+function unguardedOccurrences(): Occurrence[] {
+ const problems: Occurrence[] = [];
+ const files = [
+ ...listSourceFiles(SRC_ROOT, []),
+ ...listSourceFiles(APP_ROOT, []),
+ ];
+ for (const file of files) {
+ const relPath = relative(MOBILE_ROOT, file);
+ if (ALLOWED_FILES.has(relPath) || isIosSibling(relPath)) continue;
+ const lines = readFileSync(file, "utf8").split("\n");
+ lines.forEach((line, index) => {
+ if (isCommentLine(line)) return;
+ for (const pattern of IOS_ONLY_PATTERNS) {
+ if (!pattern.regex.test(line)) continue;
+ const windowStart = Math.max(0, index - GUARD_WINDOW_LINES);
+ const guarded = lines
+ .slice(windowStart, index + 1)
+ .some((candidate) => GUARD_REGEX.test(candidate));
+ if (!guarded) {
+ problems.push({
+ file: relPath,
+ line: index + 1,
+ label: pattern.label,
+ });
+ }
+ }
+ });
+ }
+ return problems;
+}
+
+describe("platform neutrality", () => {
+ it("keeps iOS-only APIs in *.ios.tsx siblings under src/ or behind a platform guard", () => {
+ const problems = unguardedOccurrences().map(
+ ({ file, line, label }) => `${file}:${line} ${label}`,
+ );
+ expect(problems).toEqual([]);
+ });
+
+ it("imports expo-glass-effect only from *.ios.tsx siblings under src/", () => {
+ const offenders: string[] = [];
+ const files = [
+ ...listSourceFiles(SRC_ROOT, []),
+ ...listSourceFiles(APP_ROOT, []),
+ ];
+ for (const file of files) {
+ const relPath = relative(MOBILE_ROOT, file);
+ if (ALLOWED_FILES.has(relPath) || isIosSibling(relPath)) continue;
+ readFileSync(file, "utf8")
+ .split("\n")
+ .forEach((line, index) => {
+ if (isCommentLine(line)) return;
+ if (GLASS_IMPORT_REGEX.test(line)) {
+ offenders.push(`${relPath}:${index + 1}`);
+ }
+ });
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ it("never puts platform siblings under app/ (expo-router would route them)", () => {
+ const siblings = listSourceFiles(APP_ROOT, [])
+ .map((file) => relative(MOBILE_ROOT, file))
+ .filter((relPath) => /\.(ios|android|native|web)\.tsx?$/.test(relPath));
+ expect(siblings).toEqual([]);
+ });
+
+ it("every *.ios.tsx sibling has a default twin so the Android bundle resolves", () => {
+ const missing = listSourceFiles(SRC_ROOT, [])
+ .filter((file) => /\.ios\.tsx?$/.test(file))
+ .filter((file) => {
+ const twin = file.replace(/\.ios\.(tsx?)$/, ".$1");
+ try {
+ return !statSync(twin).isFile();
+ } catch {
+ return true;
+ }
+ })
+ .map((file) => relative(MOBILE_ROOT, file));
+ expect(missing).toEqual([]);
+ });
+
+ it("no *.ios sibling imports its own basename (Metro resolves it to itself)", () => {
+ // `import … from "./X"` inside X.ios.tsx resolves to X.ios.tsx on iOS, so a
+ // value import/re-export recurses at module init ("Maximum call stack size
+ // exceeded" on every route). Shared contracts live in a non-platform module.
+ const offenders: string[] = [];
+ for (const file of listSourceFiles(SRC_ROOT, [])) {
+ const match = /([^/]+)\.ios\.tsx?$/.exec(file);
+ if (!match) continue;
+ const base = match[1];
+ const source = readFileSync(file, "utf8");
+ const selfImport = new RegExp(
+ `from\\s+["']\\./${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']|require\\(["']\\./${base}["']\\)`,
+ );
+ source.split("\n").forEach((line, index) => {
+ if (selfImport.test(line)) {
+ offenders.push(`${relative(MOBILE_ROOT, file)}:${index + 1}`);
+ }
+ });
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ it("the scan sees the iOS adapters it exempts", () => {
+ // A scan that silently stopped finding files would pass vacuously.
+ const files = listSourceFiles(SRC_ROOT, []).map((file) =>
+ relative(MOBILE_ROOT, file),
+ );
+ expect(files).toContain("src/ui/Icon.ios.tsx");
+ expect(files).toContain("src/ui/NativeMenu.ios.tsx");
+ expect(files).toContain("src/ui/GlassSurface.ios.tsx");
+ const iconSource = readFileSync(
+ join(SRC_ROOT, "ui", "Icon.ios.tsx"),
+ "utf8",
+ );
+ expect(IOS_ONLY_PATTERNS.some((p) => p.regex.test(iconSource))).toBe(true);
+ const glassSource = readFileSync(
+ join(SRC_ROOT, "ui", "GlassSurface.ios.tsx"),
+ "utf8",
+ );
+ expect(GLASS_IMPORT_REGEX.test(glassSource)).toBe(true);
+ });
+});
diff --git a/apps/mobile/src/ui/sf-symbol-map.test.ts b/apps/mobile/src/ui/sf-symbol-map.test.ts
new file mode 100644
index 0000000000..01ab8593df
--- /dev/null
+++ b/apps/mobile/src/ui/sf-symbol-map.test.ts
@@ -0,0 +1,188 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { createRequire } from "node:module";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+import { ICON_NAMES, isIconName, type IconName } from "./icon-map";
+import {
+ SF_SYMBOL_MAP,
+ SF_SYMBOL_WEIGHT,
+ SF_SYMBOL_WEIGHTS,
+ sfSymbolFor,
+} from "./sf-symbol-map";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const SCAN_ROOTS = [join(HERE, ".."), join(HERE, "..", "..", "app")];
+const SELF_FILES = new Set([
+ "icon-map.ts",
+ "icon-map.test.ts",
+ "sf-symbol-map.ts",
+ "sf-symbol-map.test.ts",
+]);
+
+/** Brand marks have no SF Symbol; `Icon.ios.tsx` keeps Hugeicons for them. */
+const BRAND_MARKS: readonly IconName[] = ["Discord", "Github"];
+
+/**
+ * The app's iOS deployment target is 16.4 (`ios/Podfile`), which ships SF
+ * Symbols 4.2. A newer symbol renders as nothing on older devices
+ * (`UIImage(systemName:)` returns nil), so every mapping must exist by then.
+ */
+const MAX_SF_SYMBOLS_VERSION = "4.2";
+
+function listSourceFiles(dir: string, out: string[]): string[] {
+ for (const entry of readdirSync(dir)) {
+ if (entry === "node_modules") continue;
+ const path = join(dir, entry);
+ if (statSync(path).isDirectory()) listSourceFiles(path, out);
+ else if (/\.tsx?$/.test(entry) && !SELF_FILES.has(entry)) out.push(path);
+ }
+ return out;
+}
+
+/**
+ * Every icon name referenced under src/ and app/: JSX `name="X"` /
+ * `icon="X"` / `leading="X"` props, `icon: "X"` object fields (models,
+ * action lists), and — in files that work with the `IconName` type — any
+ * PascalCase string literal that is an icon name, which catches the names
+ * returned from switch/ternary helpers.
+ */
+function usedIconNames(): Map {
+ const used = new Map();
+ const record = (candidate: string, location: string) => {
+ if (!isIconName(candidate)) return;
+ const locations = used.get(candidate) ?? [];
+ locations.push(location);
+ used.set(candidate, locations);
+ };
+ for (const file of SCAN_ROOTS.flatMap((root) => listSourceFiles(root, []))) {
+ const source = readFileSync(file, "utf8");
+ const typed = source.includes("IconName");
+ source.split("\n").forEach((line, index) => {
+ const location = `${file}:${index + 1}`;
+ for (const match of line.matchAll(
+ /\b(?:name|icon|leading|trailing|glyph|leadingIcon|trailingIcon)=\{?"([A-Z][A-Za-z0-9]*)"/g,
+ )) {
+ record(match[1], location);
+ }
+ for (const match of line.matchAll(
+ /\b(?:icon|leading|glyph|leadingIcon|trailingIcon|statusIcon|iconName)\??:\s*"([A-Z][A-Za-z0-9]*)"/g,
+ )) {
+ record(match[1], location);
+ }
+ if (!typed) return;
+ for (const match of line.matchAll(/"([A-Z][A-Za-z0-9]*)"/g)) {
+ record(match[1], location);
+ }
+ });
+ }
+ return used;
+}
+
+/** Symbol name → the SF Symbols release that introduced it, from the catalog. */
+function sfSymbolCatalog(): Map {
+ const require = createRequire(import.meta.url);
+ const packageJson = require.resolve("sf-symbols-typescript/package.json");
+ const source = readFileSync(
+ join(dirname(packageJson), "dist", "index.d.ts"),
+ "utf8",
+ );
+ const catalog = new Map();
+ let version: string | null = null;
+ for (const line of source.split("\n")) {
+ const block = line.match(/^export type SFSymbols(\d+)_(\d+) =/);
+ if (block) {
+ version = `${block[1]}.${block[2]}`;
+ continue;
+ }
+ const entry = line.match(/^\s*\|\s*'([^']+)'/);
+ if (entry && version && !catalog.has(entry[1])) {
+ catalog.set(entry[1], version);
+ }
+ }
+ return catalog;
+}
+
+function versionTuple(version: string): [number, number] {
+ const [major = "0", minor = "0"] = version.split(".");
+ return [Number(major), Number(minor)];
+}
+
+function isAtMost(version: string, limit: string): boolean {
+ const [major, minor] = versionTuple(version);
+ const [limitMajor, limitMinor] = versionTuple(limit);
+ return major < limitMajor || (major === limitMajor && minor <= limitMinor);
+}
+
+describe("SF_SYMBOL_MAP", () => {
+ it("maps every icon name except the brand marks", () => {
+ const unmapped = ICON_NAMES.filter(
+ (name) => sfSymbolFor(name) === undefined,
+ );
+ expect(unmapped.sort()).toEqual([...BRAND_MARKS].sort());
+ for (const key of Object.keys(SF_SYMBOL_MAP)) {
+ expect(isIconName(key), key).toBe(true);
+ }
+ });
+
+ it("covers every icon name the app renders", () => {
+ const used = usedIconNames();
+ // A scan that stops finding names would pass vacuously; pin the floor.
+ expect(used.size).toBeGreaterThan(80);
+ const missing = [...used]
+ .filter(
+ ([name]) =>
+ sfSymbolFor(name) === undefined && !BRAND_MARKS.includes(name),
+ )
+ .map(([name, locations]) => `${name} (${locations[0]})`);
+ expect(missing).toEqual([]);
+ // The brand marks are really rendered somewhere; otherwise the allowlist
+ // is stale.
+ for (const name of BRAND_MARKS) {
+ expect(used.has(name), name).toBe(true);
+ }
+ });
+
+ it("uses bare symbol names that exist by the deployment target's SF Symbols release", () => {
+ const catalog = sfSymbolCatalog();
+ expect(catalog.size).toBeGreaterThan(4000);
+ const problems: string[] = [];
+ for (const [name, symbol] of Object.entries(SF_SYMBOL_MAP)) {
+ if (!/^[a-z0-9]+(\.[a-z0-9]+)*$/.test(symbol)) {
+ problems.push(`${name}: "${symbol}" is not a bare symbol name`);
+ continue;
+ }
+ const since = catalog.get(symbol);
+ if (since === undefined) {
+ problems.push(`${name}: "${symbol}" is not in the SF Symbols catalog`);
+ } else if (!isAtMost(since, MAX_SF_SYMBOLS_VERSION)) {
+ problems.push(
+ `${name}: "${symbol}" needs SF Symbols ${since} (max ${MAX_SF_SYMBOLS_VERSION})`,
+ );
+ }
+ }
+ expect(problems).toEqual([]);
+ });
+
+ it("sfSymbolFor returns the mapped symbol and nothing for brand marks", () => {
+ expect(sfSymbolFor("Plus")).toBe("plus");
+ expect(sfSymbolFor("Trash2")).toBe("trash");
+ expect(sfSymbolFor("Github")).toBeUndefined();
+ expect(sfSymbolFor("Discord")).toBeUndefined();
+ });
+
+ it("symbol weights are the numeric fontWeight strings expo-image parses", () => {
+ expect(Object.values(SF_SYMBOL_WEIGHTS)).toEqual([
+ "100",
+ "200",
+ "300",
+ "400",
+ "500",
+ "600",
+ "700",
+ "800",
+ "900",
+ ]);
+ expect(SF_SYMBOL_WEIGHTS[SF_SYMBOL_WEIGHT]).toBe("500");
+ });
+});
diff --git a/apps/mobile/src/ui/sf-symbol-map.ts b/apps/mobile/src/ui/sf-symbol-map.ts
new file mode 100644
index 0000000000..785e7be01f
--- /dev/null
+++ b/apps/mobile/src/ui/sf-symbol-map.ts
@@ -0,0 +1,186 @@
+/**
+ * `IconName` → SF Symbol. `Icon.ios.tsx` renders a mapped name through
+ * expo-image's `sf:` source and falls back to the Hugeicons glyph for
+ * unmapped names; header/menu/tab items (`Stack.Toolbar.*`, `Link.MenuAction`,
+ * `MenuView`) take a symbol name directly, so they reuse the same table via
+ * `sfSymbolFor`. Pure data: no React Native imports, so it is testable under
+ * node (`sf-symbol-map.test.ts`).
+ *
+ * Every name maps to an outline symbol at most as new as SF Symbols 4
+ * (iOS 16, the app's deployment target); the test guards both. Brand marks
+ * (Discord, Github) have no SF Symbol and stay on Hugeicons.
+ */
+import type { SFSymbol } from "sf-symbols-typescript";
+import type { IconName } from "./icon-map";
+
+export type { SFSymbol };
+
+/** Symbol weight names → the `fontWeight` values expo-image understands. */
+export const SF_SYMBOL_WEIGHTS = {
+ ultralight: "100",
+ thin: "200",
+ light: "300",
+ regular: "400",
+ medium: "500",
+ semibold: "600",
+ bold: "700",
+ heavy: "800",
+ black: "900",
+} as const;
+
+export type SFSymbolWeight = keyof typeof SF_SYMBOL_WEIGHTS;
+
+/** Default symbol weight: optically closest to the 1.75 Hugeicons stroke. */
+export const SF_SYMBOL_WEIGHT: SFSymbolWeight = "medium";
+
+export const SF_SYMBOL_MAP = {
+ AiContentGenerator01: "sparkles",
+ AlertCircle: "exclamationmark.circle",
+ AlertTriangle: "exclamationmark.triangle",
+ AlignLeft: "text.alignleft",
+ AppWindow: "macwindow",
+ Archive: "archivebox",
+ ArchiveRestore: "tray.and.arrow.up",
+ ArrowDown: "arrow.down",
+ ArrowRight: "arrow.right",
+ ArrowReloadHorizontal: "arrow.triangle.2.circlepath",
+ ArrowUp: "arrow.up",
+ ArrowUpDown: "arrow.up.arrow.down",
+ ArrowTurnBackward: "arrow.uturn.backward",
+ ArrowTurnForward: "arrow.uturn.forward",
+ ArrowUpRight: "arrow.up.right",
+ Beaker: "testtube.2",
+ Bot: "cpu",
+ Browser: "safari",
+ Brain: "brain",
+ Bug: "ladybug",
+ Calendar: "calendar",
+ CalendarCheckOut02: "calendar.badge.minus",
+ ChartColumn: "chart.bar",
+ Check: "checkmark",
+ ChevronDown: "chevron.down",
+ ChevronLeft: "chevron.left",
+ ChevronRight: "chevron.right",
+ ChevronUp: "chevron.up",
+ // Diff header "expand all / collapse all" toggle.
+ ChevronsDown: "rectangle.expand.vertical",
+ ChevronsUp: "rectangle.compress.vertical",
+ Circle: "circle",
+ CircleArrowShrink: "arrow.down.right.and.arrow.up.left.circle",
+ CircleCheck: "checkmark.circle",
+ CircleQuestion: "questionmark.circle",
+ CircleX: "xmark.circle",
+ Clean: "paintbrush",
+ Clock: "clock",
+ Cloud: "cloud",
+ CloudOff: "icloud.slash",
+ Coffee: "cup.and.saucer",
+ Code: "chevron.left.forwardslash.chevron.right",
+ ComputerTerminal01: "terminal",
+ Columns2: "rectangle.split.2x1",
+ Copy: "doc.on.doc",
+ CornerDownLeft: "arrow.turn.down.left",
+ CornerDownRight: "arrow.turn.down.right",
+ DateTime: "calendar.badge.clock",
+ DragDropHorizontal: "ellipsis",
+ DragDropVertical: "line.3.horizontal",
+ Download: "arrow.down.circle",
+ Edit: "pencil",
+ EditFile: "square.and.pencil",
+ ElectricPlugs: "powerplug",
+ Eye: "eye",
+ EyeOff: "eye.slash",
+ Explore: "book",
+ ExternalLink: "arrow.up.right.square",
+ FileDiff: "plus.forwardslash.minus",
+ File: "doc",
+ FileAttachment: "doc",
+ FileQuestion: "questionmark.square.dashed",
+ FileText: "doc.text",
+ Folder: "folder",
+ FolderEdit: "folder.badge.gearshape",
+ FolderExport: "square.and.arrow.up",
+ FolderGit: "folder.badge.gearshape",
+ FolderOpen: "folder",
+ FolderMinus: "folder.badge.minus",
+ FolderPlus: "folder.badge.plus",
+ Fork: "arrow.triangle.branch",
+ GitBranch: "arrow.triangle.branch",
+ GitMerge: "arrow.triangle.merge",
+ GitPullRequest: "arrow.triangle.pull",
+ GitPullRequestArrow: "arrow.triangle.pull",
+ GitPullRequestClosed: "xmark.circle",
+ GitPullRequestDraft: "circle.dashed",
+ Globe: "globe",
+ GridView: "square.grid.2x2",
+ Info: "info.circle",
+ Laptop: "laptopcomputer",
+ Layers: "square.3.layers.3d",
+ ListView: "list.bullet",
+ SectionAdd: "text.badge.plus",
+ ListTodo: "checklist",
+ Loading: "circle.dotted",
+ Lock: "lock",
+ Mail: "envelope",
+ MailOpen: "envelope.open",
+ Maximize2: "arrow.up.left.and.arrow.down.right",
+ MessageQuestion: "questionmark.bubble",
+ MessageCirclePlus: "plus.bubble",
+ MessageSquarePlus: "plus.bubble",
+ MessageSquare: "bubble.left",
+ Mic: "mic",
+ Minimize2: "arrow.down.right.and.arrow.up.left",
+ MoreHorizontal: "ellipsis",
+ NewTab: "plus.square.dashed",
+ PackageReceive: "shippingbox",
+ Palette: "paintpalette",
+ PanelBottom: "rectangle.bottomthird.inset.filled",
+ PanelLeft: "sidebar.left",
+ PanelRight: "sidebar.right",
+ Paperclip: "paperclip",
+ Pause: "pause",
+ Pin: "pin",
+ PinOff: "pin.slash",
+ Play: "play",
+ Plus: "plus",
+ Puzzle: "puzzlepiece.extension",
+ Repeat: "repeat",
+ // Almost every use is "refresh / retry", so clockwise (not counterclockwise).
+ RotateCcw: "arrow.clockwise",
+ Rows2: "rectangle.split.1x2",
+ Search: "magnifyingglass",
+ SecurityCheck: "checkmark.shield",
+ Sent: "paperplane",
+ Settings: "gearshape",
+ SideChat: "plus.bubble",
+ ClosePluginPane: "xmark",
+ CloseThreadPane: "xmark",
+ SlidersHorizontal: "slider.horizontal.3",
+ Smartphone: "iphone",
+ Sort: "arrow.up.arrow.down",
+ Spinner: "circle.dotted",
+ Square: "square",
+ SquareUnlock02: "lock.open",
+ Star: "star",
+ Target: "target",
+ Terminal: "terminal",
+ TextWrap: "text.word.spacing",
+ TimeSchedule: "clock.badge.checkmark",
+ Toolbox: "wrench.and.screwdriver",
+ ToolCase: "case",
+ Trash2: "trash",
+ UserRound: "person",
+ UserRoundPlus: "person.badge.plus",
+ Workflow: "point.3.connected.trianglepath.dotted",
+ X: "xmark",
+ Zap: "bolt",
+ ZoomIn: "plus.magnifyingglass",
+ ZoomOut: "minus.magnifyingglass",
+} as const satisfies Partial>;
+
+const SYMBOL_BY_NAME: Partial> = SF_SYMBOL_MAP;
+
+/** The SF Symbol for an icon name, or `undefined` for brand marks. */
+export function sfSymbolFor(name: IconName): SFSymbol | undefined {
+ return SYMBOL_BY_NAME[name];
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d78e123b59..e2625dbe64 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -815,12 +815,9 @@ importers:
'@bb/thread-view':
specifier: workspace:*
version: link:../../packages/thread-view
- '@expo-google-fonts/fira-code':
- specifier: ^0.4.1
- version: 0.4.1
- '@expo-google-fonts/inter':
- specifier: ^0.4.2
- version: 0.4.2
+ '@expo/ui':
+ specifier: 57.0.11
+ version: 57.0.11(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(expo@57.0.14)(react-dom@19.2.4(react@19.2.4))(react-native-worklets@0.10.1(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4)
'@gorhom/bottom-sheet':
specifier: ^5.2.14
version: 5.2.14(@types/react@19.2.13)(react-native-gesture-handler@2.32.0(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4)
@@ -881,8 +878,8 @@ importers:
expo-file-system:
specifier: ~57.0.4
version: 57.0.4(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))
- expo-font:
- specifier: ~57.0.1
+ expo-glass-effect:
+ specifier: 57.0.1
version: 57.0.1(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4)
expo-haptics:
specifier: ~57.0.1
@@ -1047,6 +1044,9 @@ importers:
postcss:
specifier: ^8.5.26
version: 8.5.26
+ sf-symbols-typescript:
+ specifier: ^2.2.0
+ version: 2.2.0
tailwindcss:
specifier: ^4.3.0
version: 4.3.0
@@ -3482,7 +3482,7 @@ importers:
version: typescript@7.0.2
vitest:
specifier: ^4.1.1
- version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
+ version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
plugins/provider-retry:
dependencies:
@@ -5446,12 +5446,6 @@ packages:
'@noble/hashes':
optional: true
- '@expo-google-fonts/fira-code@0.4.1':
- resolution: {integrity: sha512-b0fx07WCnZoM1epBJUfZQX36zs2W3ASfVneIsn1hNGc8xNHMBRDnI0vUdNJsb8ix0wkcilwJknyYhCoX5a3lMA==}
-
- '@expo-google-fonts/inter@0.4.2':
- resolution: {integrity: sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ==}
-
'@expo-google-fonts/material-symbols@0.4.44':
resolution: {integrity: sha512-36JP9Chcy/QEVZ9ZGY4i6zInlyFPbQjkIm6gRNuWWltScIj0WR8rddcD57EIjSC5YKCe2ZKLO6eO/N5r8Jit0A==}
@@ -5461,7 +5455,7 @@ packages:
'@expo/bunyan@4.0.1':
resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==}
- engines: {'0': node >=0.10.0}
+ engines: {node: '>=0.10.0'}
'@expo/cli@57.0.16':
resolution: {integrity: sha512-+HyMY2nAS6QBJb0nSeMz92p11bZ1AtWSRnhWnklznN07IRoQirisnKq2vr18Ayjb/fnb5F/um+n6FRhF0fZm1Q==}
@@ -17095,10 +17089,6 @@ snapshots:
optionalDependencies:
'@noble/hashes': 2.0.1
- '@expo-google-fonts/fira-code@0.4.1': {}
-
- '@expo-google-fonts/inter@0.4.2': {}
-
'@expo-google-fonts/material-symbols@0.4.44': {}
'@expo/apple-utils@2.1.22': {}
@@ -28724,6 +28714,35 @@ snapshots:
- tsx
- yaml
+ vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)):
+ dependencies:
+ '@vitest/expect': 4.1.1
+ '@vitest/mocker': 4.1.1(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
+ '@vitest/pretty-format': 4.1.1
+ '@vitest/runner': 4.1.1
+ '@vitest/snapshot': 4.1.1
+ '@vitest/spy': 4.1.1
+ '@vitest/utils': 4.1.1
+ es-module-lexer: 2.0.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.5
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.2
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.1
+ vite: 8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.0
+ '@types/node': 22.19.10
+ jsdom: 29.0.1(@noble/hashes@2.0.1)
+ transitivePeerDependencies:
+ - msw
+
vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.19.12)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.1