From 75da29a0b41d572d35f645a036656e09fdaf32ae Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 15:32:50 -0700 Subject: [PATCH 1/6] Redesign the mobile app iOS-first with native chrome, type, and controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Expo app was a web-parity port: Inter/Fira Code, Hugeicons, an opaque header in theme.css colors, bottom sheets for every menu, bordered web cards. This makes it read as a native iOS app while keeping Android on its existing paths and custom palettes working. Foundation - System fonts (SF Pro / Menlo, sans-serif / monospace on Android); the Google-font packages and the splash font gate are gone. Apple text ramp in the generated theme (11 · 13 · 15 · 17 · 20 · 22 · 28 · 34). - A mobile-only override layer in scripts/generate-native-theme.ts retunes the default palette to iOS values (label, separators, grouped surfaces, systemBlue tint, system reds/greens) and adds --surface-grouped(-cell). Tokens stay strings; palettes still tint through --canvas/--ink. - SF Symbols through the existing Icon on iOS (src/ui/Icon.ios.tsx + sf-symbol-map.ts); Hugeicons stay on Android and for brand marks. - @expo/ui 57.0.11 (exact; its pod was already linked via expo-router) for the native segmented control and icon-button menus. - Primitives: GroupedSection/GroupedRow/IconBadge, iOS buttons, fields, untinted system switch, UIKit-look sheets, confirmDestructive (Alert), promptName (Alert.prompt), ScreenTitle. Surfaces - Transparent headers with large titles on list screens (iOS 26 draws its own bar; older iOS gets the chrome material), Stack.Toolbar menus for the workspace switcher, display options, thread actions and settings overflow, Stack.SearchBar on Home / Plugins / Skills, modal presentation for New project and Connect (with their own sheet host). - Home: 17pt rows under contentInsetAdjustmentBehavior automatic, inset hairlines, SF status glyphs, sentence-case sections, Mail-style swipe actions, search results in place. - Thread: tinted continuous-corner user bubble, native bar buttons, iOS-style interaction banners and queue, iMessage-style composer with SF buttons and capsule option pills. - Settings, machines, plugins, skills, projects, connect as inset-grouped screens with icon badges and grouped forms; panel / files / terminal restyled (pill tab bar, iOS search field, keyboard-accessory key bar). Verified on device: expo-router Link.Menu/Link.Preview and @expo/ui MenuView remove the wrapped RN subtree from the iOS accessibility tree, so text-bearing rows and pills use Pressable + the restyled sheets; native menus remain only on labeled icon-only buttons and header items (src/ui/README.md documents the rule). New guard tests pin the SF Symbol map, platform neutrality (iOS-only APIs in *.ios.tsx or behind guards; siblings never import their own basename) and the font tables. Maestro flows updated for the native header items. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QvMzqL3FUxzxaMxVNjciUc --- apps/mobile/app.json | 14 +- apps/mobile/app/_layout.tsx | 9 +- apps/mobile/app/dev/ui.tsx | 335 +++++++++-- apps/mobile/assets/splash-icon-dark.png | Bin 0 -> 91813 bytes apps/mobile/e2e/flows/phase1-shell.yaml | 23 +- apps/mobile/e2e/flows/phase3-compose.yaml | 9 +- apps/mobile/e2e/flows/phase3-threads.yaml | 45 +- apps/mobile/e2e/flows/phase4b-actions.yaml | 37 +- apps/mobile/e2e/flows/phase4b-composer.yaml | 19 +- .../e2e/flows/phase4b-thread-actions.yaml | 51 +- apps/mobile/e2e/flows/phase6-diff.yaml | 13 +- apps/mobile/e2e/flows/phase6-files.yaml | 19 +- apps/mobile/e2e/flows/phase6-panel.yaml | 10 +- .../e2e/flows/phase6-terminal-resume.yaml | 4 +- apps/mobile/e2e/flows/phase6-terminal.yaml | 36 +- apps/mobile/e2e/flows/phase7-plugins.yaml | 32 +- apps/mobile/e2e/flows/phase7-settings.yaml | 50 +- .../e2e/scripts/phase7-settings-assert.js | 8 +- .../e2e/scripts/phase7-settings-reset.js | 5 +- apps/mobile/e2e/subflows/open-settings.yaml | 12 +- apps/mobile/global.css | 60 +- apps/mobile/package.json | 5 +- apps/mobile/scripts/generate-native-theme.ts | 200 +++++-- apps/mobile/src/ansi/TerminalOutputBlock.tsx | 12 +- apps/mobile/src/composer/AttachmentChips.tsx | 103 ++-- apps/mobile/src/composer/Composer.tsx | 439 ++++++++++---- apps/mobile/src/composer/TypeaheadMenu.tsx | 87 ++- apps/mobile/src/composer/VoiceBar.tsx | 111 +++- apps/mobile/src/diff/DiffFileCard.tsx | 8 +- apps/mobile/src/diff/DiffHunkView.tsx | 1 + apps/mobile/src/diff/FileChangeDiffBlock.tsx | 9 + apps/mobile/src/markdown/CodeBlock.tsx | 5 +- apps/mobile/src/markdown/MarkdownImage.tsx | 3 +- apps/mobile/src/markdown/MarkdownTable.tsx | 7 +- apps/mobile/src/markdown/render-inline.tsx | 2 +- .../src/screens/compose/ComposeDock.tsx | 3 +- .../screens/connect/AccountServersList.tsx | 121 ++-- .../screens/connect/ConnectEnrollScreen.tsx | 498 +++++++++------- .../src/screens/connect/ConnectScanner.tsx | 18 +- .../src/screens/diff-tab/DiffTabContent.tsx | 98 ++-- .../src/screens/diff-tab/DiffTabFileCard.tsx | 4 +- .../src/screens/diff-tab/DiffTabHeader.tsx | 103 +++- .../diff-tab/DiffTargetPickerSheet.tsx | 26 +- .../extensions/RegistrySkillDetailScreen.tsx | 149 ++--- .../extensions/RegistrySkillsScreen.tsx | 189 +++--- .../screens/extensions/SkillDetailScreen.tsx | 222 ++++--- .../extensions/SkillsLibraryScreen.tsx | 241 +++++--- apps/mobile/src/screens/files/FilePathRow.tsx | 47 +- .../src/screens/files/FilePreviewScreen.tsx | 47 +- .../src/screens/files/FilePreviewStates.tsx | 22 +- .../src/screens/files/FilePreviewView.tsx | 423 +++++++++----- .../src/screens/files/FilesTabContent.tsx | 401 +++++++++---- .../screens/files/ThreadStorageBrowser.tsx | 50 +- apps/mobile/src/screens/home/HomeScreen.tsx | 235 ++++++-- .../src/screens/home/ServerInfoCard.tsx | 2 +- apps/mobile/src/screens/index.ts | 1 + .../src/screens/machines/AddMachineSheet.tsx | 48 +- .../screens/machines/MachineDetailScreen.tsx | 345 ++++++----- .../screens/machines/MachineRenameSheet.tsx | 8 +- .../src/screens/machines/MachinesScreen.tsx | 298 +++++----- .../src/screens/machines/ProviderCliRows.tsx | 55 +- .../machines/rename-machine-prompt-types.ts | 8 + .../machines/rename-machine-prompt.ios.ts | 36 ++ .../screens/machines/rename-machine-prompt.ts | 14 + .../src/screens/panel/PanelPlaceholders.tsx | 19 +- .../src/screens/panel/PanelTabStrip.tsx | 193 ++++-- apps/mobile/src/screens/panel/README.md | 11 +- .../screens/panel/ThreadInfoTabContent.tsx | 551 +++++++++--------- .../src/screens/pickers/BranchPicker.tsx | 85 +-- .../src/screens/pickers/EnvironmentPicker.tsx | 269 +++++---- .../mobile/src/screens/pickers/HostPicker.tsx | 10 +- .../screens/pickers/ModelReasoningPicker.tsx | 40 +- .../src/screens/pickers/OptionSheet.tsx | 7 +- .../mobile/src/screens/pickers/PathPicker.tsx | 2 + .../screens/pickers/PermissionModePicker.tsx | 11 +- .../src/screens/pickers/PickerTrigger.tsx | 35 +- .../src/screens/pickers/ProviderPicker.tsx | 7 +- .../mobile/src/screens/pickers/SheetInput.tsx | 48 +- .../src/screens/plugins/AddPluginSheet.tsx | 79 ++- .../screens/plugins/MarketplacesScreen.tsx | 273 +++++---- .../screens/plugins/PluginBrowseScreen.tsx | 143 ++--- .../screens/plugins/PluginDetailScreen.tsx | 311 +++++----- .../src/screens/plugins/PluginLogsScreen.tsx | 160 ++--- .../screens/plugins/PluginSettingsForm.tsx | 239 ++++---- .../src/screens/plugins/PluginsScreen.tsx | 490 ++++++++-------- apps/mobile/src/screens/plugins/plugin-ui.tsx | 95 +-- .../src/screens/projects/NewProjectScreen.tsx | 339 ++++++----- .../projects/ProjectMachineSetupSheet.tsx | 37 +- .../projects/ProjectSettingsScreen.tsx | 322 +++++----- .../src/screens/settings/AddServerScreen.tsx | 245 ++++---- .../settings/AppearanceSettingsScreen.tsx | 128 ++-- .../settings/ExperimentsSettingsScreen.tsx | 16 +- .../settings/GeneralSettingsScreen.tsx | 38 +- .../src/screens/settings/GroupedScreen.tsx | 38 ++ .../screens/settings/HapticsSettingsRow.tsx | 9 +- apps/mobile/src/screens/settings/LinkRow.tsx | 26 + .../src/screens/settings/MenuValueRow.tsx | 101 ++++ .../screens/settings/SegmentedChoice.ios.tsx | 40 ++ .../src/screens/settings/SegmentedChoice.tsx | 39 ++ .../screens/settings/ServerStatusScreen.tsx | 6 +- .../src/screens/settings/ServersScreen.tsx | 235 ++++---- .../src/screens/settings/SettingsRows.tsx | 289 ++++----- .../src/screens/settings/SettingsScreen.tsx | 291 +++++---- .../src/screens/settings/UpdatesScreen.tsx | 340 +++++------ .../screens/settings/UsageLimitsScreen.tsx | 251 ++++---- .../settings/segmented-choice-types.ts | 18 + .../src/screens/settings/settings-badges.ts | 54 ++ .../src/screens/shell/ConnectionBanner.tsx | 137 +++-- .../src/screens/shell/RootNavigator.tsx | 98 +++- .../src/screens/shell/RouteErrorBoundary.tsx | 66 ++- apps/mobile/src/screens/shell/Screen.tsx | 112 +++- apps/mobile/src/screens/shell/ScreenTitle.tsx | 22 + .../src/screens/shell/WorkspaceMenu.tsx | 163 ++++-- .../src/screens/shell/screen-options.ts | 22 + apps/mobile/src/screens/shell/sheet-rows.tsx | 34 +- .../src/screens/sidebar/SheetNameForm.tsx | 40 +- .../sidebar/SidebarActionsProvider.tsx | 487 +++++++++------- .../src/screens/sidebar/SidebarRows.tsx | 270 +++++++-- .../src/screens/sidebar/SidebarThreadList.tsx | 54 +- .../screens/sidebar/ThreadRowSwipeable.tsx | 235 ++++++++ .../screens/sidebar/ThreadStatusGlyph.ios.tsx | 100 ++++ apps/mobile/src/screens/sidebar/index.ts | 3 + .../screens/terminal/TerminalAccessoryBar.tsx | 208 +++++-- .../src/screens/terminal/TerminalScreen.tsx | 205 +++++-- .../screens/terminal/TerminalSessionsList.tsx | 66 ++- .../screens/terminal/TerminalTabContent.tsx | 111 +++- .../src/screens/terminal/TerminalView.tsx | 2 +- .../terminal/ThreadTerminalsScreen.tsx | 26 +- .../src/screens/terminal/panel-contents.tsx | 70 ++- .../src/screens/terminal/terminal-theme.ts | 13 +- .../src/screens/thread/ThreadDetailHeader.tsx | 145 ++++- .../src/screens/thread/ThreadDetailScreen.tsx | 170 ++++-- .../thread/actions/MessageActionSheet.tsx | 40 +- .../thread/actions/ThreadActionsSheet.tsx | 375 ++---------- .../thread/actions/message-actions-model.ts | 34 ++ .../thread/actions/use-thread-actions.ts | 384 ++++++++++++ .../src/screens/thread/cards/PromptChip.tsx | 124 +++- .../thread/cards/ThreadPromptStackChips.tsx | 22 +- .../thread/context/MergeBasePickerSheet.tsx | 18 +- .../thread/context/ThreadContextChips.tsx | 23 +- .../interactions/InteractionBannerShell.tsx | 34 +- .../interactions/PendingInteractionBanner.tsx | 48 +- .../thread/interactions/QuestionForm.tsx | 87 ++- .../thread/interactions/SecretRequestForm.tsx | 22 +- .../thread/prompt-area/ThreadPromptArea.tsx | 26 +- .../thread/queue/QueuedMessagesList.tsx | 284 +++++---- .../thread/timeline/FallbackTimelineRow.tsx | 1 + .../screens/thread/timeline/TimelineList.tsx | 36 +- .../thread/timeline/TimelineTitleView.tsx | 19 +- .../timeline/host/TimelineRowHostProvider.tsx | 2 + .../conversation/AssistantMessageRow.tsx | 11 +- .../conversation/AuthoredUserMessage.tsx | 71 ++- .../conversation/ConversationAttachments.tsx | 7 +- .../conversation/GeneratedMessageRow.tsx | 1 + .../renderers/shared/ExpandableRowHeader.tsx | 79 ++- .../renderers/summaries/SummaryRow.tsx | 1 + .../timeline/renderers/system/SystemRow.tsx | 3 +- .../timeline/renderers/turn/TurnRow.tsx | 1 + .../renderers/work/ImageViewWorkRow.tsx | 3 +- .../renderers/work/ToolCallDetailBlock.tsx | 12 +- .../timeline/renderers/work/WorkRowShell.tsx | 1 + .../screens/threads/ArchivedThreadsScreen.tsx | 267 ++++----- .../screens/threads/ThreadSearchResults.tsx | 255 ++++++++ .../screens/threads/ThreadSearchScreen.tsx | 270 ++------- apps/mobile/src/theme/font-platform.ios.ts | 33 ++ apps/mobile/src/theme/font-platform.ts | 40 ++ apps/mobile/src/theme/fonts.test.ts | 125 +++- apps/mobile/src/theme/fonts.ts | 103 ++-- .../src/theme/generate-native-theme.test.ts | 208 ++++++- apps/mobile/src/theme/index.ts | 8 +- apps/mobile/src/theme/mobile-overrides.css | 145 +++++ apps/mobile/src/theme/scrim.test.ts | 21 +- apps/mobile/src/theme/theme-vars.test.ts | 59 +- apps/mobile/src/theme/theme.native.ts | 551 ++++++++++-------- apps/mobile/src/theme/useAppFonts.ts | 45 -- apps/mobile/src/ui/ActionSheet.tsx | 145 +++-- apps/mobile/src/ui/Button.tsx | 179 +++++- apps/mobile/src/ui/Grouped.tsx | 367 ++++++++++++ apps/mobile/src/ui/HugeIcon.tsx | 76 +++ apps/mobile/src/ui/Icon.ios.tsx | 70 +++ apps/mobile/src/ui/Icon.tsx | 57 +- apps/mobile/src/ui/Input.tsx | 117 +++- apps/mobile/src/ui/ListRow.tsx | 121 +++- apps/mobile/src/ui/NativeMenu.ios.tsx | 107 ++++ apps/mobile/src/ui/NativeMenu.tsx | 61 ++ apps/mobile/src/ui/README.md | 106 +++- apps/mobile/src/ui/Separator.tsx | 36 +- apps/mobile/src/ui/Sheet.tsx | 62 +- apps/mobile/src/ui/Switch.tsx | 31 +- apps/mobile/src/ui/Text.tsx | 72 ++- apps/mobile/src/ui/TextArea.tsx | 36 +- apps/mobile/src/ui/Toast.tsx | 54 +- apps/mobile/src/ui/confirm.ts | 40 ++ apps/mobile/src/ui/index.ts | 60 +- apps/mobile/src/ui/name-prompt-types.ts | 14 + apps/mobile/src/ui/name-prompt.ios.ts | 37 ++ apps/mobile/src/ui/name-prompt.ts | 13 + apps/mobile/src/ui/native-menu-shared.ts | 77 +++ .../mobile/src/ui/platform-neutrality.test.ts | 167 ++++++ apps/mobile/src/ui/sf-symbol-map.test.ts | 188 ++++++ apps/mobile/src/ui/sf-symbol-map.ts | 186 ++++++ pnpm-lock.yaml | 25 +- 202 files changed, 13756 insertions(+), 6941 deletions(-) create mode 100644 apps/mobile/assets/splash-icon-dark.png create mode 100644 apps/mobile/src/screens/machines/rename-machine-prompt-types.ts create mode 100644 apps/mobile/src/screens/machines/rename-machine-prompt.ios.ts create mode 100644 apps/mobile/src/screens/machines/rename-machine-prompt.ts create mode 100644 apps/mobile/src/screens/settings/GroupedScreen.tsx create mode 100644 apps/mobile/src/screens/settings/LinkRow.tsx create mode 100644 apps/mobile/src/screens/settings/MenuValueRow.tsx create mode 100644 apps/mobile/src/screens/settings/SegmentedChoice.ios.tsx create mode 100644 apps/mobile/src/screens/settings/SegmentedChoice.tsx create mode 100644 apps/mobile/src/screens/settings/segmented-choice-types.ts create mode 100644 apps/mobile/src/screens/settings/settings-badges.ts create mode 100644 apps/mobile/src/screens/shell/ScreenTitle.tsx create mode 100644 apps/mobile/src/screens/shell/screen-options.ts create mode 100644 apps/mobile/src/screens/sidebar/ThreadRowSwipeable.tsx create mode 100644 apps/mobile/src/screens/sidebar/ThreadStatusGlyph.ios.tsx create mode 100644 apps/mobile/src/screens/thread/actions/use-thread-actions.ts create mode 100644 apps/mobile/src/screens/threads/ThreadSearchResults.tsx create mode 100644 apps/mobile/src/theme/font-platform.ios.ts create mode 100644 apps/mobile/src/theme/font-platform.ts create mode 100644 apps/mobile/src/theme/mobile-overrides.css delete mode 100644 apps/mobile/src/theme/useAppFonts.ts create mode 100644 apps/mobile/src/ui/Grouped.tsx create mode 100644 apps/mobile/src/ui/HugeIcon.tsx create mode 100644 apps/mobile/src/ui/Icon.ios.tsx create mode 100644 apps/mobile/src/ui/NativeMenu.ios.tsx create mode 100644 apps/mobile/src/ui/NativeMenu.tsx create mode 100644 apps/mobile/src/ui/confirm.ts create mode 100644 apps/mobile/src/ui/name-prompt-types.ts create mode 100644 apps/mobile/src/ui/name-prompt.ios.ts create mode 100644 apps/mobile/src/ui/name-prompt.ts create mode 100644 apps/mobile/src/ui/native-menu-shared.ts create mode 100644 apps/mobile/src/ui/platform-neutrality.test.ts create mode 100644 apps/mobile/src/ui/sf-symbol-map.test.ts create mode 100644 apps/mobile/src/ui/sf-symbol-map.ts 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() { @@ -256,13 +338,143 @@ function UiGalleryScreen() { - Small switch + Small switch (Android only)
+
+ + toast.message("General")} + /> + toast.message("Appearance")} + /> + toast.message("Machines")} + /> + } + /> + + + setSort("recent")} + /> + setSort("name")} + /> + + + + + confirmDestructive({ + title: "Remove this machine?", + message: "Threads on it stay on the server.", + actionLabel: "Remove", + onConfirm: () => toast.error("Removed"), + }) + } + /> + + + + + + + + +
+ +
+ + {/* 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. */} + + + + + 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.
-
+
+ + + + + +
@@ -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 0000000000000000000000000000000000000000..4274058bd8b1c8d2b6d9dc78c4ae167ac6c59afc GIT binary patch literal 91813 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�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 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..805f0d9907 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,6 @@ "expo-dev-client": "~57.0.13", "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.4", - "expo-font": "~57.0.1", "expo-haptics": "~57.0.1", "expo-image": "~57.0.3", "expo-image-picker": "~57.0.11", @@ -105,6 +103,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..2f5ebc6562 100644 --- a/apps/mobile/src/composer/Composer.tsx +++ b/apps/mobile/src/composer/Composer.tsx @@ -14,22 +14,33 @@ 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, 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 +77,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 +137,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 +381,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 +400,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 +408,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 +441,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 +488,7 @@ export const Composer = forwardRef( }); const submit = useCallback( (kind: ComposerSubmitKind) => { - haptic("impact-medium"); + haptic("impact-light"); void onSubmit(kind); }, [onSubmit], @@ -449,6 +523,37 @@ export const Composer = forwardRef( /> ) : null; + const micButton = ( + { + haptic("impact-light"); + void voice.start(); + }} + testID={`${testID}-voice`} + /> + ); + + const cardStyle: ViewStyle = IS_IOS + ? { + borderRadius: collapsed + ? CARD_RADIUS_COLLAPSED + : CARD_RADIUS_EXPANDED, + borderCurve: "continuous", + borderWidth: StyleSheet.hairlineWidth, + borderColor: tokens.borderHairline, + backgroundColor: tokens.secondary, + paddingHorizontal: collapsed ? 6 : 0, + } + : { + borderRadius: collapsed + ? CARD_RADIUS_COLLAPSED + : CARD_RADIUS_EXPANDED, + borderWidth: 1, + borderColor: focused && !collapsed ? tokens.ring : tokens.input, + backgroundColor: tokens.card, + paddingHorizontal: collapsed ? 6 : 0, + }; + 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 ? ( - ) : 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"} - - - - ) - } - 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} + + + + + - + - - + + + ); } 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} - - - - - 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} - - ) : ( - - )} - {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" + /> + + - + + {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 ? ( - - ) : null} - + {showAdvanced ? ( + + {fieldError.message} + + ) : ( + "The self-hosted bb connect gate this phone pairs through." + ) + } + > + + + + + ) : ( + + )} + + + {phase.kind === "failed" ? ( + + + {phase.failure.title} + + + {phase.failure.message} + + + ) : null} + + {!reauth ? ( + + ) : 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} - + ); } 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() { - - ) : 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)} + - ) : 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 ? ( + + ) : 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)} + + + + ) : isMarkdownPath(selectedPath) ? ( + + ) : ( + + {content.data?.content ?? ""} - - - ) : 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)} - - - - ) : 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)} + + + + + ) : 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 ? ( - ) : null} - {externalUrl !== null ? ( + {name} + + + {describeFilePreviewTargetSource(target)} + + + + + + {target.path} + + + + {sizeText} + + + {hasSourceToggle ? ( + + ) : null} + + {sourceText !== null ? ( + + ) : null} + {externalUrl !== null ? ( + - - + {chrome === "inline" ? ( + + + + + + + ) : 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..0c96671729 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,13 @@ import { useRef, useState, } from "react"; -import { Animated, Keyboard, Pressable, View } from "react-native"; +import { Animated, Keyboard, Pressable, StyleSheet, View } 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,6 +28,7 @@ import { COMPOSER_KEYBOARD_GAP, KeyboardPaddingView, OverlayBounds, + sfSymbolFor, Spinner, Text, } from "@/ui"; @@ -33,91 +37,155 @@ 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; -/** - * 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 HomeHeaderActions({ dimmed }: { dimmed: boolean }) { +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 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 +264,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 +278,9 @@ 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); const animateScrim = useCallback( (open: boolean) => { @@ -262,10 +335,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} + /> + + ) : ( + + )} - + {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. 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} - + - + ); } @@ -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 ? ( - - 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" - > - - + /> ); } @@ -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)} + {updateSummary?.canApply ? ( - {updateSummary?.canApply ? ( - - ) : 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"} + + + + ) : ( + + No log lines yet. + + ); return ( <> ( - void logs.refetch()} - testID="plugin-logs-refresh" - > - {logs.isFetching ? ( - - ) : ( - - )} - - ), + ...(IS_IOS + ? {} + : { + headerRight: () => ( + + ), + }), }} /> - - - Tail - {TAIL_OPTIONS.map((option) => ( - - ))} - - {logs.isPending ? ( - - - - ) : logs.isError ? ( - - - {logs.error instanceof Error - ? logs.error.message - : "Could not load logs"} - - - - ) : 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 ( - <> - - 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 ? ( -