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 0000000000..4274058bd8 Binary files /dev/null and b/apps/mobile/assets/splash-icon-dark.png differ diff --git a/apps/mobile/e2e/flows/phase1-shell.yaml b/apps/mobile/e2e/flows/phase1-shell.yaml index 5a132b2a41..3c8da2eaee 100644 --- a/apps/mobile/e2e/flows/phase1-shell.yaml +++ b/apps/mobile/e2e/flows/phase1-shell.yaml @@ -32,26 +32,23 @@ env: visible: id: "home-thread-list" timeout: 30000 +# The server label is the large title. - assertVisible: "E2E backend" - assertNotVisible: id: "connection-banner" - takeScreenshot: phase1-home -# Workspace menu (the header's server avatar): the active server with its -# realtime label, the server rows, Add server, Settings. -- tapOn: - id: "home-workspace-menu" +# Workspace menu (the native "Workspace" bar item): the menu title carries +# the active server and its realtime label, then the server rows, Add +# server, Archived threads, Settings. Native menu items have no test ids; +# they are matched by text. +- tapOn: "Workspace" - extendedWaitUntil: - visible: - id: "workspace-settings" + visible: "Settings" timeout: 10000 -- assertVisible: - id: "workspace-profile-label" -- assertVisible: "Connected" -- assertVisible: - id: "workspace-add-server" +- assertVisible: ".*E2E backend.*Connected.*" +- assertVisible: "Add server.*" - takeScreenshot: phase1-workspace-menu -- tapOn: - id: "workspace-settings" +- tapOn: "Settings" - extendedWaitUntil: visible: id: "settings-server-status" diff --git a/apps/mobile/e2e/flows/phase3-compose.yaml b/apps/mobile/e2e/flows/phase3-compose.yaml index 785ecb1ed3..aa2c481c44 100644 --- a/apps/mobile/e2e/flows/phase3-compose.yaml +++ b/apps/mobile/e2e/flows/phase3-compose.yaml @@ -1,6 +1,7 @@ # Phase 3 compose: first run → add the harness server → deep link to -# bb://compose (home opens its dock) → pick the seeded project → open the environment picker (screenshot) -# → type a prompt → Create → lands on the thread placeholder with the title → +# bb://compose (home opens its dock) → pick the seeded project → open the +# environment picker sheet (screenshot) → type a prompt → Create → lands on +# the thread placeholder with the title → # deep link to /projects/new → the machine picker lists the harness host and # the remote path browser lists that machine's folders. # @@ -72,7 +73,9 @@ env: id: "project-picker" text: "(?i).*mobile e2e project.*" timeout: 15000 -# Environment picker opens as a sheet listing the modes; screenshot it open. +# Environment picker: the pill opens a sheet listing the modes as check-mark +# rows (both platforms; the pill shows text, so it is never a native menu). +# Screenshot it open. - extendedWaitUntil: visible: id: "environment-picker" diff --git a/apps/mobile/e2e/flows/phase3-threads.yaml b/apps/mobile/e2e/flows/phase3-threads.yaml index 086ad3090a..e8363f3957 100644 --- a/apps/mobile/e2e/flows/phase3-threads.yaml +++ b/apps/mobile/e2e/flows/phase3-threads.yaml @@ -1,6 +1,11 @@ # Phase 3 threads: first run → add the harness server → home lists the seeded -# project and threads → long-press menu (rename, pin, archive) → Settings → -# Archived (unarchive) → search. +# project and threads → long-press action sheet (rename, pin, archive) → +# Settings → Archived (unarchive) → search from the header search bar. +# +# Thread rows open the action sheet on long-press on both platforms (a native +# context menu would hide the row from the accessibility tree); its rows carry +# `sidebar-action-` ids. Rename is the system prompt on iOS: the focused +# text field takes `inputText`, and its "Rename" button commits. # # Requires Metro started with EXPO_PUBLIC_BB_E2E=1 (profiles/preferences are # wiped on launch) and the harness backend (seeds "Mobile E2E Project" with @@ -39,7 +44,7 @@ env: - assertVisible: "Completed thread" - assertVisible: "Idle thread" - takeScreenshot: phase3-home -# Long-press → Rename. +# Long-press → action sheet → Rename → system prompt. - longPressOn: "Idle thread" - extendedWaitUntil: visible: @@ -48,15 +53,17 @@ env: - tapOn: id: "sidebar-action-rename" - extendedWaitUntil: - visible: - id: "rename-input" + visible: "Rename thread" + timeout: 10000 +# The sheet's Rename row is still animating out as the prompt appears; wait +# for it to leave so "Rename" matches only the prompt's button. +- extendedWaitUntil: + notVisible: + id: "sidebar-action-rename" timeout: 10000 -- tapOn: - id: "rename-input" - eraseText: 40 - inputText: "Renamed thread" -- tapOn: - id: "rename-submit" +- tapOn: "Rename" - extendedWaitUntil: visible: "Renamed thread" timeout: 15000 @@ -109,8 +116,9 @@ env: visible: "Renamed thread" timeout: 15000 - takeScreenshot: phase3-archived -# Unarchive from the long-press menu (the trailing button sits under the -# dev-client's floating gear on some simulators); the row leaves the list. +# Unarchive from the long-press action sheet (the trailing swipe action sits +# under the dev-client's floating gear on some simulators); the row leaves +# the list. - longPressOn: "Renamed thread" - extendedWaitUntil: visible: @@ -137,20 +145,15 @@ env: - extendedWaitUntil: visible: "Renamed thread" timeout: 15000 -# Search from the home header. +# Search from the home header's search bar (matched by its placeholder): +# recent threads while empty, then results in place of the list. - extendedWaitUntil: - visible: - id: "home-search" + visible: "Search threads" timeout: 10000 -- tapOn: - id: "home-search" +- tapOn: "Search threads" - extendedWaitUntil: - visible: - id: "thread-search-input" + visible: "Recent" timeout: 10000 -- assertVisible: "Recent" -- tapOn: - id: "thread-search-input" - inputText: "Compl" - extendedWaitUntil: visible: "Completed thread" diff --git a/apps/mobile/e2e/flows/phase4b-actions.yaml b/apps/mobile/e2e/flows/phase4b-actions.yaml index 00fea1f52e..34c868a0c9 100644 --- a/apps/mobile/e2e/flows/phase4b-actions.yaml +++ b/apps/mobile/e2e/flows/phase4b-actions.yaml @@ -1,8 +1,14 @@ # Phase 4b actions on the thread screen: open the thread named by -# THREAD_TITLE → the header shows the environment line (project · host · -# workspace) → "…" → Rename through the sheet → the title updates → -# long-press the user message → Copy text toasts "Copied" → long-press again -# → Add to chat quotes it into the follow-up composer ("> …"). +# THREAD_TITLE → the header "…" (a native menu titled with the environment +# line: project · host · workspace) → Rename through the system prompt → the +# title updates → long-press the user message → the action sheet's Copy +# text toasts "Copied" → long-press again → Add to chat quotes it into the +# follow-up composer ("> …"). +# +# iOS: native bar items and menu items have no testID, so the flow taps them +# by accessibility label / text ("Thread actions", "Rename"). Message +# actions stay a bottom sheet on both platforms (its rows keep +# `action-sheet-` ids). # # Create an idle thread titled "P4b actions" first (see phase4b-send.yaml): # maestro test e2e/flows/phase4b-actions.yaml @@ -28,34 +34,29 @@ env: id: "thread-composer-input" timeout: 30000 - takeScreenshot: phase4b-actions-thread -# Rename through the "…" sheet; its header carries the environment line +# Rename through the "…" menu; its title carries the environment line # (project · host · workspace). -- tapOn: - id: "thread-actions-button" +- tapOn: "Thread actions" - extendedWaitUntil: - visible: - id: "thread-action-rename" + visible: "Rename" timeout: 10000 - assertVisible: "Mobile E2E Project.*" -- tapOn: - id: "thread-action-rename" +- tapOn: "Rename" +# The system prompt: the field is pre-filled with the current title and +# focused, the confirming button reads "Rename". - extendedWaitUntil: - visible: - id: "thread-rename-input" + visible: "Rename thread" timeout: 10000 -- tapOn: - id: "thread-rename-input" - eraseText: 60 - inputText: "${THREAD_TITLE} renamed" -- tapOn: - id: "thread-rename-submit" +- tapOn: "Rename" - extendedWaitUntil: visible: id: "thread-detail-title" text: "${THREAD_TITLE} renamed" timeout: 15000 - takeScreenshot: phase4b-actions-renamed -# Long-press the user message → Copy text. +# Long-press the user message → the action sheet → Copy text. - longPressOn: id: "conversation-user-bubble" index: 0 diff --git a/apps/mobile/e2e/flows/phase4b-composer.yaml b/apps/mobile/e2e/flows/phase4b-composer.yaml index cf28c2af5f..249784ec75 100644 --- a/apps/mobile/e2e/flows/phase4b-composer.yaml +++ b/apps/mobile/e2e/flows/phase4b-composer.yaml @@ -2,9 +2,9 @@ # Developer) against the harness backend. Types an `@` query → the typeahead # opens above the input listing the seeded threads; picking one inserts a # pill and the serialized PromptInput carries `@thread:`; backspace at the -# pill end removes it whole; `/` lists provider commands; the "+" menu opens -# with the attachment + prompt actions; then the compose screen creates a -# thread through the composer. +# pill end removes it whole; `/` lists provider commands; the "+" menu (a +# native pull-down menu on iOS) opens with the attachment + prompt actions; +# then the compose screen creates a thread through the composer. # # Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) appId: app.getbb.mobile @@ -72,13 +72,20 @@ env: id: "dev-composer-typeahead-row-0" - assertVisible: '(?s).*"kind": "command".*' - eraseText: 30 -# "+" menu: attachment + prompt actions. +# "+" menu (a native pull-down menu on iOS): attachment + prompt actions. +# A native menu has no Cancel row; a tap outside it dismisses it. - tapOn: id: "dev-composer-actions" -- assertVisible: "Photo library" +- extendedWaitUntil: + visible: "Photo library" + timeout: 10000 - assertVisible: "Attach file" - takeScreenshot: phase4b-composer-actions -- tapOn: "Cancel" +- tapOn: + point: "50%,15%" +- extendedWaitUntil: + notVisible: "Photo library" + timeout: 10000 # Submit in the ready mode reports the kind. - tapOn: id: "dev-composer-input" diff --git a/apps/mobile/e2e/flows/phase4b-thread-actions.yaml b/apps/mobile/e2e/flows/phase4b-thread-actions.yaml index f3e488652c..2e0ffb6397 100644 --- a/apps/mobile/e2e/flows/phase4b-thread-actions.yaml +++ b/apps/mobile/e2e/flows/phase4b-thread-actions.yaml @@ -1,8 +1,14 @@ # Phase 4b thread actions + context banner: add the harness server → open # the thread named by THREAD_TITLE (create it first; see README) → header -# title tap opens the rename sheet → rename → "…" menu lists the thread -# actions (Copy link toasts) → the git button opens the git sheet (when the -# worktree is dirty) → the context banner's changed-files row expands. +# title tap opens the rename prompt → rename → the "…" native menu lists +# the thread actions (Copy link toasts) → its git row opens the git sheet +# (when the worktree is dirty) → the context banner's changed-files row +# expands → long-pressing an assistant message opens its action sheet. +# +# iOS: native bar items and menu items have no testID, so the flow taps them +# by accessibility label / text ("Thread actions", "Commit", "Copy link"). +# Message actions stay a bottom sheet on both platforms (its rows keep +# `action-sheet-` ids). # # Setup (the flow asserts the banner's git row, which needs a dirty # worktree): create a managed-worktree thread through the API, write a file @@ -49,15 +55,12 @@ env: notVisible: id: "thread-chip-changes-sheet" timeout: 10000 -# Git sheet from the "…" menu (the header has no second row). -- tapOn: - id: "thread-actions-button" +# Git sheet from the "…" menu's first row (the dirty worktree offers Commit). +- tapOn: "Thread actions" - extendedWaitUntil: - visible: - id: "thread-git-button" + visible: "Commit" timeout: 10000 -- tapOn: - id: "thread-git-button" +- tapOn: "Commit" - extendedWaitUntil: visible: id: "thread-git-sheet" @@ -71,40 +74,30 @@ env: notVisible: id: "thread-git-sheet" timeout: 10000 -# Rename through the title. +# Rename through the title (the system prompt, pre-filled and focused). - tapOn: id: "thread-detail-title" - extendedWaitUntil: - visible: - id: "thread-rename-input" + visible: "Rename thread" timeout: 10000 -- tapOn: - id: "thread-rename-input" - eraseText: 60 - inputText: "${THREAD_TITLE} renamed" -- tapOn: - id: "thread-rename-submit" +- tapOn: "Rename" - extendedWaitUntil: visible: id: "thread-detail-title" text: "${THREAD_TITLE} renamed" timeout: 15000 # Actions menu: copy link. -- tapOn: - id: "thread-actions-button" +- tapOn: "Thread actions" - extendedWaitUntil: - visible: - id: "thread-action-copy-link" + visible: "Copy link" timeout: 10000 -- assertVisible: - id: "thread-action-handoff" -- assertVisible: - id: "thread-action-pin" -- assertVisible: - id: "thread-action-delete" +- assertVisible: "Handoff to new thread" +- assertVisible: "Pin" +- assertVisible: "Delete" - takeScreenshot: phase4b-thread-menu -- tapOn: - id: "thread-action-copy-link" +- tapOn: "Copy link" - extendedWaitUntil: visible: "Link copied" timeout: 10000 diff --git a/apps/mobile/e2e/flows/phase6-diff.yaml b/apps/mobile/e2e/flows/phase6-diff.yaml index 296545c034..1ca738da3d 100644 --- a/apps/mobile/e2e/flows/phase6-diff.yaml +++ b/apps/mobile/e2e/flows/phase6-diff.yaml @@ -1,7 +1,7 @@ # Phase 6 Diff tab: add the harness server → open the thread named by # THREAD_TITLE (its worktree is dirty) → the context banner's changed-files # row → "Open diff" → the Diff sheet lists the modified / deleted / added -# cards with the modified file's hunk → the target picker offers the +# cards with the modified file's hunk → the target picker sheet offers the # uncommitted target → "Add to chat" quotes the patch into the composer # ("> diff --git …") → a file row in the banner opens the diff focused on it # → an API commit + refresh offers the committed target. @@ -62,8 +62,10 @@ env: - assertVisible: id: "diff-tab-removed" - takeScreenshot: phase6-diff -# Target picker: the working tree is the only target on a fresh worktree -# with no commits above the default branch; pick it and the list holds. +# Target picker: the capsule opens the picker sheet (both platforms; its rows +# carry `diff-target-` ids). The working tree is the only target on a +# fresh worktree with no commits above the default branch; pick it and the +# list holds. - tapOn: id: "diff-tab-target" - extendedWaitUntil: @@ -118,8 +120,9 @@ env: timeout: 30000 - takeScreenshot: phase6-diff-focused # Commit through the API (the fake provider cannot), refresh: the picker now -# offers "Committed changes" and the commit itself; the committed target -# lists the same files. +# offers "Committed changes" and the commit itself (a row with the short sha +# as its mono prefix and the subject); the committed target lists the same +# files. - runScript: file: ../scripts/phase6-commit.js - tapOn: diff --git a/apps/mobile/e2e/flows/phase6-files.yaml b/apps/mobile/e2e/flows/phase6-files.yaml index 9ccda190fc..0e20134bfd 100644 --- a/apps/mobile/e2e/flows/phase6-files.yaml +++ b/apps/mobile/e2e/flows/phase6-files.yaml @@ -25,9 +25,9 @@ env: visible: id: "thread-detail-header" timeout: 30000 -# Workspace panel → Files launcher. -- tapOn: - id: "thread-panel-button" +# Workspace panel (the header's toolbar item, tapped by its accessibility +# label) → Files launcher. +- tapOn: "Workspace panel" - extendedWaitUntil: visible: id: "panel-tab-files" @@ -66,9 +66,9 @@ env: timeout: 30000 - assertVisible: "Mobile E2E Project" - takeScreenshot: phase6-files-readme-preview -# Source view + jump to line 60. -- tapOn: - id: "file-preview-mode-source" +# Source view (the Preview / Source segmented control's second segment) + +# jump to line 60 through the panel's sheet field. +- tapOn: "Source" - extendedWaitUntil: visible: id: "file-preview-lines" @@ -118,7 +118,8 @@ env: - assertVisible: "Build the files tab" - takeScreenshot: phase6-files-storage-preview # Close the panel (tap the backdrop), then the full-screen preview route by -# deep link: highlighted line 12 of src/app.ts. +# deep link: highlighted line 12 of src/app.ts. The file name is the native +# navigation title there. - tapOn: point: "50%,8%" - extendedWaitUntil: @@ -127,9 +128,7 @@ env: timeout: 10000 - openLink: "bb://threads/${THREAD_ID}/files?kind=workspace&path=src%2Fapp.ts&line=12" - extendedWaitUntil: - visible: - id: "file-preview-name" - text: "app.ts" + visible: "app.ts" timeout: 30000 - extendedWaitUntil: visible: diff --git a/apps/mobile/e2e/flows/phase6-panel.yaml b/apps/mobile/e2e/flows/phase6-panel.yaml index af0fc30f28..8140452772 100644 --- a/apps/mobile/e2e/flows/phase6-panel.yaml +++ b/apps/mobile/e2e/flows/phase6-panel.yaml @@ -25,13 +25,13 @@ env: id: "thread-detail-title" text: "${THREAD_TITLE}" timeout: 30000 -# The header's panel button presents the sheet on the Info tab. +# The header's panel button presents the sheet on the Info tab. It is a +# native toolbar item on iOS (no test id), so the flow taps its +# accessibility label; the Android Pressable carries the same label. - extendedWaitUntil: - visible: - id: "thread-panel-button" + visible: "Workspace panel" timeout: 10000 -- tapOn: - id: "thread-panel-button" +- tapOn: "Workspace panel" - extendedWaitUntil: visible: id: "workspace-panel-tab-strip" diff --git a/apps/mobile/e2e/flows/phase6-terminal-resume.yaml b/apps/mobile/e2e/flows/phase6-terminal-resume.yaml index 4dd065deca..4bebb867e5 100644 --- a/apps/mobile/e2e/flows/phase6-terminal-resume.yaml +++ b/apps/mobile/e2e/flows/phase6-terminal-resume.yaml @@ -17,8 +17,8 @@ env: visible: "${THREAD_TITLE}" timeout: 30000 - tapOn: "${THREAD_TITLE}" -- tapOn: - id: "thread-panel-button" +# The header's panel button is a native toolbar item: tap its label. +- tapOn: "Workspace panel" - extendedWaitUntil: visible: id: "panel-tab-terminal" diff --git a/apps/mobile/e2e/flows/phase6-terminal.yaml b/apps/mobile/e2e/flows/phase6-terminal.yaml index 5ce41e76eb..68431138ab 100644 --- a/apps/mobile/e2e/flows/phase6-terminal.yaml +++ b/apps/mobile/e2e/flows/phase6-terminal.yaml @@ -25,9 +25,9 @@ env: visible: id: "thread-detail-screen" timeout: 30000 -# Workspace panel → Terminal. -- tapOn: - id: "thread-panel-button" +# Workspace panel (the header's toolbar item, tapped by its accessibility +# label) → Terminal. +- tapOn: "Workspace panel" - extendedWaitUntil: visible: id: "panel-tab-terminal" @@ -92,33 +92,27 @@ env: id: "terminal-key-ctrl" - inputText: "u" - takeScreenshot: phase6-terminal-keys -# Terminal menu: rename the session. (The accessory bar's "…" mirrors the -# header's, which the dev client's floating gear covers on large simulators.) +# Terminal menu: rename the session. The accessory bar's "…" anchors the +# same native menu as the header's (which the dev client's floating gear +# covers on large simulators); its items have no test ids, so they are +# tapped by title. - tapOn: id: "terminal-key-menu" - extendedWaitUntil: - visible: - id: "terminal-action-rename" + visible: "Rename" timeout: 10000 -- assertVisible: - id: "terminal-action-restart" -- assertVisible: - id: "terminal-action-new" -- assertVisible: - id: "terminal-action-close" +- assertVisible: "Restart terminal" +- assertVisible: "New terminal" +- assertVisible: "Close terminal" - takeScreenshot: phase6-terminal-menu -- tapOn: - id: "terminal-action-rename" +- tapOn: "Rename" +# The system prompt opens focused on the current title; replace it. - extendedWaitUntil: - visible: - id: "terminal-rename-input" + visible: "Rename terminal" timeout: 10000 -- tapOn: - id: "terminal-rename-input" - eraseText: 60 - inputText: "P6 shell" -- tapOn: - id: "terminal-rename-submit" +- tapOn: "Rename" - extendedWaitUntil: visible: "P6 shell" timeout: 15000 diff --git a/apps/mobile/e2e/flows/phase7-plugins.yaml b/apps/mobile/e2e/flows/phase7-plugins.yaml index c362ba125a..3f77eccb44 100644 --- a/apps/mobile/e2e/flows/phase7-plugins.yaml +++ b/apps/mobile/e2e/flows/phase7-plugins.yaml @@ -50,6 +50,22 @@ env: timeout: 20000 - assertVisible: ".*BB Community.*" - takeScreenshot: p7-plugins-marketplaces +# Tapping the row opens its action sheet (refresh / remove) on both +# platforms; the rows carry `action-sheet-` ids. Cancel leaves the +# catalog alone. +- tapOn: + id: "marketplace-row-bb-community" +- extendedWaitUntil: + visible: + id: "action-sheet-refresh" + timeout: 10000 +- takeScreenshot: p7-plugins-marketplace-menu +- tapOn: + id: "action-sheet-cancel" +- extendedWaitUntil: + notVisible: + id: "action-sheet-refresh" + timeout: 10000 # The add-marketplace sheet (also behind the header "+", which the dev # client's floating gear can cover on larger simulators). - tapOn: @@ -108,20 +124,20 @@ env: id: "skill-row-.*" timeout: 20000 # The in-process daemon discovers this Mac's real skill folders too, so the -# library can be long: filter down to the built-in bb-cli skill when the -# filter field is shown (more than a handful of skills). +# library can be long: filter down to the built-in bb-cli skill through the +# native header search bar (iOS; Android keeps the in-body skills-filter +# field), then scroll to the row in case the list is still long. - runFlow: when: - visible: - id: "skills-filter" + visible: "Search skills" commands: - - tapOn: - id: "skills-filter" + - tapOn: "Search skills" - inputText: "bb-cli" - tapOn: ".*My skills.*" -- extendedWaitUntil: - visible: +- scrollUntilVisible: + element: id: "skill-row-bb-cli" + direction: DOWN timeout: 20000 - takeScreenshot: p7-plugins-skills - tapOn: diff --git a/apps/mobile/e2e/flows/phase7-settings.yaml b/apps/mobile/e2e/flows/phase7-settings.yaml index 6051e1a5bf..dc0405a896 100644 --- a/apps/mobile/e2e/flows/phase7-settings.yaml +++ b/apps/mobile/e2e/flows/phase7-settings.yaml @@ -1,5 +1,6 @@ -# Phase 7 settings: Settings home → Experiments (toggle "New onboarding", -# persisted server-side: re-open shows it on, the API agrees) → Appearance +# Phase 7 settings: Settings home → Experiments (toggle "Edit messages" off — +# it defaults on — persisted server-side: re-open shows it off, the API +# agrees; toggle it back on) → Appearance # (palette → Nord: the row shows "Nord", the API agrees, the UI re-tints) → # Machines (the harness host row → detail → rename → the list shows the new # name) → restore. Resets the server settings it touches at start and end, so @@ -23,22 +24,24 @@ env: id: "settings-experiments" timeout: 10000 - takeScreenshot: p7-settings-home -# Experiments: toggle "New onboarding" on (PUT /settings/experiments). +# Experiments: toggle "Edit messages" off (PUT /settings/experiments). It is +# the one experiment the reset leaves on, so the flow drives it on → off → on. - tapOn: id: "settings-experiments" - extendedWaitUntil: visible: - id: "experiment-newOnboarding" + id: "experiment-editMessages" + checked: true timeout: 15000 - tapOn: - id: "experiment-newOnboarding" + id: "experiment-editMessages" - extendedWaitUntil: visible: - id: "experiment-newOnboarding" - checked: true + id: "experiment-editMessages" + checked: false timeout: 10000 - takeScreenshot: p7-settings-experiments -# Persisted: leave, come back, still on; and the server says so. +# Persisted: leave, come back, still off; and the server says so. - tapOn: id: "BackButton" - extendedWaitUntil: @@ -49,21 +52,21 @@ env: id: "settings-experiments" - extendedWaitUntil: visible: - id: "experiment-newOnboarding" - checked: true + id: "experiment-editMessages" + checked: false timeout: 15000 - runScript: file: ../scripts/phase7-settings-assert.js env: - EXPECT_NEW_ONBOARDING: "true" + EXPECT_EDIT_MESSAGES: "false" EXPECT_THEME_ID: "default" -# Toggle it back off through the UI (the reset at the end covers failures). +# Toggle it back on through the UI (the reset at the end covers failures). - tapOn: - id: "experiment-newOnboarding" + id: "experiment-editMessages" - extendedWaitUntil: visible: - id: "experiment-newOnboarding" - checked: false + id: "experiment-editMessages" + checked: true timeout: 10000 - tapOn: id: "BackButton" @@ -79,8 +82,10 @@ env: visible: id: "appearance-palette" timeout: 15000 +# The mode control is a native segmented control on iOS (one id for the +# whole control); Android keeps per-option appearance-mode- buttons. - assertVisible: - id: "appearance-mode-system" + id: "appearance-mode" - tapOn: id: "appearance-palette" - extendedWaitUntil: @@ -97,7 +102,7 @@ env: - runScript: file: ../scripts/phase7-settings-assert.js env: - EXPECT_NEW_ONBOARDING: "false" + EXPECT_EDIT_MESSAGES: "true" EXPECT_THEME_ID: "nord" - takeScreenshot: p7-settings-appearance-nord - tapOn: @@ -139,16 +144,15 @@ env: - takeScreenshot: p7-settings-machine-detail - tapOn: id: "machine-rename-row" +# iOS renames through the system prompt (Alert.prompt: Cancel / Save, the +# field pre-filled and focused); Android presents the rename sheet +# (machine-rename-input / machine-rename-save). - extendedWaitUntil: - visible: - id: "machine-rename-input" + visible: "Save" timeout: 10000 -- tapOn: - id: "machine-rename-input" - eraseText: 80 - inputText: "P7 renamed machine" -- tapOn: - id: "machine-rename-save" +- tapOn: "Save" - extendedWaitUntil: visible: id: "machine-detail-name" diff --git a/apps/mobile/e2e/scripts/phase7-settings-assert.js b/apps/mobile/e2e/scripts/phase7-settings-assert.js index e222fa4e1a..5193e84e36 100644 --- a/apps/mobile/e2e/scripts/phase7-settings-assert.js +++ b/apps/mobile/e2e/scripts/phase7-settings-assert.js @@ -1,11 +1,11 @@ // Maestro runScript: read the server-persisted settings the Phase 7 flow // changed through the UI and fail unless they landed. Env: SERVER_URL, -// EXPECT_NEW_ONBOARDING ("true" | "false"), EXPECT_THEME_ID. +// EXPECT_EDIT_MESSAGES ("true" | "false"), EXPECT_THEME_ID. const config = json(http.get(`${SERVER_URL}/api/v1/system/config`).body); -const newOnboarding = String(config.experiments.newOnboarding); -if (newOnboarding !== EXPECT_NEW_ONBOARDING) { +const editMessages = String(config.experiments.editMessages); +if (editMessages !== EXPECT_EDIT_MESSAGES) { throw new Error( - `experiments.newOnboarding is ${newOnboarding}, expected ${EXPECT_NEW_ONBOARDING}`, + `experiments.editMessages is ${editMessages}, expected ${EXPECT_EDIT_MESSAGES}`, ); } if (config.appearance.themeId !== EXPECT_THEME_ID) { diff --git a/apps/mobile/e2e/scripts/phase7-settings-reset.js b/apps/mobile/e2e/scripts/phase7-settings-reset.js index 9e3f1a67f4..6879cf642b 100644 --- a/apps/mobile/e2e/scripts/phase7-settings-reset.js +++ b/apps/mobile/e2e/scripts/phase7-settings-reset.js @@ -1,14 +1,17 @@ // Maestro runScript: put the harness server's settings back to the defaults // the Phase 7 settings flow toggles (experiments, appearance) so the flow // starts and ends from a known state on a shared backend. Env: SERVER_URL. +// The experiments body must name every key of @bb/domain `experimentKeys` +// (the schema is exhaustive); the values are `defaultExperiments`. const headers = { "Content-Type": "application/json" }; const experiments = http.put(`${SERVER_URL}/api/v1/settings/experiments`, { headers, body: JSON.stringify({ + changelogPreview: false, editMessages: true, mobileApp: false, - newOnboarding: false, providerSessionReaping: false, + timelineWindowing: false, }), }); if (!experiments.ok) { diff --git a/apps/mobile/e2e/subflows/open-settings.yaml b/apps/mobile/e2e/subflows/open-settings.yaml index 0eb301b8bd..a6b0858eae 100644 --- a/apps/mobile/e2e/subflows/open-settings.yaml +++ b/apps/mobile/e2e/subflows/open-settings.yaml @@ -1,11 +1,9 @@ -# Home → workspace menu (the header's server avatar) → Settings. +# Home → workspace menu (the native "Workspace" bar item on the header's +# left; its items are matched by text) → Settings. appId: ${APP_ID} --- -- tapOn: - id: "home-workspace-menu" +- tapOn: "Workspace" - extendedWaitUntil: - visible: - id: "workspace-settings" + visible: "Settings" timeout: 10000 -- tapOn: - id: "workspace-settings" +- tapOn: "Settings" diff --git a/apps/mobile/global.css b/apps/mobile/global.css index b700e22a47..1a863987ae 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -84,48 +84,64 @@ --color-sidebar-search-match: var(--sidebar-search-match); --color-sidebar-search-match-border: var(--sidebar-search-match-border); --color-shadow-color: var(--shadow-color); + /* iOS inset-grouped lists (src/theme/mobile-overrides.css): the page behind + the cards and the cards themselves. */ + --color-surface-grouped: var(--surface-grouped); + --color-surface-grouped-cell: var(--surface-grouped-cell); /* - * Fonts. Expo Google Fonts register one family name per weight, so the - * `font-sans-*` / `font-mono-*` utilities select the weight-specific family. - * `` (src/ui/Text.tsx) picks the right family for you and - * also derives it from web-style `font-medium|semibold|bold` classes. + * Fonts. The app renders in the platform faces (SF Pro / Menlo on iOS, + * sans-serif / monospace on Android); CSS cannot pick a family per + * platform, so these only keep the `font-sans` / `font-mono` class names + * resolvable. `System` is React Native's iOS alias for the system font and + * falls through to the default sans-serif typeface on Android; `monospace` + * is Android's mono family (iOS gets Menlo from ``, below). + * `` (src/ui/Text.tsx) re-resolves the real family + numeric weight + * through `resolveFont` (src/theme/fonts.ts, font-platform*.ts) as an + * inline style, and that is what renders — outside `` use + * `theme.fonts.mono.regular` rather than a `font-mono` class. */ - --font-sans: "Inter_400Regular"; - --font-sans-medium: "Inter_500Medium"; - --font-sans-semibold: "Inter_600SemiBold"; - --font-sans-bold: "Inter_700Bold"; - --font-mono: "FiraCode_400Regular"; - --font-mono-medium: "FiraCode_500Medium"; - --font-mono-semibold: "FiraCode_600SemiBold"; - --font-mono-bold: "FiraCode_700Bold"; + --font-sans: "System"; + --font-mono: monospace; - /* Radii mirror `--radius: 0.5rem` and its steps (nativeRadii). */ + /* Radii mirror `--radius: 0.5rem` and its steps (nativeRadii); 2xl/full are + Tailwind's defaults pinned as numbers (nativeRadii.xl2 / .full). */ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px; + --radius-2xl: 16px; + --radius-full: 9999px; } /* - * Typography scale: the touch (coarse-pointer) values from theme.css, which - * the mobile PWA renders on a phone. Sizes are px so react-native-css does + * Typography scale: the Apple text-style ramp from + * src/theme/mobile-overrides.css (caption2, footnote, subheadline, body, + * title3, title2, title1, largeTitle). Sizes are px so react-native-css does * not depend on the rem multiplier. Line heights are unitless ratios * (`calc(line-height / font-size)`): Tailwind emits them as the fallback of * `var(--tw-leading, …)`, and react-native-css drops the unit inside that * fallback and treats the number as an em multiplier at runtime (`22px` * became `22 × 15 = 330`). A ratio survives that path and yields the px value. - * Mirrors nativeTypography in theme.native.ts. + * Mirrors nativeTypography in theme.native.ts (theme-vars.test.ts). */ @theme { --text-2xs: 11px; - --text-2xs--line-height: calc(15 / 11); - --text-xs: 14px; - --text-xs--line-height: calc(20 / 14); + --text-2xs--line-height: calc(13 / 11); + --text-xs: 13px; + --text-xs--line-height: calc(18 / 13); --text-sm: 15px; - --text-sm--line-height: calc(22 / 15); - --text-base: 16px; - --text-base--line-height: calc(24 / 16); + --text-sm--line-height: calc(20 / 15); + --text-base: 17px; + --text-base--line-height: calc(22 / 17); + --text-lg: 20px; + --text-lg--line-height: calc(25 / 20); + --text-xl: 22px; + --text-xl--line-height: calc(28 / 22); + --text-2xl: 28px; + --text-2xl--line-height: calc(34 / 28); + --text-3xl: 34px; + --text-3xl--line-height: calc(41 / 34); } /* diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8c5eb45664..138ebc2c87 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -26,8 +26,7 @@ "@bb/sdk": "workspace:*", "@bb/server-contract": "workspace:*", "@bb/thread-view": "workspace:*", - "@expo-google-fonts/fira-code": "^0.4.1", - "@expo-google-fonts/inter": "^0.4.2", + "@expo/ui": "57.0.11", "@gorhom/bottom-sheet": "^5.2.14", "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react-native": "^1.0.15", @@ -48,7 +47,7 @@ "expo-dev-client": "~57.0.13", "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.4", - "expo-font": "~57.0.1", + "expo-glass-effect": "57.0.1", "expo-haptics": "~57.0.1", "expo-image": "~57.0.3", "expo-image-picker": "~57.0.11", @@ -105,6 +104,7 @@ "lightningcss": "1.30.1", "mdast-util-directive": "^3.1.0", "postcss": "^8.5.26", + "sf-symbols-typescript": "^2.2.0", "tailwindcss": "^4.3.0", "typescript": "npm:@typescript/typescript6@^6.0.2", "vitest": "^4.1.1" diff --git a/apps/mobile/scripts/generate-native-theme.ts b/apps/mobile/scripts/generate-native-theme.ts index 4db23693cb..3e935ad4f1 100644 --- a/apps/mobile/scripts/generate-native-theme.ts +++ b/apps/mobile/scripts/generate-native-theme.ts @@ -3,11 +3,19 @@ * Generates `src/theme/theme.native.ts` from the web app's CSS theme tokens. * * React Native has no `var()`, `color-mix()`, or `oklch()`, so this script - * replays the web cascade (theme.css light/dark blocks, then a built-in + * replays the web cascade (theme.css light/dark blocks, then the mobile-only + * override layer in `src/theme/mobile-overrides.css`, then a built-in * palette's overrides) for every palette × mode and resolves each token to a * plain color string. The result is committed; a vitest drift test regenerates * it in memory and fails when it no longer matches. * + * Cascade order is the whole contract: the mobile layer re-tunes the DEFAULT + * palette to the iOS system look (pure black/white anchors, systemBlue tint, + * system status colors, grouped-list surfaces) while palettes that set their + * own anchors and literals (Nord, Dracula, …) still win because they cascade + * last. Every mobile value derives from `--canvas`/`--ink` so those palettes + * keep tinting the surfaces the layer touches. + * * Run: `pnpm --filter @bb/mobile theme:generate` * (`node --conditions=source --import tsx scripts/generate-native-theme.ts`). */ @@ -21,6 +29,17 @@ const MOBILE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const APP_ROOT = join(MOBILE_ROOT, "..", "app"); const THEME_CSS_PATH = join(APP_ROOT, "src", "components", "ui", "theme.css"); const PALETTES_DIR = join(APP_ROOT, "src", "lib", "themes"); +/** + * The mobile-only override layer. Same `:root, .light` / `.dark` custom + * property shape as theme.css plus a `@theme` block for the native type ramp + * and the extra radii; the web app never loads it. + */ +export const MOBILE_OVERRIDES_CSS_PATH = join( + MOBILE_ROOT, + "src", + "theme", + "mobile-overrides.css", +); export const NATIVE_THEME_OUTPUT_PATH = join( MOBILE_ROOT, "src", @@ -441,12 +460,39 @@ export interface SkippedToken { reason: string; } +export interface NativeRadii { + base: number; + sm: number; + md: number; + lg: number; + xl: number; + /** Tailwind's `rounded-2xl` step (`--radius-2xl`). */ + xl2: number; + /** Tailwind's `rounded-full` (`--radius-full`): any pill/circle. */ + full: number; +} + +export const RADII_KEYS = [ + "base", + "sm", + "md", + "lg", + "xl", + "xl2", + "full", +] as const satisfies readonly (keyof NativeRadii)[]; + export interface NativeThemeModel { /** palette → mode → camelCase token → RN color string. */ themes: Map>>; /** Sorted camelCase color token names (identical across palettes/modes). */ tokenKeys: string[]; - radii: { base: number; sm: number; md: number; lg: number; xl: number }; + /** + * Kebab-case names the mobile layer declares that theme.css does not. The + * web has no utility class for these; global.css maps them by hand. + */ + mobileOnlyTokens: string[]; + radii: NativeRadii; /** Ordered by font size. */ typography: [name: string, style: NativeTextStyle][]; skipped: SkippedToken[]; @@ -454,12 +500,18 @@ export interface NativeThemeModel { export interface ThemeSources { themeCss: string; + /** + * Mobile-only override layer (`MOBILE_OVERRIDES_CSS_PATH`). Cascades after + * theme.css and before every palette, so palettes keep winning. + */ + mobileCss: string; /** Palette override CSS per built-in id ("" for `default`). */ paletteCss: ReadonlyMap; } function readSources(): ThemeSources { const themeCss = readFileSync(THEME_CSS_PATH, "utf8"); + const mobileCss = readFileSync(MOBILE_OVERRIDES_CSS_PATH, "utf8"); const paletteCss = new Map(); for (const id of BUILTIN_THEME_IDS) { // "default" is theme.css itself (the registry maps it to ""). @@ -474,7 +526,7 @@ function readSources(): ThemeSources { } paletteCss.set(id, css); } - return { themeCss, paletteCss }; + return { themeCss, mobileCss, paletteCss }; } function webOnlyReason(name: string): string | null { @@ -484,11 +536,29 @@ function webOnlyReason(name: string): string | null { return null; } -/** Radii from the `@theme inline` block: `--radius-*` derived from `--radius`. */ +/** Custom properties of every `@theme` block (not `@theme inline`) in `css`. */ +function themeBlockProperties(css: string): Map { + const declared = new Map(); + for (const rule of splitRules(stripComments(css))) { + if (rule.prelude !== "@theme") continue; + for (const [name, value] of parseCustomProperties(rule.body)) { + declared.set(name, value); + } + } + return declared; +} + +/** + * Radii: `--radius-sm|md|lg|xl` from theme.css's `@theme inline` block + * (derived from `--radius`), plus `--radius-2xl` / `--radius-full` from the + * mobile layer's `@theme` block — the web leaves those at Tailwind's defaults + * (1rem and `calc(infinity * 1px)`), which the native side needs as numbers. + */ function readRadii( themeCss: string, + mobileCss: string, lightTokens: ReadonlyMap, -): NativeThemeModel["radii"] { +): NativeRadii { const inline = splitRules(stripComments(themeCss)).find( (rule) => rule.prelude === "@theme inline", ); @@ -500,28 +570,39 @@ function readRadii( throw new Error(`--${name} missing in @theme inline`); return lengthToPx(substituteVars(raw, lightTokens)); }; + const mobile = themeBlockProperties(mobileCss); + const mobileRadius = (name: string): number => { + const raw = mobile.get(name); + if (raw === undefined) { + throw new Error(`--${name} missing in mobile-overrides.css @theme`); + } + return lengthToPx(raw); + }; return { base: lengthToPx(substituteVars("var(--radius)", lightTokens)), sm: radius("radius-sm"), md: radius("radius-md"), lg: radius("radius-lg"), xl: radius("radius-xl"), + xl2: mobileRadius("radius-2xl"), + full: mobileRadius("radius-full"), }; } /** - * `--text-*` scale: the `@theme` overrides, then the coarse-pointer - * `@media … (pointer: coarse) { :root {…} }` block on top. Touch sizes are the - * native base (there is no fine pointer on a phone). + * `--text-*` scale: theme.css's `@theme` overrides, then its coarse-pointer + * `@media … (pointer: coarse) { :root {…} }` block (touch sizes are the + * native base; there is no fine pointer on a phone), then the mobile layer's + * `@theme` block on top, which carries the Apple text-style ramp. */ -function readTypography(themeCss: string): NativeThemeModel["typography"] { +function readTypography( + themeCss: string, + mobileCss: string, +): NativeThemeModel["typography"] { const declared = new Map(); const rules = splitRules(stripComments(themeCss)); - for (const rule of rules) { - if (rule.prelude !== "@theme") continue; - for (const [name, value] of parseCustomProperties(rule.body)) { - if (name.startsWith("text-")) declared.set(name, value); - } + for (const [name, value] of themeBlockProperties(themeCss)) { + if (name.startsWith("text-")) declared.set(name, value); } for (const rule of rules) { if ( @@ -537,6 +618,9 @@ function readTypography(themeCss: string): NativeThemeModel["typography"] { } } } + for (const [name, value] of themeBlockProperties(mobileCss)) { + if (name.startsWith("text-")) declared.set(name, value); + } const styles: [string, NativeTextStyle][] = []; for (const [name, value] of declared) { if (name.includes("--")) continue; @@ -565,15 +649,53 @@ function describeNonColor(name: string, value: string): string { return `unsupported value (${value.slice(0, 40)})`; } -/** Builds the full native theme model from theme.css + palette CSS strings. */ +/** + * A token the mobile layer sets under `:root` must also get a `.dark` value: + * `:root` reaches dark mode too, so without one the override would silently + * replace theme.css's dark value. `.dark`-only overrides are fine (light keeps + * the web value), as are `.light`-only ones. + */ +function assertRootOverridesHaveDarkTwins(rules: ModeRule[]): void { + const viaRoot = new Set(); + const viaDark = new Set(); + for (const rule of rules) { + const target = + rule.modes.length === 2 + ? viaRoot + : rule.modes[0] === "dark" + ? viaDark + : null; + if (target === null) continue; + for (const [name] of rule.declarations) target.add(name); + } + const missing = [...viaRoot].filter((name) => !viaDark.has(name)); + if (missing.length > 0) { + throw new Error( + `mobile-overrides.css sets ${missing.map((name) => `--${name}`).join(", ")} under \`:root\` (which reaches dark mode) without a \`.dark\` value`, + ); + } +} + +/** + * Builds the full native theme model from theme.css, the mobile override + * layer, and the palette CSS strings. + */ export function buildNativeThemeModel( sources: ThemeSources = readSources(), ): NativeThemeModel { const baseRules = modeRules(sources.themeCss); + const mobileRules = modeRules(sources.mobileCss); + assertRootOverridesHaveDarkTwins(mobileRules); + // The default palette is theme.css with the mobile layer on top; every other + // palette cascades after both, so its anchors and literals keep winning. const defaultTokens = { - light: cascade("light", [baseRules]), - dark: cascade("dark", [baseRules]), + light: cascade("light", [baseRules, mobileRules]), + dark: cascade("dark", [baseRules, mobileRules]), }; + const webNames = new Set([ + ...cascade("light", [baseRules]).keys(), + ...cascade("dark", [baseRules]).keys(), + ]); // Classify every token once, from the default palette. The set of native // color tokens must be identical in both modes: a token added to one mode @@ -624,7 +746,7 @@ export function buildNativeThemeModel( } if (oneModeOnly.length > 0) { throw new Error( - `theme.css defines tokens in one mode only: ${oneModeOnly.map((name) => `--${name}`).join(", ")}`, + `theme.css + mobile-overrides.css define tokens in one mode only: ${oneModeOnly.map((name) => `--${name}`).join(", ")}`, ); } @@ -643,7 +765,7 @@ export function buildNativeThemeModel( } } const resolveMode = (mode: Mode): Record => { - const tokens = cascade(mode, [baseRules, paletteRules]); + const tokens = cascade(mode, [baseRules, mobileRules, paletteRules]); const resolved: Record = {}; for (const name of colorNames) { const raw = tokens.get(name); @@ -665,8 +787,9 @@ export function buildNativeThemeModel( return { themes, tokenKeys: colorNames.map(camelCase).sort(), - radii: readRadii(sources.themeCss, defaultTokens.light), - typography: readTypography(sources.themeCss), + mobileOnlyTokens: colorNames.filter((name) => !webNames.has(name)), + radii: readRadii(sources.themeCss, sources.mobileCss, defaultTokens.light), + typography: readTypography(sources.themeCss, sources.mobileCss), skipped, }; } @@ -694,19 +817,29 @@ export function renderNativeThemeSource(model: NativeThemeModel): string { const skippedLines = model.skipped.map( ({ name, reason }) => ` * --${name}: ${reason}`, ); + const mobileOnlyLines = model.mobileOnlyTokens.map( + (name) => ` * --${name}`, + ); const header = [ "/**", " * GENERATED FILE — run pnpm --filter @bb/mobile theme:generate", " *", - " * Source: apps/app/src/components/ui/theme.css and the built-in palettes in", - " * apps/app/src/lib/themes/*.ts, replayed through the web cascade per palette", - " * and mode by apps/mobile/scripts/generate-native-theme.ts.", + " * Source: apps/app/src/components/ui/theme.css, then the mobile-only override", + " * layer apps/mobile/src/theme/mobile-overrides.css, then the built-in palettes", + " * in apps/app/src/lib/themes/*.ts, replayed through the web cascade per", + " * palette and mode by apps/mobile/scripts/generate-native-theme.ts. The", + " * mobile layer re-tunes the default palette to the iOS system look; palettes", + " * cascade after it, so their anchors and literals still win.", " *", " * `var()` is substituted textually; `color-mix(in oklch|oklab, …)` is", " * evaluated like Chrome (premultiplied alpha, shorter hue arc, converted", " * near-achromatic operands lose their hue). Opaque results are `#rrggbb`,", - " * translucent ones `rgba(r, g, b, a)`. Typography uses the coarse-pointer", - " * (touch) sizes as the base scale, in CSS pixels.", + " * translucent ones `rgba(r, g, b, a)`. Typography is the Apple text-style", + " * ramp from the mobile layer's `@theme` block, in CSS pixels.", + " *", + " * Tokens only the mobile layer defines (no web utility class; global.css", + " * maps them by hand):", + ...mobileOnlyLines, " *", " * Tokens deliberately left out (edit the generator to add them):", ...skippedLines, @@ -749,11 +882,12 @@ export function renderNativeThemeSource(model: NativeThemeModel): string { ]; const radiiLines = [ - "/** `--radius` and the Tailwind `--radius-*` steps, in CSS pixels. */", + "/**", + " * `--radius` and the Tailwind `--radius-*` steps, in CSS pixels. `xl2` is", + " * `rounded-2xl`; `full` is `rounded-full` (pills, circles).", + " */", "export const nativeRadii = {", - ...(["base", "sm", "md", "lg", "xl"] as const).map( - (key) => ` ${key}: ${model.radii[key]},`, - ), + ...RADII_KEYS.map((key) => ` ${key}: ${model.radii[key]},`), "};", "", ]; @@ -765,9 +899,9 @@ export function renderNativeThemeSource(model: NativeThemeModel): string { "}", "", "/**", - " * The `--text-*` scale theme.css overrides, using the coarse-pointer (touch)", - " * values as the base, in CSS pixels. Sizes theme.css does not override keep", - " * Tailwind's defaults.", + " * The `--text-*` scale: theme.css's coarse-pointer (touch) values with the", + " * mobile layer's Apple text-style ramp on top (caption2 → largeTitle), in", + " * CSS pixels. Mirrored as ratios in global.css (theme-vars.test.ts).", " */", "export const nativeTypography = {", ...model.typography.flatMap(([name, style]) => [ diff --git a/apps/mobile/src/ansi/TerminalOutputBlock.tsx b/apps/mobile/src/ansi/TerminalOutputBlock.tsx index 783d84e1dc..c3394d692a 100644 --- a/apps/mobile/src/ansi/TerminalOutputBlock.tsx +++ b/apps/mobile/src/ansi/TerminalOutputBlock.tsx @@ -30,11 +30,16 @@ export interface TerminalOutputBlockProps { testID?: string; } +/** Card corners: continuous 10pt, the grouped-card radius. */ +const CARD_STYLE = { borderRadius: 10, borderCurve: "continuous" } as const; + /** * Command card mirroring the web `TerminalOutputBlock`: command line, * metadata, ANSI-colored output in a horizontally scrolling monospace block * that collapses to its tail with an "N earlier lines" toggle, exit code. - * The web dims the whole card to 70%; so does this one. + * The web dims the whole card to 70%; here the card stays opaque and the + * secondary text tiers carry the recession (a translucent card washes out + * over the timeline background). */ export const TerminalOutputBlock = memo(function TerminalOutputBlock({ output, @@ -65,7 +70,7 @@ export const TerminalOutputBlock = memo(function TerminalOutputBlock({ "overflow-hidden rounded-lg border border-border bg-card", className, )} - style={{ opacity: 0.7 }} + style={CARD_STYLE} testID={testID} accessibilityState={streaming ? { busy: true } : undefined} > @@ -82,6 +87,7 @@ export const TerminalOutputBlock = memo(function TerminalOutputBlock({ variant="mono" className="text-xs text-foreground" numberOfLines={commandExpanded ? undefined : 2} + selectable testID={testID ? `${testID}-command` : undefined} > {commandLine} @@ -130,6 +136,7 @@ export const TerminalOutputBlock = memo(function TerminalOutputBlock({ fontSize={TERMINAL_FONT_SIZE} lineHeight={TERMINAL_LINE_HEIGHT} numberOfLines={1} + selectable /> ))} @@ -155,6 +162,7 @@ export const TerminalOutputBlock = memo(function TerminalOutputBlock({ "text-xs text-muted-foreground", (hasOutput || commandLine) && "mt-1.5", )} + numeric testID={testID ? `${testID}-exit-code` : undefined} > exit code {exitCode} diff --git a/apps/mobile/src/composer/AttachmentChips.tsx b/apps/mobile/src/composer/AttachmentChips.tsx index 55dfa59316..014184184d 100644 --- a/apps/mobile/src/composer/AttachmentChips.tsx +++ b/apps/mobile/src/composer/AttachmentChips.tsx @@ -1,7 +1,13 @@ import type { PromptDraftAttachment } from "@bb/client-core"; import { Image } from "expo-image"; import { useState } from "react"; -import { Pressable, ScrollView, View } from "react-native"; +import { Pressable, ScrollView, StyleSheet, View } from "react-native"; +import Animated, { + FadeIn, + FadeOut, + LinearTransition, +} from "react-native-reanimated"; +import { haptic } from "@/lib/haptics"; import { ImageLightbox, openLightbox, @@ -27,16 +33,18 @@ export interface AttachmentChipsProps { const THUMB = 64; const THUMB_RADIUS = 12; -/** The corner remove button on an image thumbnail. */ +/** The corner remove badge on an image thumbnail. */ const REMOVE_BUTTON = 20; -// The remove button sits on the photograph, so it is black/white like the -// lightbox chrome (web `bg-black/55 text-white`), not a palette token. -const REMOVE_BUTTON_BACKGROUND = "rgba(0, 0, 0, 0.6)"; -const REMOVE_BUTTON_PRESSED_BACKGROUND = "rgba(0, 0, 0, 0.8)"; +// The remove badge sits on the photograph, so it is black/white like the +// lightbox chrome (web `bg-black/55 text-white`), not a palette token: a +// white `xmark.circle.fill` over a soft shadow. const REMOVE_BUTTON_FOREGROUND = "#ffffff"; +const REMOVE_BUTTON_SHADOW = "0 1px 3px rgba(0, 0, 0, 0.4)"; +const CHIP_ENTER_MS = 180; +const CHIP_EXIT_MS = 140; /** - * An image thumbnail with a small remove button in its top-right corner. A + * An image thumbnail with a small remove badge in its top-right corner. A * tap on the picture opens the lightbox. */ function ImageChip({ @@ -54,7 +62,10 @@ function ImageChip({ }) { const { tokens } = useTheme(); return ( - - + ) : null} - + ); } +/** A file chip: capsule, hairline outline, optional remove. */ function ChipFrame({ children, onRemove, @@ -123,18 +140,21 @@ function ChipFrame({ }) { const { tokens } = useTheme(); return ( - - + ) : null} - + ); } @@ -172,6 +197,7 @@ interface ResolvedAttachment { /** * Horizontal strip of attached files (image thumbnails, file chips, uploads * in flight). Image thumbnails open the same lightbox as timeline images. + * Chips fade in and out and the strip reflows as they come and go. */ export function AttachmentChips({ attachments, @@ -199,6 +225,10 @@ export function AttachmentChips({ ({ attachment, uri }) => uri === null ? [] : [{ src: uri, alt: attachment.name }], ); + const remove = (path: string) => { + haptic("selection"); + onRemove(path); + }; return ( <> @@ -214,9 +244,9 @@ export function AttachmentChips({ testID={testID} > {resolved.map(({ attachment, uri }, index) => { - const remove = disabled + const removeThis = disabled ? undefined - : () => onRemove(attachment.path); + : () => remove(attachment.path); if (uri !== null) { const imageIndex = lightboxImages.findIndex( (image) => image.src === uri, @@ -229,7 +259,7 @@ export function AttachmentChips({ onPress={() => setLightbox(openLightbox(lightboxImages, imageIndex)) } - onRemove={remove} + onRemove={removeThis} testID={`${testID}-${index}`} /> ); @@ -238,14 +268,17 @@ export function AttachmentChips({ - + @@ -258,16 +291,20 @@ export function AttachmentChips({ })} {pending.map((entry) => entry.previewUri ? ( - - + ) : ( - + {entry.name} diff --git a/apps/mobile/src/composer/Composer.tsx b/apps/mobile/src/composer/Composer.tsx index 9a6531c9e0..47ba432fba 100644 --- a/apps/mobile/src/composer/Composer.tsx +++ b/apps/mobile/src/composer/Composer.tsx @@ -14,22 +14,34 @@ import { type ReactNode, type RefObject, } from "react"; -import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; +import { + Pressable, + StyleSheet, + View, + type StyleProp, + type ViewStyle, +} from "react-native"; +import Animated, { + FadeIn, + FadeOut, + LinearTransition, +} from "react-native-reanimated"; import { haptic } from "@/lib/haptics"; import { useProfileClient } from "@/app-shell/ProfilesProvider"; import { buildProjectAttachmentContentUrl } from "@/data/thread-detail"; import { useSystemConfig, useSystemProviders } from "@/data/system"; import { useTheme } from "@/theme"; import { - ActionSheet, Button, + GlassSurface, Icon, LONG_PRESS_DELAY_MS, + NativeMenu, SheetPresenceContext, Spinner, useOverlayBounds, - useSheet, - type ActionSheetAction, + type NativeMenuAction, + type SFSymbol, } from "@/ui"; import { AttachmentChips } from "./AttachmentChips"; import { ComposerInput, type ComposerInputHandle } from "./ComposerInput"; @@ -66,6 +78,8 @@ import { import { useComposerVoice } from "./useComposerVoice"; import { VoiceBar } from "./VoiceBar"; +const IS_IOS = process.env.EXPO_OS === "ios"; + export interface ComposerHandle { focus: () => void; blur: () => void; @@ -124,12 +138,36 @@ const EMPTY_ACTIONS: readonly ComposerAction[] = []; /** A blur this close before a sheet opens is the sheet's keyboard dismissal. */ const BLUR_FOR_SHEET_MS = 600; +/** Card corners: the pill and the expanded card (web parity). */ +const CARD_RADIUS_COLLAPSED = 26; +const CARD_RADIUS_EXPANDED = 22; +/** Footer / pill control metrics: the 36pt circle the send button draws. */ +const CONTROL_SIZE = 36; +/** iOS: the filled circle symbols are the buttons (send / queue / stop). */ +const SYMBOL_SIZE = 32; +/** iOS: the "+" and mic glyph sizes. */ +const PLUS_SYMBOL_SIZE = 28; +const MIC_SYMBOL_SIZE = 22; +const ROW_FADE_MS = 120; +const CARD_LAYOUT_MS = 200; + +/** The iOS symbol for a "+" menu entry (the map covers the rest). */ +const ATTACHMENT_SYMBOLS: Record< + "photo-library" | "camera" | "file", + SFSymbol +> = { + "photo-library": "photo.on.rectangle", + camera: "camera", + file: "paperclip", +}; + /** * The shared native composer (root compose + follow-up): mention pills in a * native `TextInput`, `@` / `#` / `/` typeahead, attachments (library, * camera, files → `POST /projects/:id/attachments`), voice (expo-audio → - * `POST /system/voice-transcription`), the "+" actions menu, execution - * pills, and a submit button driven by the client-core submit mode. + * `POST /system/voice-transcription`), the "+" actions menu (a native + * pull-down menu on iOS, the action sheet elsewhere), execution pills, + * and a submit button driven by the client-core submit mode. */ export const Composer = forwardRef( function Composer( @@ -344,18 +382,18 @@ export const Composer = forwardRef( voice.state === "recording" || voice.state === "transcribing"; // --- "+" menu ----------------------------------------------------------- - const actionsSheet = useSheet(); const applyPromptAction = usePromptActionApplier({ valueRef, inputRef, commit, }); - const sheetActions = useMemo((): ActionSheetAction[] => { - const rows: ActionSheetAction[] = [ + const menuActions = useMemo((): NativeMenuAction[] => { + const rows: NativeMenuAction[] = [ { key: "photo-library", label: "Photo library", icon: "Eye", + symbol: ATTACHMENT_SYMBOLS["photo-library"], disabled: scope.projectId === null, onPress: () => void attachmentsController.pickFromLibrary(), }, @@ -363,6 +401,7 @@ export const Composer = forwardRef( key: "camera", label: "Take photo", icon: "Smartphone", + symbol: ATTACHMENT_SYMBOLS.camera, disabled: scope.projectId === null, onPress: () => void attachmentsController.takePhoto(), }, @@ -370,6 +409,7 @@ export const Composer = forwardRef( key: "file", label: "Attach file", icon: "Paperclip", + symbol: ATTACHMENT_SYMBOLS.file, disabled: scope.projectId === null, onPress: () => void attachmentsController.pickDocument(), }, @@ -402,6 +442,41 @@ export const Composer = forwardRef( promptActionModel.actions, scope.projectId, ]); + // Inert while an attachment upload is in flight too: the spinner replaces + // the glyph and a second pick would race the upload. + const plusInert = + disabled || isSubmitting || attachmentsController.isUploading; + // An icon-only trigger: the menu host is the accessible element (label, + // role, state, testID), the glyph view inside it is not one. + const plusButton = ( + + + {attachmentsController.isUploading ? ( + + ) : ( + + )} + + + ); // --- Submit ------------------------------------------------------------- const hasInput = hasComposerText(value) || attachments.length > 0; @@ -414,7 +489,7 @@ export const Composer = forwardRef( }); const submit = useCallback( (kind: ComposerSubmitKind) => { - haptic("impact-medium"); + haptic("impact-light"); void onSubmit(kind); }, [onSubmit], @@ -449,6 +524,38 @@ export const Composer = forwardRef( /> ) : null; + const micButton = ( + { + haptic("impact-light"); + void voice.start(); + }} + testID={`${testID}-voice`} + /> + ); + + // The card's shape (the pill and the expanded card) is what Liquid Glass + // refracts through on iOS 26; the fill and border are the fallback + // surface (older iOS: secondary fill + hairline; Android: the web card). + const cardShape: ViewStyle = { + borderRadius: collapsed ? CARD_RADIUS_COLLAPSED : CARD_RADIUS_EXPANDED, + paddingHorizontal: collapsed ? 6 : 0, + }; + const cardStyle: ViewStyle = IS_IOS + ? { ...cardShape, borderCurve: "continuous" } + : cardShape; + const cardFallbackStyle: ViewStyle = IS_IOS + ? { + borderWidth: StyleSheet.hairlineWidth, + borderColor: tokens.borderHairline, + backgroundColor: tokens.secondary, + } + : { + borderWidth: 1, + borderColor: focused && !collapsed ? tokens.ring : tokens.input, + backgroundColor: tokens.card, + }; + return ( ( {menuNode} ) : null} - ( > {header} {!collapsed && topControls ? ( - {topControls} - + ) : null} ( {/* The input keeps its tree position in both layouts so the pill → card transition never remounts it (that would drop focus). */} - {collapsed ? ( - ) : 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..dd254eaff7 100644 --- a/apps/mobile/src/screens/home/HomeScreen.tsx +++ b/apps/mobile/src/screens/home/HomeScreen.tsx @@ -1,5 +1,6 @@ import { Redirect, + Stack, useLocalSearchParams, useNavigation, useRouter, @@ -12,11 +13,21 @@ import { useRef, useState, } from "react"; -import { Animated, Keyboard, Pressable, View } from "react-native"; +import { + Animated, + Keyboard, + Pressable, + StyleSheet, + View, + type LayoutChangeEvent, + type ViewStyle, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useProfiles } from "@/app-shell"; import type { ComposerHandle } from "@/composer"; -import { blendOver, withAlpha } from "@/markdown/colors"; +import { useSidebarPreferences } from "@/data/sidebar"; +import { haptic } from "@/lib/haptics"; +import { withAlpha } from "@/markdown/colors"; import { scrimBaseColor, useTheme } from "@/theme"; import { Button, @@ -25,99 +36,174 @@ import { COMPOSER_KEYBOARD_GAP, KeyboardPaddingView, OverlayBounds, + sfSymbolFor, Spinner, Text, + useLiquidGlass, } from "@/ui"; import { ComposeDock } from "../compose/ComposeDock"; import { useComposeController, type ComposeParams, } from "../compose/useComposeController"; +import { ConnectionBanner } from "../shell/ConnectionBanner"; import { threadHref, threadSearchHref } from "../shell/hrefs"; import { Screen } from "../shell/Screen"; -import { WorkspaceMenuButton } from "../shell/WorkspaceMenu"; +import { ScreenTitle } from "../shell/ScreenTitle"; +import { WorkspaceMenuButton, WorkspaceToolbar } from "../shell/WorkspaceMenu"; import { + ORGANIZE_OPTIONS, SidebarActionsProvider, SidebarThreadList, + SORT_OPTIONS, useSidebarActions, } from "../sidebar"; +import { ThreadSearchResults } from "../threads/ThreadSearchResults"; + +const IS_IOS = process.env.EXPO_OS === "ios"; const SCRIM_DURATION_MS = 180; /** Opacity of the scrim over the list while the dock is expanded. */ const SCRIM_ALPHA = 0.35; +/** Gap between the last row and the dock (in flow, or floating over it). */ +const LIST_BOTTOM_GAP = 16; +/** Liquid Glass: the dock host floats at the bottom of the overlay bounds. */ +const FLOATING_DOCK_STYLE: ViewStyle = { + position: "absolute", + left: 0, + right: 0, + bottom: 0, +}; -/** - * Home header: the server label as the title, the workspace menu (server - * switcher / archived / Settings) on the left. Rendered in every ready state - * so the menu is reachable before a server connects. - */ -function HomeHeaderShell({ dimmed = false }: { dimmed?: boolean }) { +/** Android: the workspace avatar button on the header's left. */ +function HomeWorkspaceButton() { const navigation = useNavigation(); - const { activeProfile } = useProfiles(); useLayoutEffect(() => { navigation.setOptions({ - title: activeProfile?.label ?? "bb", - headerLeft: () => , + headerLeft: () => , }); - }, [activeProfile?.label, dimmed, navigation]); + }, [navigation]); return null; } /** - * Search + display-options buttons in the home header (set from inside the - * provider). While the dock is expanded the header is painted the same gray - * as the scrim (it is navigator chrome above the screen, so the scrim view - * cannot cover it) and its controls are muted. + * Home header: the server label as the (large) title and the workspace menu + * — server switcher / archived / Settings — on the left, a native pull-down + * on iOS and the avatar button's sheet on Android. Rendered in every ready + * state so the menu is reachable before a server connects. + */ +function HomeHeaderShell() { + const { activeProfile } = useProfiles(); + return ( + <> + {activeProfile?.label ?? "bb"} + {IS_IOS ? : } + + ); +} + +/** + * iOS: the display-options pull-down on the header's right — organize and + * sort as checked groups, then the section commands. Preferences apply as + * soon as an item is picked. + */ +function HomeDisplayOptionsToolbar() { + const actions = useSidebarActions(); + const [preferences, preferenceActions] = useSidebarPreferences(); + return ( + + + + {ORGANIZE_OPTIONS.map((option) => ( + { + haptic("selection"); + preferenceActions.setOrganize(option.mode); + }} + > + {option.label} + + ))} + + + {SORT_OPTIONS.map((option) => ( + { + haptic("selection"); + preferenceActions.setSort(option.sort); + }} + > + {option.label} + + ))} + + actions.openSectionCreate(null)} + > + New section… + + + Reorder sections… + + + + ); +} + +/** + * Android: search + display-options buttons in the home header (set from + * inside the provider; iOS has the header search bar and the native menu). */ -function HomeHeaderActions({ dimmed }: { dimmed: boolean }) { +function HomeHeaderActionsAndroid() { const navigation = useNavigation(); const router = useRouter(); - const { tokens, fonts, mode } = useTheme(); + const { tokens } = useTheme(); const actions = useSidebarActions(); - const scrimColor = scrimBaseColor(mode, tokens); - const background = dimmed - ? blendOver(tokens.background, scrimColor, SCRIM_ALPHA) - : tokens.background; - const foreground = dimmed - ? blendOver(tokens.foreground, scrimColor, SCRIM_ALPHA) - : tokens.foreground; useLayoutEffect(() => { navigation.setOptions({ - headerStyle: { backgroundColor: background }, - headerTintColor: foreground, - headerTitleStyle: { - fontFamily: fonts.sans.semibold, - fontWeight: "600", - color: foreground, - }, headerRight: () => ( router.push(threadSearchHref())} className="h-10 w-10 items-center justify-center rounded-full active:bg-state-hover" testID="home-search" > - + - + ), }); - }, [actions, background, dimmed, fonts, foreground, navigation, router]); + }, [actions, navigation, router, tokens.foreground]); return null; } @@ -196,7 +282,9 @@ function useNewThreadRouteParams(): { * project's "+", or a routed new-thread request) expands it in place over a * scrim that dims the list, with the where-it-runs pickers on top and the * agent pickers below the prompt. Creating a thread collapses the dock and - * opens the thread. + * opens the thread. On iOS the header search bar searches in place: while + * it is open the list shows recent threads, then results, and the dock + * steps aside. */ function HomeBody() { const insets = useSafeAreaInsets(); @@ -208,6 +296,25 @@ function HomeBody() { const [expanded, setExpanded] = useState(false); const [scrim] = useState(() => new Animated.Value(0)); const [scrimMounted, setScrimMounted] = useState(false); + const [query, setQuery] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); + const searching = IS_IOS && (searchOpen || query.trim().length > 0); + + // Liquid Glass (iOS 26): the dock floats over the list as a glass pill + // and the rows scroll under it. The host reports its height; the rows pad + // for the part of it above the list's bottom edge (the home-indicator + // padding sits below that edge). + const glass = useLiquidGlass(); + const dockBottomPadding = Math.max(insets.bottom, 8); + const [dockHeight, setDockHeight] = useState(0); + const handleDockLayout = useCallback((event: LayoutChangeEvent) => { + setDockHeight(event.nativeEvent.layout.height); + }, []); + const floatingDock = glass && !searching; + const dockOverlap = floatingDock + ? Math.max(0, dockHeight - dockBottomPadding) + : 0; + const listBottomPadding = dockOverlap + LIST_BOTTOM_GAP; const animateScrim = useCallback( (open: boolean) => { @@ -262,10 +369,39 @@ function HomeBody() { [router], ); + const closeSearch = useCallback(() => { + setSearchOpen(false); + setQuery(""); + }, []); + + // The banner scrolls with the rows, right under the large title. + const banner = ; + return ( - - + + {IS_IOS ? ( + <> + + setQuery(event.nativeEvent.text)} + onOpen={() => setSearchOpen(true)} + onFocus={() => setSearchOpen(true)} + onClose={closeSearch} + onCancelButtonPress={closeSearch} + /> + + ) : ( + + )} - - + {/* Floating dock: the list's frame ends at the pill's bottom edge + (the home-indicator inset stays outside it), so the scroll + view's own safe-area inset stays zero and the content padding + below is exact — RN's `scrollToEnd` ignores that inset. */} + + {searching ? ( + + ) : ( + + )} {/* The scrim dims everything under the card — the list and the - dock's own margins — so the expanded card floats over it. It - overhangs the bounds by the keyboard gap: on devices without a - home-indicator inset the bounds end that far above the - keyboard, and the strip would otherwise show undimmed. */} + dock's own margins — so the expanded card floats over it (and + shows through the header's material). It overhangs the bounds + by the keyboard gap: on devices without a home-indicator inset + the bounds end that far above the keyboard, and the strip + would otherwise show undimmed. */} {scrimMounted ? ( ) : null} + {/* The dock host: a hairline above the collapsed pill on the page + background; while the card is expanded the scrim owns the + region and the rule disappears. With Liquid Glass the host is + a transparent overlay at the bottom of the bounds instead — + the glass pill floats over the rows, and the host's own + margins pass touches through (to the rows, or to the scrim + while expanded). Hidden (kept mounted) while the header + search bar is open. */} + ); diff --git a/apps/mobile/src/screens/home/ServerInfoCard.tsx b/apps/mobile/src/screens/home/ServerInfoCard.tsx index c88148e7da..930cc61770 100644 --- a/apps/mobile/src/screens/home/ServerInfoCard.tsx +++ b/apps/mobile/src/screens/home/ServerInfoCard.tsx @@ -116,7 +116,7 @@ export function ServerInfoCard() { {config.isPending ? ( ) : config.isError ? ( - + {describeError(config.error)} ) : ( diff --git a/apps/mobile/src/screens/index.ts b/apps/mobile/src/screens/index.ts index 039d24f2a6..7495b12180 100644 --- a/apps/mobile/src/screens/index.ts +++ b/apps/mobile/src/screens/index.ts @@ -15,6 +15,7 @@ export { MachinesScreen } from "./machines/MachinesScreen"; export { TerminalScreen } from "./terminal/TerminalScreen"; export { ThreadTerminalsScreen } from "./terminal/ThreadTerminalsScreen"; export { RootNavigator } from "./shell/RootNavigator"; +export { ScreenTitle } from "./shell/ScreenTitle"; export { RouteErrorBoundary } from "./shell/RouteErrorBoundary"; export { ArchivedThreadsScreen } from "./threads/ArchivedThreadsScreen"; export { ThreadDetailScreen } from "./thread/ThreadDetailScreen"; diff --git a/apps/mobile/src/screens/machines/AddMachineSheet.tsx b/apps/mobile/src/screens/machines/AddMachineSheet.tsx index 8f216214ba..d5dcf4003d 100644 --- a/apps/mobile/src/screens/machines/AddMachineSheet.tsx +++ b/apps/mobile/src/screens/machines/AddMachineSheet.tsx @@ -1,11 +1,12 @@ import * as Clipboard from "expo-clipboard"; import { useRouter } from "expo-router"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { Linking, View } from "react-native"; import { formatCountdown, type AddMachineSession } from "@/data/hosts"; import { useTheme } from "@/theme"; import { Button, + GROUPED_CARD_RADIUS, Sheet, Spinner, Text, @@ -24,6 +25,29 @@ interface AddMachineSheetProps { session: AddMachineSession; } +/** A recessed panel inside the sheet (the command, the connection status). */ +function SheetPanel({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + const { tokens } = useTheme(); + return ( + + {children} + + ); +} + /** * Add-a-machine pairing (web AddMachineDialog): mints a join code (and a * connect machine code when bb connect is paired), shows the installer @@ -55,7 +79,7 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) { onDismiss={session.end} > - + {presentation.kind === "unreachable" ? "Pair a machine to run projects and threads on it." : "Run this on the machine you want to add. It pairs the machine to this server and keeps it available for your projects."} @@ -71,7 +95,7 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) { ) : presentation.kind === "error" || presentation.kind === "connect-unavailable" ? ( - + {presentation.kind === "connect-unavailable" ? "Remote access isn't ready yet." : presentation.message} @@ -87,8 +111,10 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) { ) : presentation.kind === "unreachable" ? ( - - Another machine cannot use this address. + + + Another machine cannot use this address. + The pairing command would target{" "} @@ -124,10 +150,10 @@ export function AddMachineSheet({ controller, session }: AddMachineSheetProps) { Other options - + ) : ( - + {presentation.command} - + - + ); } @@ -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 ? ( -