From 2ab712188d91631731233716bb4c5d7299889005 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 03:52:03 +0800 Subject: [PATCH 01/37] feat(mobile): add android form vocabulary behind the platform seam --- apps/mobile/src/app/(tabs)/threads/index.tsx | 2 +- .../components/account/devices-section.tsx | 2 +- .../src/components/account/profile-row.tsx | 2 +- .../connect/my-machines-section.tsx | 2 +- .../tool-detail-sheet/diff-block.tsx | 2 +- .../tool-detail-sheet/tool-detail-sheet.tsx | 2 +- .../src/components/form/list.android.tsx | 12 ++++ .../components/form/navigation-row.ios.tsx | 29 +++++++++ .../src/components/form/navigation-row.tsx | 51 ++++++++------- .../components/form/navigation-row.types.ts | 7 +++ .../src/components/form/rows.android.tsx | 62 +++++++++++++++++++ .../src/components/form/section.android.tsx | 44 +++++++++++++ .../form/{styles.ts => styles.ios.ts} | 0 .../components/host/host-connection-state.tsx | 2 +- .../host/thread-list/thread-row.tsx | 2 +- 15 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 apps/mobile/src/components/form/list.android.tsx create mode 100644 apps/mobile/src/components/form/navigation-row.ios.tsx create mode 100644 apps/mobile/src/components/form/navigation-row.types.ts create mode 100644 apps/mobile/src/components/form/rows.android.tsx create mode 100644 apps/mobile/src/components/form/section.android.tsx rename apps/mobile/src/components/form/{styles.ts => styles.ios.ts} (100%) diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index 052d99180..4fe7a58cf 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -15,7 +15,7 @@ import { repositoryLabel, withoutAutomationSessions, } from '@linkcode/ui/native'; -import { SECONDARY } from '@mobile/components/form/styles'; +import { SECONDARY } from '@mobile/components/form/styles.ios'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; diff --git a/apps/mobile/src/components/account/devices-section.tsx b/apps/mobile/src/components/account/devices-section.tsx index d5c370d05..944f674d9 100644 --- a/apps/mobile/src/components/account/devices-section.tsx +++ b/apps/mobile/src/components/account/devices-section.tsx @@ -9,7 +9,7 @@ import { VStack, } from '@expo/ui/swift-ui'; import { badge, buttonStyle, disabled, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; -import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles'; +import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; import { signOutOfCloud } from '@mobile/runtime/cloud/account'; import type { CloudDevice } from '@mobile/runtime/cloud/devices'; import { fetchDevices, getEnrolledDeviceId, revokeDevice } from '@mobile/runtime/cloud/devices'; diff --git a/apps/mobile/src/components/account/profile-row.tsx b/apps/mobile/src/components/account/profile-row.tsx index 59c45e5b4..93d095e17 100644 --- a/apps/mobile/src/components/account/profile-row.tsx +++ b/apps/mobile/src/components/account/profile-row.tsx @@ -1,6 +1,6 @@ import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; import { font } from '@expo/ui/swift-ui/modifiers'; -import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles'; +import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; import type { CloudUser } from '@mobile/runtime/cloud/account'; /** SF Symbol rather than the account's picture: `@expo/ui`'s `Image` takes SF Symbols, asset diff --git a/apps/mobile/src/components/connect/my-machines-section.tsx b/apps/mobile/src/components/connect/my-machines-section.tsx index df8ee5b19..f7e7e04ca 100644 --- a/apps/mobile/src/components/connect/my-machines-section.tsx +++ b/apps/mobile/src/components/connect/my-machines-section.tsx @@ -1,7 +1,7 @@ import { Button, HStack, ProgressView, Section, Spacer, Text } from '@expo/ui/swift-ui'; import { buttonStyle, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; import { NavigationRow } from '@mobile/components/form/navigation-row'; -import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles'; +import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; import { ensureDeviceRegistered } from '@mobile/runtime/cloud/devices'; import type { OnlineHost } from '@mobile/runtime/cloud/hosts'; import { fetchOnlineHosts } from '@mobile/runtime/cloud/hosts'; diff --git a/apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.tsx b/apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.tsx index 6bac4b203..42de9181b 100644 --- a/apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.tsx +++ b/apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.tsx @@ -1,7 +1,7 @@ import { HStack, ScrollView, Spacer, Text, VStack } from '@expo/ui/swift-ui'; import { font, foregroundStyle, lineLimit } from '@expo/ui/swift-ui/modifiers'; import { diffLines, patchLines } from '@linkcode/ui/native'; -import { SECONDARY } from '@mobile/components/form/styles'; +import { SECONDARY } from '@mobile/components/form/styles.ios'; const MONO_FOOTNOTE = font({ textStyle: 'footnote', design: 'monospaced' }); const ADDED = foregroundStyle('#28A745'); diff --git a/apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-sheet.tsx b/apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-sheet.tsx index e977859dc..c8f1ec83a 100644 --- a/apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-sheet.tsx +++ b/apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-sheet.tsx @@ -25,7 +25,7 @@ import { toolCallFailureMessage, toolCallMetadata, } from '@linkcode/ui/native'; -import { SECONDARY } from '@mobile/components/form/styles'; +import { SECONDARY } from '@mobile/components/form/styles.ios'; import { useTranslations } from 'use-intl'; import { DiffBlock } from './diff-block'; diff --git a/apps/mobile/src/components/form/list.android.tsx b/apps/mobile/src/components/form/list.android.tsx new file mode 100644 index 000000000..b696e23d6 --- /dev/null +++ b/apps/mobile/src/components/form/list.android.tsx @@ -0,0 +1,12 @@ +import { Host, LazyColumn } from '@expo/ui/jetpack-compose'; + +/** Android form scaffold — the Compose counterpart of the iOS `Host`+`Form` pair. The Host needs + * the viewport as its proposed size or the column collapses to its content, same trap as SwiftUI. + * LazyColumn maps each direct child to one lazy item, so keep children at section granularity. */ +export function FormList({ children }: React.PropsWithChildren): React.ReactNode { + return ( + + {children} + + ); +} diff --git a/apps/mobile/src/components/form/navigation-row.ios.tsx b/apps/mobile/src/components/form/navigation-row.ios.tsx new file mode 100644 index 000000000..b34229204 --- /dev/null +++ b/apps/mobile/src/components/form/navigation-row.ios.tsx @@ -0,0 +1,29 @@ +import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { badge, contentShape, onTapGesture, shapes } from '@expo/ui/swift-ui/modifiers'; +import type { NavigationRowProps } from '@mobile/components/form/navigation-row.types'; +import { FOOTNOTE, SECONDARY, TERTIARY } from '@mobile/components/form/styles.ios'; + +// The stack only covers its text without this, so taps in the empty part of the row are lost. +const WHOLE_ROW = contentShape(shapes.rectangle()); + +/** A form row that pushes a route. SwiftUI draws the disclosure chevron from `NavigationLink`, + * which `@expo/ui` does not expose, so the row draws its own. A tap gesture rather than a + * `Button`, which is also what `NavigationLink` is: a Button swallows horizontal drags, and a + * row inside `SwipeActions` then opens the route instead of revealing its actions. */ +export function NavigationRow({ + title, + subtitle, + badgeText, + onPress, +}: NavigationRowProps): React.ReactNode { + return ( + + + {title} + {subtitle ? {subtitle} : null} + + + + + ); +} diff --git a/apps/mobile/src/components/form/navigation-row.tsx b/apps/mobile/src/components/form/navigation-row.tsx index 616a9a67c..911508cd7 100644 --- a/apps/mobile/src/components/form/navigation-row.tsx +++ b/apps/mobile/src/components/form/navigation-row.tsx @@ -1,34 +1,37 @@ -import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; -import { badge, contentShape, onTapGesture, shapes } from '@expo/ui/swift-ui/modifiers'; -import { FOOTNOTE, SECONDARY, TERTIARY } from '@mobile/components/form/styles'; +import { ListItem, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { clickable } from '@expo/ui/jetpack-compose/modifiers'; +import type { NavigationRowProps } from '@mobile/components/form/navigation-row.types'; -// The stack only covers its text without this, so taps in the empty part of the row are lost. -const WHOLE_ROW = contentShape(shapes.rectangle()); - -/** A form row that pushes a route. SwiftUI draws the disclosure chevron from `NavigationLink`, - * which `@expo/ui` does not expose, so the row draws its own. A tap gesture rather than a - * `Button`, which is also what `NavigationLink` is: a Button swallows horizontal drags, and a - * row inside `SwipeActions` then opens the route instead of revealing its actions. */ +/** Android form row that pushes a route: an MD3 `ListItem` whose whole-row ripple comes from + * `clickable`. MD3 list rows draw no disclosure chevron, so none is drawn. The SwiftUI variant + * lives in `navigation-row.ios.tsx`. */ export function NavigationRow({ title, subtitle, badgeText, onPress, -}: { - title: string; - subtitle?: string; - /** Trailing status text, drawn the way a `List` row badge is — before the chevron. */ - badgeText?: string; - onPress: () => void; -}): React.ReactNode { +}: NavigationRowProps): React.ReactNode { + const colors = useMaterialColors(); + return ( - - + + {title} - {subtitle ? {subtitle} : null} - - - - + + {subtitle === undefined ? null : ( + + + {subtitle} + + + )} + {badgeText === undefined ? null : ( + + + {badgeText} + + + )} + ); } diff --git a/apps/mobile/src/components/form/navigation-row.types.ts b/apps/mobile/src/components/form/navigation-row.types.ts new file mode 100644 index 000000000..dc19cdd45 --- /dev/null +++ b/apps/mobile/src/components/form/navigation-row.types.ts @@ -0,0 +1,7 @@ +export interface NavigationRowProps { + title: string; + subtitle?: string; + /** Trailing status text, drawn the way a `List` row badge is. */ + badgeText?: string; + onPress: () => void; +} diff --git a/apps/mobile/src/components/form/rows.android.tsx b/apps/mobile/src/components/form/rows.android.tsx new file mode 100644 index 000000000..b72b3e3ec --- /dev/null +++ b/apps/mobile/src/components/form/rows.android.tsx @@ -0,0 +1,62 @@ +import { + CircularProgressIndicator, + ListItem, + Row, + Switch, + Text, + useMaterialColors, +} from '@expo/ui/jetpack-compose'; +import { padding, toggleable } from '@expo/ui/jetpack-compose/modifiers'; + +/** Quiet in-section message row: empty states, hints, and errors. */ +export function FormHint({ + tone = 'secondary', + children, +}: { + tone?: 'secondary' | 'error'; + children: string; +}): React.ReactNode { + const colors = useMaterialColors(); + + return ( + + {children} + + ); +} + +/** Centered in-section loading row, the `ProgressView` stand-in. */ +export function FormLoadingRow(): React.ReactNode { + return ( + + + + ); +} + +/** Labeled switch row. The row owns the tap (`toggleable`), the Switch only displays the state — + * giving it its own handler would double-toggle a tap that lands on the thumb. */ +export function ToggleRow({ + label, + value, + onValueChange, +}: { + label: string; + value: boolean; + onValueChange: (value: boolean) => void; +}): React.ReactNode { + return ( + onValueChange(!value), { role: 'switch' })]}> + + {label} + + + + + + ); +} diff --git a/apps/mobile/src/components/form/section.android.tsx b/apps/mobile/src/components/form/section.android.tsx new file mode 100644 index 000000000..b9f259e0c --- /dev/null +++ b/apps/mobile/src/components/form/section.android.tsx @@ -0,0 +1,44 @@ +import { Column, Row, Spacer, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { padding, weight } from '@expo/ui/jetpack-compose/modifiers'; + +/** MD3 stand-in for a SwiftUI Form `Section`: a text subheader above the rows and quiet footer + * text below — the full-width list idiom, not iOS's inset-grouped chrome. `trailing` renders at + * the header's end; SwiftUI needed a hand-drawn header for that, a Row does it natively. */ +export function FormSection({ + title, + trailing, + footer, + children, +}: { + title?: string; + /** Header-trailing action, e.g. a `TextButton` refresh. */ + trailing?: React.ReactNode; + footer?: string; + children: React.ReactNode; +}): React.ReactNode { + const colors = useMaterialColors(); + + return ( + + {title === undefined && trailing === undefined ? null : ( + + + {title ?? ''} + + + {trailing} + + )} + {children} + {footer === undefined ? null : ( + + {footer} + + )} + + ); +} diff --git a/apps/mobile/src/components/form/styles.ts b/apps/mobile/src/components/form/styles.ios.ts similarity index 100% rename from apps/mobile/src/components/form/styles.ts rename to apps/mobile/src/components/form/styles.ios.ts diff --git a/apps/mobile/src/components/host/host-connection-state.tsx b/apps/mobile/src/components/host/host-connection-state.tsx index be981c328..a0bd249e0 100644 --- a/apps/mobile/src/components/host/host-connection-state.tsx +++ b/apps/mobile/src/components/host/host-connection-state.tsx @@ -5,7 +5,7 @@ import { multilineTextAlignment, textSelection, } from '@expo/ui/swift-ui/modifiers'; -import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles'; +import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; import { useTranslations } from 'use-intl'; const CENTERED = multilineTextAlignment('center'); diff --git a/apps/mobile/src/components/host/thread-list/thread-row.tsx b/apps/mobile/src/components/host/thread-list/thread-row.tsx index 521701e6a..9ed81248f 100644 --- a/apps/mobile/src/components/host/thread-list/thread-row.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-row.tsx @@ -8,7 +8,7 @@ import { } from '@expo/ui/swift-ui/modifiers'; import type { SessionInfo, SessionStatus } from '@linkcode/schema'; import { AGENT_LABELS, repositoryLabel } from '@linkcode/ui/native'; -import { FOOTNOTE, SECONDARY, TERTIARY } from '@mobile/components/form/styles'; +import { FOOTNOTE, SECONDARY, TERTIARY } from '@mobile/components/form/styles.ios'; import { formatRelativeShort } from '@mobile/utils/relative-time'; /** SwiftUI's semantic colours standing in for the `bg-*` tokens the RN dot used. */ From 0f8077d421f9f0a5db33f22514dda346cf2cccb7 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 03:57:11 +0800 Subject: [PATCH 02/37] feat(mobile): android settings, account, and terminal-appearance screens --- apps/mobile/src/app/account.tsx | 35 +-- apps/mobile/src/app/terminal-appearance.tsx | 109 +-------- .../components/account/account-screen.ios.tsx | 39 +++ .../src/components/account/account-screen.tsx | 45 ++++ .../account/devices-section.ios.tsx | 68 ++++++ .../components/account/devices-section.tsx | 162 +++--------- .../components/account/profile-row.ios.tsx | 20 ++ .../src/components/account/profile-row.tsx | 29 +-- .../components/account/use-devices-section.ts | 91 +++++++ .../settings/settings-screen.ios.tsx | 180 ++++++++++++++ .../settings/settings-screen.shared.ts | 13 + .../components/settings/settings-screen.tsx | 231 ++++++++++-------- .../terminal-appearance-screen.ios.tsx | 113 +++++++++ .../settings/terminal-appearance-screen.tsx | 123 ++++++++++ 14 files changed, 877 insertions(+), 381 deletions(-) create mode 100644 apps/mobile/src/components/account/account-screen.ios.tsx create mode 100644 apps/mobile/src/components/account/account-screen.tsx create mode 100644 apps/mobile/src/components/account/devices-section.ios.tsx create mode 100644 apps/mobile/src/components/account/profile-row.ios.tsx create mode 100644 apps/mobile/src/components/account/use-devices-section.ts create mode 100644 apps/mobile/src/components/settings/settings-screen.ios.tsx create mode 100644 apps/mobile/src/components/settings/settings-screen.shared.ts create mode 100644 apps/mobile/src/components/settings/terminal-appearance-screen.ios.tsx create mode 100644 apps/mobile/src/components/settings/terminal-appearance-screen.tsx diff --git a/apps/mobile/src/app/account.tsx b/apps/mobile/src/app/account.tsx index fb11505b1..b3ebf289f 100644 --- a/apps/mobile/src/app/account.tsx +++ b/apps/mobile/src/app/account.tsx @@ -1,15 +1,10 @@ -import { Button, Form, Host, ProgressView, Section } from '@expo/ui/swift-ui'; -import { DeleteAccountSection } from '@mobile/components/account/delete-account-section'; -import { DevicesSection } from '@mobile/components/account/devices-section'; -import { ProfileRow } from '@mobile/components/account/profile-row'; +import { AccountScreen } from '@mobile/components/account/account-screen'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; -import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; +import { useCloudAccount } from '@mobile/runtime/cloud/account'; import { Redirect, Stack } from 'expo-router'; -import { Alert } from 'react-native'; import { useTranslations } from 'use-intl'; -/** Account screen: profile, the account's device registry, and sign-out. */ -export default function AccountScreen(): React.ReactNode { +export default function AccountRoute(): React.ReactNode { const t = useTranslations('mobile.account'); const account = useCloudAccount(); @@ -18,29 +13,7 @@ export default function AccountScreen(): React.ReactNode { return ( <> - {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} - -
- {account.status === 'loading' ? ( - - ) : ( - <> -
- -
- -
-
- - - )} - -
+ ); } diff --git a/apps/mobile/src/app/terminal-appearance.tsx b/apps/mobile/src/app/terminal-appearance.tsx index 65fa7d439..ce1099fa1 100644 --- a/apps/mobile/src/app/terminal-appearance.tsx +++ b/apps/mobile/src/app/terminal-appearance.tsx @@ -1,118 +1,15 @@ -import { - Circle, - Form, - Host, - HStack, - Picker, - Section, - Text, - VStack, - ZStack, -} from '@expo/ui/swift-ui'; -import { - font, - foregroundStyle, - frame, - pickerStyle, - strokeBorder, - tag, -} from '@expo/ui/swift-ui/modifiers'; +import { TerminalAppearanceScreen } from '@mobile/components/settings/terminal-appearance-screen'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; -import { - resolveTerminalTheme, - TERMINAL_COLOR_SCHEMES, - TERMINAL_FONT_SIZES, - useTerminalPrefsStore, -} from '@mobile/stores/terminal-prefs-store'; import { Stack } from 'expo-router'; import { useTranslations } from 'use-intl'; -const SWATCH = 24; - -const HIERARCHICAL_SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); - -/** Renders a theme's own colours so the row previews what it selects. `auto` has no theme - * of its own — it defers to ghostty's defaults — so it shows a neutral placeholder. */ -function ThemeSwatch({ theme }: { theme?: { background?: string; foreground?: string } }) { - return ( - - - {theme?.foreground ? ( - - a - - ) : null} - - ); -} - -/** Client-side terminal appearance: font size and color scheme. */ -export default function TerminalAppearanceScreen(): React.ReactNode { +export default function TerminalAppearanceRoute(): React.ReactNode { const t = useTranslations('mobile.terminalAppearance'); - const fontSize = useTerminalPrefsStore((state) => state.fontSize); - const colorScheme = useTerminalPrefsStore((state) => state.colorScheme); - const setFontSize = useTerminalPrefsStore((state) => state.setFontSize); - const setColorScheme = useTerminalPrefsStore((state) => state.setColorScheme); return ( <> - -
-
- - {TERMINAL_FONT_SIZES.map((size) => ( - - {String(size)} - - ))} - -
- - {/* An inline picker draws the selection checkmarks itself, so the rows only - have to carry the swatch and the label. */} -
- - {TERMINAL_COLOR_SCHEMES.map((scheme) => { - const theme = resolveTerminalTheme(scheme); - return ( - - - {scheme === 'auto' ? ( - - {t('colorSchemeAuto')} - - {t('colorSchemeAutoHint')} - - - ) : ( - {scheme} - )} - - ); - })} - -
-
-
+ ); } diff --git a/apps/mobile/src/components/account/account-screen.ios.tsx b/apps/mobile/src/components/account/account-screen.ios.tsx new file mode 100644 index 000000000..2f71a80f0 --- /dev/null +++ b/apps/mobile/src/components/account/account-screen.ios.tsx @@ -0,0 +1,39 @@ +import { Button, Form, Host, ProgressView, Section } from '@expo/ui/swift-ui'; +import { DevicesSection } from '@mobile/components/account/devices-section'; +import { ProfileRow } from '@mobile/components/account/profile-row'; +import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; +import { useTranslations } from 'use-intl'; + +/** Account body: profile, the account's device registry, and sign-out. The route shell owns the + * signed-out redirect; a transient non-signed-in status renders as loading. */ +export function AccountScreen(): React.ReactNode { + const t = useTranslations('mobile.account'); + const account = useCloudAccount(); + + return ( + // Form needs the viewport as its proposed size, otherwise it collapses to its content. + +
+ {account.status !== 'signed-in' ? ( + + ) : ( + <> +
+ +
+ +
+
+ + )} + +
+ ); +} diff --git a/apps/mobile/src/components/account/account-screen.tsx b/apps/mobile/src/components/account/account-screen.tsx new file mode 100644 index 000000000..c11567446 --- /dev/null +++ b/apps/mobile/src/components/account/account-screen.tsx @@ -0,0 +1,45 @@ +import { ListItem, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { clickable } from '@expo/ui/jetpack-compose/modifiers'; +import { DevicesSection } from '@mobile/components/account/devices-section'; +import { ProfileRow } from '@mobile/components/account/profile-row'; +import { FormList } from '@mobile/components/form/list.android'; +import { FormLoadingRow } from '@mobile/components/form/rows.android'; +import { FormSection } from '@mobile/components/form/section.android'; +import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; +import { useTranslations } from 'use-intl'; + +/** Android account body, mirroring `account-screen.ios.tsx`. Sign-out is a plain error-colored + * row — MD3's shape for a destructive entry in a settings list. */ +export function AccountScreen(): React.ReactNode { + const t = useTranslations('mobile.account'); + const colors = useMaterialColors(); + const account = useCloudAccount(); + + return ( + + {account.status !== 'signed-in' ? ( + + ) : ( + <> + + + + + + { + void signOutOfCloud(); + }), + ]} + > + + {t('signOut')} + + + + + )} + + ); +} diff --git a/apps/mobile/src/components/account/devices-section.ios.tsx b/apps/mobile/src/components/account/devices-section.ios.tsx new file mode 100644 index 000000000..8607119fc --- /dev/null +++ b/apps/mobile/src/components/account/devices-section.ios.tsx @@ -0,0 +1,68 @@ +import { + Button, + HStack, + ProgressView, + Section, + Spacer, + SwipeActions, + Text, + VStack, +} from '@expo/ui/swift-ui'; +import { badge, buttonStyle, disabled, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; +import { useDevicesSection } from '@mobile/components/account/use-devices-section'; +import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; +import { useTranslations } from 'use-intl'; + +/** The account's registered devices; the view model lives in `use-devices-section`. */ +export function DevicesSection(): React.ReactNode { + const t = useTranslations('mobile.account'); + const { devices, devicesError, enrolledId, busyId, refresh, confirmRevoke, describeDevice } = + useDevicesSection(); + + return ( + // A titled Section can't also carry an action, so the header is drawn by hand. +
+ {t('devices')} + +
+ ); +} diff --git a/apps/mobile/src/components/account/devices-section.tsx b/apps/mobile/src/components/account/devices-section.tsx index 944f674d9..f6e9f6690 100644 --- a/apps/mobile/src/components/account/devices-section.tsx +++ b/apps/mobile/src/components/account/devices-section.tsx @@ -1,140 +1,58 @@ -import { - Button, - HStack, - ProgressView, - Section, - Spacer, - SwipeActions, - Text, - VStack, -} from '@expo/ui/swift-ui'; -import { badge, buttonStyle, disabled, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; -import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; -import { signOutOfCloud } from '@mobile/runtime/cloud/account'; -import type { CloudDevice } from '@mobile/runtime/cloud/devices'; -import { fetchDevices, getEnrolledDeviceId, revokeDevice } from '@mobile/runtime/cloud/devices'; -import { formatRelativeShort } from '@mobile/utils/relative-time'; -import { noop } from 'foxact/noop'; -import { useCallback, useEffect, useState } from 'react'; -import { Alert } from 'react-native'; +import { ListItem, Text, TextButton, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { useDevicesSection } from '@mobile/components/account/use-devices-section'; +import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; +import { FormSection } from '@mobile/components/form/section.android'; import { useTranslations } from 'use-intl'; -/** - * The account's registered devices. Revoking cuts access to new tunnel tokens; - * revoking this phone also signs it out (the cloud kills its sessions). - */ +/** Android device registry; the view model lives in `use-devices-section`. Compose has no + * SwipeActions, so revoke is a visible trailing text button — the MD3-discoverable shape for a + * destructive-only row action. */ export function DevicesSection(): React.ReactNode { const t = useTranslations('mobile.account'); - - const [devices, setDevices] = useState(null); - const [devicesError, setDevicesError] = useState(false); - const [enrolledId, setEnrolledId] = useState(null); - const [busyId, setBusyId] = useState(null); - - const load = useCallback(() => { - fetchDevices() - .then(setDevices) - .catch(() => setDevicesError(true)); - }, []); - - useEffect(() => { - getEnrolledDeviceId().then(setEnrolledId).catch(noop); - load(); - }, [load]); - - const refresh = () => { - setDevicesError(false); - setDevices(null); - load(); - }; - - const revoke = async (device: CloudDevice) => { - setBusyId(device.id); - try { - await revokeDevice(device.id); - if (device.id === enrolledId) { - // The device revoke already removed its push token and killed this phone's sessions. - await signOutOfCloud({ revokePushToken: false }).catch(noop); - return; - } - refresh(); - } catch { - Alert.alert(t('revokeError')); - } finally { - setBusyId(null); - } - }; - - const confirmRevoke = (device: CloudDevice) => { - Alert.alert( - t('revokeTitle', { name: device.name }), - device.id === enrolledId ? t('revokeThisDeviceMessage') : t('revokeMessage'), - [ - { text: t('revokeCancel'), style: 'cancel' }, - { - text: t('revoke'), - style: 'destructive', - onPress() { - void revoke(device); - }, - }, - ], - ); - }; - - const describeDevice = (device: CloudDevice): string => { - const kind = t(`deviceKind.${device.kind}`); - const platform = device.platform ? `${kind} · ${device.platform}` : kind; - return device.lastSeenAt - ? `${platform} · ${t('lastSeen', { time: formatRelativeShort(new Date(device.lastSeenAt).getTime()) })}` - : platform; - }; + const colors = useMaterialColors(); + const { devices, devicesError, enrolledId, busyId, refresh, confirmRevoke, describeDevice } = + useDevicesSection(); return ( - // A titled Section can't also carry an action, so the header is drawn by hand. -
- {t('devices')} - -
+ ); } diff --git a/apps/mobile/src/components/account/profile-row.ios.tsx b/apps/mobile/src/components/account/profile-row.ios.tsx new file mode 100644 index 000000000..93d095e17 --- /dev/null +++ b/apps/mobile/src/components/account/profile-row.ios.tsx @@ -0,0 +1,20 @@ +import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { font } from '@expo/ui/swift-ui/modifiers'; +import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; +import type { CloudUser } from '@mobile/runtime/cloud/account'; + +/** SF Symbol rather than the account's picture: `@expo/ui`'s `Image` takes SF Symbols, asset + * catalog names, and local files — never a remote URL. Apple sign-in supplies no picture + * anyway, so this is the fallback the old avatar already showed in the common case. */ +export function ProfileRow({ user }: { user: CloudUser }): React.ReactNode { + return ( + + + + {user.name || user.email} + {user.email} + + + + ); +} diff --git a/apps/mobile/src/components/account/profile-row.tsx b/apps/mobile/src/components/account/profile-row.tsx index 93d095e17..459424810 100644 --- a/apps/mobile/src/components/account/profile-row.tsx +++ b/apps/mobile/src/components/account/profile-row.tsx @@ -1,20 +1,21 @@ -import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; -import { font } from '@expo/ui/swift-ui/modifiers'; -import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; +import { ListItem, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; import type { CloudUser } from '@mobile/runtime/cloud/account'; -/** SF Symbol rather than the account's picture: `@expo/ui`'s `Image` takes SF Symbols, asset - * catalog names, and local files — never a remote URL. Apple sign-in supplies no picture - * anyway, so this is the fallback the old avatar already showed in the common case. */ +/** Android profile row. No avatar: MD3 needs no placeholder glyph, and Apple sign-in supplies no + * picture anyway (the iOS variant shows an SF Symbol for the same reason). */ export function ProfileRow({ user }: { user: CloudUser }): React.ReactNode { + const colors = useMaterialColors(); + return ( - - - - {user.name || user.email} - {user.email} - - - + + + {user.name || user.email} + + + + {user.email} + + + ); } diff --git a/apps/mobile/src/components/account/use-devices-section.ts b/apps/mobile/src/components/account/use-devices-section.ts new file mode 100644 index 000000000..54df8c2f8 --- /dev/null +++ b/apps/mobile/src/components/account/use-devices-section.ts @@ -0,0 +1,91 @@ +import { signOutOfCloud } from '@mobile/runtime/cloud/account'; +import type { CloudDevice } from '@mobile/runtime/cloud/devices'; +import { fetchDevices, getEnrolledDeviceId, revokeDevice } from '@mobile/runtime/cloud/devices'; +import { formatRelativeShort } from '@mobile/utils/relative-time'; +import { noop } from 'foxact/noop'; +import { useCallback, useEffect, useState } from 'react'; +import { Alert } from 'react-native'; +import { useTranslations } from 'use-intl'; + +export interface DevicesSectionState { + devices: CloudDevice[] | null; + devicesError: boolean; + enrolledId: string | null; + busyId: string | null; + refresh: () => void; + confirmRevoke: (device: CloudDevice) => void; + describeDevice: (device: CloudDevice) => string; +} + +/** View-model of the account's device registry, shared by both platform views. Revoking cuts + * access to new tunnel tokens; revoking this phone also signs it out (the cloud kills its + * sessions). Confirmation stays an RN `Alert` — that already is the native alert on both. */ +export function useDevicesSection(): DevicesSectionState { + const t = useTranslations('mobile.account'); + + const [devices, setDevices] = useState(null); + const [devicesError, setDevicesError] = useState(false); + const [enrolledId, setEnrolledId] = useState(null); + const [busyId, setBusyId] = useState(null); + + const load = useCallback(() => { + fetchDevices() + .then(setDevices) + .catch(() => setDevicesError(true)); + }, []); + + useEffect(() => { + getEnrolledDeviceId().then(setEnrolledId).catch(noop); + load(); + }, [load]); + + const refresh = () => { + setDevicesError(false); + setDevices(null); + load(); + }; + + const revoke = async (device: CloudDevice) => { + setBusyId(device.id); + try { + await revokeDevice(device.id); + if (device.id === enrolledId) { + // The device revoke already removed its push token and killed this phone's sessions. + await signOutOfCloud({ revokePushToken: false }).catch(noop); + return; + } + refresh(); + } catch { + Alert.alert(t('revokeError')); + } finally { + setBusyId(null); + } + }; + + const confirmRevoke = (device: CloudDevice) => { + Alert.alert( + t('revokeTitle', { name: device.name }), + device.id === enrolledId ? t('revokeThisDeviceMessage') : t('revokeMessage'), + [ + { text: t('revokeCancel'), style: 'cancel' }, + { + text: t('revoke'), + style: 'destructive', + onPress() { + void revoke(device); + }, + }, + ], + ); + }; + + const describeDevice = (device: CloudDevice): string => { + const kind = t(`deviceKind.${device.kind}`); + const platform = device.platform ? `${kind} · ${device.platform}` : kind; + return device.lastSeenAt + ? `${platform} · ${t('lastSeen', { time: formatRelativeShort(new Date(device.lastSeenAt).getTime()) })}` + : platform; + }; + + return { devices, devicesError, enrolledId, busyId, refresh, confirmRevoke, describeDevice }; +} diff --git a/apps/mobile/src/components/settings/settings-screen.ios.tsx b/apps/mobile/src/components/settings/settings-screen.ios.tsx new file mode 100644 index 000000000..65ccba0b3 --- /dev/null +++ b/apps/mobile/src/components/settings/settings-screen.ios.tsx @@ -0,0 +1,180 @@ +import { Form, Host, Link, Picker, Section, Text, Toggle, VStack } from '@expo/ui/swift-ui'; +import { disabled, font, foregroundStyle, pickerStyle, tag } from '@expo/ui/swift-ui/modifiers'; +import { AgentKindSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { + PRIVACY_POLICY_URL, + SUPPORT_URL, + TERMS_OF_SERVICE_URL, + THEME_LABEL_KEY, + THEME_PREFERENCES, +} from '@mobile/components/settings/settings-screen.shared'; +import { LARGE_TITLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; +import { useCloudAccount } from '@mobile/runtime/cloud/account'; +import { + disableDeviceNotifications, + enableDeviceNotifications, +} from '@mobile/runtime/notifications'; +import { setMobileProductAnalyticsEnabled } from '@mobile/runtime/product-analytics'; +import { useAnalyticsPreferenceStore } from '@mobile/stores/analytics-store'; +import { useSettingsStore } from '@mobile/stores/settings-store'; +import { Stack, useRouter } from 'expo-router'; +import { useRef, useState } from 'react'; +import { Alert, Linking, View } from 'react-native'; +import { useTranslations } from 'use-intl'; + +const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); + +/** App settings: account + host management entries plus the About/contract summary. Nothing here is + * host-scoped, and the screen is deliberately ungated — this is where "Manage hosts" lives, so it + * has to survive the selected host being unreachable. */ +export function SettingsScreen(): React.ReactNode { + const t = useTranslations('mobile.settings'); + const tAbout = useTranslations('mobile.about'); + const router = useRouter(); + const account = useCloudAccount(); + const productAnalyticsEnabled = useAnalyticsPreferenceStore((state) => state.enabled); + const themePreference = useSettingsStore((state) => state.themePreference); + const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled); + const setThemePreference = useSettingsStore((state) => state.setThemePreference); + const keepHostsConnected = useSettingsStore((state) => state.keepHostsConnected); + const setKeepHostsConnected = useSettingsStore((state) => state.setKeepHostsConnected); + const [notificationUpdatePending, setNotificationUpdatePending] = useState(false); + const notificationUpdatePendingRef = useRef(false); + + const updateNotifications = async (enabled: boolean) => { + if (account.status !== 'signed-in' || notificationUpdatePendingRef.current) return; + notificationUpdatePendingRef.current = true; + setNotificationUpdatePending(true); + try { + if (!enabled) { + await disableDeviceNotifications(); + return; + } + if (await enableDeviceNotifications(account.user.id)) return; + Alert.alert(t('notificationsDeniedTitle'), t('notificationsDenied'), [ + { text: t('cancel'), style: 'cancel' }, + { + text: t('openSettings'), + onPress() { + void Linking.openSettings(); + }, + }, + ]); + } catch { + Alert.alert(t('notificationsErrorTitle'), t('notificationsError')); + } finally { + notificationUpdatePendingRef.current = false; + setNotificationUpdatePending(false); + } + }; + + // The flex container is load-bearing: a SwiftUI host left as the screen's direct child is + // proposed the whole window and paints straight over the large title. + return ( + + + {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} + +
+
+ {account.status === 'signed-in' ? ( + router.push('/account')} + /> + ) : account.status === 'signed-out' ? ( + router.push('/sign-in')} /> + ) : null} + router.push('/connect')} /> + router.push('/terminal-appearance')} + /> +
+ +
{t('keepHostsConnectedHint')}}> + +
+ +
+ + {THEME_PREFERENCES.map((preference) => ( + + {t(THEME_LABEL_KEY[preference])} + + ))} + +
+ +
{t('analyticsHint')}}> + +
+ +
+ {account.status === 'signed-in' + ? t('notificationsHint') + : t('notificationsRequiresCloud')} + + } + > + +
+ + {/* Native links open the URL themselves — no Linking.openURL fallback to get wrong. */} +
+ + + +
+ +
+ + {tAbout('title')} + + {tAbout('contract', { version: WIRE_PROTOCOL_VERSION })} + + +
+ +
{tAbout('note')} + } + > + {AgentKindSchema.options.map((kind) => ( + {kind} + ))} +
+
+
+
+ ); +} diff --git a/apps/mobile/src/components/settings/settings-screen.shared.ts b/apps/mobile/src/components/settings/settings-screen.shared.ts new file mode 100644 index 000000000..75abcc828 --- /dev/null +++ b/apps/mobile/src/components/settings/settings-screen.shared.ts @@ -0,0 +1,13 @@ +import type { ThemePreference } from '@mobile/stores/settings-store'; + +export const THEME_PREFERENCES: readonly ThemePreference[] = ['system', 'light', 'dark']; + +export const THEME_LABEL_KEY = { + system: 'appearanceSystem', + light: 'appearanceLight', + dark: 'appearanceDark', +} as const; + +export const PRIVACY_POLICY_URL = 'https://linkcode.ai/privacy'; +export const TERMS_OF_SERVICE_URL = 'https://linkcode.ai/terms'; +export const SUPPORT_URL = 'https://linkcode.ai/support'; diff --git a/apps/mobile/src/components/settings/settings-screen.tsx b/apps/mobile/src/components/settings/settings-screen.tsx index e3d0b7816..d955fde36 100644 --- a/apps/mobile/src/components/settings/settings-screen.tsx +++ b/apps/mobile/src/components/settings/settings-screen.tsx @@ -1,7 +1,23 @@ -import { Form, Host, Link, Picker, Section, Text, Toggle, VStack } from '@expo/ui/swift-ui'; -import { disabled, font, foregroundStyle, pickerStyle, tag } from '@expo/ui/swift-ui/modifiers'; +import { + Column, + SegmentedButton, + SingleChoiceSegmentedButtonRow, + Text, + useMaterialColors, +} from '@expo/ui/jetpack-compose'; +import { fillMaxWidth, padding } from '@expo/ui/jetpack-compose/modifiers'; import { AgentKindSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { FormList } from '@mobile/components/form/list.android'; import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { ToggleRow } from '@mobile/components/form/rows.android'; +import { FormSection } from '@mobile/components/form/section.android'; +import { + PRIVACY_POLICY_URL, + SUPPORT_URL, + TERMS_OF_SERVICE_URL, + THEME_LABEL_KEY, + THEME_PREFERENCES, +} from '@mobile/components/settings/settings-screen.shared'; import { LARGE_TITLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useCloudAccount } from '@mobile/runtime/cloud/account'; import { @@ -10,30 +26,20 @@ import { } from '@mobile/runtime/notifications'; import { setMobileProductAnalyticsEnabled } from '@mobile/runtime/product-analytics'; import { useAnalyticsPreferenceStore } from '@mobile/stores/analytics-store'; -import type { ThemePreference } from '@mobile/stores/settings-store'; import { useSettingsStore } from '@mobile/stores/settings-store'; import { Stack, useRouter } from 'expo-router'; +import { noop } from 'foxact/noop'; import { useRef, useState } from 'react'; import { Alert, Linking, View } from 'react-native'; import { useTranslations } from 'use-intl'; -const THEME_PREFERENCES: readonly ThemePreference[] = ['system', 'light', 'dark']; - -const THEME_LABEL_KEY = { - system: 'appearanceSystem', - light: 'appearanceLight', - dark: 'appearanceDark', -} as const; - -const PRIVACY_POLICY_URL = 'https://linkcode.ai/privacy'; -const TERMS_OF_SERVICE_URL = 'https://linkcode.ai/terms'; -const SUPPORT_URL = 'https://linkcode.ai/support'; - -const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); +/** Compose has no `Link` row, so legal rows open the URL through RN `Linking`. */ +function openUrl(url: string): void { + Linking.openURL(url).catch(noop); +} -/** App settings: account + host management entries plus the About/contract summary. Nothing here is - * host-scoped, and the screen is deliberately ungated — this is where "Manage hosts" lives, so it - * has to survive the selected host being unreachable. */ +/** Android settings, mirroring the SwiftUI screen in `settings-screen.ios.tsx`: same sections, + * MD3 dress. */ export function SettingsScreen(): React.ReactNode { const t = useTranslations('mobile.settings'); const tAbout = useTranslations('mobile.about'); @@ -47,6 +53,8 @@ export function SettingsScreen(): React.ReactNode { const setKeepHostsConnected = useSettingsStore((state) => state.setKeepHostsConnected); const [notificationUpdatePending, setNotificationUpdatePending] = useState(false); const notificationUpdatePendingRef = useRef(false); + const notificationsToggleEnabled = + account.status === 'signed-in' && !notificationUpdatePending; const updateNotifications = async (enabled: boolean) => { if (account.status !== 'signed-in' || notificationUpdatePendingRef.current) return; @@ -75,8 +83,6 @@ export function SettingsScreen(): React.ReactNode { } }; - // The flex container is load-bearing: a SwiftUI host left as the screen's direct child is - // proposed the whole window and paints straight over the large title. return ( - {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} - -
-
- {account.status === 'signed-in' ? ( - router.push('/account')} - /> - ) : account.status === 'signed-out' ? ( - router.push('/sign-in')} /> - ) : null} - router.push('/connect')} /> + + + {account.status === 'signed-in' ? ( router.push('/terminal-appearance')} + title={account.user.name || account.user.email} + subtitle={account.user.email} + onPress={() => router.push('/account')} /> -
+ ) : account.status === 'signed-out' ? ( + router.push('/sign-in')} /> + ) : null} + router.push('/connect')} /> + router.push('/terminal-appearance')} + /> + -
{t('keepHostsConnectedHint')}}> - -
+ + + -
- - {THEME_PREFERENCES.map((preference) => ( - - {t(THEME_LABEL_KEY[preference])} - - ))} - -
+ + + {THEME_PREFERENCES.map((preference) => ( + setThemePreference(preference)} + > + + {t(THEME_LABEL_KEY[preference])} + + + ))} + + -
{t('analyticsHint')}}> - -
+ + + -
- {account.status === 'signed-in' - ? t('notificationsHint') - : t('notificationsRequiresCloud')} - - } - > - -
+ + { + if (!notificationsToggleEnabled) return; + void updateNotifications(enabled); + }} + /> + - {/* Native links open the URL themselves — no Linking.openURL fallback to get wrong. */} -
- - - -
+ + openUrl(PRIVACY_POLICY_URL)} /> + openUrl(TERMS_OF_SERVICE_URL)} + /> + openUrl(SUPPORT_URL)} /> + -
- - {tAbout('title')} - - {tAbout('contract', { version: WIRE_PROTOCOL_VERSION })} - - -
+ + + -
{tAbout('note')} - } - > - {AgentKindSchema.options.map((kind) => ( - {kind} - ))} -
- -
+ + {AgentKindSchema.options.map((kind) => ( + + {kind} + + ))} + +
); } + +function AboutBlock({ title, contract }: { title: string; contract: string }): React.ReactNode { + const colors = useMaterialColors(); + + return ( + + {title} + + {contract} + + + ); +} diff --git a/apps/mobile/src/components/settings/terminal-appearance-screen.ios.tsx b/apps/mobile/src/components/settings/terminal-appearance-screen.ios.tsx new file mode 100644 index 000000000..9143c3914 --- /dev/null +++ b/apps/mobile/src/components/settings/terminal-appearance-screen.ios.tsx @@ -0,0 +1,113 @@ +import { + Circle, + Form, + Host, + HStack, + Picker, + Section, + Text, + VStack, + ZStack, +} from '@expo/ui/swift-ui'; +import { + font, + foregroundStyle, + frame, + pickerStyle, + strokeBorder, + tag, +} from '@expo/ui/swift-ui/modifiers'; +import { + resolveTerminalTheme, + TERMINAL_COLOR_SCHEMES, + TERMINAL_FONT_SIZES, + useTerminalPrefsStore, +} from '@mobile/stores/terminal-prefs-store'; +import { useTranslations } from 'use-intl'; + +const SWATCH = 24; + +const HIERARCHICAL_SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); + +/** Renders a theme's own colours so the row previews what it selects. `auto` has no theme + * of its own — it defers to ghostty's defaults — so it shows a neutral placeholder. */ +function ThemeSwatch({ theme }: { theme?: { background?: string; foreground?: string } }) { + return ( + + + {theme?.foreground ? ( + + a + + ) : null} + + ); +} + +/** Client-side terminal appearance: font size and color scheme. */ +export function TerminalAppearanceScreen(): React.ReactNode { + const t = useTranslations('mobile.terminalAppearance'); + const fontSize = useTerminalPrefsStore((state) => state.fontSize); + const colorScheme = useTerminalPrefsStore((state) => state.colorScheme); + const setFontSize = useTerminalPrefsStore((state) => state.setFontSize); + const setColorScheme = useTerminalPrefsStore((state) => state.setColorScheme); + + return ( + +
+
+ + {TERMINAL_FONT_SIZES.map((size) => ( + + {String(size)} + + ))} + +
+ + {/* An inline picker draws the selection checkmarks itself, so the rows only + have to carry the swatch and the label. */} +
+ + {TERMINAL_COLOR_SCHEMES.map((scheme) => { + const theme = resolveTerminalTheme(scheme); + return ( + + + {scheme === 'auto' ? ( + + {t('colorSchemeAuto')} + + {t('colorSchemeAutoHint')} + + + ) : ( + {scheme} + )} + + ); + })} + +
+
+
+ ); +} diff --git a/apps/mobile/src/components/settings/terminal-appearance-screen.tsx b/apps/mobile/src/components/settings/terminal-appearance-screen.tsx new file mode 100644 index 000000000..55b47bbf0 --- /dev/null +++ b/apps/mobile/src/components/settings/terminal-appearance-screen.tsx @@ -0,0 +1,123 @@ +import { + Box, + Column, + RadioButton, + Row, + SegmentedButton, + SingleChoiceSegmentedButtonRow, + Text, + useMaterialColors, +} from '@expo/ui/jetpack-compose'; +import { + background, + border, + clip, + fillMaxWidth, + padding, + Shapes, + selectable, + selectableGroup, + size, +} from '@expo/ui/jetpack-compose/modifiers'; +import { FormList } from '@mobile/components/form/list.android'; +import { FormSection } from '@mobile/components/form/section.android'; +import { + resolveTerminalTheme, + TERMINAL_COLOR_SCHEMES, + TERMINAL_FONT_SIZES, + useTerminalPrefsStore, +} from '@mobile/stores/terminal-prefs-store'; +import { useTranslations } from 'use-intl'; + +const SWATCH = 24; + +/** Renders a theme's own colours so the row previews what it selects. `auto` has no theme + * of its own — it defers to ghostty's defaults — so it shows a neutral placeholder. */ +function ThemeSwatch({ theme }: { theme?: { background?: string; foreground?: string } }) { + const colors = useMaterialColors(); + + return ( + + {theme?.foreground ? ( + + a + + ) : null} + + ); +} + +/** Android terminal appearance, mirroring `terminal-appearance-screen.ios.tsx`: the segmented + * font-size picker maps to MD3 segmented buttons; the inline color-scheme picker becomes a + * radio group, which draws the selection state the checkmarks carried on iOS. */ +export function TerminalAppearanceScreen(): React.ReactNode { + const t = useTranslations('mobile.terminalAppearance'); + const colors = useMaterialColors(); + const fontSize = useTerminalPrefsStore((state) => state.fontSize); + const colorScheme = useTerminalPrefsStore((state) => state.colorScheme); + const setFontSize = useTerminalPrefsStore((state) => state.setFontSize); + const setColorScheme = useTerminalPrefsStore((state) => state.setColorScheme); + + return ( + + + + {TERMINAL_FONT_SIZES.map((candidate) => ( + setFontSize(candidate)} + > + + {String(candidate)} + + + ))} + + + + + + {TERMINAL_COLOR_SCHEMES.map((scheme) => { + const theme = resolveTerminalTheme(scheme); + const selected = colorScheme === scheme; + return ( + setColorScheme(scheme), 'radioButton'), + fillMaxWidth(), + padding(16, 8, 16, 8), + ]} + > + + + {scheme === 'auto' ? ( + + {t('colorSchemeAuto')} + + {t('colorSchemeAutoHint')} + + + ) : ( + {scheme} + )} + + ); + })} + + + + ); +} From e548cc960d1408b38b3a2a5d58809b227600c231 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 04:01:39 +0800 Subject: [PATCH 03/37] feat(mobile): android connect and add-host screens --- apps/mobile/src/app/connect.tsx | 27 +--- .../connect/add-host-screen.ios.tsx | 86 +++++++++++ .../components/connect/add-host-screen.tsx | 141 +++++++----------- .../components/connect/connect-screen.ios.tsx | 13 ++ .../src/components/connect/connect-screen.tsx | 10 ++ .../components/connect/connect-sections.tsx | 27 ++++ .../connect/discovered-hosts-section.ios.tsx | 40 +++++ .../connect/discovered-hosts-section.tsx | 45 ++---- .../connect/my-machines-section.ios.tsx | 47 ++++++ .../connect/my-machines-section.tsx | 76 ++-------- .../connect/saved-hosts-section.ios.tsx | 31 ++++ .../connect/saved-hosts-section.tsx | 39 +++-- .../connect/sign-in-section.ios.tsx | 14 ++ .../components/connect/sign-in-section.tsx | 16 +- .../src/components/connect/use-add-host.ts | 39 +++++ .../connect/use-discovered-hosts.ts | 27 ++++ .../src/components/connect/use-my-machines.ts | 53 +++++++ 17 files changed, 510 insertions(+), 221 deletions(-) create mode 100644 apps/mobile/src/components/connect/add-host-screen.ios.tsx create mode 100644 apps/mobile/src/components/connect/connect-screen.ios.tsx create mode 100644 apps/mobile/src/components/connect/connect-screen.tsx create mode 100644 apps/mobile/src/components/connect/connect-sections.tsx create mode 100644 apps/mobile/src/components/connect/discovered-hosts-section.ios.tsx create mode 100644 apps/mobile/src/components/connect/my-machines-section.ios.tsx create mode 100644 apps/mobile/src/components/connect/saved-hosts-section.ios.tsx create mode 100644 apps/mobile/src/components/connect/sign-in-section.ios.tsx create mode 100644 apps/mobile/src/components/connect/use-add-host.ts create mode 100644 apps/mobile/src/components/connect/use-discovered-hosts.ts create mode 100644 apps/mobile/src/components/connect/use-my-machines.ts diff --git a/apps/mobile/src/app/connect.tsx b/apps/mobile/src/app/connect.tsx index e6199a053..bd7eb7e14 100644 --- a/apps/mobile/src/app/connect.tsx +++ b/apps/mobile/src/app/connect.tsx @@ -1,18 +1,10 @@ -import { Form, Host } from '@expo/ui/swift-ui'; -import { DiscoveredHostsSection } from '@mobile/components/connect/discovered-hosts-section'; -import { MyMachinesSection } from '@mobile/components/connect/my-machines-section'; -import { SavedHostsSection } from '@mobile/components/connect/saved-hosts-section'; -import { SignInSection } from '@mobile/components/connect/sign-in-section'; +import { ConnectScreen } from '@mobile/components/connect/connect-screen'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; -import { useCloudAccount } from '@mobile/runtime/cloud/account'; -import { useHostRegistryStore } from '@mobile/stores/host-store'; import { Stack } from 'expo-router'; import { useTranslations } from 'use-intl'; -export default function ConnectScreen(): React.ReactNode { +export default function ConnectRoute(): React.ReactNode { const t = useTranslations('mobile.connect'); - const account = useCloudAccount(); - const hosts = useHostRegistryStore((state) => state.hosts); return ( <> @@ -22,20 +14,7 @@ export default function ConnectScreen(): React.ReactNode { title: t('title'), }} /> - {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} - -
- {account.status === 'signed-in' ? ( - - ) : account.status === 'signed-out' ? ( - - ) : null} - - {hosts.length > 0 ? : null} - - - -
+ ); } diff --git a/apps/mobile/src/components/connect/add-host-screen.ios.tsx b/apps/mobile/src/components/connect/add-host-screen.ios.tsx new file mode 100644 index 000000000..d3e390260 --- /dev/null +++ b/apps/mobile/src/components/connect/add-host-screen.ios.tsx @@ -0,0 +1,86 @@ +import { Form, Host, HStack, Section, Text, TextField, useNativeState } from '@expo/ui/swift-ui'; +import { + autocorrectionDisabled, + keyboardType, + onSubmit, + submitLabel, + textContentType, + textInputAutocapitalization, +} from '@expo/ui/swift-ui/modifiers'; +import { useAddHost } from '@mobile/components/connect/use-add-host'; +import { Stack, useRouter } from 'expo-router'; +import { useTranslations } from 'use-intl'; + +export function AddHostScreen(): React.ReactNode { + const t = useTranslations('mobile.connect'); + const router = useRouter(); + const name = useNativeState(''); + const url = useNativeState(''); + const { urlInvalid, urlValid, onUrlChange, submit } = useAddHost(); + + const submitFields = () => submit(name.get(), url.get()); + + return ( + <> + [ + { + type: 'button', + label: t('cancel'), + accessibilityLabel: t('cancel'), + icon: { type: 'sfSymbol', name: 'xmark' }, + onPress: () => router.dismiss(), + }, + ], + unstable_headerRightItems: () => [ + { + type: 'button', + label: t('add'), + accessibilityLabel: t('add'), + icon: { type: 'sfSymbol', name: 'checkmark' }, + variant: 'prominent', + disabled: !urlValid, + onPress: submitFields, + }, + ], + }} + /> + {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} + +
+
{urlInvalid ? t('invalidUrl') : t('emptyHint')}}> + {/* `LabeledContent` only gives the field its intrinsic width; the stack fills the row. */} + + {t('nameLabel')} + + + + {t('urlLabel')} + + +
+
+
+ + ); +} diff --git a/apps/mobile/src/components/connect/add-host-screen.tsx b/apps/mobile/src/components/connect/add-host-screen.tsx index 427f265c0..bdbece801 100644 --- a/apps/mobile/src/components/connect/add-host-screen.tsx +++ b/apps/mobile/src/components/connect/add-host-screen.tsx @@ -1,101 +1,74 @@ -import { Form, Host, HStack, Section, Text, TextField, useNativeState } from '@expo/ui/swift-ui'; import { - autocorrectionDisabled, - keyboardType, - onSubmit, - submitLabel, - textContentType, - textInputAutocapitalization, -} from '@expo/ui/swift-ui/modifiers'; -import { HostUrlSchema, useHostRegistryStore } from '@mobile/stores/host-store'; -import { Stack, useRouter } from 'expo-router'; -import { useState } from 'react'; + Button, + Column, + Host, + OutlinedTextField, + Text, + useMaterialColors, + useNativeState, +} from '@expo/ui/jetpack-compose'; +import { fillMaxWidth, padding, testID } from '@expo/ui/jetpack-compose/modifiers'; +import { useAddHost } from '@mobile/components/connect/use-add-host'; +import { Stack } from 'expo-router'; import { useTranslations } from 'use-intl'; +/** Android add-host form. The iOS header bar items (`unstable_header*Items`) don't exist on + * Android, so submit is an in-form button and dismissal is the sheet's own back/swipe. */ export function AddHostScreen(): React.ReactNode { const t = useTranslations('mobile.connect'); - const router = useRouter(); - const addHost = useHostRegistryStore((state) => state.addHost); - const setLastActiveHostId = useHostRegistryStore((state) => state.setLastActiveHostId); + const colors = useMaterialColors(); const name = useNativeState(''); const url = useNativeState(''); - const [urlInvalid, setUrlInvalid] = useState(false); - const [urlValid, setUrlValid] = useState(false); + const { urlInvalid, urlValid, onUrlChange, submit } = useAddHost(); - const submit = () => { - const trimmedUrl = url.get().trim(); - if (!HostUrlSchema.safeParse(trimmedUrl).success) { - setUrlInvalid(true); - return; - } - const profile = addHost({ name: name.get().trim() || t('namePlaceholder'), url: trimmedUrl }); - setLastActiveHostId(profile.id); - router.dismissTo('/threads'); - }; + const submitFields = () => submit(name.get(), url.get()); return ( <> - [ - { - type: 'button', - label: t('cancel'), - accessibilityLabel: t('cancel'), - icon: { type: 'sfSymbol', name: 'xmark' }, - onPress: () => router.dismiss(), - }, - ], - unstable_headerRightItems: () => [ - { - type: 'button', - label: t('add'), - accessibilityLabel: t('add'), - icon: { type: 'sfSymbol', name: 'checkmark' }, - variant: 'prominent', - disabled: !urlValid, - onPress: submit, - }, - ], - }} - /> - {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} + -
-
{urlInvalid ? t('invalidUrl') : t('emptyHint')}}> - {/* `LabeledContent` only gives the field its intrinsic width; the stack fills the row. */} - + + + {t('nameLabel')} - - - + + + {t('namePlaceholder')} + + + + {t('urlLabel')} - { - setUrlInvalid(false); - setUrlValid(HostUrlSchema.safeParse(text.trim()).success); - }} - modifiers={[ - textInputAutocapitalization('never'), - autocorrectionDisabled(), - keyboardType('url'), - textContentType('URL'), - submitLabel('go'), - onSubmit(submit), - ]} - /> - -
-
+ + + {t('urlPlaceholder')} + + + {urlInvalid ? t('invalidUrl') : t('emptyHint')} + + + +
); diff --git a/apps/mobile/src/components/connect/connect-screen.ios.tsx b/apps/mobile/src/components/connect/connect-screen.ios.tsx new file mode 100644 index 000000000..400930360 --- /dev/null +++ b/apps/mobile/src/components/connect/connect-screen.ios.tsx @@ -0,0 +1,13 @@ +import { Form, Host } from '@expo/ui/swift-ui'; +import { ConnectSections } from '@mobile/components/connect/connect-sections'; + +export function ConnectScreen(): React.ReactNode { + return ( + // Form needs the viewport as its proposed size, otherwise it collapses to its content. + +
+ + +
+ ); +} diff --git a/apps/mobile/src/components/connect/connect-screen.tsx b/apps/mobile/src/components/connect/connect-screen.tsx new file mode 100644 index 000000000..e41320645 --- /dev/null +++ b/apps/mobile/src/components/connect/connect-screen.tsx @@ -0,0 +1,10 @@ +import { ConnectSections } from '@mobile/components/connect/connect-sections'; +import { FormList } from '@mobile/components/form/list.android'; + +export function ConnectScreen(): React.ReactNode { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/connect/connect-sections.tsx b/apps/mobile/src/components/connect/connect-sections.tsx new file mode 100644 index 000000000..48c8b4a94 --- /dev/null +++ b/apps/mobile/src/components/connect/connect-sections.tsx @@ -0,0 +1,27 @@ +import { DiscoveredHostsSection } from '@mobile/components/connect/discovered-hosts-section'; +import { MyMachinesSection } from '@mobile/components/connect/my-machines-section'; +import { SavedHostsSection } from '@mobile/components/connect/saved-hosts-section'; +import { SignInSection } from '@mobile/components/connect/sign-in-section'; +import { useCloudAccount } from '@mobile/runtime/cloud/account'; +import { useHostRegistryStore } from '@mobile/stores/host-store'; + +/** The connect screen's section stack, single-sourced: each section resolves to its platform + * implementation through the Metro extension seam, so only the scaffold around this differs. */ +export function ConnectSections(): React.ReactNode { + const account = useCloudAccount(); + const hosts = useHostRegistryStore((state) => state.hosts); + + return ( + <> + {account.status === 'signed-in' ? ( + + ) : account.status === 'signed-out' ? ( + + ) : null} + + {hosts.length > 0 ? : null} + + + + ); +} diff --git a/apps/mobile/src/components/connect/discovered-hosts-section.ios.tsx b/apps/mobile/src/components/connect/discovered-hosts-section.ios.tsx new file mode 100644 index 000000000..965883eae --- /dev/null +++ b/apps/mobile/src/components/connect/discovered-hosts-section.ios.tsx @@ -0,0 +1,40 @@ +import { HStack, ProgressView, Section, Text } from '@expo/ui/swift-ui'; +import { accessibilityLabel, controlSize, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; +import { ManualHostRow } from '@mobile/components/connect/manual-host-row'; +import { useDiscoveredHosts } from '@mobile/components/connect/use-discovered-hosts'; +import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { useTranslations } from 'use-intl'; + +export function DiscoveredHostsSection(): React.ReactNode { + const t = useTranslations('mobile.connect'); + const { discoveredHosts, status, saveAndOpen } = useDiscoveredHosts(); + + return ( +
+ {t('discovery.title')} + {status === 'error' ? null : ( + + )} + + } + > + {status === 'error' ? ( + {t('discovery.error')} + ) : ( + discoveredHosts.map((host) => ( + saveAndOpen(host)} + /> + )) + )} + +
+ ); +} diff --git a/apps/mobile/src/components/connect/discovered-hosts-section.tsx b/apps/mobile/src/components/connect/discovered-hosts-section.tsx index 7d600f8b3..7893c4ece 100644 --- a/apps/mobile/src/components/connect/discovered-hosts-section.tsx +++ b/apps/mobile/src/components/connect/discovered-hosts-section.tsx @@ -1,46 +1,25 @@ -import { HStack, ProgressView, Section, Text } from '@expo/ui/swift-ui'; -import { accessibilityLabel, controlSize, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; +import { CircularProgressIndicator } from '@expo/ui/jetpack-compose'; +import { size } from '@expo/ui/jetpack-compose/modifiers'; import { ManualHostRow } from '@mobile/components/connect/manual-host-row'; +import { useDiscoveredHosts } from '@mobile/components/connect/use-discovered-hosts'; import { NavigationRow } from '@mobile/components/form/navigation-row'; -import { canonicalDirectHostUrl } from '@mobile/runtime/daemon-discovery'; -import { useDaemonDiscovery } from '@mobile/runtime/use-daemon-discovery'; -import { useOpenHost } from '@mobile/runtime/use-open-host'; -import { useHostRegistryStore } from '@mobile/stores/host-store'; +import { FormHint } from '@mobile/components/form/rows.android'; +import { FormSection } from '@mobile/components/form/section.android'; import { useTranslations } from 'use-intl'; export function DiscoveredHostsSection(): React.ReactNode { const t = useTranslations('mobile.connect'); - const { hosts, status } = useDaemonDiscovery(); - const openHost = useOpenHost(); - const savedHosts = useHostRegistryStore((state) => state.hosts); - const addHost = useHostRegistryStore((state) => state.addHost); - const savedUrls = new Set(); - for (let i = 0, len = savedHosts.length; i < len; i++) { - const host = savedHosts[i]; - if ('url' in host) savedUrls.add(canonicalDirectHostUrl(host.url)); - } - const discoveredHosts = hosts.filter((host) => !savedUrls.has(canonicalDirectHostUrl(host.url))); - - const saveAndOpen = (host: (typeof discoveredHosts)[number]) => { - const profile = addHost({ name: host.name, url: host.url }); - openHost(profile.id); - }; + const { discoveredHosts, status, saveAndOpen } = useDiscoveredHosts(); return ( -
- {t('discovery.title')} - {status === 'error' ? null : ( - - )} - + } > {status === 'error' ? ( - {t('discovery.error')} + {t('discovery.error')} ) : ( discoveredHosts.map((host) => ( -
+ ); } diff --git a/apps/mobile/src/components/connect/my-machines-section.ios.tsx b/apps/mobile/src/components/connect/my-machines-section.ios.tsx new file mode 100644 index 000000000..e6e3a6571 --- /dev/null +++ b/apps/mobile/src/components/connect/my-machines-section.ios.tsx @@ -0,0 +1,47 @@ +import { Button, HStack, ProgressView, Section, Spacer, Text } from '@expo/ui/swift-ui'; +import { buttonStyle, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; +import { useMyMachines } from '@mobile/components/connect/use-my-machines'; +import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; +import { useTranslations } from 'use-intl'; + +/** Online machines; the view model lives in `use-my-machines`. */ +export function MyMachinesSection({ userId }: { userId: string }): React.ReactNode { + const t = useTranslations('mobile.connect.cloud'); + const { onlineHosts, hostsError, refresh, saveAndOpen } = useMyMachines(userId); + + return ( + // A titled Section can't also carry an action, so the header is drawn by hand — + // footnote + secondary is what SwiftUI gives a plain `title` on iOS. +
+ {t('machines')} + +
+ ); +} diff --git a/apps/mobile/src/components/connect/my-machines-section.tsx b/apps/mobile/src/components/connect/my-machines-section.tsx index f7e7e04ca..5de489f26 100644 --- a/apps/mobile/src/components/connect/my-machines-section.tsx +++ b/apps/mobile/src/components/connect/my-machines-section.tsx @@ -1,74 +1,30 @@ -import { Button, HStack, ProgressView, Section, Spacer, Text } from '@expo/ui/swift-ui'; -import { buttonStyle, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; +import { Text, TextButton } from '@expo/ui/jetpack-compose'; +import { useMyMachines } from '@mobile/components/connect/use-my-machines'; import { NavigationRow } from '@mobile/components/form/navigation-row'; -import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; -import { ensureDeviceRegistered } from '@mobile/runtime/cloud/devices'; -import type { OnlineHost } from '@mobile/runtime/cloud/hosts'; -import { fetchOnlineHosts } from '@mobile/runtime/cloud/hosts'; -import { useOpenHost } from '@mobile/runtime/use-open-host'; -import { useHostRegistryStore } from '@mobile/stores/host-store'; -import { noop } from 'foxact/noop'; -import { useCallback, useEffect, useState } from 'react'; +import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; +import { FormSection } from '@mobile/components/form/section.android'; import { useTranslations } from 'use-intl'; -/** Online machines (daemons connected to the relay) — tapping one saves it as a tunnel host and opens it. */ +/** Android online machines; the view model lives in `use-my-machines`. */ export function MyMachinesSection({ userId }: { userId: string }): React.ReactNode { const t = useTranslations('mobile.connect.cloud'); - const openHost = useOpenHost(); - const addTunnelHost = useHostRegistryStore((state) => state.addTunnelHost); - - const [onlineHosts, setOnlineHosts] = useState(null); - const [hostsError, setHostsError] = useState(false); - - const load = useCallback(() => { - fetchOnlineHosts() - .then(setOnlineHosts) - .catch(() => setHostsError(true)); - }, []); - - const refresh = () => { - setHostsError(false); - setOnlineHosts(null); - load(); - }; - - useEffect(() => { - // Best-effort: registration only lists the phone under the account's - // devices; discovering and connecting to hosts does not depend on it. - ensureDeviceRegistered(userId).catch(noop); - load(); - }, [userId, load]); - - const saveAndOpen = (host: OnlineHost) => { - const profile = addTunnelHost({ - name: host.name ?? host.hostId.slice(0, 8), - tunnelHostId: host.hostId, - }); - openHost(profile.id); - }; + const { onlineHosts, hostsError, refresh, saveAndOpen } = useMyMachines(userId); return ( - // A titled Section can't also carry an action, so the header is drawn by hand — - // footnote + secondary is what SwiftUI gives a plain `title` on iOS. -
- {t('machines')} - -
+ ); } diff --git a/apps/mobile/src/components/connect/saved-hosts-section.ios.tsx b/apps/mobile/src/components/connect/saved-hosts-section.ios.tsx new file mode 100644 index 000000000..727baac44 --- /dev/null +++ b/apps/mobile/src/components/connect/saved-hosts-section.ios.tsx @@ -0,0 +1,31 @@ +import { Button, Section, SwipeActions } from '@expo/ui/swift-ui'; +import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { useOpenHost } from '@mobile/runtime/use-open-host'; +import { useHostRegistryStore } from '@mobile/stores/host-store'; +import { useTranslations } from 'use-intl'; + +/** Saved hosts, direct or tunnelled. Removal is the list's own swipe action rather than a + * row button, so the row itself stays a single tap target for opening the host. */ +export function SavedHostsSection(): React.ReactNode { + const t = useTranslations('mobile.connect'); + const openHost = useOpenHost(); + const hosts = useHostRegistryStore((state) => state.hosts); + const removeHost = useHostRegistryStore((state) => state.removeHost); + + return ( +
+ {hosts.map((host) => ( + + +
+ ); +} diff --git a/apps/mobile/src/components/connect/saved-hosts-section.tsx b/apps/mobile/src/components/connect/saved-hosts-section.tsx index 727baac44..06e155185 100644 --- a/apps/mobile/src/components/connect/saved-hosts-section.tsx +++ b/apps/mobile/src/components/connect/saved-hosts-section.tsx @@ -1,31 +1,38 @@ -import { Button, Section, SwipeActions } from '@expo/ui/swift-ui'; -import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { ListItem, Text, TextButton, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { clickable } from '@expo/ui/jetpack-compose/modifiers'; +import { FormSection } from '@mobile/components/form/section.android'; import { useOpenHost } from '@mobile/runtime/use-open-host'; import { useHostRegistryStore } from '@mobile/stores/host-store'; import { useTranslations } from 'use-intl'; -/** Saved hosts, direct or tunnelled. Removal is the list's own swipe action rather than a - * row button, so the row itself stays a single tap target for opening the host. */ +/** Android saved hosts. Compose has no SwipeActions, so removal is a visible trailing text + * button; the rest of the row stays a single tap target for opening the host. */ export function SavedHostsSection(): React.ReactNode { const t = useTranslations('mobile.connect'); + const colors = useMaterialColors(); const openHost = useOpenHost(); const hosts = useHostRegistryStore((state) => state.hosts); const removeHost = useHostRegistryStore((state) => state.removeHost); return ( -
+ {hosts.map((host) => ( - - -
+ ); } diff --git a/apps/mobile/src/components/connect/sign-in-section.ios.tsx b/apps/mobile/src/components/connect/sign-in-section.ios.tsx new file mode 100644 index 000000000..daea8c1fc --- /dev/null +++ b/apps/mobile/src/components/connect/sign-in-section.ios.tsx @@ -0,0 +1,14 @@ +import { Button, Section, Text } from '@expo/ui/swift-ui'; +import { useRouter } from 'expo-router'; +import { useTranslations } from 'use-intl'; + +/** Signed-out lead-in: the account is how machines appear on the connect screen. */ +export function SignInSection(): React.ReactNode { + const t = useTranslations('mobile.connect.cloud'); + const router = useRouter(); + return ( +
{t('hint')}}> +
+ ); +} diff --git a/apps/mobile/src/components/connect/sign-in-section.tsx b/apps/mobile/src/components/connect/sign-in-section.tsx index daea8c1fc..22654e041 100644 --- a/apps/mobile/src/components/connect/sign-in-section.tsx +++ b/apps/mobile/src/components/connect/sign-in-section.tsx @@ -1,4 +1,6 @@ -import { Button, Section, Text } from '@expo/ui/swift-ui'; +import { Button, Text } from '@expo/ui/jetpack-compose'; +import { fillMaxWidth, padding } from '@expo/ui/jetpack-compose/modifiers'; +import { FormSection } from '@mobile/components/form/section.android'; import { useRouter } from 'expo-router'; import { useTranslations } from 'use-intl'; @@ -6,9 +8,15 @@ import { useTranslations } from 'use-intl'; export function SignInSection(): React.ReactNode { const t = useTranslations('mobile.connect.cloud'); const router = useRouter(); + return ( -
{t('hint')}}> -
+ + + ); } diff --git a/apps/mobile/src/components/connect/use-add-host.ts b/apps/mobile/src/components/connect/use-add-host.ts new file mode 100644 index 000000000..2435188d7 --- /dev/null +++ b/apps/mobile/src/components/connect/use-add-host.ts @@ -0,0 +1,39 @@ +import { HostUrlSchema, useHostRegistryStore } from '@mobile/stores/host-store'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslations } from 'use-intl'; + +/** Validation + submit flow of the add-host form. Field text stays in each platform view's own + * `useNativeState` (the shared `@expo/ui` State export is the web polyfill, typed without + * `.get()`), so submit takes the values instead of owning them. */ +export function useAddHost(): { + urlInvalid: boolean; + urlValid: boolean; + onUrlChange: (text: string) => void; + submit: (rawName: string, rawUrl: string) => void; +} { + const t = useTranslations('mobile.connect'); + const router = useRouter(); + const addHost = useHostRegistryStore((state) => state.addHost); + const setLastActiveHostId = useHostRegistryStore((state) => state.setLastActiveHostId); + const [urlInvalid, setUrlInvalid] = useState(false); + const [urlValid, setUrlValid] = useState(false); + + const onUrlChange = (text: string) => { + setUrlInvalid(false); + setUrlValid(HostUrlSchema.safeParse(text.trim()).success); + }; + + const submit = (rawName: string, rawUrl: string) => { + const trimmedUrl = rawUrl.trim(); + if (!HostUrlSchema.safeParse(trimmedUrl).success) { + setUrlInvalid(true); + return; + } + const profile = addHost({ name: rawName.trim() || t('namePlaceholder'), url: trimmedUrl }); + setLastActiveHostId(profile.id); + router.dismissTo('/threads'); + }; + + return { urlInvalid, urlValid, onUrlChange, submit }; +} diff --git a/apps/mobile/src/components/connect/use-discovered-hosts.ts b/apps/mobile/src/components/connect/use-discovered-hosts.ts new file mode 100644 index 000000000..c163f8d0f --- /dev/null +++ b/apps/mobile/src/components/connect/use-discovered-hosts.ts @@ -0,0 +1,27 @@ +import { canonicalDirectHostUrl } from '@mobile/runtime/daemon-discovery'; +import { useDaemonDiscovery } from '@mobile/runtime/use-daemon-discovery'; +import { useOpenHost } from '@mobile/runtime/use-open-host'; +import { useHostRegistryStore } from '@mobile/stores/host-store'; + +/** Discovered LAN daemons minus the ones already saved, plus the save-and-open action — + * shared by both platform views of the discovered-hosts section. */ +export function useDiscoveredHosts() { + const { hosts, status } = useDaemonDiscovery(); + const openHost = useOpenHost(); + const savedHosts = useHostRegistryStore((state) => state.hosts); + const addHost = useHostRegistryStore((state) => state.addHost); + + const savedUrls = new Set(); + for (let i = 0, len = savedHosts.length; i < len; i++) { + const host = savedHosts[i]; + if ('url' in host) savedUrls.add(canonicalDirectHostUrl(host.url)); + } + const discoveredHosts = hosts.filter((host) => !savedUrls.has(canonicalDirectHostUrl(host.url))); + + const saveAndOpen = (host: (typeof discoveredHosts)[number]) => { + const profile = addHost({ name: host.name, url: host.url }); + openHost(profile.id); + }; + + return { discoveredHosts, status, saveAndOpen }; +} diff --git a/apps/mobile/src/components/connect/use-my-machines.ts b/apps/mobile/src/components/connect/use-my-machines.ts new file mode 100644 index 000000000..48091de72 --- /dev/null +++ b/apps/mobile/src/components/connect/use-my-machines.ts @@ -0,0 +1,53 @@ +import { ensureDeviceRegistered } from '@mobile/runtime/cloud/devices'; +import type { OnlineHost } from '@mobile/runtime/cloud/hosts'; +import { fetchOnlineHosts } from '@mobile/runtime/cloud/hosts'; +import { useOpenHost } from '@mobile/runtime/use-open-host'; +import { useHostRegistryStore } from '@mobile/stores/host-store'; +import { noop } from 'foxact/noop'; +import { useCallback, useEffect, useState } from 'react'; + +export interface MyMachinesState { + onlineHosts: OnlineHost[] | null; + hostsError: boolean; + refresh: () => void; + saveAndOpen: (host: OnlineHost) => void; +} + +/** Online machines (daemons connected to the relay), shared by both platform views. Tapping one + * saves it as a tunnel host and opens it. */ +export function useMyMachines(userId: string): MyMachinesState { + const openHost = useOpenHost(); + const addTunnelHost = useHostRegistryStore((state) => state.addTunnelHost); + + const [onlineHosts, setOnlineHosts] = useState(null); + const [hostsError, setHostsError] = useState(false); + + const load = useCallback(() => { + fetchOnlineHosts() + .then(setOnlineHosts) + .catch(() => setHostsError(true)); + }, []); + + const refresh = () => { + setHostsError(false); + setOnlineHosts(null); + load(); + }; + + useEffect(() => { + // Best-effort: registration only lists the phone under the account's + // devices; discovering and connecting to hosts does not depend on it. + ensureDeviceRegistered(userId).catch(noop); + load(); + }, [userId, load]); + + const saveAndOpen = (host: OnlineHost) => { + const profile = addTunnelHost({ + name: host.name ?? host.hostId.slice(0, 8), + tunnelHostId: host.hostId, + }); + openHost(profile.id); + }; + + return { onlineHosts, hostsError, refresh, saveAndOpen }; +} From 01712836c91f17ccfa10fca98e172d55d32829a0 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 04:09:26 +0800 Subject: [PATCH 04/37] feat(mobile): android host switcher via shared bottom-sheet picker --- .../mobile/src/app/(tabs)/terminals/index.tsx | 6 +- apps/mobile/src/app/(tabs)/threads/index.tsx | 6 +- .../components/form/sheet-picker.android.tsx | 116 ++++++++++++++++++ .../host/host-switcher-button.ios.ts | 5 + .../components/host/host-switcher-button.tsx | 63 ++++++++++ .../host/use-host-header-options.tsx | 16 +++ 6 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/components/form/sheet-picker.android.tsx create mode 100644 apps/mobile/src/components/host/host-switcher-button.ios.ts create mode 100644 apps/mobile/src/components/host/host-switcher-button.tsx create mode 100644 apps/mobile/src/components/host/use-host-header-options.tsx diff --git a/apps/mobile/src/app/(tabs)/terminals/index.tsx b/apps/mobile/src/app/(tabs)/terminals/index.tsx index acdd0f672..035c97921 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -13,7 +13,7 @@ import type { TerminalMetadata } from '@linkcode/schema'; import { repositoryLabel } from '@linkcode/ui/native'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; -import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; +import { useHostHeaderOptions } from '@mobile/components/host/use-host-header-options'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; @@ -37,7 +37,7 @@ const SUPPORTS_CONTENT_UNAVAILABLE_VIEW = * reachable when the host is not. */ export default function TerminalsRoute(): React.ReactNode { const t = useTranslations('mobile.terminals'); - const hostMenuItems = useHostMenuItems(); + const hostHeaderOptions = useHostHeaderOptions(); const connection = useHostConnection(); const [sheetOpen, setSheetOpen] = useState(false); @@ -61,7 +61,7 @@ export default function TerminalsRoute(): React.ReactNode { options={{ ...VISIBLE_HEADER_OPTIONS, title: t('title'), - unstable_headerLeftItems: () => hostMenuItems, + ...hostHeaderOptions, ...trailingActions, }} /> diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index 4fe7a58cf..d0506529c 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -18,7 +18,7 @@ import { import { SECONDARY } from '@mobile/components/form/styles.ios'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; -import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; +import { useHostHeaderOptions } from '@mobile/components/host/use-host-header-options'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; @@ -47,7 +47,7 @@ function threadTitle(session: SessionInfo): string { export default function ThreadsRoute(): React.ReactNode { const t = useTranslations('mobile.sessions'); const router = useRouter(); - const hostMenuItems = useHostMenuItems(); + const hostHeaderOptions = useHostHeaderOptions(); const connection = useHostConnection(); const primaryAction: PrimaryAction | null = @@ -68,7 +68,7 @@ export default function ThreadsRoute(): React.ReactNode { options={{ ...VISIBLE_HEADER_OPTIONS, title: t('title'), - unstable_headerLeftItems: () => hostMenuItems, + ...hostHeaderOptions, ...trailingActions, }} /> diff --git a/apps/mobile/src/components/form/sheet-picker.android.tsx b/apps/mobile/src/components/form/sheet-picker.android.tsx new file mode 100644 index 000000000..fe872d114 --- /dev/null +++ b/apps/mobile/src/components/form/sheet-picker.android.tsx @@ -0,0 +1,116 @@ +import { + Column, + Host, + ListItem, + ModalBottomSheet, + RadioButton, + Text, + useMaterialColors, +} from '@expo/ui/jetpack-compose'; +import { clickable, padding, verticalScroll } from '@expo/ui/jetpack-compose/modifiers'; + +export interface SheetPickerOption { + id: string; + label: string; + hint?: string; +} + +export interface SheetPickerSection { + id: string; + title?: string; + /** Option id drawn as selected, or null for none. */ + selection: string | null; + options: SheetPickerOption[]; + onSelect: (id: string) => void; +} + +export interface SheetPickerAction { + id: string; + label: string; + onPress: () => void; +} + +const NO_ACTIONS: SheetPickerAction[] = []; + +/** MD3 bottom-sheet radio picker — the Android stand-in for every UIMenu-style selector (the + * Compose DropdownMenu has no submenus and no built-in trigger). Selecting closes the sheet; + * `actions` render below the sections as plain rows. */ +export function SheetPicker({ + open, + onClose, + sections, + actions = NO_ACTIONS, +}: { + open: boolean; + onClose: () => void; + sections: SheetPickerSection[]; + actions?: SheetPickerAction[]; +}): React.ReactNode { + const colors = useMaterialColors(); + + if (!open) return null; + + return ( + + + + {sections.map((section) => ( + + {section.title === undefined ? null : ( + + {section.title} + + )} + {section.options.map((option) => ( + { + section.onSelect(option.id); + onClose(); + }), + ]} + > + + + + + {option.label} + + {option.hint === undefined ? null : ( + + + {option.hint} + + + )} + + ))} + + ))} + {actions.map((action) => ( + { + action.onPress(); + onClose(); + }), + ]} + > + + {action.label} + + + ))} + + + + ); +} diff --git a/apps/mobile/src/components/host/host-switcher-button.ios.ts b/apps/mobile/src/components/host/host-switcher-button.ios.ts new file mode 100644 index 000000000..8202d567d --- /dev/null +++ b/apps/mobile/src/components/host/host-switcher-button.ios.ts @@ -0,0 +1,5 @@ +/** iOS renders the host switcher as native bar items (`use-host-menu-items`); this twin exists + * only so the shared header hook can reference one component name on both platforms. */ +export function HostSwitcherButton(): React.ReactNode { + return null; +} diff --git a/apps/mobile/src/components/host/host-switcher-button.tsx b/apps/mobile/src/components/host/host-switcher-button.tsx new file mode 100644 index 000000000..9486f2c20 --- /dev/null +++ b/apps/mobile/src/components/host/host-switcher-button.tsx @@ -0,0 +1,63 @@ +import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; +import { useHostRegistryStore, useSelectedHost } from '@mobile/stores/host-store'; +import { useRouter } from 'expo-router'; +import { useThemeColor } from 'heroui-native'; +import { ChevronsUpDownIcon } from 'lucide-react-native'; +import { useState } from 'react'; +import { Pressable, Text } from 'react-native'; +import { useTranslations } from 'use-intl'; + +/** Android host switcher: `unstable_headerLeftItems` is iOS-only, so the header carries a plain + * pressable that opens the host list as a bottom sheet. Switching writes the registry and stops — + * the host is not in the path, so whichever tab you are standing in stays put. */ +export function HostSwitcherButton(): React.ReactNode { + const t = useTranslations('mobile.connect'); + const tSettings = useTranslations('mobile.settings'); + const router = useRouter(); + const hosts = useHostRegistryStore((state) => state.hosts); + const setLastActiveHostId = useHostRegistryStore((state) => state.setLastActiveHostId); + const selected = useSelectedHost(); + const [open, setOpen] = useState(false); + const muted = useThemeColor('muted'); + + if (!selected) return null; + + return ( + <> + setOpen(true)} + className="flex-row items-center gap-1 py-1 pr-3" + > + + {selected.name} + + + + setOpen(false)} + sections={[ + { + id: 'hosts', + selection: selected.id, + options: hosts.map((host) => ({ + id: host.id, + label: host.name, + hint: 'url' in host ? host.url : t('viaTunnel'), + })), + onSelect: setLastActiveHostId, + }, + ]} + actions={[ + { + id: 'manage', + label: tSettings('manageHosts'), + onPress: () => router.push('/connect'), + }, + ]} + /> + + ); +} diff --git a/apps/mobile/src/components/host/use-host-header-options.tsx b/apps/mobile/src/components/host/use-host-header-options.tsx new file mode 100644 index 000000000..34bcaf254 --- /dev/null +++ b/apps/mobile/src/components/host/use-host-header-options.tsx @@ -0,0 +1,16 @@ +import { HostSwitcherButton } from '@mobile/components/host/host-switcher-button'; +import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; +import type { NativeStackNavigationOptions } from 'expo-router'; +import { Platform } from 'react-native'; + +/** The header's host switcher as stack-screen options: native bar items on iOS, a headerLeft + * pressable opening a bottom sheet on Android — `unstable_headerLeftItems` is silently ignored + * there. Spread into each tab's `Stack.Screen` options. */ +export function useHostHeaderOptions(): Partial { + const hostMenuItems = useHostMenuItems(); + + if (Platform.OS === 'ios') { + return { unstable_headerLeftItems: () => hostMenuItems }; + } + return { headerLeft: () => }; +} From 69572b1c24d4c93c7c365a009ab697e127623964 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 04:15:01 +0800 Subject: [PATCH 05/37] feat(mobile): android threads inbox and host connection state --- apps/mobile/src/app/(tabs)/threads/index.tsx | 119 +----------------- .../host/host-connection-state.ios.tsx | 57 +++++++++ .../components/host/host-connection-state.tsx | 87 ++++++------- .../host/host-connection-state.types.ts | 7 ++ .../host/thread-list/thread-list.ios.tsx | 41 ++++++ .../host/thread-list/thread-list.tsx | 116 +++++++++-------- .../host/thread-list/thread-list.types.ts | 10 ++ .../host/thread-list/thread-row.ios.tsx | 52 ++++++++ .../host/thread-list/thread-row.tsx | 88 ++++++------- .../host/thread-list/thread-row.types.ts | 8 ++ .../host/thread-list/thread-title.ts | 7 ++ .../host/thread-list/use-thread-list-state.ts | 29 +++++ .../components/host/threads-screen.ios.tsx | 68 ++++++++++ .../src/components/host/threads-screen.tsx | 58 +++++++++ .../src/components/host/use-thread-inbox.ts | 64 ++++++++++ 15 files changed, 537 insertions(+), 274 deletions(-) create mode 100644 apps/mobile/src/components/host/host-connection-state.ios.tsx create mode 100644 apps/mobile/src/components/host/host-connection-state.types.ts create mode 100644 apps/mobile/src/components/host/thread-list/thread-list.ios.tsx create mode 100644 apps/mobile/src/components/host/thread-list/thread-list.types.ts create mode 100644 apps/mobile/src/components/host/thread-list/thread-row.ios.tsx create mode 100644 apps/mobile/src/components/host/thread-list/thread-row.types.ts create mode 100644 apps/mobile/src/components/host/thread-list/thread-title.ts create mode 100644 apps/mobile/src/components/host/thread-list/use-thread-list-state.ts create mode 100644 apps/mobile/src/components/host/threads-screen.ios.tsx create mode 100644 apps/mobile/src/components/host/threads-screen.tsx create mode 100644 apps/mobile/src/components/host/use-thread-inbox.ts diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index d0506529c..7a78ce3af 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -1,46 +1,16 @@ -import { - Form, - Host, - ProgressView, - Section, - Button as UIButton, - Text as UIText, -} from '@expo/ui/swift-ui'; -import { useSessions } from '@linkcode/client-core'; -import type { SessionInfo } from '@linkcode/schema'; -import type { ThreadGroup } from '@linkcode/ui/native'; -import { - AGENT_LABELS, - groupThreadsByWorkspace, - repositoryLabel, - withoutAutomationSessions, -} from '@linkcode/ui/native'; -import { SECONDARY } from '@mobile/components/form/styles.ios'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; -import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; +import { ThreadsScreen } from '@mobile/components/host/threads-screen'; import { useHostHeaderOptions } from '@mobile/components/host/use-host-header-options'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; import { useHostConnection } from '@mobile/runtime/host-connection'; -import { useWorkspaces } from '@mobile/runtime/use-workspaces'; import { Stack, useRouter } from 'expo-router'; import { SquarePenIcon } from 'lucide-react-native'; -import { useCallback, useState } from 'react'; import { View } from 'react-native'; import { useTranslations } from 'use-intl'; -/** Taken from the search bar itself: RN's own replacement for the event it declares carries no text. */ -type SearchBarChangeEvent = Parameters< - NonNullable['onChangeText']> ->[0]; - -/** The title a thread is listed and searched under — the same fallback the row renders. */ -function threadTitle(session: SessionInfo): string { - return session.title ?? `${AGENT_LABELS[session.kind]} in ${repositoryLabel(session.cwd)}`; -} - /** The header outlives the connection: it carries the host switcher, which is the way out of a host * that cannot be reached, so it is mounted above the gate rather than inside it. New-thread needs a * client, so its entry points are dropped until the connection is ready. */ @@ -78,90 +48,3 @@ export default function ThreadsRoute(): React.ReactNode { ); } - -/** Threads inbox: sessions grouped by workspace (project) under collapsible headers, with the - * native search bar stacked below the navigation bar. Empty workspace groups are hidden — the - * new-thread page is where they surface. */ -function ThreadsScreen(): React.ReactNode { - const t = useTranslations('mobile.sessions'); - const router = useRouter(); - const { sessions, refresh, loading } = useSessions(); - const { workspaces, refresh: refreshWorkspaces } = useWorkspaces(); - - const [query, setQuery] = useState(''); - - // Stable so the search bar's options object survives a keystroke without re-registering. - const onSearchChange = useCallback( - (event: SearchBarChangeEvent) => setQuery(event.nativeEvent.text), - [], - ); - - const needle = query.trim().toLowerCase(); - const groups = groupThreadsByWorkspace(withoutAutomationSessions(sessions), workspaces).reduce< - ThreadGroup[] - >((kept, group) => { - const matched = - needle === '' - ? group.sessions - : group.sessions.filter((session) => threadTitle(session).toLowerCase().includes(needle)); - if (matched.length > 0) kept.push({ ...group, sessions: matched }); - return kept; - }, []); - - const groupLabel = (group: ThreadGroup): string => { - if (group.isChat) return t('chats'); - if (!group.workspace) return t('otherThreads'); - return group.workspace.name ?? repositoryLabel(group.workspace.cwd); - }; - - const onRefresh = async () => { - await Promise.all([refresh(), refreshWorkspaces()]); - }; - - return ( - <> - {/* `stacked` keeps the field below the inline title instead of moving into the iOS 26 toolbar. */} - - {/* The list needs the viewport as its proposed size, otherwise SwiftUI collapses it. */} - - {loading ? ( -
-
- -
-
- ) : groups.length === 0 ? ( - // A query that matched nothing is not an empty inbox: saying "no threads yet" there - // reads as though the existing threads were lost, and offering to start one is no - // remedy for a bad search. -
- {needle === '' ? ( -
{t('emptyHint')}}> - {t('emptyTitle')} - router.push('/new-thread')} /> -
- ) : ( -
- {t('searchEmpty')} -
- )} -
- ) : ( - router.push(`/session/${sessionId}`)} - onRefresh={onRefresh} - /> - )} -
- - ); -} diff --git a/apps/mobile/src/components/host/host-connection-state.ios.tsx b/apps/mobile/src/components/host/host-connection-state.ios.tsx new file mode 100644 index 000000000..c5a935d11 --- /dev/null +++ b/apps/mobile/src/components/host/host-connection-state.ios.tsx @@ -0,0 +1,57 @@ +import { Button, Host, Image, ProgressView, Text, VStack } from '@expo/ui/swift-ui'; +import { + buttonStyle, + font, + multilineTextAlignment, + textSelection, +} from '@expo/ui/swift-ui/modifiers'; +import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; +import type { HostConnectionStateProps } from '@mobile/components/host/host-connection-state.types'; +import { useTranslations } from 'use-intl'; + +const CENTERED = multilineTextAlignment('center'); +const TITLE = font({ textStyle: 'title2', weight: 'semibold' }); + +/** Full-screen fallback shown while a host connection is being established or has failed. */ +export function HostConnectionState({ + status, + url, + failure, + onRetry, +}: HostConnectionStateProps): React.ReactNode { + const t = useTranslations('mobile.connection'); + + return ( + + + {status === 'connecting' ? ( + <> + + {t('connecting')} + + ) : ( + <> + + + {t('unavailableTitle')} + + {t('error', { url })} + + + + {failure ? ( + + {failure} + + ) : null} + + )} + ); } diff --git a/apps/mobile/src/components/host/host-connection-state.types.ts b/apps/mobile/src/components/host/host-connection-state.types.ts new file mode 100644 index 000000000..33e29aa99 --- /dev/null +++ b/apps/mobile/src/components/host/host-connection-state.types.ts @@ -0,0 +1,7 @@ +export interface HostConnectionStateProps { + status: 'connecting' | 'error'; + url: string; + /** The underlying failure, when the controller reported one. */ + failure?: string; + onRetry: () => void; +} diff --git a/apps/mobile/src/components/host/thread-list/thread-list.ios.tsx b/apps/mobile/src/components/host/thread-list/thread-list.ios.tsx new file mode 100644 index 000000000..807417b6a --- /dev/null +++ b/apps/mobile/src/components/host/thread-list/thread-list.ios.tsx @@ -0,0 +1,41 @@ +import { List, Section } from '@expo/ui/swift-ui'; +import { listStyle, refreshable } from '@expo/ui/swift-ui/modifiers'; +import type { ThreadListProps } from './thread-list.types'; +import { ThreadRow } from './thread-row'; +import { useThreadListState } from './use-thread-list-state'; + +/** The thread inbox body: one collapsible section per group. Grouping is decided by the + * caller — this only renders it. + * + * `sidebar` is not cosmetic — SwiftUI only honours a `Section`'s expanded state under that list + * style, so it is what makes the groups collapsible at all. */ +export function ThreadList({ + groups, + labelFor, + onOpenThread, + onRefresh, +}: ThreadListProps): React.ReactNode { + const { collapsed, setGroupExpanded, now } = useThreadListState(); + + return ( + + {groups.map((group) => ( +
setGroupExpanded(group.key, expanded)} + > + {group.sessions.map((session) => ( + onOpenThread(session.sessionId)} + /> + ))} +
+ ))} +
+ ); +} diff --git a/apps/mobile/src/components/host/thread-list/thread-list.tsx b/apps/mobile/src/components/host/thread-list/thread-list.tsx index d438a7ebc..07ca947fb 100644 --- a/apps/mobile/src/components/host/thread-list/thread-list.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-list.tsx @@ -1,65 +1,75 @@ -import { List, Section } from '@expo/ui/swift-ui'; -import { listStyle, refreshable } from '@expo/ui/swift-ui/modifiers'; -import type { SessionInfo } from '@linkcode/schema'; -import type { ThreadGroup } from '@linkcode/ui/native'; -import { useEffect, useState } from 'react'; +import { + Host, + LazyColumn, + PullToRefreshBox, + Row, + Spacer, + Text, + useMaterialColors, +} from '@expo/ui/jetpack-compose'; +import { clickable, fillMaxWidth, padding, weight } from '@expo/ui/jetpack-compose/modifiers'; +import { Fragment, useState } from 'react'; +import type { ThreadListProps } from './thread-list.types'; import { ThreadRow } from './thread-row'; +import { useThreadListState } from './use-thread-list-state'; -const MINUTE = 60000; - -/** The thread inbox body: one collapsible section per group. Grouping is decided by the - * caller — this only renders it. Collapsed keys live here because the state is presentational: - * nothing outside the list cares which groups are open. - * - * `sidebar` is not cosmetic — SwiftUI only honours a `Section`'s expanded state under that list - * style, so it is what makes the groups collapsible at all. */ +/** Android thread inbox body. Compose has no collapsible Section: each group renders a clickable + * MD3 subheader row and conditionally its rows. PullToRefreshBox takes a controlled flag instead + * of awaiting the promise, so the spinner state is adapted here. */ export function ThreadList({ groups, labelFor, onOpenThread, onRefresh, -}: { - groups: ThreadGroup[]; - labelFor: (group: ThreadGroup) => string; - onOpenThread: (sessionId: SessionInfo['sessionId']) => void; - /** Drives SwiftUI's own pull-to-refresh, which holds its spinner until this resolves. */ - onRefresh: () => Promise; -}): React.ReactNode { - const [collapsed, setCollapsed] = useState>(() => new Set()); - const [now, setNow] = useState(Date.now); - - useEffect(() => { - const timer = setInterval(() => setNow(Date.now()), MINUTE); - return () => clearInterval(timer); - }, []); +}: ThreadListProps): React.ReactNode { + const { collapsed, setGroupExpanded, now } = useThreadListState(); + const colors = useMaterialColors(); + const [refreshing, setRefreshing] = useState(false); - const setGroupExpanded = (key: string, expanded: boolean) => - setCollapsed((current) => { - const next = new Set(current); - if (expanded) next.delete(key); - else next.add(key); - return next; - }); + const refresh = () => { + setRefreshing(true); + void onRefresh().finally(() => setRefreshing(false)); + }; return ( - - {groups.map((group) => ( -
setGroupExpanded(group.key, expanded)} - > - {group.sessions.map((session) => ( - onOpenThread(session.sessionId)} - /> - ))} -
- ))} -
+ + + + {groups.map((group) => { + const expanded = !collapsed.has(group.key); + return ( + + setGroupExpanded(group.key, !expanded)), + fillMaxWidth(), + padding(16, 14, 16, 4), + ]} + > + + {labelFor(group)} + + + + {expanded ? '▾' : '▸'} + + + {expanded + ? group.sessions.map((session) => ( + onOpenThread(session.sessionId)} + /> + )) + : null} + + ); + })} + + + ); } diff --git a/apps/mobile/src/components/host/thread-list/thread-list.types.ts b/apps/mobile/src/components/host/thread-list/thread-list.types.ts new file mode 100644 index 000000000..eb759c928 --- /dev/null +++ b/apps/mobile/src/components/host/thread-list/thread-list.types.ts @@ -0,0 +1,10 @@ +import type { SessionInfo } from '@linkcode/schema'; +import type { ThreadGroup } from '@linkcode/ui/native'; + +export interface ThreadListProps { + groups: ThreadGroup[]; + labelFor: (group: ThreadGroup) => string; + onOpenThread: (sessionId: SessionInfo['sessionId']) => void; + /** Pull-to-refresh driver; the platform view holds its spinner until this resolves. */ + onRefresh: () => Promise; +} diff --git a/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx b/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx new file mode 100644 index 000000000..0f5b0f441 --- /dev/null +++ b/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx @@ -0,0 +1,52 @@ +import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { + contentShape, + foregroundStyle, + lineLimit, + onTapGesture, + shapes, +} from '@expo/ui/swift-ui/modifiers'; +import type { SessionStatus } from '@linkcode/schema'; +import { AGENT_LABELS } from '@linkcode/ui/native'; +import { FOOTNOTE, SECONDARY, TERTIARY } from '@mobile/components/form/styles.ios'; +import { formatRelativeShort } from '@mobile/utils/relative-time'; +import type { ThreadRowProps } from './thread-row.types'; +import { threadTitle } from './thread-title'; + +/** SwiftUI's semantic colours standing in for the `bg-*` tokens the RN dot used. */ +const STATUS_COLOR = { + starting: 'orange', + idle: 'gray', + running: 'green', + 'awaiting-input': 'orange', + stopped: 'secondary', +} as const satisfies Record; + +const WHOLE_ROW = contentShape(shapes.rectangle()); + +/** One thread row: title (desktop-matching fallback), which agent is driving it and how long ago it + * moved, then a status dot and the chevron `NavigationLink` would have drawn. + * + * The subtitle names the agent rather than the project because the list is already grouped by + * project — repeating it there would spend the line on something the section header already says. + * The agent is text, not a glyph: the brand marks are RN SVG components and cannot cross into + * SwiftUI. */ +export function ThreadRow({ session, now, onPress }: ThreadRowProps): React.ReactNode { + const subtitle = `${AGENT_LABELS[session.kind]} · ${formatRelativeShort(session.updatedAt, now)}`; + + return ( + + + {threadTitle(session)} + {subtitle} + + + + + + ); +} diff --git a/apps/mobile/src/components/host/thread-list/thread-row.tsx b/apps/mobile/src/components/host/thread-list/thread-row.tsx index 9ed81248f..f28622de8 100644 --- a/apps/mobile/src/components/host/thread-list/thread-row.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-row.tsx @@ -1,59 +1,43 @@ -import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; -import { - contentShape, - foregroundStyle, - lineLimit, - onTapGesture, - shapes, -} from '@expo/ui/swift-ui/modifiers'; -import type { SessionInfo, SessionStatus } from '@linkcode/schema'; -import { AGENT_LABELS, repositoryLabel } from '@linkcode/ui/native'; -import { FOOTNOTE, SECONDARY, TERTIARY } from '@mobile/components/form/styles.ios'; +import { Box, ListItem, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { background, clickable, clip, Shapes, size } from '@expo/ui/jetpack-compose/modifiers'; +import type { SessionStatus } from '@linkcode/schema'; +import { AGENT_LABELS } from '@linkcode/ui/native'; import { formatRelativeShort } from '@mobile/utils/relative-time'; +import { useThemeColor } from 'heroui-native'; +import type { ThreadRowProps } from './thread-row.types'; +import { threadTitle } from './thread-title'; -/** SwiftUI's semantic colours standing in for the `bg-*` tokens the RN dot used. */ -const STATUS_COLOR = { - starting: 'orange', - idle: 'gray', - running: 'green', - 'awaiting-input': 'orange', - stopped: 'secondary', -} as const satisfies Record; - -const WHOLE_ROW = contentShape(shapes.rectangle()); - -/** One thread row: title (desktop-matching fallback), which agent is driving it and how long ago it - * moved, then a status dot and the chevron `NavigationLink` would have drawn. - * - * The subtitle names the agent rather than the project because the list is already grouped by - * project — repeating it there would spend the line on something the section header already says. - * The agent is text, not a glyph: the brand marks are RN SVG components and cannot cross into - * SwiftUI. */ -export function ThreadRow({ - session, - now, - onPress, -}: { - session: SessionInfo; - now: number; - onPress: () => void; -}): React.ReactNode { - const title = session.title ?? `${AGENT_LABELS[session.kind]} in ${repositoryLabel(session.cwd)}`; +/** Android thread row: MD3 ListItem with the status dot drawn as a clipped Box (no icon asset + * needed) and no disclosure chevron, per MD3. The dot palette matches the session-title dot. */ +export function ThreadRow({ session, now, onPress }: ThreadRowProps): React.ReactNode { + const colors = useMaterialColors(); + const [success, warning, muted] = useThemeColor(['success', 'warning', 'muted']); + const statusColor = { + starting: warning, + idle: muted, + running: success, + 'awaiting-input': warning, + stopped: muted, + } satisfies Record; const subtitle = `${AGENT_LABELS[session.kind]} · ${formatRelativeShort(session.updatedAt, now)}`; return ( - - - {title} - {subtitle} - - - - - + + + + {threadTitle(session)} + + + + + {subtitle} + + + + + + ); } diff --git a/apps/mobile/src/components/host/thread-list/thread-row.types.ts b/apps/mobile/src/components/host/thread-list/thread-row.types.ts new file mode 100644 index 000000000..09ce6fd2a --- /dev/null +++ b/apps/mobile/src/components/host/thread-list/thread-row.types.ts @@ -0,0 +1,8 @@ +import type { SessionInfo } from '@linkcode/schema'; + +export interface ThreadRowProps { + session: SessionInfo; + /** Clock for the relative timestamp, ticked by the list so rows re-render together. */ + now: number; + onPress: () => void; +} diff --git a/apps/mobile/src/components/host/thread-list/thread-title.ts b/apps/mobile/src/components/host/thread-list/thread-title.ts new file mode 100644 index 000000000..6b34e7192 --- /dev/null +++ b/apps/mobile/src/components/host/thread-list/thread-title.ts @@ -0,0 +1,7 @@ +import type { SessionInfo } from '@linkcode/schema'; +import { AGENT_LABELS, repositoryLabel } from '@linkcode/ui/native'; + +/** The title a thread is listed and searched under — the same fallback the row renders. */ +export function threadTitle(session: SessionInfo): string { + return session.title ?? `${AGENT_LABELS[session.kind]} in ${repositoryLabel(session.cwd)}`; +} diff --git a/apps/mobile/src/components/host/thread-list/use-thread-list-state.ts b/apps/mobile/src/components/host/thread-list/use-thread-list-state.ts new file mode 100644 index 000000000..f9115f17c --- /dev/null +++ b/apps/mobile/src/components/host/thread-list/use-thread-list-state.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +const MINUTE = 60000; + +/** Presentational state both platform lists share: which groups are collapsed (nothing outside + * the list cares) and a once-a-minute clock for the relative timestamps. */ +export function useThreadListState(): { + collapsed: ReadonlySet; + setGroupExpanded: (key: string, expanded: boolean) => void; + now: number; +} { + const [collapsed, setCollapsed] = useState>(() => new Set()); + const [now, setNow] = useState(Date.now); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), MINUTE); + return () => clearInterval(timer); + }, []); + + const setGroupExpanded = (key: string, expanded: boolean) => + setCollapsed((current) => { + const next = new Set(current); + if (expanded) next.delete(key); + else next.add(key); + return next; + }); + + return { collapsed, setGroupExpanded, now }; +} diff --git a/apps/mobile/src/components/host/threads-screen.ios.tsx b/apps/mobile/src/components/host/threads-screen.ios.tsx new file mode 100644 index 000000000..4cb1ce017 --- /dev/null +++ b/apps/mobile/src/components/host/threads-screen.ios.tsx @@ -0,0 +1,68 @@ +import { + Form, + Host, + ProgressView, + Section, + Button as UIButton, + Text as UIText, +} from '@expo/ui/swift-ui'; +import { SECONDARY } from '@mobile/components/form/styles.ios'; +import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; +import { useThreadInbox } from '@mobile/components/host/use-thread-inbox'; +import { Stack, useRouter } from 'expo-router'; +import { useTranslations } from 'use-intl'; + +/** Threads inbox body: grouped sessions under collapsible headers, with the native search bar + * stacked below the navigation bar. */ +export function ThreadsScreen(): React.ReactNode { + const t = useTranslations('mobile.sessions'); + const router = useRouter(); + const { loading, groups, hasQuery, onSearchChange, groupLabel, onRefresh } = useThreadInbox(); + + return ( + <> + {/* `stacked` keeps the field below the inline title instead of moving into the iOS 26 toolbar. */} + + {/* The list needs the viewport as its proposed size, otherwise SwiftUI collapses it. */} + + {loading ? ( +
+
+ +
+
+ ) : groups.length === 0 ? ( + // A query that matched nothing is not an empty inbox: saying "no threads yet" there + // reads as though the existing threads were lost, and offering to start one is no + // remedy for a bad search. +
+ {hasQuery ? ( +
+ {t('searchEmpty')} +
+ ) : ( +
{t('emptyHint')}}> + {t('emptyTitle')} + router.push('/new-thread')} /> +
+ )} +
+ ) : ( + router.push(`/session/${sessionId}`)} + onRefresh={onRefresh} + /> + )} +
+ + ); +} diff --git a/apps/mobile/src/components/host/threads-screen.tsx b/apps/mobile/src/components/host/threads-screen.tsx new file mode 100644 index 000000000..3dd883b8b --- /dev/null +++ b/apps/mobile/src/components/host/threads-screen.tsx @@ -0,0 +1,58 @@ +import { Button, Text } from '@expo/ui/jetpack-compose'; +import { padding } from '@expo/ui/jetpack-compose/modifiers'; +import { FormList } from '@mobile/components/form/list.android'; +import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; +import { FormSection } from '@mobile/components/form/section.android'; +import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; +import { useThreadInbox } from '@mobile/components/host/use-thread-inbox'; +import { Stack, useRouter } from 'expo-router'; +import { useTranslations } from 'use-intl'; + +/** Android threads inbox body. The search bar is the toolbar's collapsing magnifier field — + * the iOS-only placement props are left off. */ +export function ThreadsScreen(): React.ReactNode { + const t = useTranslations('mobile.sessions'); + const router = useRouter(); + const { loading, groups, hasQuery, onSearchChange, groupLabel, onRefresh } = useThreadInbox(); + + return ( + <> + + {loading ? ( + + + + ) : groups.length === 0 ? ( + // A query that matched nothing is not an empty inbox: saying "no threads yet" there + // reads as though the existing threads were lost, and offering to start one is no + // remedy for a bad search. + + {hasQuery ? ( + {t('searchEmpty')} + ) : ( + + {t('emptyTitle')} + + + )} + + ) : ( + router.push(`/session/${sessionId}`)} + onRefresh={onRefresh} + /> + )} + + ); +} diff --git a/apps/mobile/src/components/host/use-thread-inbox.ts b/apps/mobile/src/components/host/use-thread-inbox.ts new file mode 100644 index 000000000..f15d73532 --- /dev/null +++ b/apps/mobile/src/components/host/use-thread-inbox.ts @@ -0,0 +1,64 @@ +import { useSessions } from '@linkcode/client-core'; +import type { ThreadGroup } from '@linkcode/ui/native'; +import { + groupThreadsByWorkspace, + repositoryLabel, + withoutAutomationSessions, +} from '@linkcode/ui/native'; +import { threadTitle } from '@mobile/components/host/thread-list/thread-title'; +import { useWorkspaces } from '@mobile/runtime/use-workspaces'; +import type { Stack } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { useTranslations } from 'use-intl'; + +/** Taken from the search bar itself: RN's own replacement for the event it declares carries no text. */ +type SearchBarChangeEvent = Parameters< + NonNullable['onChangeText']> +>[0]; + +/** The threads inbox view model: sessions grouped by workspace, filtered by the search query. + * Empty workspace groups are hidden — the new-thread page is where they surface. */ +export function useThreadInbox(): { + loading: boolean; + groups: ThreadGroup[]; + hasQuery: boolean; + onSearchChange: (event: SearchBarChangeEvent) => void; + groupLabel: (group: ThreadGroup) => string; + onRefresh: () => Promise; +} { + const t = useTranslations('mobile.sessions'); + const { sessions, refresh, loading } = useSessions(); + const { workspaces, refresh: refreshWorkspaces } = useWorkspaces(); + + const [query, setQuery] = useState(''); + + // Stable so the search bar's options object survives a keystroke without re-registering. + const onSearchChange = useCallback( + (event: SearchBarChangeEvent) => setQuery(event.nativeEvent.text), + [], + ); + + const needle = query.trim().toLowerCase(); + const groups = groupThreadsByWorkspace(withoutAutomationSessions(sessions), workspaces).reduce< + ThreadGroup[] + >((kept, group) => { + const matched = + needle === '' + ? group.sessions + : group.sessions.filter((session) => threadTitle(session).toLowerCase().includes(needle)); + if (matched.length > 0) kept.push({ ...group, sessions: matched }); + return kept; + }, []); + + const groupLabel = (group: ThreadGroup): string => { + if (group.isChat) return t('chats'); + if (!group.workspace) return t('otherThreads'); + return group.workspace.name ?? repositoryLabel(group.workspace.cwd); + }; + + const onRefresh = async () => { + await Promise.all([refresh(), refreshWorkspaces()]); + }; + + return { loading, groups, hasQuery: needle !== '', onSearchChange, groupLabel, onRefresh }; +} From 15b4576568687f2dfbee2af094df8b568b74bacc Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 04:17:41 +0800 Subject: [PATCH 06/37] feat(mobile): android terminals inbox and new-terminal sheet --- .../mobile/src/app/(tabs)/terminals/index.tsx | 182 +----------------- .../terminal/new-terminal-sheet.ios.tsx | 82 ++++++++ .../terminal/new-terminal-sheet.tsx | 102 +++++----- .../terminal/new-terminal-sheet.types.ts | 7 + .../terminal/terminals-screen.ios.tsx | 109 +++++++++++ .../components/terminal/terminals-screen.tsx | 88 +++++++++ .../components/terminal/use-terminal-inbox.ts | 110 +++++++++++ 7 files changed, 446 insertions(+), 234 deletions(-) create mode 100644 apps/mobile/src/components/terminal/new-terminal-sheet.ios.tsx create mode 100644 apps/mobile/src/components/terminal/new-terminal-sheet.types.ts create mode 100644 apps/mobile/src/components/terminal/terminals-screen.ios.tsx create mode 100644 apps/mobile/src/components/terminal/terminals-screen.tsx create mode 100644 apps/mobile/src/components/terminal/use-terminal-inbox.ts diff --git a/apps/mobile/src/app/(tabs)/terminals/index.tsx b/apps/mobile/src/app/(tabs)/terminals/index.tsx index 035c97921..49a76b8cd 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -1,38 +1,17 @@ -import { - Button, - ContentUnavailableView, - Form, - Host, - ProgressView, - Section, - Text, -} from '@expo/ui/swift-ui'; -import { foregroundStyle, refreshable } from '@expo/ui/swift-ui/modifiers'; -import { useLinkCodeClient } from '@linkcode/client-core'; -import type { TerminalMetadata } from '@linkcode/schema'; -import { repositoryLabel } from '@linkcode/ui/native'; -import { NavigationRow } from '@mobile/components/form/navigation-row'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { useHostHeaderOptions } from '@mobile/components/host/use-host-header-options'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; -import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; +import { TerminalsScreen } from '@mobile/components/terminal/terminals-screen'; import { useHostConnection } from '@mobile/runtime/host-connection'; -import { Stack, useFocusEffect, useRouter } from 'expo-router'; -import { extractErrorMessage } from 'foxts/extract-error-message'; +import { Stack } from 'expo-router'; import { PlusIcon } from 'lucide-react-native'; -import { useCallback, useState } from 'react'; -import { Platform, View } from 'react-native'; +import { useState } from 'react'; +import { View } from 'react-native'; import { useTranslations } from 'use-intl'; -const INITIAL_TERMINAL_SIZE = { cols: 80, rows: 24 }; - -const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); -const SUPPORTS_CONTENT_UNAVAILABLE_VIEW = - Platform.OS === 'ios' && Number.parseInt(Platform.Version, 10) >= 17; - /** Header above the gate, for the same reason as the threads tab: the host switcher has to stay * reachable when the host is not. */ export default function TerminalsRoute(): React.ReactNode { @@ -75,156 +54,3 @@ export default function TerminalsRoute(): React.ReactNode { ); } - -/** Host terminal inbox: attach to a running PTY or start a new one on the host. */ -function TerminalsScreen({ - sheetOpen, - onSheetOpenChange, -}: { - sheetOpen: boolean; - onSheetOpenChange: (open: boolean) => void; -}): React.ReactNode { - const t = useTranslations('mobile.terminals'); - const router = useRouter(); - const client = useLinkCodeClient(); - const [terminals, setTerminals] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [createError, setCreateError] = useState(null); - const [creating, setCreating] = useState(false); - - const load = useCallback( - async () => (await client.listTerminals()).sort((a, b) => b.createdAt - a.createdAt), - [client], - ); - - useFocusEffect( - useCallback(() => { - let active = true; - setLoadError(null); - void load() - .then((nextTerminals) => { - if (active) setTerminals(nextTerminals); - }) - .catch((error_: unknown) => { - if (active) setLoadError(extractErrorMessage(error_, false) ?? 'Unknown error'); - }) - .finally(() => { - if (active) setLoading(false); - }); - return () => { - active = false; - }; - }, [load]), - ); - - // Drives SwiftUI's own pull-to-refresh, which keeps its spinner until this resolves. - const onRefresh = async () => { - setLoadError(null); - try { - setTerminals(await load()); - } catch (error_) { - setLoadError(extractErrorMessage(error_, false) ?? 'Unknown error'); - } - }; - - const openTerminal = (terminalId: string, takeControl = false) => { - const query = takeControl ? '?takeover=1' : ''; - router.push(`/terminal/${encodeURIComponent(terminalId)}${query}`); - }; - - const onCreate = async (cwd: string): Promise => { - if (creating) return false; - setCreating(true); - setCreateError(null); - try { - const terminalId = await client.openTerminal({ - ...INITIAL_TERMINAL_SIZE, - cwd: cwd || undefined, - }); - client.detachTerminal(terminalId); - onSheetOpenChange(false); - openTerminal(terminalId, true); - return true; - } catch (error_) { - setCreateError(extractErrorMessage(error_, false) ?? 'Unknown error'); - return false; - } finally { - setCreating(false); - } - }; - - return ( - <> - {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} - - {loading ? ( - - ) : loadError && terminals.length === 0 ? ( -
-
- - {t('loadError', { error: loadError })} - -
-
- ) : terminals.length === 0 ? ( - SUPPORTS_CONTENT_UNAVAILABLE_VIEW ? ( - - ) : ( -
-
- {t('emptyHint')} -
-
- ) - ) : ( -
- {loadError ? ( -
- - {t('loadError', { error: loadError })} - -
- ) : null} -
- {terminals.map((terminal) => ( - openTerminal(terminal.terminalId)} - /> - ))} -
-
- )} -
- { - if (!open) setCreateError(null); - onSheetOpenChange(open); - }} - creating={creating} - error={createError} - onCreate={onCreate} - /> - - ); -} diff --git a/apps/mobile/src/components/terminal/new-terminal-sheet.ios.tsx b/apps/mobile/src/components/terminal/new-terminal-sheet.ios.tsx new file mode 100644 index 000000000..0a08ea8e8 --- /dev/null +++ b/apps/mobile/src/components/terminal/new-terminal-sheet.ios.tsx @@ -0,0 +1,82 @@ +import { + BottomSheet, + Button, + Form, + Host, + HStack, + Section, + Text, + TextField, + useNativeState, +} from '@expo/ui/swift-ui'; +import { + autocorrectionDisabled, + disabled, + foregroundStyle, + onSubmit, + submitLabel, + textInputAutocapitalization, +} from '@expo/ui/swift-ui/modifiers'; +import { useTranslations } from 'use-intl'; +import type { NewTerminalSheetProps } from './new-terminal-sheet.types'; + +export function NewTerminalSheet({ + isPresented, + onIsPresentedChange, + creating, + error, + onCreate, +}: NewTerminalSheetProps): React.ReactNode { + const t = useTranslations('mobile.terminals'); + const cwd = useNativeState(''); + + const create = () => { + if (creating) return; + void onCreate(cwd.get().trim()).then((created) => { + if (created) cwd.set(''); + }); + }; + + return ( + + +
+ {error ? ( +
+ {t('createError', { error })} +
+ ) : null} + +
+ + {t('cwdLabel')} + + +
+ +
+
+
+
+
+ ); +} diff --git a/apps/mobile/src/components/terminal/new-terminal-sheet.tsx b/apps/mobile/src/components/terminal/new-terminal-sheet.tsx index e3b9c1158..cd5db142a 100644 --- a/apps/mobile/src/components/terminal/new-terminal-sheet.tsx +++ b/apps/mobile/src/components/terminal/new-terminal-sheet.tsx @@ -1,40 +1,32 @@ import { - BottomSheet, Button, - Form, + Column, Host, - HStack, - Section, + ModalBottomSheet, + OutlinedTextField, Text, - TextField, + useMaterialColors, useNativeState, -} from '@expo/ui/swift-ui'; -import { - autocorrectionDisabled, - disabled, - foregroundStyle, - onSubmit, - submitLabel, - textInputAutocapitalization, -} from '@expo/ui/swift-ui/modifiers'; +} from '@expo/ui/jetpack-compose'; +import { fillMaxWidth, imePadding, padding, testID } from '@expo/ui/jetpack-compose/modifiers'; import { useTranslations } from 'use-intl'; +import type { NewTerminalSheetProps } from './new-terminal-sheet.types'; +/** Android new-terminal sheet. Compose's ModalBottomSheet has no `isPresented` — mounting shows + * it — so this renders nothing while closed and maps dismissal back through the prop. */ export function NewTerminalSheet({ isPresented, onIsPresentedChange, creating, error, onCreate, -}: { - isPresented: boolean; - onIsPresentedChange: (isPresented: boolean) => void; - creating: boolean; - error: string | null; - onCreate: (cwd: string) => Promise; -}): React.ReactNode { +}: NewTerminalSheetProps): React.ReactNode { const t = useTranslations('mobile.terminals'); + const colors = useMaterialColors(); const cwd = useNativeState(''); + if (!isPresented) return null; + const create = () => { if (creating) return; void onCreate(cwd.get().trim()).then((created) => { @@ -44,44 +36,42 @@ export function NewTerminalSheet({ return ( - -
+ onIsPresentedChange(false)}> + + + {t('newTerminal')} + {error ? ( -
- {t('createError', { error })} -
+ + {t('createError', { error })} + ) : null} - -
- + + {t('cwdLabel')} - - -
- -
-
- -
+ + + {t('cwdPlaceholder')} + + + + +
); } diff --git a/apps/mobile/src/components/terminal/new-terminal-sheet.types.ts b/apps/mobile/src/components/terminal/new-terminal-sheet.types.ts new file mode 100644 index 000000000..d9ee6e422 --- /dev/null +++ b/apps/mobile/src/components/terminal/new-terminal-sheet.types.ts @@ -0,0 +1,7 @@ +export interface NewTerminalSheetProps { + isPresented: boolean; + onIsPresentedChange: (isPresented: boolean) => void; + creating: boolean; + error: string | null; + onCreate: (cwd: string) => Promise; +} diff --git a/apps/mobile/src/components/terminal/terminals-screen.ios.tsx b/apps/mobile/src/components/terminal/terminals-screen.ios.tsx new file mode 100644 index 000000000..b58598537 --- /dev/null +++ b/apps/mobile/src/components/terminal/terminals-screen.ios.tsx @@ -0,0 +1,109 @@ +import { + Button, + ContentUnavailableView, + Form, + Host, + ProgressView, + Section, + Text, +} from '@expo/ui/swift-ui'; +import { foregroundStyle, refreshable } from '@expo/ui/swift-ui/modifiers'; +import { repositoryLabel } from '@linkcode/ui/native'; +import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; +import { useTerminalInbox } from '@mobile/components/terminal/use-terminal-inbox'; +import { Platform } from 'react-native'; +import { useTranslations } from 'use-intl'; + +const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); +const SUPPORTS_CONTENT_UNAVAILABLE_VIEW = + Platform.OS === 'ios' && Number.parseInt(Platform.Version, 10) >= 17; + +/** Host terminal inbox body: attach to a running PTY or start a new one on the host. */ +export function TerminalsScreen({ + sheetOpen, + onSheetOpenChange, +}: { + sheetOpen: boolean; + onSheetOpenChange: (open: boolean) => void; +}): React.ReactNode { + const t = useTranslations('mobile.terminals'); + const { + terminals, + loading, + loadError, + createError, + creating, + onRefresh, + retry, + openTerminal, + onCreate, + } = useTerminalInbox(() => onSheetOpenChange(false)); + + return ( + <> + {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} + + {loading ? ( + + ) : loadError && terminals.length === 0 ? ( +
+
+ + {t('loadError', { error: loadError })} + +
+
+ ) : terminals.length === 0 ? ( + SUPPORTS_CONTENT_UNAVAILABLE_VIEW ? ( + + ) : ( +
+
+ {t('emptyHint')} +
+
+ ) + ) : ( +
+ {loadError ? ( +
+ + {t('loadError', { error: loadError })} + +
+ ) : null} +
+ {terminals.map((terminal) => ( + openTerminal(terminal.terminalId)} + /> + ))} +
+
+ )} +
+ { + onSheetOpenChange(open); + }} + creating={creating} + error={createError} + onCreate={onCreate} + /> + + ); +} diff --git a/apps/mobile/src/components/terminal/terminals-screen.tsx b/apps/mobile/src/components/terminal/terminals-screen.tsx new file mode 100644 index 000000000..30baf5ed4 --- /dev/null +++ b/apps/mobile/src/components/terminal/terminals-screen.tsx @@ -0,0 +1,88 @@ +import { Button, Host, LazyColumn, PullToRefreshBox, Text } from '@expo/ui/jetpack-compose'; +import { fillMaxWidth, padding } from '@expo/ui/jetpack-compose/modifiers'; +import { repositoryLabel } from '@linkcode/ui/native'; +import { FormList } from '@mobile/components/form/list.android'; +import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; +import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; +import { useTerminalInbox } from '@mobile/components/terminal/use-terminal-inbox'; +import { useState } from 'react'; +import { useTranslations } from 'use-intl'; + +/** Android terminal inbox body, mirroring `terminals-screen.ios.tsx` with MD3 list rows and a + * controlled PullToRefreshBox in place of SwiftUI's awaiting `refreshable`. */ +export function TerminalsScreen({ + sheetOpen, + onSheetOpenChange, +}: { + sheetOpen: boolean; + onSheetOpenChange: (open: boolean) => void; +}): React.ReactNode { + const t = useTranslations('mobile.terminals'); + const { + terminals, + loading, + loadError, + createError, + creating, + onRefresh, + retry, + openTerminal, + onCreate, + } = useTerminalInbox(() => onSheetOpenChange(false)); + const [refreshing, setRefreshing] = useState(false); + + const refresh = () => { + setRefreshing(true); + void onRefresh().finally(() => setRefreshing(false)); + }; + + return ( + <> + {loading ? ( + + + + ) : loadError && terminals.length === 0 ? ( + + {t('loadError', { error: loadError })} + + + ) : ( + + + + {loadError ? ( + {t('loadError', { error: loadError })} + ) : null} + {terminals.length === 0 ? ( + {t('emptyHint')} + ) : ( + terminals.map((terminal) => ( + openTerminal(terminal.terminalId)} + /> + )) + )} + + + + )} + + + ); +} diff --git a/apps/mobile/src/components/terminal/use-terminal-inbox.ts b/apps/mobile/src/components/terminal/use-terminal-inbox.ts new file mode 100644 index 000000000..47a454422 --- /dev/null +++ b/apps/mobile/src/components/terminal/use-terminal-inbox.ts @@ -0,0 +1,110 @@ +import { useLinkCodeClient } from '@linkcode/client-core'; +import type { TerminalMetadata } from '@linkcode/schema'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { useCallback, useState } from 'react'; + +const INITIAL_TERMINAL_SIZE = { cols: 80, rows: 24 }; + +export interface TerminalInboxState { + terminals: TerminalMetadata[]; + loading: boolean; + loadError: string | null; + createError: string | null; + creating: boolean; + /** Pull-to-refresh driver; the platform view holds its spinner until this resolves. */ + onRefresh: () => Promise; + /** Full-screen retry after a load error: re-enters the loading state around a refresh. */ + retry: () => void; + openTerminal: (terminalId: string, takeControl?: boolean) => void; + onCreate: (cwd: string) => Promise; +} + +/** The terminal inbox view model: attach to a running PTY or start a new one on the host. + * `onCreated` closes whatever surface collected the cwd (the new-terminal sheet). */ +export function useTerminalInbox(onCreated: () => void): TerminalInboxState { + const router = useRouter(); + const client = useLinkCodeClient(); + const [terminals, setTerminals] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [createError, setCreateError] = useState(null); + const [creating, setCreating] = useState(false); + + const load = useCallback( + async () => (await client.listTerminals()).sort((a, b) => b.createdAt - a.createdAt), + [client], + ); + + useFocusEffect( + useCallback(() => { + let active = true; + setLoadError(null); + void load() + .then((nextTerminals) => { + if (active) setTerminals(nextTerminals); + }) + .catch((error_: unknown) => { + if (active) setLoadError(extractErrorMessage(error_, false) ?? 'Unknown error'); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [load]), + ); + + const onRefresh = async () => { + setLoadError(null); + try { + setTerminals(await load()); + } catch (error_) { + setLoadError(extractErrorMessage(error_, false) ?? 'Unknown error'); + } + }; + + const retry = () => { + setLoading(true); + void onRefresh().finally(() => setLoading(false)); + }; + + const openTerminal = (terminalId: string, takeControl = false) => { + const query = takeControl ? '?takeover=1' : ''; + router.push(`/terminal/${encodeURIComponent(terminalId)}${query}`); + }; + + const onCreate = async (cwd: string): Promise => { + if (creating) return false; + setCreating(true); + setCreateError(null); + try { + const terminalId = await client.openTerminal({ + ...INITIAL_TERMINAL_SIZE, + cwd: cwd || undefined, + }); + client.detachTerminal(terminalId); + onCreated(); + openTerminal(terminalId, true); + return true; + } catch (error_) { + setCreateError(extractErrorMessage(error_, false) ?? 'Unknown error'); + return false; + } finally { + setCreating(false); + } + }; + + return { + terminals, + loading, + loadError, + createError, + creating, + onRefresh, + retry, + openTerminal, + onCreate, + }; +} From b08fcd642c7a03cf07c06e0cf6b4fd75f78747b4 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 04:23:47 +0800 Subject: [PATCH 07/37] feat(mobile): android composer chips and workspace picker as bottom sheets --- .../{option-chip.tsx => option-chip.ios.tsx} | 0 .../conversation/session-tools.ios.tsx | 124 +++++++++++ .../components/conversation/session-tools.tsx | 169 +++++++-------- .../conversation/session-tools.types.ts | 21 ++ .../conversation/tool-chip.android.tsx | 48 +++++ .../host/new-thread/draft-tools.ios.tsx | 127 +++++++++++ .../host/new-thread/draft-tools.tsx | 202 +++++++++--------- .../host/new-thread/draft-tools.types.ts | 34 +++ .../host/new-thread/project-row.ios.tsx | 87 ++++++++ .../host/new-thread/project-row.tsx | 94 ++++---- .../host/new-thread/project-row.types.ts | 11 + 11 files changed, 664 insertions(+), 253 deletions(-) rename apps/mobile/src/components/conversation/{option-chip.tsx => option-chip.ios.tsx} (100%) create mode 100644 apps/mobile/src/components/conversation/session-tools.ios.tsx create mode 100644 apps/mobile/src/components/conversation/session-tools.types.ts create mode 100644 apps/mobile/src/components/conversation/tool-chip.android.tsx create mode 100644 apps/mobile/src/components/host/new-thread/draft-tools.ios.tsx create mode 100644 apps/mobile/src/components/host/new-thread/draft-tools.types.ts create mode 100644 apps/mobile/src/components/host/new-thread/project-row.ios.tsx create mode 100644 apps/mobile/src/components/host/new-thread/project-row.types.ts diff --git a/apps/mobile/src/components/conversation/option-chip.tsx b/apps/mobile/src/components/conversation/option-chip.ios.tsx similarity index 100% rename from apps/mobile/src/components/conversation/option-chip.tsx rename to apps/mobile/src/components/conversation/option-chip.ios.tsx diff --git a/apps/mobile/src/components/conversation/session-tools.ios.tsx b/apps/mobile/src/components/conversation/session-tools.ios.tsx new file mode 100644 index 000000000..432bb0092 --- /dev/null +++ b/apps/mobile/src/components/conversation/session-tools.ios.tsx @@ -0,0 +1,124 @@ +import { Host, Menu, Picker, Text as UIText } from '@expo/ui/swift-ui'; +import { tag } from '@expo/ui/swift-ui/modifiers'; +import { AgentIcon, groupModelsByProvider, modelChoiceKey } from '@linkcode/ui/native'; +import { OptionChip } from '@mobile/components/conversation/option-chip.ios'; +import type { + SessionApprovalChipProps, + SessionSelectorChipProps, +} from '@mobile/components/conversation/session-tools.types'; +import { useThemeColor } from 'heroui-native'; +import { View } from 'react-native'; +import { useTranslations } from 'use-intl'; + +/** The live session's approval chip: a ghost shield opening the adapter-advertised policy menu. + * Absent state means the adapter has no switchable policies, so nothing renders. Selection is + * server-reflected — the checkmark moves once `approval-policy-update` echoes the switch. */ +export function SessionApprovalChip({ + approvalPolicy, + onPolicyChange, +}: SessionApprovalChipProps): React.ReactNode { + const t = useTranslations('mobile.sessions'); + if (!approvalPolicy || approvalPolicy.availablePolicies.length === 0) return null; + + // eslint-disable-next-line sukka/react-no-performance-impacting-array-find -- one lookup against a handful of policies per render + const current = approvalPolicy.availablePolicies.find( + (policy) => policy.policyId === approvalPolicy.currentPolicyId, + ); + return ( + + + + {approvalPolicy.availablePolicies.map((policy) => ( + + {policy.name} + + ))} + + + + ); +} + +/** The live session's selector: the harness brand mark beside one menu with Model / Effort + * submenus. No harness submenu (the agent is fixed) and no "Default" entries — a running session + * is always on a concrete value, and the checkmarks follow the `model-update` / `effort-update` + * echoes rather than any local pick. */ +export function SessionSelectorChip({ + kind, + selectorValue, + models, + currentModelKey, + onModelChange, + effortOptions, + currentEffort, + onEffortChange, +}: SessionSelectorChipProps): React.ReactNode { + const t = useTranslations('mobile.sessions'); + const muted = useThemeColor('muted'); + const hasModels = models !== null && models.length > 0; + const hasEfforts = effortOptions !== undefined && effortOptions.length > 0; + if (!hasModels && !hasEfforts) return null; + + // The account label joins a row only when the list spans several accounts — the same threshold + // as the web's provider grouping; a single-account list repeating its account is noise. + const spansAccounts = models !== null && groupModelsByProvider(models) !== null; + + return ( + + {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} + + + {/* Keyed by menu shape: children added to an already-created Menu never reach the + native UIMenu, so late-loading models/efforts must remount the chip. */} + + {hasModels ? ( + + { + const option = models.find((model) => modelChoiceKey(model) === key); + if (option) onModelChange(option); + }} + > + {models.map((model) => ( + + {spansAccounts && model.description + ? `${model.label} — ${model.description}` + : model.label} + + ))} + + + ) : null} + {hasEfforts ? ( + + { + const option = effortOptions.find((candidate) => candidate.id === value); + if (option) onEffortChange(option.id); + }} + > + {effortOptions.map((option) => ( + + {option.label} + + ))} + + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/components/conversation/session-tools.tsx b/apps/mobile/src/components/conversation/session-tools.tsx index e09721a6e..22a27b505 100644 --- a/apps/mobile/src/components/conversation/session-tools.tsx +++ b/apps/mobile/src/components/conversation/session-tools.tsx @@ -1,54 +1,64 @@ -import { Host, Menu, Picker, Text as UIText } from '@expo/ui/swift-ui'; -import { tag } from '@expo/ui/swift-ui/modifiers'; -import type { AgentKind, ApprovalPolicyState, EffortLevel } from '@linkcode/schema'; -import type { EffortOption, ModelOption } from '@linkcode/ui/native'; import { AgentIcon, groupModelsByProvider, modelChoiceKey } from '@linkcode/ui/native'; -import { OptionChip } from '@mobile/components/conversation/option-chip'; +import type { + SessionApprovalChipProps, + SessionSelectorChipProps, +} from '@mobile/components/conversation/session-tools.types'; +import { ToolChip } from '@mobile/components/conversation/tool-chip.android'; +import type { SheetPickerSection } from '@mobile/components/form/sheet-picker.android'; +import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; import { useThemeColor } from 'heroui-native'; +import { ShieldIcon } from 'lucide-react-native'; +import { useState } from 'react'; import { View } from 'react-native'; import { useTranslations } from 'use-intl'; -/** The live session's approval chip: a ghost shield opening the adapter-advertised policy menu. - * Absent state means the adapter has no switchable policies, so nothing renders. Selection is - * server-reflected — the checkmark moves once `approval-policy-update` echoes the switch. */ +/** The live session's approval chip on Android. Selection is server-reflected — the radio moves + * once `approval-policy-update` echoes the switch, so a rejected switch just leaves the old + * value showing. */ export function SessionApprovalChip({ approvalPolicy, onPolicyChange, -}: { - approvalPolicy: ApprovalPolicyState | null; - onPolicyChange: (policyId: string) => void; -}): React.ReactNode { +}: SessionApprovalChipProps): React.ReactNode { const t = useTranslations('mobile.sessions'); + const [open, setOpen] = useState(false); if (!approvalPolicy || approvalPolicy.availablePolicies.length === 0) return null; // eslint-disable-next-line vibe-proof/react-no-performance-impacting-array-find -- one lookup against a handful of policies per render const current = approvalPolicy.availablePolicies.find( (policy) => policy.policyId === approvalPolicy.currentPolicyId, ); + return ( - - + - - {approvalPolicy.availablePolicies.map((policy) => ( - - {policy.name} - - ))} - - - + onPress={() => setOpen(true)} + /> + setOpen(false)} + sections={[ + { + id: 'policy', + title: t('approvalLabel'), + selection: approvalPolicy.currentPolicyId, + options: approvalPolicy.availablePolicies.map((policy) => ({ + id: policy.policyId, + label: policy.name, + })), + onSelect: onPolicyChange, + }, + ]} + /> + ); } -/** The live session's selector: the harness brand mark beside one menu with Model / Effort - * submenus. No harness submenu (the agent is fixed) and no "Default" entries — a running session - * is always on a concrete value, and the checkmarks follow the `model-update` / `effort-update` - * echoes rather than any local pick. */ +/** The live session's selector on Android: Model / Effort sections, no harness section (the agent + * is fixed) and no "Default" entries — a running session is always on a concrete value. */ export function SessionSelectorChip({ kind, selectorValue, @@ -58,21 +68,10 @@ export function SessionSelectorChip({ effortOptions, currentEffort, onEffortChange, -}: { - kind: AgentKind; - /** Text on the chip — the running model (and effort), or a placeholder. */ - selectorValue: string; - /** Account-backed options; null while loading (model submenu hidden). */ - models: ModelOption[] | null; - /** `modelChoiceKey` of the entry matching the running model, or null while unknown. */ - currentModelKey: string | null; - onModelChange: (model: ModelOption) => void; - effortOptions: EffortOption[] | undefined; - currentEffort: EffortLevel | null; - onEffortChange: (effort: EffortLevel) => void; -}): React.ReactNode { +}: SessionSelectorChipProps): React.ReactNode { const t = useTranslations('mobile.sessions'); const muted = useThemeColor('muted'); + const [open, setOpen] = useState(false); const hasModels = models !== null && models.length > 0; const hasEfforts = effortOptions !== undefined && effortOptions.length > 0; if (!hasModels && !hasEfforts) return null; @@ -81,57 +80,49 @@ export function SessionSelectorChip({ // as the web's provider grouping; a single-account list repeating its account is noise. const spansAccounts = models !== null && groupModelsByProvider(models) !== null; + const sections: SheetPickerSection[] = []; + if (hasModels) { + sections.push({ + id: 'model', + title: t('modelLabel'), + selection: currentModelKey, + options: models.map((model) => ({ + id: modelChoiceKey(model), + label: + spansAccounts && model.description + ? `${model.label} — ${model.description}` + : model.label, + })), + onSelect(key) { + const option = models.find((model) => modelChoiceKey(model) === key); + if (option) onModelChange(option); + }, + }); + } + if (hasEfforts) { + sections.push({ + id: 'effort', + title: t('effortLabel'), + selection: currentEffort, + options: effortOptions.map((option) => ({ id: option.id, label: option.label })), + onSelect(value) { + const option = effortOptions.find((candidate) => candidate.id === value); + if (option) onEffortChange(option.id); + }, + }); + } + return ( {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} - - {/* Keyed by menu shape: children added to an already-created Menu never reach the - native UIMenu, so late-loading models/efforts must remount the chip. */} - - {hasModels ? ( - - { - const option = models.find((model) => modelChoiceKey(model) === key); - if (option) onModelChange(option); - }} - > - {models.map((model) => ( - - {spansAccounts && model.description - ? `${model.label} — ${model.description}` - : model.label} - - ))} - - - ) : null} - {hasEfforts ? ( - - { - const option = effortOptions.find((candidate) => candidate.id === value); - if (option) onEffortChange(option.id); - }} - > - {effortOptions.map((option) => ( - - {option.label} - - ))} - - - ) : null} - - + setOpen(true)} + /> + setOpen(false)} sections={sections} /> ); } diff --git a/apps/mobile/src/components/conversation/session-tools.types.ts b/apps/mobile/src/components/conversation/session-tools.types.ts new file mode 100644 index 000000000..f5d3acea8 --- /dev/null +++ b/apps/mobile/src/components/conversation/session-tools.types.ts @@ -0,0 +1,21 @@ +import type { AgentKind, ApprovalPolicyState, EffortLevel } from '@linkcode/schema'; +import type { EffortOption, ModelOption } from '@linkcode/ui/native'; + +export interface SessionApprovalChipProps { + approvalPolicy: ApprovalPolicyState | null; + onPolicyChange: (policyId: string) => void; +} + +export interface SessionSelectorChipProps { + kind: AgentKind; + /** Text on the chip — the running model (and effort), or a placeholder. */ + selectorValue: string; + /** Account-backed options; null while loading (model submenu hidden). */ + models: ModelOption[] | null; + /** `modelChoiceKey` of the entry matching the running model, or null while unknown. */ + currentModelKey: string | null; + onModelChange: (model: ModelOption) => void; + effortOptions: EffortOption[] | undefined; + currentEffort: EffortLevel | null; + onEffortChange: (effort: EffortLevel) => void; +} diff --git a/apps/mobile/src/components/conversation/tool-chip.android.tsx b/apps/mobile/src/components/conversation/tool-chip.android.tsx new file mode 100644 index 000000000..508e22532 --- /dev/null +++ b/apps/mobile/src/components/conversation/tool-chip.android.tsx @@ -0,0 +1,48 @@ +import { useThemeColor } from 'heroui-native'; +import type { LucideIcon } from 'lucide-react-native'; +import { Pressable, Text } from 'react-native'; + +/** One composer tool on Android: a ghost chip that opens a bottom-sheet picker — the RN twin of + * the iOS `OptionChip` UIMenu trigger, styled to match the composer footer it sits in. */ +export function ToolChip({ + icon: Icon, + label, + value, + iconOnly = false, + maxValueWidth, + onPress, +}: { + /** Optional leading glyph; omit when an RN brand icon sits beside the chip instead. */ + icon?: LucideIcon; + /** Accessibility name of the chip ("Model"); not rendered. */ + label: string; + /** Resolved current value — rendered unless `iconOnly`, always spoken. */ + value: string; + /** Icon-size the chip (the shield); the value still reaches accessibility. */ + iconOnly?: boolean; + /** Cap for long values so one chip cannot push the send button out. */ + maxValueWidth?: number; + onPress: () => void; +}): React.ReactNode { + const muted = useThemeColor('muted'); + + return ( + + {Icon ? : null} + {iconOnly ? null : ( + + {value} + + )} + + ); +} diff --git a/apps/mobile/src/components/host/new-thread/draft-tools.ios.tsx b/apps/mobile/src/components/host/new-thread/draft-tools.ios.tsx new file mode 100644 index 000000000..1184e96f2 --- /dev/null +++ b/apps/mobile/src/components/host/new-thread/draft-tools.ios.tsx @@ -0,0 +1,127 @@ +import { Host, Menu, Picker, Text as UIText } from '@expo/ui/swift-ui'; +import { tag } from '@expo/ui/swift-ui/modifiers'; +import { AgentKindSchema } from '@linkcode/schema'; +import { + AGENT_LABELS, + AgentIcon, + groupModelsByProvider, + modelChoiceKey, +} from '@linkcode/ui/native'; +import { OptionChip } from '@mobile/components/conversation/option-chip.ios'; +import type { + AgentSelectorChipProps, + ApprovalChipProps, +} from '@mobile/components/host/new-thread/draft-tools.types'; +import { clearable, DEFAULT_TAG } from '@mobile/components/host/new-thread/draft-tools.types'; +import { useThemeColor } from 'heroui-native'; +import { View } from 'react-native'; +import { useTranslations } from 'use-intl'; + +/** The draft's approval-policy chip: a ghost shield opening the policy menu. */ +export function ApprovalChip({ + policies, + policyId, + policyValue, + onPolicyIdChange, +}: ApprovalChipProps): React.ReactNode { + const t = useTranslations('mobile.sessions'); + if (policies.length === 0) return null; + + return ( + + + + {t('defaultOption')} + {policies.map((policy) => ( + + {policy.name} + + ))} + + + + ); +} + +/** The draft's combined selector: the harness brand mark beside one menu with Agent / Model / + * Effort submenus, mirroring the desktop `ModelSelectorMenu`. The brand mark is an RN view and + * cannot enter the SwiftUI tree, so it sits flush beside the menu trigger — ghost styling makes + * the pair read as one control. */ +export function AgentSelectorChip({ + kind, + onKindChange, + models, + modelKey, + onModelKeyChange, + selectorValue, + effortOptions, + effort, + onEffortChange, +}: AgentSelectorChipProps): React.ReactNode { + const t = useTranslations('mobile.sessions'); + const muted = useThemeColor('muted'); + // The account label joins a row only when the list spans several accounts — the same threshold + // as the web's provider grouping; a single-account list repeating its account is noise. + const spansAccounts = models !== null && groupModelsByProvider(models) !== null; + + return ( + + {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} + + + {/* Keyed by menu shape: children added to an already-created Menu never reach the + native UIMenu, so late-loading models/efforts must remount the chip. */} + + + + {AgentKindSchema.options.map((option) => ( + + {AGENT_LABELS[option]} + + ))} + + + {/* A labeled submenu, not an inline group: a bare Picker between sibling menus drops + out of the native UIMenu entirely. */} + {models !== null && models.length > 0 ? ( + + + {t('defaultOption')} + {models.map((model) => ( + + {spansAccounts && model.description + ? `${model.label} — ${model.description}` + : model.label} + + ))} + + + ) : null} + {effortOptions !== undefined && effortOptions.length > 0 ? ( + + + {t('defaultOption')} + {effortOptions.map((option) => ( + + {option.label} + + ))} + + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/components/host/new-thread/draft-tools.tsx b/apps/mobile/src/components/host/new-thread/draft-tools.tsx index 42e74c645..e298d1487 100644 --- a/apps/mobile/src/components/host/new-thread/draft-tools.tsx +++ b/apps/mobile/src/components/host/new-thread/draft-tools.tsx @@ -1,64 +1,67 @@ -import { Host, Menu, Picker, Text as UIText } from '@expo/ui/swift-ui'; -import { tag } from '@expo/ui/swift-ui/modifiers'; -import type { AgentKind, ApprovalPolicy } from '@linkcode/schema'; import { AgentKindSchema } from '@linkcode/schema'; -import type { EffortOption, ModelOption } from '@linkcode/ui/native'; import { AGENT_LABELS, AgentIcon, groupModelsByProvider, modelChoiceKey, } from '@linkcode/ui/native'; -import { OptionChip } from '@mobile/components/conversation/option-chip'; +import { ToolChip } from '@mobile/components/conversation/tool-chip.android'; +import type { SheetPickerSection } from '@mobile/components/form/sheet-picker.android'; +import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; +import type { + AgentSelectorChipProps, + ApprovalChipProps, +} from '@mobile/components/host/new-thread/draft-tools.types'; +import { clearable, DEFAULT_TAG } from '@mobile/components/host/new-thread/draft-tools.types'; import { useThemeColor } from 'heroui-native'; +import { ShieldIcon } from 'lucide-react-native'; +import { useState } from 'react'; import { View } from 'react-native'; import { useTranslations } from 'use-intl'; -/** Tag for the leading "Default" entry in each menu — picking it clears the explicit pick, so - * the agent's own startup resolution applies again. */ -const DEFAULT_TAG = '__default__'; - -function clearable(onChange: (value: string | null) => void): (value: string) => void { - return (value) => { - onChange(value === DEFAULT_TAG ? null : value); - }; -} - -/** The draft's approval-policy chip: a ghost shield opening the policy menu. */ +/** The draft's approval-policy chip on Android: a ghost shield opening the policy sheet. */ export function ApprovalChip({ policies, policyId, policyValue, onPolicyIdChange, -}: { - policies: ApprovalPolicy[]; - policyId: string | null; - policyValue: string; - onPolicyIdChange: (policyId: string | null) => void; -}): React.ReactNode { +}: ApprovalChipProps): React.ReactNode { const t = useTranslations('mobile.sessions'); + const [open, setOpen] = useState(false); if (policies.length === 0) return null; return ( - - - - {t('defaultOption')} - {policies.map((policy) => ( - - {policy.name} - - ))} - - - + <> + setOpen(true)} + /> + setOpen(false)} + sections={[ + { + id: 'policy', + title: t('approvalLabel'), + selection: policyId ?? DEFAULT_TAG, + options: [ + { id: DEFAULT_TAG, label: t('defaultOption') }, + ...policies.map((policy) => ({ id: policy.policyId, label: policy.name })), + ], + onSelect: clearable(onPolicyIdChange), + }, + ]} + /> + ); } -/** The draft's combined selector: the harness brand mark beside one menu with Agent / Model / - * Effort submenus, mirroring the desktop `ModelSelectorMenu`. The brand mark is an RN view and - * cannot enter the SwiftUI tree, so it sits flush beside the menu trigger — ghost styling makes - * the pair read as one control. */ +/** The draft's combined selector on Android: the harness brand mark beside one chip opening a + * sheet with Agent / Model / Effort sections — the same groups the iOS UIMenu nests as submenus, + * flattened because bottom sheets don't nest. */ export function AgentSelectorChip({ kind, onKindChange, @@ -69,84 +72,71 @@ export function AgentSelectorChip({ effortOptions, effort, onEffortChange, -}: { - kind: AgentKind; - onKindChange: (kind: AgentKind) => void; - /** Account-backed options; null while loading (model submenu hidden). */ - models: ModelOption[] | null; - /** `modelChoiceKey` of the explicit pick, or null for default. */ - modelKey: string | null; - onModelKeyChange: (key: string | null) => void; - /** Text on the chip — resolved model (and effort), or the harness name. */ - selectorValue: string; - effortOptions: EffortOption[] | undefined; - effort: string | null; - onEffortChange: (effort: string | null) => void; -}): React.ReactNode { +}: AgentSelectorChipProps): React.ReactNode { const t = useTranslations('mobile.sessions'); const muted = useThemeColor('muted'); + const [open, setOpen] = useState(false); // The account label joins a row only when the list spans several accounts — the same threshold // as the web's provider grouping; a single-account list repeating its account is noise. const spansAccounts = models !== null && groupModelsByProvider(models) !== null; + const sections: SheetPickerSection[] = [ + { + id: 'kind', + title: t('kindLabel'), + selection: kind, + options: AgentKindSchema.options.map((option) => ({ + id: option, + label: AGENT_LABELS[option], + })), + onSelect(id) { + const parsed = AgentKindSchema.safeParse(id); + if (parsed.success) onKindChange(parsed.data); + }, + }, + ]; + if (models !== null && models.length > 0) { + sections.push({ + id: 'model', + title: t('modelLabel'), + selection: modelKey ?? DEFAULT_TAG, + options: [ + { id: DEFAULT_TAG, label: t('defaultOption') }, + ...models.map((model) => ({ + id: modelChoiceKey(model), + label: + spansAccounts && model.description + ? `${model.label} — ${model.description}` + : model.label, + })), + ], + onSelect: clearable(onModelKeyChange), + }); + } + if (effortOptions !== undefined && effortOptions.length > 0) { + sections.push({ + id: 'effort', + title: t('effortLabel'), + selection: effort ?? DEFAULT_TAG, + options: [ + { id: DEFAULT_TAG, label: t('defaultOption') }, + ...effortOptions.map((option) => ({ id: option.id, label: option.label })), + ], + onSelect: clearable(onEffortChange), + }); + } + return ( {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} - - {/* Keyed by menu shape: children added to an already-created Menu never reach the - native UIMenu, so late-loading models/efforts must remount the chip. */} - - - - {AgentKindSchema.options.map((option) => ( - - {AGENT_LABELS[option]} - - ))} - - - {/* A labeled submenu, not an inline group: a bare Picker between sibling menus drops - out of the native UIMenu entirely. */} - {models !== null && models.length > 0 ? ( - - - {t('defaultOption')} - {models.map((model) => ( - - {spansAccounts && model.description - ? `${model.label} — ${model.description}` - : model.label} - - ))} - - - ) : null} - {effortOptions !== undefined && effortOptions.length > 0 ? ( - - - {t('defaultOption')} - {effortOptions.map((option) => ( - - {option.label} - - ))} - - - ) : null} - - + setOpen(true)} + /> + setOpen(false)} sections={sections} /> ); } diff --git a/apps/mobile/src/components/host/new-thread/draft-tools.types.ts b/apps/mobile/src/components/host/new-thread/draft-tools.types.ts new file mode 100644 index 000000000..8f1d10b71 --- /dev/null +++ b/apps/mobile/src/components/host/new-thread/draft-tools.types.ts @@ -0,0 +1,34 @@ +import type { AgentKind, ApprovalPolicy } from '@linkcode/schema'; +import type { EffortOption, ModelOption } from '@linkcode/ui/native'; + +/** Tag for the leading "Default" entry in each menu — picking it clears the explicit pick, so + * the agent's own startup resolution applies again. */ +export const DEFAULT_TAG = '__default__'; + +export function clearable(onChange: (value: string | null) => void): (value: string) => void { + return (value) => { + onChange(value === DEFAULT_TAG ? null : value); + }; +} + +export interface ApprovalChipProps { + policies: ApprovalPolicy[]; + policyId: string | null; + policyValue: string; + onPolicyIdChange: (policyId: string | null) => void; +} + +export interface AgentSelectorChipProps { + kind: AgentKind; + onKindChange: (kind: AgentKind) => void; + /** Account-backed options; null while loading (model submenu hidden). */ + models: ModelOption[] | null; + /** `modelChoiceKey` of the explicit pick, or null for default. */ + modelKey: string | null; + onModelKeyChange: (key: string | null) => void; + /** Text on the chip — resolved model (and effort), or the harness name. */ + selectorValue: string; + effortOptions: EffortOption[] | undefined; + effort: string | null; + onEffortChange: (effort: string | null) => void; +} diff --git a/apps/mobile/src/components/host/new-thread/project-row.ios.tsx b/apps/mobile/src/components/host/new-thread/project-row.ios.tsx new file mode 100644 index 000000000..3dc1d7b81 --- /dev/null +++ b/apps/mobile/src/components/host/new-thread/project-row.ios.tsx @@ -0,0 +1,87 @@ +import { Host, HStack, Image, Menu, Picker, Text as UIText } from '@expo/ui/swift-ui'; +import { + accessibilityLabel, + font, + foregroundStyle, + padding, + tag, + tint, +} from '@expo/ui/swift-ui/modifiers'; +import { repositoryLabel } from '@linkcode/ui/native'; +import type { ProjectRowProps } from '@mobile/components/host/new-thread/project-row.types'; +import { Color } from 'expo-router'; +import { useThemeColor } from 'heroui-native'; +import { FolderIcon } from 'lucide-react-native'; +import { TextInput, View } from 'react-native'; +import { useTranslations } from 'use-intl'; + +const ICON = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); +const VALUE = foregroundStyle({ type: 'hierarchical', style: 'primary' }); +const BODY = font({ textStyle: 'body' }); +/** SwiftUI draws a Menu label with the accent tint, and hierarchical styles derive from it — + * re-tinting to the label color turns the value primary and the icons secondary. */ +const LABEL_TINT = tint(Color.ios.label); + +/** The workspace picker as its own frame segment above the composer — outside the card, like the + * web composer's context bar: a bare row (icon + value + the up/down affordance) opening a native + * `UIMenu`. Falls back to a free path field while the host has no workspaces. */ +export function ProjectRow({ + workspaces, + workspaceLabel, + cwd, + onCwdChange, + customPath, + onCustomPathChange, +}: ProjectRowProps): React.ReactNode { + const t = useTranslations('mobile.sessions'); + const muted = useThemeColor('muted'); + + if (workspaces.length === 0) { + return ( + + + + + ); + } + + return ( + + + + + {workspaceLabel} + + + } + > + + {workspaces.map((workspace) => ( + + {workspace.name ?? repositoryLabel(workspace.cwd)} + + ))} + + + + + ); +} diff --git a/apps/mobile/src/components/host/new-thread/project-row.tsx b/apps/mobile/src/components/host/new-thread/project-row.tsx index 45c6ad16f..ff7389238 100644 --- a/apps/mobile/src/components/host/new-thread/project-row.tsx +++ b/apps/mobile/src/components/host/new-thread/project-row.tsx @@ -1,30 +1,14 @@ -import { Host, HStack, Image, Menu, Picker, Text as UIText } from '@expo/ui/swift-ui'; -import { - accessibilityLabel, - font, - foregroundStyle, - padding, - tag, - tint, -} from '@expo/ui/swift-ui/modifiers'; -import type { WorkspaceRecord } from '@linkcode/schema'; import { repositoryLabel } from '@linkcode/ui/native'; -import { Color } from 'expo-router'; +import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; +import type { ProjectRowProps } from '@mobile/components/host/new-thread/project-row.types'; import { useThemeColor } from 'heroui-native'; -import { FolderIcon } from 'lucide-react-native'; -import { TextInput, View } from 'react-native'; +import { ChevronsUpDownIcon, FolderIcon } from 'lucide-react-native'; +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; import { useTranslations } from 'use-intl'; -const ICON = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); -const VALUE = foregroundStyle({ type: 'hierarchical', style: 'primary' }); -const BODY = font({ textStyle: 'body' }); -/** SwiftUI draws a Menu label with the accent tint, and hierarchical styles derive from it — - * re-tinting to the label color turns the value primary and the icons secondary. */ -const LABEL_TINT = tint(Color.ios.label); - -/** The workspace picker as its own frame segment above the composer — outside the card, like the - * web composer's context bar: a bare row (icon + value + the up/down affordance) opening a native - * `UIMenu`. Falls back to a free path field while the host has no workspaces. */ +/** Android workspace picker above the composer: a bare row opening the workspace list as a + * bottom sheet. Falls back to a free path field while the host has no workspaces. */ export function ProjectRow({ workspaces, workspaceLabel, @@ -32,17 +16,10 @@ export function ProjectRow({ onCwdChange, customPath, onCustomPathChange, -}: { - /** Already in recency order; the head is the default pick. */ - workspaces: WorkspaceRecord[]; - workspaceLabel: string; - cwd: string | null; - onCwdChange: (cwd: string) => void; - customPath: string; - onCustomPathChange: (path: string) => void; -}): React.ReactNode { +}: ProjectRowProps): React.ReactNode { const t = useTranslations('mobile.sessions'); const muted = useThemeColor('muted'); + const [open, setOpen] = useState(false); if (workspaces.length === 0) { return ( @@ -64,32 +41,33 @@ export function ProjectRow({ return ( - - - - {workspaceLabel} - - - } - > - - {workspaces.map((workspace) => ( - - {workspace.name ?? repositoryLabel(workspace.cwd)} - - ))} - - - + setOpen(true)} + className="flex-row items-center gap-2 py-1" + > + + {workspaceLabel} + + + setOpen(false)} + sections={[ + { + id: 'workspace', + title: t('projectLabel'), + selection: cwd, + options: workspaces.map((workspace) => ({ + id: workspace.cwd, + label: workspace.name ?? repositoryLabel(workspace.cwd), + hint: workspace.cwd, + })), + onSelect: onCwdChange, + }, + ]} + /> ); } diff --git a/apps/mobile/src/components/host/new-thread/project-row.types.ts b/apps/mobile/src/components/host/new-thread/project-row.types.ts new file mode 100644 index 000000000..14bdf6ad7 --- /dev/null +++ b/apps/mobile/src/components/host/new-thread/project-row.types.ts @@ -0,0 +1,11 @@ +import type { WorkspaceRecord } from '@linkcode/schema'; + +export interface ProjectRowProps { + /** Already in recency order; the head is the default pick. */ + workspaces: WorkspaceRecord[]; + workspaceLabel: string; + cwd: string | null; + onCwdChange: (cwd: string) => void; + customPath: string; + onCustomPathChange: (path: string) => void; +} From a42c76ef17c38e6f815600a815e560e2480acc7b Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 04:27:44 +0800 Subject: [PATCH 08/37] feat(mobile): android prompt dock and tool-detail sheet --- .../prompt-dock/permission-prompt.ios.tsx | 81 ++++++ .../prompt-dock/permission-prompt.shared.ts | 52 ++++ .../prompt-dock/permission-prompt.tsx | 156 ++++-------- .../prompt-dock/plan-tracker.ios.tsx | 92 +++++++ .../conversation/prompt-dock/plan-tracker.tsx | 140 ++++++----- .../prompt-dock/question-page.ios.tsx | 140 +++++++++++ .../prompt-dock/question-page.tsx | 126 ++++++++++ .../prompt-dock/question-page.types.ts | 18 ++ .../prompt-dock/question-prompt.tsx | 167 +----------- .../tool-detail-sheet/diff-block.ios.tsx | 62 +++++ .../tool-detail-sheet/diff-block.shared.ts | 7 + .../tool-detail-sheet/diff-block.tsx | 89 ++++--- .../tool-detail-sheet/tool-detail-content.ts | 28 +++ .../tool-detail-sheet.ios.tsx | 158 ++++++++++++ .../tool-detail-sheet/tool-detail-sheet.tsx | 237 +++++++----------- 15 files changed, 1041 insertions(+), 512 deletions(-) create mode 100644 apps/mobile/src/components/conversation/prompt-dock/permission-prompt.ios.tsx create mode 100644 apps/mobile/src/components/conversation/prompt-dock/permission-prompt.shared.ts create mode 100644 apps/mobile/src/components/conversation/prompt-dock/plan-tracker.ios.tsx create mode 100644 apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx create mode 100644 apps/mobile/src/components/conversation/prompt-dock/question-page.tsx create mode 100644 apps/mobile/src/components/conversation/prompt-dock/question-page.types.ts create mode 100644 apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.ios.tsx create mode 100644 apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.shared.ts create mode 100644 apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-content.ts create mode 100644 apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-sheet.ios.tsx diff --git a/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.ios.tsx b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.ios.tsx new file mode 100644 index 000000000..e38b52d77 --- /dev/null +++ b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.ios.tsx @@ -0,0 +1,81 @@ +import { Button, Host, HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { + buttonStyle, + contentShape, + disabled, + font, + foregroundStyle, + lineLimit, + onTapGesture, + shapes, +} from '@expo/ui/swift-ui/modifiers'; +import type { PermissionPromptProps } from '@mobile/components/conversation/prompt-dock/permission-prompt.shared'; +import { + DANGER_KINDS, + detailRows, +} from '@mobile/components/conversation/prompt-dock/permission-prompt.shared'; +import { View } from 'react-native'; +import { useTranslations } from 'use-intl'; + +const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); +const TERTIARY = foregroundStyle({ type: 'hierarchical', style: 'tertiary' }); +const MONO_FOOTNOTE = font({ textStyle: 'footnote', design: 'monospaced' }); +const WHOLE_ROW = contentShape(shapes.rectangle()); + +/** + * Desktop `PermissionPrompt` grammar on SwiftUI: title + skip, mono detail rows, one tappable + * row per option (deny options draw destructive red). The RN shell provides the card chrome so + * it themes with the rest of the conversation surface. + */ +export function PermissionPrompt({ + toolCall, + options, + responding, + onRespond, +}: PermissionPromptProps): React.ReactNode { + const t = useTranslations('mobile.chat'); + + return ( + + + + + + {t('allowTitle', { title: toolCall.title ?? '' })} + + + { + if (!responding) onRespond({ outcome: 'cancelled' }); + }), + ]} + /> + + {detailRows(toolCall).map((row) => ( + + {row.value} + + ))} + + {options.map((option) => ( + + + + + ); +} diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-page.types.ts b/apps/mobile/src/components/conversation/prompt-dock/question-page.types.ts new file mode 100644 index 000000000..1eb33d0d4 --- /dev/null +++ b/apps/mobile/src/components/conversation/prompt-dock/question-page.types.ts @@ -0,0 +1,18 @@ +import type { Question } from '@linkcode/schema'; + +export interface QuestionDraft { + selected: string[]; + customText: string; +} + +export interface QuestionPageProps { + question: Question; + draft: QuestionDraft; + current: number; + total: number; + isLast: boolean; + responding: boolean; + onDraftChange: (draft: QuestionDraft) => void; + onAdvance: (draft: QuestionDraft) => void; + onCancel: () => void; +} diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx index 0fda9748c..1960f3d8d 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx @@ -1,41 +1,13 @@ -import { - Button, - Host, - HStack, - Image, - Spacer, - Text, - TextField, - useNativeState, - VStack, -} from '@expo/ui/swift-ui'; -import { - buttonStyle, - contentShape, - disabled, - font, - foregroundStyle, - onTapGesture, - shapes, -} from '@expo/ui/swift-ui/modifiers'; import type { Question, QuestionAnswer, QuestionOutcome } from '@linkcode/schema'; import { useState } from 'react'; -import { View } from 'react-native'; -import { useTranslations } from 'use-intl'; - -const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); -const TERTIARY = foregroundStyle({ type: 'hierarchical', style: 'tertiary' }); -const WHOLE_ROW = contentShape(shapes.rectangle()); - -interface Draft { - selected: string[]; - customText: string; -} +import { QuestionPage } from './question-page'; +import type { QuestionDraft } from './question-page.types'; /** * One agent question batch pages within its card (desktop `question-prompt.tsx`): tappable - * option rows draw their own checkmark, `multiSelect` toggles, single-select auto-advances, - * an optional free-text answer, and the whole batch resolves as one `QuestionOutcome`. + * option rows draw their own selection state, `multiSelect` toggles, single-select + * auto-advances, an optional free-text answer, and the whole batch resolves as one + * `QuestionOutcome`. The page rendering is platform-split; this orchestrator is shared. */ export function QuestionPrompt({ questions, @@ -47,17 +19,17 @@ export function QuestionPrompt({ onRespond: (outcome: QuestionOutcome) => void; }): React.ReactNode { const [index, setIndex] = useState(0); - const [drafts, setDrafts] = useState>({}); + const [drafts, setDrafts] = useState>({}); const question = questions[Math.min(index, questions.length - 1)]; const draft = drafts[question.questionId] ?? { selected: [], customText: '' }; const isLast = index >= questions.length - 1; - const setDraft = (next: Draft): void => { + const setDraft = (next: QuestionDraft): void => { setDrafts((current) => ({ ...current, [question.questionId]: next })); }; - const buildAnswers = (finalDraft: Draft): QuestionAnswer[] => + const buildAnswers = (finalDraft: QuestionDraft): QuestionAnswer[] => questions.map((entry) => { const value = entry.questionId === question.questionId @@ -71,7 +43,7 @@ export function QuestionPrompt({ }; }); - const advanceOrSubmit = (finalDraft: Draft): void => { + const advanceOrSubmit = (finalDraft: QuestionDraft): void => { if (isLast) { onRespond({ outcome: 'answered', answers: buildAnswers(finalDraft) }); } else { @@ -95,124 +67,3 @@ export function QuestionPrompt({ /> ); } - -function QuestionPage({ - question, - draft, - current, - total, - isLast, - responding, - onDraftChange, - onAdvance, - onCancel, -}: { - question: Question; - draft: Draft; - current: number; - total: number; - isLast: boolean; - responding: boolean; - onDraftChange: (draft: Draft) => void; - onAdvance: (draft: Draft) => void; - onCancel: () => void; -}): React.ReactNode { - const t = useTranslations('mobile.chat'); - // Each page owns a distinct native state. Remounting after a structured selection guarantees - // that an asynchronous native write from the previous question/answer mode cannot leak here. - const customText = useNativeState(draft.customText); - - const toggleOption = (optionId: string): void => { - if (responding) return; - const selected = question.multiSelect - ? draft.selected.includes(optionId) - ? draft.selected.filter((id) => id !== optionId) - : [...draft.selected, optionId] - : [optionId]; - const next = { selected, customText: '' }; - onDraftChange(next); - if (!question.multiSelect) onAdvance(next); - }; - - const advance = (): void => { - const text = customText.get().trim(); - onAdvance(text ? { selected: [], customText: text } : { ...draft, customText: '' }); - }; - - return ( - - - - - {question.header ? ( - - {question.header} - - ) : null} - - {question.prompt} - - - {total > 1 ? ( - - {t('questionProgress', { current, total })} - - ) : null} - { - if (!responding) onCancel(); - }), - ]} - /> - - - {question.options.map((option) => { - const selected = draft.selected.includes(option.optionId); - return ( - toggleOption(option.optionId))]} - > - - - {option.label} - {option.description ? ( - - {option.description} - - ) : null} - - - - ); - })} - - { - if (text.trim() && draft.selected.length > 0) { - onDraftChange({ selected: [], customText: text }); - } - }} - /> - - + ); } diff --git a/apps/mobile/src/components/connect/saved-hosts-section.tsx b/apps/mobile/src/components/connect/saved-hosts-section.tsx index 06e155185..854608109 100644 --- a/apps/mobile/src/components/connect/saved-hosts-section.tsx +++ b/apps/mobile/src/components/connect/saved-hosts-section.tsx @@ -1,5 +1,6 @@ -import { ListItem, Text, TextButton, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { ListItem, Text, TextButton } from '@expo/ui/jetpack-compose'; import { clickable } from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { FormSection } from '@mobile/components/form/section.android'; import { useOpenHost } from '@mobile/runtime/use-open-host'; import { useHostRegistryStore } from '@mobile/stores/host-store'; @@ -9,7 +10,7 @@ import { useTranslations } from 'use-intl'; * button; the rest of the row stays a single tap target for opening the host. */ export function SavedHostsSection(): React.ReactNode { const t = useTranslations('mobile.connect'); - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); const openHost = useOpenHost(); const hosts = useHostRegistryStore((state) => state.hosts); const removeHost = useHostRegistryStore((state) => state.removeHost); diff --git a/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx index 8ca7e2eda..ec87f1de9 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx @@ -1,10 +1,12 @@ -import { Column, Host, OutlinedButton, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { Column, OutlinedButton, Text } from '@expo/ui/jetpack-compose'; import { fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers'; import type { PermissionPromptProps } from '@mobile/components/conversation/prompt-dock/permission-prompt.shared'; import { DANGER_KINDS, detailRows, } from '@mobile/components/conversation/prompt-dock/permission-prompt.shared'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { useThemeColor } from 'heroui-native'; import { XIcon } from 'lucide-react-native'; import { Pressable, Text as RNText, View } from 'react-native'; @@ -22,7 +24,7 @@ export function PermissionPrompt({ onRespond, }: PermissionPromptProps): React.ReactNode { const t = useTranslations('mobile.chat'); - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); const muted = useThemeColor('muted'); return ( @@ -50,7 +52,7 @@ export function PermissionPrompt({ {row.value} ))} - + {options.map((option) => ( ))} - + ); } diff --git a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx index 213c94266..57393001c 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx @@ -1,13 +1,8 @@ -import { - CircularProgressIndicator, - Column, - Host, - Row, - Text, - useMaterialColors, -} from '@expo/ui/jetpack-compose'; +import { CircularProgressIndicator, Column, Row, Text } from '@expo/ui/jetpack-compose'; import { clickable, fillMaxWidth, size, weight } from '@expo/ui/jetpack-compose/modifiers'; import type { CurrentPlan } from '@linkcode/ui/native'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { useState } from 'react'; import { View } from 'react-native'; import { useTranslations } from 'use-intl'; @@ -26,7 +21,7 @@ const STATUS_GLYPH = { */ export function PlanTracker({ plan }: { plan: CurrentPlan }): React.ReactNode { const t = useTranslations('mobile.chat'); - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); const [expanded, setExpanded] = useState(false); const entries = plan.item.plan.entries; @@ -34,7 +29,7 @@ export function PlanTracker({ plan }: { plan: CurrentPlan }): React.ReactNode { return ( - + ) : null} - + ); } diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx index e72e7915b..5e8d09f0c 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx @@ -2,15 +2,15 @@ import { Button, Checkbox, Column, - Host, OutlinedTextField, RadioButton, Row, Text, - useMaterialColors, useNativeState, } from '@expo/ui/jetpack-compose'; import { clickable, fillMaxWidth, weight } from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { useThemeColor } from 'heroui-native'; import { XIcon } from 'lucide-react-native'; import { Pressable, Text as RNText, View } from 'react-native'; @@ -32,7 +32,7 @@ export function QuestionPage({ onCancel, }: QuestionPageProps): React.ReactNode { const t = useTranslations('mobile.chat'); - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); const muted = useThemeColor('muted'); // Each page owns a distinct native state; the orchestrator remounts pages by key so a stale // native write from the previous question cannot leak here. @@ -73,7 +73,7 @@ export function QuestionPage({ - + {question.options.map((option) => { @@ -120,7 +120,7 @@ export function QuestionPage({ {isLast ? t('submitAnswers') : t('next')} - + ); } diff --git a/apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.tsx b/apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.tsx index 71be1e949..c88ed3337 100644 --- a/apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.tsx +++ b/apps/mobile/src/components/conversation/tool-detail-sheet/diff-block.tsx @@ -1,4 +1,4 @@ -import { Column, Row, Spacer, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { Column, Row, Spacer, Text } from '@expo/ui/jetpack-compose'; import { fillMaxWidth, horizontalScroll, weight } from '@expo/ui/jetpack-compose/modifiers'; import { diffLines, patchLines } from '@linkcode/ui/native'; import { @@ -6,6 +6,7 @@ import { DIFF_REMOVED_HEX, gutterLine, } from '@mobile/components/conversation/tool-detail-sheet/diff-block.shared'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; const MONO = { typography: 'bodySmall', fontFamily: 'monospace' } as const; @@ -23,7 +24,7 @@ export function DiffBlock({ * as `diffStats` — codex ships hunk text alongside a patch). */ patch?: string; }): React.ReactNode { - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); const patchRows = patch === undefined ? undefined : patchLines(patch); const rows = patchRows !== undefined && patchRows.length > 0 diff --git a/apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-sheet.tsx b/apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-sheet.tsx index 68f3aa1b6..4b9837a72 100644 --- a/apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-sheet.tsx +++ b/apps/mobile/src/components/conversation/tool-detail-sheet/tool-detail-sheet.tsx @@ -1,4 +1,4 @@ -import { Column, Host, ModalBottomSheet, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { Column, ModalBottomSheet, Text } from '@expo/ui/jetpack-compose'; import { fillMaxWidth, horizontalScroll, @@ -6,6 +6,8 @@ import { verticalScroll, } from '@expo/ui/jetpack-compose/modifiers'; import type { ToolCall } from '@linkcode/schema'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { useTranslations } from 'use-intl'; import { DiffBlock } from './diff-block'; import { toolDetailContent } from './tool-detail-content'; @@ -26,13 +28,13 @@ export function ToolDetailSheet({ onDismiss: () => void; }): React.ReactNode { const t = useTranslations('mobile.chat'); - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); const { metadata, contents, failure, command, rawOutput } = toolDetailContent(toolCall); if (toolCall === null) return null; return ( - + - + ); } diff --git a/apps/mobile/src/components/form/compose-theme.android.ts b/apps/mobile/src/components/form/compose-theme.android.ts new file mode 100644 index 000000000..0d332d6ab --- /dev/null +++ b/apps/mobile/src/components/form/compose-theme.android.ts @@ -0,0 +1,19 @@ +import type { MaterialColors } from '@expo/ui/jetpack-compose'; +import { useMaterialColors } from '@expo/ui/jetpack-compose'; +import { useSettingsStore } from '@mobile/stores/settings-store'; +import { useColorScheme } from 'react-native'; + +/** The app's resolved scheme: the in-app appearance preference, falling back to the system. */ +export function useResolvedColorScheme(): 'light' | 'dark' { + const preference = useSettingsStore((state) => state.themePreference); + const system = useColorScheme(); + if (preference === 'system') return system === 'dark' ? 'dark' : 'light'; + return preference; +} + +/** `useMaterialColors` pinned to the APP theme: the bare hook follows the system scheme, which + * diverges the moment the in-app appearance preference overrides it. Android components must use + * this (and `ThemedHost`), never the bare forms. */ +export function useAppMaterialColors(): MaterialColors { + return useMaterialColors({ colorScheme: useResolvedColorScheme() }); +} diff --git a/apps/mobile/src/components/form/list.android.tsx b/apps/mobile/src/components/form/list.android.tsx index b696e23d6..a63a7a007 100644 --- a/apps/mobile/src/components/form/list.android.tsx +++ b/apps/mobile/src/components/form/list.android.tsx @@ -1,12 +1,13 @@ -import { Host, LazyColumn } from '@expo/ui/jetpack-compose'; +import { LazyColumn } from '@expo/ui/jetpack-compose'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; /** Android form scaffold — the Compose counterpart of the iOS `Host`+`Form` pair. The Host needs * the viewport as its proposed size or the column collapses to its content, same trap as SwiftUI. * LazyColumn maps each direct child to one lazy item, so keep children at section granularity. */ export function FormList({ children }: React.PropsWithChildren): React.ReactNode { return ( - + {children} - + ); } diff --git a/apps/mobile/src/components/form/navigation-row.tsx b/apps/mobile/src/components/form/navigation-row.tsx index 911508cd7..bf3a65553 100644 --- a/apps/mobile/src/components/form/navigation-row.tsx +++ b/apps/mobile/src/components/form/navigation-row.tsx @@ -1,5 +1,6 @@ -import { ListItem, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { ListItem, Text } from '@expo/ui/jetpack-compose'; import { clickable } from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import type { NavigationRowProps } from '@mobile/components/form/navigation-row.types'; /** Android form row that pushes a route: an MD3 `ListItem` whose whole-row ripple comes from @@ -11,7 +12,7 @@ export function NavigationRow({ badgeText, onPress, }: NavigationRowProps): React.ReactNode { - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); return ( diff --git a/apps/mobile/src/components/form/rows.android.tsx b/apps/mobile/src/components/form/rows.android.tsx index b72b3e3ec..6b60471a8 100644 --- a/apps/mobile/src/components/form/rows.android.tsx +++ b/apps/mobile/src/components/form/rows.android.tsx @@ -1,12 +1,6 @@ -import { - CircularProgressIndicator, - ListItem, - Row, - Switch, - Text, - useMaterialColors, -} from '@expo/ui/jetpack-compose'; +import { CircularProgressIndicator, ListItem, Row, Switch, Text } from '@expo/ui/jetpack-compose'; import { padding, toggleable } from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; /** Quiet in-section message row: empty states, hints, and errors. */ export function FormHint({ @@ -16,7 +10,7 @@ export function FormHint({ tone?: 'secondary' | 'error'; children: string; }): React.ReactNode { - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); return ( diff --git a/apps/mobile/src/components/form/sheet-picker.android.tsx b/apps/mobile/src/components/form/sheet-picker.android.tsx index fe872d114..856cca6de 100644 --- a/apps/mobile/src/components/form/sheet-picker.android.tsx +++ b/apps/mobile/src/components/form/sheet-picker.android.tsx @@ -1,13 +1,7 @@ -import { - Column, - Host, - ListItem, - ModalBottomSheet, - RadioButton, - Text, - useMaterialColors, -} from '@expo/ui/jetpack-compose'; +import { Column, ListItem, ModalBottomSheet, RadioButton, Text } from '@expo/ui/jetpack-compose'; import { clickable, padding, verticalScroll } from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; export interface SheetPickerOption { id: string; @@ -46,12 +40,12 @@ export function SheetPicker({ sections: SheetPickerSection[]; actions?: SheetPickerAction[]; }): React.ReactNode { - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); if (!open) return null; return ( - + {sections.map((section) => ( @@ -111,6 +105,6 @@ export function SheetPicker({ ))} - + ); } diff --git a/apps/mobile/src/components/form/themed-host.android.tsx b/apps/mobile/src/components/form/themed-host.android.tsx new file mode 100644 index 000000000..ac23ba737 --- /dev/null +++ b/apps/mobile/src/components/form/themed-host.android.tsx @@ -0,0 +1,10 @@ +import { Host } from '@expo/ui/jetpack-compose'; +import { useResolvedColorScheme } from '@mobile/components/form/compose-theme.android'; + +/** `Host` pinned to the APP theme. A bare Host follows the system scheme, which diverges from + * the RN surfaces the moment the in-app appearance preference overrides it — dark text on a + * dark background. Android components must use this, never `Host` directly. */ +export function ThemedHost(props: React.ComponentProps): React.ReactNode { + const colorScheme = useResolvedColorScheme(); + return ; +} diff --git a/apps/mobile/src/components/host/thread-list/thread-list.tsx b/apps/mobile/src/components/host/thread-list/thread-list.tsx index 07ca947fb..e5afb53ce 100644 --- a/apps/mobile/src/components/host/thread-list/thread-list.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-list.tsx @@ -1,13 +1,7 @@ -import { - Host, - LazyColumn, - PullToRefreshBox, - Row, - Spacer, - Text, - useMaterialColors, -} from '@expo/ui/jetpack-compose'; +import { LazyColumn, PullToRefreshBox, Row, Spacer, Text } from '@expo/ui/jetpack-compose'; import { clickable, fillMaxWidth, padding, weight } from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { Fragment, useState } from 'react'; import type { ThreadListProps } from './thread-list.types'; import { ThreadRow } from './thread-row'; @@ -23,7 +17,7 @@ export function ThreadList({ onRefresh, }: ThreadListProps): React.ReactNode { const { collapsed, setGroupExpanded, now } = useThreadListState(); - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); const [refreshing, setRefreshing] = useState(false); const refresh = () => { @@ -32,7 +26,7 @@ export function ThreadList({ }; return ( - + {groups.map((group) => { @@ -70,6 +64,6 @@ export function ThreadList({ })} - + ); } diff --git a/apps/mobile/src/components/host/thread-list/thread-row.tsx b/apps/mobile/src/components/host/thread-list/thread-row.tsx index f28622de8..638b58cd4 100644 --- a/apps/mobile/src/components/host/thread-list/thread-row.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-row.tsx @@ -1,7 +1,8 @@ -import { Box, ListItem, Text, useMaterialColors } from '@expo/ui/jetpack-compose'; +import { Box, ListItem, Text } from '@expo/ui/jetpack-compose'; import { background, clickable, clip, Shapes, size } from '@expo/ui/jetpack-compose/modifiers'; import type { SessionStatus } from '@linkcode/schema'; import { AGENT_LABELS } from '@linkcode/ui/native'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { formatRelativeShort } from '@mobile/utils/relative-time'; import { useThemeColor } from 'heroui-native'; import type { ThreadRowProps } from './thread-row.types'; @@ -10,7 +11,7 @@ import { threadTitle } from './thread-title'; /** Android thread row: MD3 ListItem with the status dot drawn as a clipped Box (no icon asset * needed) and no disclosure chevron, per MD3. The dot palette matches the session-title dot. */ export function ThreadRow({ session, now, onPress }: ThreadRowProps): React.ReactNode { - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); const [success, warning, muted] = useThemeColor(['success', 'warning', 'muted']); const statusColor = { starting: warning, diff --git a/apps/mobile/src/components/settings/settings-screen.tsx b/apps/mobile/src/components/settings/settings-screen.tsx index d955fde36..ac13db59f 100644 --- a/apps/mobile/src/components/settings/settings-screen.tsx +++ b/apps/mobile/src/components/settings/settings-screen.tsx @@ -3,10 +3,10 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text, - useMaterialColors, } from '@expo/ui/jetpack-compose'; import { fillMaxWidth, padding } from '@expo/ui/jetpack-compose/modifiers'; import { AgentKindSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { FormList } from '@mobile/components/form/list.android'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { ToggleRow } from '@mobile/components/form/rows.android'; @@ -188,7 +188,7 @@ export function SettingsScreen(): React.ReactNode { } function AboutBlock({ title, contract }: { title: string; contract: string }): React.ReactNode { - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); return ( diff --git a/apps/mobile/src/components/settings/terminal-appearance-screen.tsx b/apps/mobile/src/components/settings/terminal-appearance-screen.tsx index 55b47bbf0..c51b77dd4 100644 --- a/apps/mobile/src/components/settings/terminal-appearance-screen.tsx +++ b/apps/mobile/src/components/settings/terminal-appearance-screen.tsx @@ -6,7 +6,6 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text, - useMaterialColors, } from '@expo/ui/jetpack-compose'; import { background, @@ -19,6 +18,7 @@ import { selectableGroup, size, } from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { FormList } from '@mobile/components/form/list.android'; import { FormSection } from '@mobile/components/form/section.android'; import { @@ -34,7 +34,7 @@ const SWATCH = 24; /** Renders a theme's own colours so the row previews what it selects. `auto` has no theme * of its own — it defers to ghostty's defaults — so it shows a neutral placeholder. */ function ThemeSwatch({ theme }: { theme?: { background?: string; foreground?: string } }) { - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); return ( state.fontSize); const colorScheme = useTerminalPrefsStore((state) => state.colorScheme); const setFontSize = useTerminalPrefsStore((state) => state.setFontSize); diff --git a/apps/mobile/src/components/terminal/new-terminal-sheet.tsx b/apps/mobile/src/components/terminal/new-terminal-sheet.tsx index cd5db142a..dc151fe12 100644 --- a/apps/mobile/src/components/terminal/new-terminal-sheet.tsx +++ b/apps/mobile/src/components/terminal/new-terminal-sheet.tsx @@ -1,14 +1,14 @@ import { Button, Column, - Host, ModalBottomSheet, OutlinedTextField, Text, - useMaterialColors, useNativeState, } from '@expo/ui/jetpack-compose'; import { fillMaxWidth, imePadding, padding, testID } from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { useTranslations } from 'use-intl'; import type { NewTerminalSheetProps } from './new-terminal-sheet.types'; @@ -22,7 +22,7 @@ export function NewTerminalSheet({ onCreate, }: NewTerminalSheetProps): React.ReactNode { const t = useTranslations('mobile.terminals'); - const colors = useMaterialColors(); + const colors = useAppMaterialColors(); const cwd = useNativeState(''); if (!isPresented) return null; @@ -35,7 +35,7 @@ export function NewTerminalSheet({ }; return ( - + onIsPresentedChange(false)}> - + ); } diff --git a/apps/mobile/src/components/terminal/terminals-screen.tsx b/apps/mobile/src/components/terminal/terminals-screen.tsx index 30baf5ed4..fedd7f2aa 100644 --- a/apps/mobile/src/components/terminal/terminals-screen.tsx +++ b/apps/mobile/src/components/terminal/terminals-screen.tsx @@ -1,9 +1,10 @@ -import { Button, Host, LazyColumn, PullToRefreshBox, Text } from '@expo/ui/jetpack-compose'; +import { Button, LazyColumn, PullToRefreshBox, Text } from '@expo/ui/jetpack-compose'; import { fillMaxWidth, padding } from '@expo/ui/jetpack-compose/modifiers'; import { repositoryLabel } from '@linkcode/ui/native'; import { FormList } from '@mobile/components/form/list.android'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; import { useTerminalInbox } from '@mobile/components/terminal/use-terminal-inbox'; import { useState } from 'react'; @@ -51,7 +52,7 @@ export function TerminalsScreen({ ) : ( - + {loadError ? ( @@ -74,7 +75,7 @@ export function TerminalsScreen({ )} - + )} Date: Tue, 18 Aug 2026 12:06:30 +0800 Subject: [PATCH 12/37] feat(mobile): android primary action as a floating action button --- apps/mobile/assets/icons/add.xml | 9 ++++++++ .../mobile/src/app/(tabs)/terminals/index.tsx | 2 ++ apps/mobile/src/app/(tabs)/threads/index.tsx | 2 ++ apps/mobile/src/assets.d.ts | 6 +++++ .../shell/primary-action-fab.ios.ts | 7 ++++++ .../components/shell/primary-action-fab.tsx | 23 +++++++++++++++++++ .../components/shell/use-trailing-actions.tsx | 10 ++++---- 7 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/assets/icons/add.xml create mode 100644 apps/mobile/src/components/shell/primary-action-fab.ios.ts create mode 100644 apps/mobile/src/components/shell/primary-action-fab.tsx diff --git a/apps/mobile/assets/icons/add.xml b/apps/mobile/assets/icons/add.xml new file mode 100644 index 000000000..fb14723ce --- /dev/null +++ b/apps/mobile/assets/icons/add.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/src/app/(tabs)/terminals/index.tsx b/apps/mobile/src/app/(tabs)/terminals/index.tsx index 49a76b8cd..4b96a7d99 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -2,6 +2,7 @@ import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { useHostHeaderOptions } from '@mobile/components/host/use-host-header-options'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; +import { PrimaryActionFab } from '@mobile/components/shell/primary-action-fab'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; import { TerminalsScreen } from '@mobile/components/terminal/terminals-screen'; @@ -51,6 +52,7 @@ export default function TerminalsRoute(): React.ReactNode { onSheetOpenChange={setSheetOpen} /> + ); } diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index 7a78ce3af..9f0a8910b 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -3,6 +3,7 @@ import { ThreadsScreen } from '@mobile/components/host/threads-screen'; import { useHostHeaderOptions } from '@mobile/components/host/use-host-header-options'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; +import { PrimaryActionFab } from '@mobile/components/shell/primary-action-fab'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; import { useHostConnection } from '@mobile/runtime/host-connection'; @@ -45,6 +46,7 @@ export default function ThreadsRoute(): React.ReactNode { + ); } diff --git a/apps/mobile/src/assets.d.ts b/apps/mobile/src/assets.d.ts index 7f063b4ca..16123cab9 100644 --- a/apps/mobile/src/assets.d.ts +++ b/apps/mobile/src/assets.d.ts @@ -2,3 +2,9 @@ declare module '*.woff2' { const url: string; export default url; } + +// Android XML vector drawables, served as Metro assets for the Compose `Icon` component. +declare module '*.xml' { + const assetId: number; + export default assetId; +} diff --git a/apps/mobile/src/components/shell/primary-action-fab.ios.ts b/apps/mobile/src/components/shell/primary-action-fab.ios.ts new file mode 100644 index 000000000..884a73a1e --- /dev/null +++ b/apps/mobile/src/components/shell/primary-action-fab.ios.ts @@ -0,0 +1,7 @@ +import type { PrimaryAction } from '@mobile/components/shell/primary-action'; + +/** iOS carries the primary action in the header bar items (pre-26) or the tab-bar slot (26+); + * this twin exists so the shared tab routes can reference one component name on both platforms. */ +export function PrimaryActionFab(_props: { action: PrimaryAction | null }): React.ReactNode { + return null; +} diff --git a/apps/mobile/src/components/shell/primary-action-fab.tsx b/apps/mobile/src/components/shell/primary-action-fab.tsx new file mode 100644 index 000000000..39624d134 --- /dev/null +++ b/apps/mobile/src/components/shell/primary-action-fab.tsx @@ -0,0 +1,23 @@ +import { FloatingActionButton, Icon } from '@expo/ui/jetpack-compose'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import type { PrimaryAction } from '@mobile/components/shell/primary-action'; +import { View } from 'react-native'; +import addGlyph from '../../../assets/icons/add.xml'; + +/** The tab's primary action as an MD3 floating action button at the screen's bottom-right — the + * Android counterpart of the iOS header bar item / iOS-26 tab-bar slot. */ +export function PrimaryActionFab({ action }: { action: PrimaryAction | null }): React.ReactNode { + if (!action) return null; + + return ( + + + + + + + + + + ); +} diff --git a/apps/mobile/src/components/shell/use-trailing-actions.tsx b/apps/mobile/src/components/shell/use-trailing-actions.tsx index 58af300c5..ea40dc454 100644 --- a/apps/mobile/src/components/shell/use-trailing-actions.tsx +++ b/apps/mobile/src/components/shell/use-trailing-actions.tsx @@ -12,9 +12,10 @@ type TrailingHeaderOptions = Pick< 'headerRight' | 'unstable_headerRightItems' >; -/** Trailing navigation-bar chrome for the tab screens: the screen's primary action — except on - * iOS 26, whose tab-bar slot already carries it — then the overflow menu that leads to Settings. - * Native bar items are iOS-only, so Android keeps RN header buttons. */ +/** Trailing navigation-bar chrome for the tab screens: on iOS the screen's primary action — + * except on iOS 26, whose tab-bar slot already carries it — then the overflow menu that leads to + * Settings. Native bar items are iOS-only; Android keeps an RN overflow button and carries the + * primary action as a floating action button instead (`PrimaryActionFab`). */ export function useTrailingActions(primary: PrimaryAction | null): TrailingHeaderOptions { const t = useTranslations('mobile.settings'); const router = useRouter(); @@ -50,9 +51,6 @@ export function useTrailingActions(primary: PrimaryAction | null): TrailingHeade return { headerRight: () => ( - {primary ? ( - - ) : null} Date: Tue, 18 Aug 2026 22:05:58 +0800 Subject: [PATCH 13/37] feat(mobile): drive android chrome with material you dynamic colors RN-drawn Android chrome (headers, backgrounds, header controls, chips, status dots, prompt cards) read app brand tokens; they now follow the same dynamic Material palette as the Compose surfaces via a platform-split useChromeColors. iOS keeps its tokens. --- .../mobile/src/app/(tabs)/terminals/index.tsx | 4 +- apps/mobile/src/app/(tabs)/threads/index.tsx | 4 +- .../prompt-dock/permission-prompt.tsx | 19 ++++++---- .../conversation/prompt-dock/plan-tracker.tsx | 5 ++- .../prompt-dock/question-page.tsx | 17 +++++---- .../components/conversation/session-tools.tsx | 6 +-- .../conversation/tool-chip.android.tsx | 10 ++--- .../components/host/host-connection-state.tsx | 37 ++++++++++++------- .../components/host/host-switcher-button.tsx | 12 ++++-- .../host/new-thread/draft-tools.tsx | 6 +-- .../host/new-thread/project-row.tsx | 19 ++++++---- .../host/thread-list/thread-row.tsx | 15 ++++---- .../components/shell/header-icon-button.tsx | 6 +-- .../components/shell/use-chrome-colors.ios.ts | 14 +++++++ .../src/components/shell/use-chrome-colors.ts | 15 ++++++++ .../shell/use-chrome-colors.types.ts | 11 ++++++ .../shell/use-stack-screen-options.ts | 17 +++++---- 17 files changed, 145 insertions(+), 72 deletions(-) create mode 100644 apps/mobile/src/components/shell/use-chrome-colors.ios.ts create mode 100644 apps/mobile/src/components/shell/use-chrome-colors.ts create mode 100644 apps/mobile/src/components/shell/use-chrome-colors.types.ts diff --git a/apps/mobile/src/app/(tabs)/terminals/index.tsx b/apps/mobile/src/app/(tabs)/terminals/index.tsx index 4b96a7d99..5d10843bd 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -3,6 +3,7 @@ import { useHostHeaderOptions } from '@mobile/components/host/use-host-header-op import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { PrimaryActionFab } from '@mobile/components/shell/primary-action-fab'; +import { useChromeColors } from '@mobile/components/shell/use-chrome-colors'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; import { TerminalsScreen } from '@mobile/components/terminal/terminals-screen'; @@ -18,6 +19,7 @@ import { useTranslations } from 'use-intl'; export default function TerminalsRoute(): React.ReactNode { const t = useTranslations('mobile.terminals'); const hostHeaderOptions = useHostHeaderOptions(); + const chrome = useChromeColors(); const connection = useHostConnection(); const [sheetOpen, setSheetOpen] = useState(false); @@ -36,7 +38,7 @@ export default function TerminalsRoute(): React.ReactNode { // The flex container is load-bearing: a SwiftUI host left as the screen's direct child is // proposed the whole window and paints straight over the navigation header. return ( - + + + - + {t('allowTitle', { title: toolCall.title ?? '' })} onRespond({ outcome: 'cancelled' })} > - + {detailRows(toolCall).map((row) => ( {row.value} diff --git a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx index 57393001c..6901c708d 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx @@ -28,7 +28,10 @@ export function PlanTracker({ plan }: { plan: CurrentPlan }): React.ReactNode { const current = entries[plan.currentIndex]; return ( - + + {question.header ? ( - {question.header} + + {question.header} + ) : null} - + {question.prompt} {total > 1 ? ( - + {t('questionProgress', { current, total })} ) : null} - + diff --git a/apps/mobile/src/components/conversation/session-tools.tsx b/apps/mobile/src/components/conversation/session-tools.tsx index 22a27b505..be45bcef3 100644 --- a/apps/mobile/src/components/conversation/session-tools.tsx +++ b/apps/mobile/src/components/conversation/session-tools.tsx @@ -4,9 +4,9 @@ import type { SessionSelectorChipProps, } from '@mobile/components/conversation/session-tools.types'; import { ToolChip } from '@mobile/components/conversation/tool-chip.android'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import type { SheetPickerSection } from '@mobile/components/form/sheet-picker.android'; import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; -import { useThemeColor } from 'heroui-native'; import { ShieldIcon } from 'lucide-react-native'; import { useState } from 'react'; import { View } from 'react-native'; @@ -70,7 +70,7 @@ export function SessionSelectorChip({ onEffortChange, }: SessionSelectorChipProps): React.ReactNode { const t = useTranslations('mobile.sessions'); - const muted = useThemeColor('muted'); + const colors = useAppMaterialColors(); const [open, setOpen] = useState(false); const hasModels = models !== null && models.length > 0; const hasEfforts = effortOptions !== undefined && effortOptions.length > 0; @@ -115,7 +115,7 @@ export function SessionSelectorChip({ return ( {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} - + void; }): React.ReactNode { - const muted = useThemeColor('muted'); + const colors = useAppMaterialColors(); return ( - {Icon ? : null} + {Icon ? : null} {iconOnly ? null : ( {value} diff --git a/apps/mobile/src/components/host/host-connection-state.tsx b/apps/mobile/src/components/host/host-connection-state.tsx index d6e91fcf0..27d41f0de 100644 --- a/apps/mobile/src/components/host/host-connection-state.tsx +++ b/apps/mobile/src/components/host/host-connection-state.tsx @@ -1,12 +1,14 @@ +import { Button, Text as ComposeText } from '@expo/ui/jetpack-compose'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; import type { HostConnectionStateProps } from '@mobile/components/host/host-connection-state.types'; -import { Button, useThemeColor } from 'heroui-native'; import { WifiOffIcon } from 'lucide-react-native'; import { ActivityIndicator, Text, View } from 'react-native'; import { useTranslations } from 'use-intl'; /** Android full-screen fallback while a host connection is being established or has failed. - * Deliberately plain RN: the failure text stays selectable (`selectable`), which Compose text - * cannot offer, and nothing here is list chrome that would want MD3 dress. */ + * The text stays RN so the failure detail remains selectable (Compose text cannot offer that); + * colors come from the Material palette so it matches the Compose surfaces around it. */ export function HostConnectionState({ status, url, @@ -14,31 +16,40 @@ export function HostConnectionState({ onRetry, }: HostConnectionStateProps): React.ReactNode { const t = useTranslations('mobile.connection'); - const muted = useThemeColor('muted'); + const colors = useAppMaterialColors(); return ( {status === 'connecting' ? ( <> - - {t('connecting')} + + {t('connecting')} ) : ( <> - + - + {t('unavailableTitle')} - + {t('error', { url })} - + + + {failure ? ( - + {failure} ) : null} diff --git a/apps/mobile/src/components/host/host-switcher-button.tsx b/apps/mobile/src/components/host/host-switcher-button.tsx index 9486f2c20..422b25b7f 100644 --- a/apps/mobile/src/components/host/host-switcher-button.tsx +++ b/apps/mobile/src/components/host/host-switcher-button.tsx @@ -1,7 +1,7 @@ import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; +import { useChromeColors } from '@mobile/components/shell/use-chrome-colors'; import { useHostRegistryStore, useSelectedHost } from '@mobile/stores/host-store'; import { useRouter } from 'expo-router'; -import { useThemeColor } from 'heroui-native'; import { ChevronsUpDownIcon } from 'lucide-react-native'; import { useState } from 'react'; import { Pressable, Text } from 'react-native'; @@ -18,7 +18,7 @@ export function HostSwitcherButton(): React.ReactNode { const setLastActiveHostId = useHostRegistryStore((state) => state.setLastActiveHostId); const selected = useSelectedHost(); const [open, setOpen] = useState(false); - const muted = useThemeColor('muted'); + const chrome = useChromeColors(); if (!selected) return null; @@ -30,10 +30,14 @@ export function HostSwitcherButton(): React.ReactNode { onPress={() => setOpen(true)} className="flex-row items-center gap-1 py-1 pr-3" > - + {selected.name} - + {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} - + - + setOpen(true)} className="flex-row items-center gap-2 py-1" > - - {workspaceLabel} - + + + {workspaceLabel} + + ; const subtitle = `${AGENT_LABELS[session.kind]} · ${formatRelativeShort(session.updatedAt, now)}`; diff --git a/apps/mobile/src/components/shell/header-icon-button.tsx b/apps/mobile/src/components/shell/header-icon-button.tsx index abab1ba4a..b68e568c0 100644 --- a/apps/mobile/src/components/shell/header-icon-button.tsx +++ b/apps/mobile/src/components/shell/header-icon-button.tsx @@ -1,4 +1,4 @@ -import { useThemeColor } from 'heroui-native'; +import { useChromeColors } from '@mobile/components/shell/use-chrome-colors'; import type { LucideIcon } from 'lucide-react-native'; import { Pressable } from 'react-native'; @@ -13,7 +13,7 @@ export function HeaderIconButton({ label: string; onPress: () => void; }): React.ReactNode { - const foreground = useThemeColor('foreground'); + const { title } = useChromeColors(); return ( ({ opacity: pressed ? 0.4 : 1 })} > - + ); } diff --git a/apps/mobile/src/components/shell/use-chrome-colors.ios.ts b/apps/mobile/src/components/shell/use-chrome-colors.ios.ts new file mode 100644 index 000000000..ee3781c7f --- /dev/null +++ b/apps/mobile/src/components/shell/use-chrome-colors.ios.ts @@ -0,0 +1,14 @@ +import type { ChromeColors } from '@mobile/components/shell/use-chrome-colors.types'; +import { useThemeColor } from 'heroui-native'; + +/** iOS chrome keeps the app's own theme tokens; the contract exists for Android's Material You. */ +export function useChromeColors(): ChromeColors { + const [background, foreground, accent, muted] = useThemeColor([ + 'background', + 'foreground', + 'accent', + 'muted', + ]); + + return { background, title: foreground, tint: accent, subtle: muted }; +} diff --git a/apps/mobile/src/components/shell/use-chrome-colors.ts b/apps/mobile/src/components/shell/use-chrome-colors.ts new file mode 100644 index 000000000..2b4f81f34 --- /dev/null +++ b/apps/mobile/src/components/shell/use-chrome-colors.ts @@ -0,0 +1,15 @@ +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import type { ChromeColors } from '@mobile/components/shell/use-chrome-colors.types'; + +/** Android chrome reads Material You dynamic roles, so RN-drawn headers and backgrounds match + * the Compose surfaces instead of the app's own brand tokens. */ +export function useChromeColors(): ChromeColors { + const colors = useAppMaterialColors(); + + return { + background: colors.surface, + title: colors.onSurface, + tint: colors.primary, + subtle: colors.onSurfaceVariant, + }; +} diff --git a/apps/mobile/src/components/shell/use-chrome-colors.types.ts b/apps/mobile/src/components/shell/use-chrome-colors.types.ts new file mode 100644 index 000000000..b41555bfd --- /dev/null +++ b/apps/mobile/src/components/shell/use-chrome-colors.types.ts @@ -0,0 +1,11 @@ +/** Navigation-chrome palette: headers, screen backgrounds, and header controls. */ +export interface ChromeColors { + /** Screen + header background. */ + background: string; + /** Titles and primary glyphs. */ + title: string; + /** Interactive tint (back chevron, header actions). */ + tint: string; + /** Quiet glyphs and secondary text. */ + subtle: string; +} diff --git a/apps/mobile/src/components/shell/use-stack-screen-options.ts b/apps/mobile/src/components/shell/use-stack-screen-options.ts index 86e234011..e0bf1f8ef 100644 --- a/apps/mobile/src/components/shell/use-stack-screen-options.ts +++ b/apps/mobile/src/components/shell/use-stack-screen-options.ts @@ -1,5 +1,5 @@ +import { useChromeColors } from '@mobile/components/shell/use-chrome-colors'; import type { Stack } from 'expo-router'; -import { useThemeColor } from 'heroui-native'; import { NavigationBarBackdrop } from '../../../modules/linkcode-navigation-bar-backdrop'; import { USES_IOS_26_NAVIGATION } from './ios-26-navigation'; @@ -30,23 +30,24 @@ export const LARGE_TITLE_HEADER_OPTIONS: StackScreenOptions = { }; /** Theme-synced native-stack chrome; every Stack in the app spreads these defaults. - * Screens opt into a header per route via `Stack.Screen` options. */ + * Screens opt into a header per route via `Stack.Screen` options. Colors come from the + * platform-split chrome palette: app tokens on iOS, Material You roles on Android. */ export function useStackScreenOptions(): StackScreenOptions { - const [background, foreground, accent] = useThemeColor(['background', 'foreground', 'accent']); + const chrome = useChromeColors(); return { headerShown: false, headerLargeTitleEnabled: false, - headerTintColor: accent, - headerTitleStyle: { color: foreground }, - headerLargeTitleStyle: { color: foreground }, + headerTintColor: chrome.tint, + headerTitleStyle: { color: chrome.title }, + headerLargeTitleStyle: { color: chrome.title }, // Android has no header blur; painting the header the content color keeps the screen // reading as one surface (the shadow is already off). ...(process.env.EXPO_OS !== 'ios' && { - headerStyle: { backgroundColor: background }, + headerStyle: { backgroundColor: chrome.background }, }), headerShadowVisible: false, headerBackButtonDisplayMode: 'minimal', - contentStyle: { backgroundColor: background }, + contentStyle: { backgroundColor: chrome.background }, }; } From 6188a9202859e5f2858c05b4bbd668e0a52053ba Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 22:45:06 +0800 Subject: [PATCH 14/37] feat(mobile): android header menus as compose dropdowns The tab overflow jumped straight to settings and the session menu degraded to an RN alert dialog; both now open an MD3 DropdownMenu anchored to the bar button, like the iOS UIMenu items. --- apps/mobile/assets/icons/more-vert.xml | 9 +++++ apps/mobile/src/app/session/[sessionId].tsx | 36 +++++++---------- .../components/shell/header-icon-button.tsx | 29 -------------- .../shell/header-menu-button.ios.ts | 7 ++++ .../components/shell/header-menu-button.tsx | 40 +++++++++++++++++++ .../shell/header-menu-button.types.ts | 12 ++++++ .../components/shell/use-trailing-actions.tsx | 20 ++++------ 7 files changed, 90 insertions(+), 63 deletions(-) create mode 100644 apps/mobile/assets/icons/more-vert.xml delete mode 100644 apps/mobile/src/components/shell/header-icon-button.tsx create mode 100644 apps/mobile/src/components/shell/header-menu-button.ios.ts create mode 100644 apps/mobile/src/components/shell/header-menu-button.tsx create mode 100644 apps/mobile/src/components/shell/header-menu-button.types.ts diff --git a/apps/mobile/assets/icons/more-vert.xml b/apps/mobile/assets/icons/more-vert.xml new file mode 100644 index 000000000..9c2a0c227 --- /dev/null +++ b/apps/mobile/assets/icons/more-vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/src/app/session/[sessionId].tsx b/apps/mobile/src/app/session/[sessionId].tsx index f5dca6630..2e7d0fc49 100644 --- a/apps/mobile/src/app/session/[sessionId].tsx +++ b/apps/mobile/src/app/session/[sessionId].tsx @@ -22,6 +22,7 @@ import { import { TimelineItem } from '@mobile/components/conversation/timeline-item'; import { ToolDetailSheet } from '@mobile/components/conversation/tool-detail-sheet/tool-detail-sheet'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; +import { HeaderMenuButton } from '@mobile/components/shell/header-menu-button'; import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useAccountModels } from '@mobile/runtime/use-account-models'; @@ -32,10 +33,8 @@ import * as Clipboard from 'expo-clipboard'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useHeaderHeight } from 'expo-router/react-navigation'; import { noop } from 'foxact/noop'; -import { useThemeColor } from 'heroui-native'; -import { EllipsisIcon } from 'lucide-react-native'; import { useEffect, useRef, useState } from 'react'; -import { Alert, FlatList, Pressable, View } from 'react-native'; +import { FlatList, View } from 'react-native'; import { KeyboardStickyView } from 'react-native-keyboard-controller'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslations } from 'use-intl'; @@ -60,7 +59,6 @@ function SessionScreen(): React.ReactNode { const tSettings = useTranslations('mobile.settings'); const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); - const muted = useThemeColor('muted'); const router = useRouter(); const { sessionId: rawSessionId, autoResume } = useLocalSearchParams<{ sessionId: string; @@ -133,16 +131,6 @@ function SessionScreen(): React.ReactNode { if (sessionId) void Clipboard.setStringAsync(sessionId); }; - // Android fallback only — native bar items are iOS-only, so the menu degrades to an alert. - const showMenu = (): void => { - if (!sessionId) return; - Alert.alert(title, undefined, [ - { text: tChat('stopThread'), style: 'destructive', onPress: stopThread }, - { text: tChat('copyThreadId'), onPress: copyThreadId }, - { text: tChat('cancel'), style: 'cancel' }, - ]); - }; - // Inverted list: index 0 renders at the visual bottom, so newest items pin there. const reversed = [...conversation.items].reverse(); @@ -185,14 +173,18 @@ function SessionScreen(): React.ReactNode { } : { headerRight: () => ( - - - + ), }), }} diff --git a/apps/mobile/src/components/shell/header-icon-button.tsx b/apps/mobile/src/components/shell/header-icon-button.tsx deleted file mode 100644 index b68e568c0..000000000 --- a/apps/mobile/src/components/shell/header-icon-button.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useChromeColors } from '@mobile/components/shell/use-chrome-colors'; -import type { LucideIcon } from 'lucide-react-native'; -import { Pressable } from 'react-native'; - -/** Icon-only tap target for navigation headers. */ -export function HeaderIconButton({ - icon: Icon, - label, - onPress, -}: { - icon: LucideIcon; - /** Accessibility label — icon buttons carry no visible text. */ - label: string; - onPress: () => void; -}): React.ReactNode { - const { title } = useChromeColors(); - return ( - ({ opacity: pressed ? 0.4 : 1 })} - > - - - ); -} diff --git a/apps/mobile/src/components/shell/header-menu-button.ios.ts b/apps/mobile/src/components/shell/header-menu-button.ios.ts new file mode 100644 index 000000000..9f1e57aa1 --- /dev/null +++ b/apps/mobile/src/components/shell/header-menu-button.ios.ts @@ -0,0 +1,7 @@ +import type { HeaderMenuButtonProps } from '@mobile/components/shell/header-menu-button.types'; + +/** iOS renders header menus as native `UIMenu` bar items; this twin exists so shared files can + * reference one component name on both platforms. */ +export function HeaderMenuButton(_props: HeaderMenuButtonProps): React.ReactNode { + return null; +} diff --git a/apps/mobile/src/components/shell/header-menu-button.tsx b/apps/mobile/src/components/shell/header-menu-button.tsx new file mode 100644 index 000000000..d6e19a142 --- /dev/null +++ b/apps/mobile/src/components/shell/header-menu-button.tsx @@ -0,0 +1,40 @@ +import { DropdownMenu, DropdownMenuItem, Icon, IconButton, Text } from '@expo/ui/jetpack-compose'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import type { HeaderMenuButtonProps } from '@mobile/components/shell/header-menu-button.types'; +import { useState } from 'react'; +import moreVertGlyph from '../../../assets/icons/more-vert.xml'; + +/** Android header overflow: an MD3 `DropdownMenu` anchored to its trigger — the Compose twin of + * the iOS `UIMenu` bar item. The menu's trigger is fully controlled and the anchor is the + * non-Items child, so the IconButton opens it explicitly. */ +export function HeaderMenuButton({ label, actions }: HeaderMenuButtonProps): React.ReactNode { + const [expanded, setExpanded] = useState(false); + const colors = useAppMaterialColors(); + + return ( + + setExpanded(false)}> + setExpanded(true)}> + + + + {actions.map((action) => ( + { + setExpanded(false); + action.onPress(); + }} + > + + {action.label} + + + ))} + + + + ); +} diff --git a/apps/mobile/src/components/shell/header-menu-button.types.ts b/apps/mobile/src/components/shell/header-menu-button.types.ts new file mode 100644 index 000000000..3da4a4f40 --- /dev/null +++ b/apps/mobile/src/components/shell/header-menu-button.types.ts @@ -0,0 +1,12 @@ +export interface HeaderMenuAction { + id: string; + label: string; + destructive?: boolean; + onPress: () => void; +} + +export interface HeaderMenuButtonProps { + /** Accessibility name of the trigger — icon buttons carry no visible text. */ + label: string; + actions: HeaderMenuAction[]; +} diff --git a/apps/mobile/src/components/shell/use-trailing-actions.tsx b/apps/mobile/src/components/shell/use-trailing-actions.tsx index ea40dc454..99d6fa0fb 100644 --- a/apps/mobile/src/components/shell/use-trailing-actions.tsx +++ b/apps/mobile/src/components/shell/use-trailing-actions.tsx @@ -1,10 +1,9 @@ -import { HeaderIconButton } from '@mobile/components/shell/header-icon-button'; +import { HeaderMenuButton } from '@mobile/components/shell/header-menu-button'; import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import type { NativeStackHeaderItem, NativeStackNavigationOptions } from 'expo-router'; import { useRouter } from 'expo-router'; -import { EllipsisIcon } from 'lucide-react-native'; -import { Platform, View } from 'react-native'; +import { Platform } from 'react-native'; import { useTranslations } from 'use-intl'; type TrailingHeaderOptions = Pick< @@ -14,8 +13,8 @@ type TrailingHeaderOptions = Pick< /** Trailing navigation-bar chrome for the tab screens: on iOS the screen's primary action — * except on iOS 26, whose tab-bar slot already carries it — then the overflow menu that leads to - * Settings. Native bar items are iOS-only; Android keeps an RN overflow button and carries the - * primary action as a floating action button instead (`PrimaryActionFab`). */ + * Settings. Native bar items are iOS-only; Android renders the overflow as a Compose + * `DropdownMenu` and carries the primary action as a floating action button (`PrimaryActionFab`). */ export function useTrailingActions(primary: PrimaryAction | null): TrailingHeaderOptions { const t = useTranslations('mobile.settings'); const router = useRouter(); @@ -50,13 +49,10 @@ export function useTrailingActions(primary: PrimaryAction | null): TrailingHeade return { headerRight: () => ( - - router.push('/settings')} - /> - + router.push('/settings') }]} + /> ), }; } From b8da545d71e4d17531d97c29d5cc0dec04588e03 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 18 Aug 2026 23:46:33 +0800 Subject: [PATCH 15/37] feat(mobile,ui): native color bridge, android search view, and add-host sheet One useNativePalette contract resolves to UIKit PlatformColors on iOS and Material You roles on Android; chrome, the session surfaces, and the first-run screens (splash, sign-in, terminal) consume it instead of brand tokens. Android search hides behind a header icon and expands as a full-width view replacing the top app bar; the manual add-host flow joins the switchers' ModalBottomSheet family. --- apps/mobile/assets/icons/search.xml | 9 ++ .../mobile/src/app/(tabs)/terminals/index.tsx | 6 +- apps/mobile/src/app/(tabs)/threads/index.tsx | 45 ++++++++-- apps/mobile/src/app/index.tsx | 7 +- apps/mobile/src/app/new-thread.tsx | 7 +- apps/mobile/src/app/session/[sessionId].tsx | 9 +- apps/mobile/src/app/sign-in.tsx | 50 ++++------- apps/mobile/src/app/terminal/[terminalId].tsx | 26 ++++-- .../connect/add-host-sheet.android.tsx | 88 +++++++++++++++++++ .../connect/manual-host-row.ios.tsx | 10 +++ .../components/connect/manual-host-row.tsx | 14 ++- .../src/components/conversation/composer.tsx | 28 +++--- .../prompt-dock/permission-prompt.ios.tsx | 7 +- .../prompt-dock/plan-tracker.ios.tsx | 7 +- .../conversation/prompt-dock/prompt-dock.tsx | 10 +-- .../prompt-dock/question-page.ios.tsx | 7 +- .../components/conversation/send-button.tsx | 12 +-- .../components/conversation/session-title.tsx | 16 ++-- .../conversation/session-tools.ios.tsx | 6 +- .../components/conversation/timeline-item.tsx | 81 ++++++++++++----- .../form/use-search-header-options.ios.ts | 14 +++ .../form/use-search-header-options.tsx | 69 +++++++++++++++ .../components/host/host-switcher-button.tsx | 8 +- .../host/new-thread/draft-tools.ios.tsx | 6 +- .../host/new-thread/project-row.ios.tsx | 11 +-- .../components/host/threads-screen.ios.tsx | 28 +++++- .../src/components/host/threads-screen.tsx | 38 +++++--- .../src/components/host/use-thread-inbox.ts | 24 ++--- .../components/shell/use-chrome-colors.ios.ts | 14 --- .../src/components/shell/use-chrome-colors.ts | 15 ---- .../shell/use-chrome-colors.types.ts | 11 --- .../shell/use-stack-screen-options.ts | 20 ++--- .../components/shell/use-trailing-actions.tsx | 37 ++++++-- .../components/theme/native-palette.ios.ts | 23 +++++ .../src/components/theme/native-palette.ts | 21 +++++ .../components/theme/native-palette.types.ts | 23 +++++ .../presentation/ui/src/native/agent-icon.tsx | 3 +- 37 files changed, 596 insertions(+), 214 deletions(-) create mode 100644 apps/mobile/assets/icons/search.xml create mode 100644 apps/mobile/src/components/connect/add-host-sheet.android.tsx create mode 100644 apps/mobile/src/components/connect/manual-host-row.ios.tsx create mode 100644 apps/mobile/src/components/form/use-search-header-options.ios.ts create mode 100644 apps/mobile/src/components/form/use-search-header-options.tsx delete mode 100644 apps/mobile/src/components/shell/use-chrome-colors.ios.ts delete mode 100644 apps/mobile/src/components/shell/use-chrome-colors.ts delete mode 100644 apps/mobile/src/components/shell/use-chrome-colors.types.ts create mode 100644 apps/mobile/src/components/theme/native-palette.ios.ts create mode 100644 apps/mobile/src/components/theme/native-palette.ts create mode 100644 apps/mobile/src/components/theme/native-palette.types.ts diff --git a/apps/mobile/assets/icons/search.xml b/apps/mobile/assets/icons/search.xml new file mode 100644 index 000000000..83a9d154c --- /dev/null +++ b/apps/mobile/assets/icons/search.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/src/app/(tabs)/terminals/index.tsx b/apps/mobile/src/app/(tabs)/terminals/index.tsx index 5d10843bd..300375f1e 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -3,10 +3,10 @@ import { useHostHeaderOptions } from '@mobile/components/host/use-host-header-op import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { PrimaryActionFab } from '@mobile/components/shell/primary-action-fab'; -import { useChromeColors } from '@mobile/components/shell/use-chrome-colors'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; import { TerminalsScreen } from '@mobile/components/terminal/terminals-screen'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { useHostConnection } from '@mobile/runtime/host-connection'; import { Stack } from 'expo-router'; import { PlusIcon } from 'lucide-react-native'; @@ -19,7 +19,7 @@ import { useTranslations } from 'use-intl'; export default function TerminalsRoute(): React.ReactNode { const t = useTranslations('mobile.terminals'); const hostHeaderOptions = useHostHeaderOptions(); - const chrome = useChromeColors(); + const palette = useNativePalette(); const connection = useHostConnection(); const [sheetOpen, setSheetOpen] = useState(false); @@ -38,7 +38,7 @@ export default function TerminalsRoute(): React.ReactNode { // The flex container is load-bearing: a SwiftUI host left as the screen's direct child is // proposed the whole window and paints straight over the navigation header. return ( - + { + setSearchOpen(false); + setQuery(''); + }, []); const primaryAction: PrimaryAction | null = connection?.status === 'ready' @@ -33,20 +44,42 @@ export default function ThreadsRoute(): React.ReactNode { } : null; usePrimaryAction('threads', primaryAction); - const trailingActions = useTrailingActions(primaryAction); + const trailingActions = useTrailingActions(primaryAction, { + ...(Platform.OS === 'android' && { + onSearchPress() { + setQuery(''); + setSearchOpen((open) => !open); + }, + }), + }); + const searchHeaderOptions = useSearchHeaderOptions({ + open: searchOpen, + placeholder: t('searchPlaceholder'), + closeLabel: tChat('cancel'), + onQueryChange: setQuery, + onClose: closeSearch, + }); return ( - + - + diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index e8de4c245..386c2cc50 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,4 +1,5 @@ import { BrandMark } from '@mobile/components/shell/brand-mark'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { useCloudAccount } from '@mobile/runtime/cloud/account'; import { resolveStartupTarget } from '@mobile/runtime/startup'; import { useHostRegistryHydrated, useHostRegistryStore } from '@mobile/stores/host-store'; @@ -12,6 +13,7 @@ import { View } from 'react-native'; */ export default function StartupScreen() { const hydrated = useHostRegistryHydrated(); + const palette = useNativePalette(); const account = useCloudAccount(); const hosts = useHostRegistryStore((state) => state.hosts); const lastActiveHostId = useHostRegistryStore((state) => state.lastActiveHostId); @@ -20,7 +22,10 @@ export default function StartupScreen() { // saved hosts must not block on a slow or offline cloud session check. if (!hydrated || (hosts.length === 0 && account.status === 'loading')) { return ( - + diff --git a/apps/mobile/src/app/new-thread.tsx b/apps/mobile/src/app/new-thread.tsx index e30fe5230..397bd25e9 100644 --- a/apps/mobile/src/app/new-thread.tsx +++ b/apps/mobile/src/app/new-thread.tsx @@ -3,6 +3,7 @@ import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { AgentSelectorChip, ApprovalChip } from '@mobile/components/host/new-thread/draft-tools'; import { ProjectRow } from '@mobile/components/host/new-thread/project-row'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { useNewThreadDraft } from '@mobile/runtime/use-new-thread-draft'; import { Stack, useRouter } from 'expo-router'; import { noop } from 'foxact/noop'; @@ -15,9 +16,13 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; * starts on the host with the prompt riding behind it. A root push, so it covers the tab bar. */ export default function NewThreadRoute(): React.ReactNode { const insets = useSafeAreaInsets(); + const palette = useNativePalette(); return ( - + {/* Title-less on purpose: the composer says everything, and the bare back chevron keeps the page reading as a sheet of options rather than a destination. */} diff --git a/apps/mobile/src/app/session/[sessionId].tsx b/apps/mobile/src/app/session/[sessionId].tsx index 2e7d0fc49..ba7b1dd2d 100644 --- a/apps/mobile/src/app/session/[sessionId].tsx +++ b/apps/mobile/src/app/session/[sessionId].tsx @@ -25,6 +25,7 @@ import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { HeaderMenuButton } from '@mobile/components/shell/header-menu-button'; import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { useAccountModels } from '@mobile/runtime/use-account-models'; import { useSeededConversation } from '@mobile/runtime/use-seeded-conversation'; import { useSessionActions } from '@mobile/runtime/use-session-actions'; @@ -59,6 +60,7 @@ function SessionScreen(): React.ReactNode { const tSettings = useTranslations('mobile.settings'); const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); + const palette = useNativePalette(); const router = useRouter(); const { sessionId: rawSessionId, autoResume } = useLocalSearchParams<{ sessionId: string; @@ -136,8 +138,11 @@ function SessionScreen(): React.ReactNode { return ( - + ); } @@ -153,8 +139,8 @@ export default function SignInScreen() { return ( LinkCode {t('tagline')} @@ -189,16 +175,16 @@ export default function SignInScreen() { {busy ? ( ) : failed ? ( {t('error')} @@ -228,7 +214,7 @@ export default function SignInScreen() { size="md" style={[ styles.authButton, - appleAvailable ? iosStyles.secondaryButton : iosStyles.primaryButton, + { backgroundColor: appleAvailable ? palette.surface : palette.tint }, ]} variant={appleAvailable ? 'secondary' : 'primary'} onPress={() => { @@ -239,7 +225,7 @@ export default function SignInScreen() { dynamicTypeRamp="body" style={[ styles.buttonLabel, - appleAvailable ? iosStyles.label : iosStyles.primaryButtonLabel, + { color: appleAvailable ? palette.text : palette.onTint }, ]} > {appleAvailable ? t('other') : t('signIn')} @@ -253,7 +239,7 @@ export default function SignInScreen() { > {t('skip')} diff --git a/apps/mobile/src/app/terminal/[terminalId].tsx b/apps/mobile/src/app/terminal/[terminalId].tsx index 2e4457248..92c039818 100644 --- a/apps/mobile/src/app/terminal/[terminalId].tsx +++ b/apps/mobile/src/app/terminal/[terminalId].tsx @@ -1,6 +1,7 @@ import { TerminalIdSchema } from '@linkcode/schema'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; import TerminalRenderer from '@mobile/components/terminal/terminal-renderer'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { useTerminalSession } from '@mobile/runtime/use-terminal-session'; import { resolveTerminalTheme, useTerminalPrefsStore } from '@mobile/stores/terminal-prefs-store'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -24,6 +25,7 @@ function TerminalScreen(): React.ReactNode { const t = useTranslations('mobile.terminal'); const router = useRouter(); const insets = useSafeAreaInsets(); + const palette = useNativePalette(); const params = useLocalSearchParams<{ terminalId: string; takeover?: string }>(); const parsed = TerminalIdSchema.safeParse(params.terminalId); const terminalId = parsed.success ? parsed.data : null; @@ -55,15 +57,19 @@ function TerminalScreen(): React.ReactNode { return ( - + {terminal?.cwd ?? t('title')} @@ -83,7 +89,10 @@ function TerminalScreen(): React.ReactNode { ) : null} {exit ? ( - + {exit.code === null ? t('exitedSignal') : t('exited', { code: exit.code })} ) : null} @@ -91,7 +100,9 @@ function TerminalScreen(): React.ReactNode { {status === 'attaching' ? ( - {t('attaching')} + + {t('attaching')} + ) : status === 'error' ? ( @@ -119,7 +130,10 @@ function TerminalScreen(): React.ReactNode { {t('close')} ) : terminal?.managed ? ( - + {t('managedReadOnly')} ) : ( diff --git a/apps/mobile/src/components/connect/add-host-sheet.android.tsx b/apps/mobile/src/components/connect/add-host-sheet.android.tsx new file mode 100644 index 000000000..24810920e --- /dev/null +++ b/apps/mobile/src/components/connect/add-host-sheet.android.tsx @@ -0,0 +1,88 @@ +import { + Button, + Column, + ModalBottomSheet, + OutlinedTextField, + Text, + useNativeState, +} from '@expo/ui/jetpack-compose'; +import { fillMaxWidth, imePadding, padding, testID } from '@expo/ui/jetpack-compose/modifiers'; +import { useAddHost } from '@mobile/components/connect/use-add-host'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import { useTranslations } from 'use-intl'; + +/** Android add-host form as an MD3 bottom sheet — the same modal family as the host and model + * switchers. A successful submit dismisses to the threads tab, which unmounts the sheet. */ +export function AddHostSheet({ + isPresented, + onIsPresentedChange, +}: { + isPresented: boolean; + onIsPresentedChange: (isPresented: boolean) => void; +}): React.ReactNode { + const t = useTranslations('mobile.connect'); + const colors = useAppMaterialColors(); + const name = useNativeState(''); + const url = useNativeState(''); + const { urlInvalid, urlValid, onUrlChange, submit } = useAddHost(); + + if (!isPresented) return null; + + const submitFields = () => submit(name.get(), url.get()); + + return ( + + onIsPresentedChange(false)}> + + + {t('add')} + + + + {t('nameLabel')} + + + {t('namePlaceholder')} + + + + + {t('urlLabel')} + + + {t('urlPlaceholder')} + + + {urlInvalid ? t('invalidUrl') : t('emptyHint')} + + + + + + + ); +} diff --git a/apps/mobile/src/components/connect/manual-host-row.ios.tsx b/apps/mobile/src/components/connect/manual-host-row.ios.tsx new file mode 100644 index 000000000..af814f644 --- /dev/null +++ b/apps/mobile/src/components/connect/manual-host-row.ios.tsx @@ -0,0 +1,10 @@ +import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { useRouter } from 'expo-router'; +import { useTranslations } from 'use-intl'; + +export function ManualHostRow(): React.ReactNode { + const t = useTranslations('mobile.connect'); + const router = useRouter(); + + return router.push('/add-host')} />; +} diff --git a/apps/mobile/src/components/connect/manual-host-row.tsx b/apps/mobile/src/components/connect/manual-host-row.tsx index af814f644..e19bf92eb 100644 --- a/apps/mobile/src/components/connect/manual-host-row.tsx +++ b/apps/mobile/src/components/connect/manual-host-row.tsx @@ -1,10 +1,18 @@ +import { AddHostSheet } from '@mobile/components/connect/add-host-sheet.android'; import { NavigationRow } from '@mobile/components/form/navigation-row'; -import { useRouter } from 'expo-router'; +import { useState } from 'react'; import { useTranslations } from 'use-intl'; +/** Android opens the add-host form as a bottom sheet in place — the same modal family as the + * host and model switchers — instead of pushing the form-sheet route iOS uses. */ export function ManualHostRow(): React.ReactNode { const t = useTranslations('mobile.connect'); - const router = useRouter(); + const [open, setOpen] = useState(false); - return router.push('/add-host')} />; + return ( + <> + setOpen(true)} /> + + + ); } diff --git a/apps/mobile/src/components/conversation/composer.tsx b/apps/mobile/src/components/conversation/composer.tsx index f1fed929f..01f2cfa3a 100644 --- a/apps/mobile/src/components/conversation/composer.tsx +++ b/apps/mobile/src/components/conversation/composer.tsx @@ -2,7 +2,7 @@ import { Host, Spacer, VStack } from '@expo/ui/swift-ui'; import { frame, glassEffect } from '@expo/ui/swift-ui/modifiers'; import { SendButton } from '@mobile/components/conversation/send-button'; import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; -import { useThemeColor } from 'heroui-native'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { Text, TextInput, View } from 'react-native'; import { useTranslations } from 'use-intl'; @@ -52,7 +52,7 @@ export function Composer({ trailing?: React.ReactNode; }): React.ReactNode { const t = useTranslations('mobile.conversation'); - const muted = useThemeColor('muted'); + const palette = useNativePalette(); const trimmed = text.trim(); const canSend = !disabled && !sendBlocked && trimmed.length > 0; @@ -64,17 +64,21 @@ export function Composer({ }; return ( - - {error ? {error} : null} + + {error ? ( + + {error} + + ) : null} {/* On iOS 26 the card's material is real Liquid Glass, drawn by a SwiftUI backdrop behind the RN input (the input itself must stay RN: multiline auto-grow + keyboard riding). Pre-26 and Android keep the filled card. */} {USES_IOS_26_NAVIGATION ? ( @@ -86,10 +90,10 @@ export function Composer({ ) : null} + diff --git a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.ios.tsx b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.ios.tsx index 4f309d422..67f1af1bd 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.ios.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.ios.tsx @@ -8,6 +8,7 @@ import { strikethrough, } from '@expo/ui/swift-ui/modifiers'; import type { CurrentPlan } from '@linkcode/ui/native'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { useState } from 'react'; import { View } from 'react-native'; import { useTranslations } from 'use-intl'; @@ -28,13 +29,17 @@ const STATUS_ICON = { */ export function PlanTracker({ plan }: { plan: CurrentPlan }): React.ReactNode { const t = useTranslations('mobile.chat'); + const palette = useNativePalette(); const [expanded, setExpanded] = useState(false); const entries = plan.item.plan.entries; const current = entries[plan.currentIndex]; return ( - + diff --git a/apps/mobile/src/components/conversation/prompt-dock/prompt-dock.tsx b/apps/mobile/src/components/conversation/prompt-dock/prompt-dock.tsx index 1098ff5cb..cc6d9959d 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/prompt-dock.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/prompt-dock.tsx @@ -1,6 +1,6 @@ import type { PermissionOutcome, QuestionOutcome } from '@linkcode/schema'; import type { CurrentPlan, PromptConversationItem } from '@linkcode/ui/native'; -import { useThemeColor } from 'heroui-native'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react-native'; import { useState } from 'react'; import { Pressable, Text, View } from 'react-native'; @@ -70,7 +70,7 @@ function PermissionPager({ }): React.ReactNode { const t = useTranslations('mobile.chat'); const [page, setPage] = useState(0); - const muted = useThemeColor('muted'); + const palette = useNativePalette(); const pageIndex = Math.min(page, approvals.length - 1); const approval = approvals[pageIndex]; @@ -84,9 +84,9 @@ function PermissionPager({ onPress={() => setPage(pageIndex - 1)} className="size-8 items-center justify-center" > - + - + {t('questionProgress', { current: pageIndex + 1, total: approvals.length })} setPage(pageIndex + 1)} className="size-8 items-center justify-center" > - + ) : null} diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx index baac0c02c..43b95fc76 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx @@ -18,6 +18,7 @@ import { onTapGesture, shapes, } from '@expo/ui/swift-ui/modifiers'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { View } from 'react-native'; import { useTranslations } from 'use-intl'; import type { QuestionPageProps } from './question-page.types'; @@ -40,6 +41,7 @@ export function QuestionPage({ onCancel, }: QuestionPageProps): React.ReactNode { const t = useTranslations('mobile.chat'); + const palette = useNativePalette(); // Each page owns a distinct native state. Remounting after a structured selection guarantees // that an asynchronous native write from the previous question/answer mode cannot leak here. const customText = useNativeState(draft.customText); @@ -62,7 +64,10 @@ export function QuestionPage({ }; return ( - + diff --git a/apps/mobile/src/components/conversation/send-button.tsx b/apps/mobile/src/components/conversation/send-button.tsx index 8c75b1779..be7ebebb7 100644 --- a/apps/mobile/src/components/conversation/send-button.tsx +++ b/apps/mobile/src/components/conversation/send-button.tsx @@ -1,10 +1,10 @@ -import { useThemeColor } from 'heroui-native'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { ArrowUpIcon, SquareIcon } from 'lucide-react-native'; import { Pressable } from 'react-native'; /** The round send action shared by the conversation composer and the new-thread draft: one * circular button that morphs between send and stop, because a turn in flight is the only thing - * the user wants to do to it. */ + * the user wants to do to it. Filled with the platform tint, like the native senders. */ export function SendButton({ isRunning, enabled, @@ -20,7 +20,7 @@ export function SendButton({ onSend: () => void; onStop: () => void; }): React.ReactNode { - const [muted, background, foreground] = useThemeColor(['muted', 'background', 'foreground']); + const palette = useNativePalette(); const ActionIcon = isRunning ? SquareIcon : ArrowUpIcon; return ( @@ -32,14 +32,14 @@ export function SendButton({ onPress={isRunning ? onStop : onSend} className="h-9 w-9 items-center justify-center rounded-full" style={({ pressed }) => ({ - backgroundColor: enabled ? foreground : muted, + backgroundColor: enabled ? palette.tint : palette.outline, opacity: pressed ? 0.6 : 1, })} > diff --git a/apps/mobile/src/components/conversation/session-title.tsx b/apps/mobile/src/components/conversation/session-title.tsx index 36cf1b448..871afb77c 100644 --- a/apps/mobile/src/components/conversation/session-title.tsx +++ b/apps/mobile/src/components/conversation/session-title.tsx @@ -1,5 +1,5 @@ import type { SessionStatus } from '@linkcode/schema'; -import { useThemeColor } from 'heroui-native'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { Text, View } from 'react-native'; /** Header title with the session's status as a dot — the same 8pt dot and palette as the thread @@ -11,18 +11,22 @@ export function SessionTitle({ title: string; status: SessionStatus | null; }): React.ReactNode { - const [success, warning, muted] = useThemeColor(['success', 'warning', 'muted']); + const palette = useNativePalette(); const color = status === 'running' - ? success + ? palette.success : status === 'starting' || status === 'awaiting-input' - ? warning - : muted; + ? palette.warning + : palette.outline; return ( {/* `headline` is the 17pt semibold metric UIKit draws inline nav titles with. */} - + {title} {status ? : null} diff --git a/apps/mobile/src/components/conversation/session-tools.ios.tsx b/apps/mobile/src/components/conversation/session-tools.ios.tsx index 432bb0092..d80c051ec 100644 --- a/apps/mobile/src/components/conversation/session-tools.ios.tsx +++ b/apps/mobile/src/components/conversation/session-tools.ios.tsx @@ -6,7 +6,7 @@ import type { SessionApprovalChipProps, SessionSelectorChipProps, } from '@mobile/components/conversation/session-tools.types'; -import { useThemeColor } from 'heroui-native'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { View } from 'react-native'; import { useTranslations } from 'use-intl'; @@ -59,7 +59,7 @@ export function SessionSelectorChip({ onEffortChange, }: SessionSelectorChipProps): React.ReactNode { const t = useTranslations('mobile.sessions'); - const muted = useThemeColor('muted'); + const palette = useNativePalette(); const hasModels = models !== null && models.length > 0; const hasEfforts = effortOptions !== undefined && effortOptions.length > 0; if (!hasModels && !hasEfforts) return null; @@ -71,7 +71,7 @@ export function SessionSelectorChip({ return ( {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} - + {/* Keyed by menu shape: children added to an already-created Menu never reach the native UIMenu, so late-loading models/efforts must remount the chip. */} diff --git a/apps/mobile/src/components/conversation/timeline-item.tsx b/apps/mobile/src/components/conversation/timeline-item.tsx index 045d8a4e5..4320a9cf0 100644 --- a/apps/mobile/src/components/conversation/timeline-item.tsx +++ b/apps/mobile/src/components/conversation/timeline-item.tsx @@ -1,7 +1,8 @@ import type { ConversationItem } from '@linkcode/client-core'; import type { ContentBlock, ToolCall } from '@linkcode/schema'; import { NativeMarkdown } from '@linkcode/ui/native'; -import { Spinner, useThemeColor } from 'heroui-native'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; +import { Spinner } from 'heroui-native'; import { ChevronDownIcon, ChevronRightIcon, CircleAlertIcon } from 'lucide-react-native'; import { useState } from 'react'; import { Pressable, Text, View } from 'react-native'; @@ -38,13 +39,19 @@ export function TimelineItem({ onPressTool?: (toolCall: ToolCall) => void; }): React.ReactNode { const t = useTranslations('mobile.conversation'); + const palette = useNativePalette(); switch (item.kind) { case 'message': return item.role === 'user' ? ( - - {blocksToText(item.blocks)} + + + {blocksToText(item.blocks)} + ) : ( @@ -54,8 +61,12 @@ export function TimelineItem({ // eslint-disable-next-line @eslint-react/no-array-index-key -- blocks carry no ids; the array only ever appends while streaming ) : ( - // eslint-disable-next-line @eslint-react/no-array-index-key -- see above - + [{block.type}] ), @@ -74,17 +85,27 @@ export function TimelineItem({ ); case 'plan': return ( - - {t('plan')} + + + {t('plan')} + {item.plan.entries.map((entry, index) => ( {PLAN_STATUS_MARK[entry.status]} {entry.content} @@ -123,15 +144,19 @@ export function TimelineItem({ if (item.status === 'in_progress') { return ( - {t('compacting')} + + {t('compacting')} + ); } return ( - {t('compacted')} + + {t('compacted')} + {item.preTokens !== undefined && item.postTokens !== undefined ? ( - + {t('compactedTokens', { pre: formatTokens(item.preTokens), post: formatTokens(item.postTokens), @@ -147,7 +172,7 @@ export function TimelineItem({ function ReasoningRow({ text, streaming }: { text: string; streaming: boolean }): React.ReactNode { const t = useTranslations('mobile.conversation'); - const muted = useThemeColor('muted'); + const palette = useNativePalette(); const [open, setOpen] = useState(false); const Chevron = open ? ChevronDownIcon : ChevronRightIcon; @@ -158,10 +183,16 @@ function ReasoningRow({ text, streaming }: { text: string; streaming: boolean }) className="flex-row items-center gap-1.5" onPress={() => setOpen((current) => !current)} > - {t('reasoning')} - {streaming ? : } + + {t('reasoning')} + + {streaming ? : } - {open ? {text} : null} + {open ? ( + + {text} + + ) : null} ); } @@ -179,7 +210,7 @@ function ToolRow({ status: 'pending' | 'in_progress' | 'completed' | 'failed'; onPress?: () => void; }): React.ReactNode { - const [muted, danger] = useThemeColor(['muted', 'danger']); + const palette = useNativePalette(); return ( - + {title} - {status === 'failed' ? : null} + {status === 'failed' ? : null} {status === 'in_progress' ? ( ) : ( - + )} ); diff --git a/apps/mobile/src/components/form/use-search-header-options.ios.ts b/apps/mobile/src/components/form/use-search-header-options.ios.ts new file mode 100644 index 000000000..fd21d871c --- /dev/null +++ b/apps/mobile/src/components/form/use-search-header-options.ios.ts @@ -0,0 +1,14 @@ +import type { NativeStackNavigationOptions } from 'expo-router'; + +/** iOS threads search lives in the native search bar (`Stack.SearchBar`); there is no header + * search view to configure. */ +// eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix -- platform stub; the name must mirror the Android hook +export function useSearchHeaderOptions(_options: { + open: boolean; + placeholder: string; + closeLabel: string; + onQueryChange: (query: string) => void; + onClose: () => void; +}): Partial { + return {}; +} diff --git a/apps/mobile/src/components/form/use-search-header-options.tsx b/apps/mobile/src/components/form/use-search-header-options.tsx new file mode 100644 index 000000000..dd4b2ff69 --- /dev/null +++ b/apps/mobile/src/components/form/use-search-header-options.tsx @@ -0,0 +1,69 @@ +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; +import type { NativeStackNavigationOptions } from 'expo-router'; +import { ArrowLeftIcon } from 'lucide-react-native'; +import { Pressable, TextInput, View } from 'react-native'; + +/** Header options that swap the top app bar's content for a full-width M3 search view while + * `open` — the classic Android search pattern. Spread LAST into the screen's options, after the + * usual leading/trailing header options. The search renders as the toolbar's center view (full + * width on Android) so the toolbar itself never unmounts: hiding the native header and mounting + * an in-content bar land on different frames and flicker. Every key is set in both states with + * explicit undefined where cleared: expo-router applies options via `setOptions`, a shallow + * merge, so an omitted key would keep its stale value. On iOS this is a no-op — search lives in + * the native search bar. */ +export function useSearchHeaderOptions({ + open, + placeholder, + closeLabel, + onQueryChange, + onClose, +}: { + open: boolean; + placeholder: string; + /** Accessibility name of the leading back arrow. */ + closeLabel: string; + onQueryChange: (query: string) => void; + onClose: () => void; +}): Partial { + const colors = useAppMaterialColors(); + const palette = useNativePalette(); + + // Closed restores the navigator's Android header color rather than clearing: `setOptions` + // merges per key, so `undefined` would override the stack default down to the theme's white. + if (!open) { + return { headerTitle: undefined, headerStyle: { backgroundColor: palette.background } }; + } + return { + headerLeft: undefined, + headerRight: undefined, + headerStyle: { backgroundColor: colors.surfaceContainerHigh }, + headerTitle: () => ( + + {/* Pulls the arrow out of the toolbar's 16dp content inset to the nav-icon position. */} + + + + + + ), + }; +} diff --git a/apps/mobile/src/components/host/host-switcher-button.tsx b/apps/mobile/src/components/host/host-switcher-button.tsx index 422b25b7f..eeae6de5d 100644 --- a/apps/mobile/src/components/host/host-switcher-button.tsx +++ b/apps/mobile/src/components/host/host-switcher-button.tsx @@ -1,5 +1,5 @@ import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; -import { useChromeColors } from '@mobile/components/shell/use-chrome-colors'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { useHostRegistryStore, useSelectedHost } from '@mobile/stores/host-store'; import { useRouter } from 'expo-router'; import { ChevronsUpDownIcon } from 'lucide-react-native'; @@ -18,7 +18,7 @@ export function HostSwitcherButton(): React.ReactNode { const setLastActiveHostId = useHostRegistryStore((state) => state.setLastActiveHostId); const selected = useSelectedHost(); const [open, setOpen] = useState(false); - const chrome = useChromeColors(); + const palette = useNativePalette(); if (!selected) return null; @@ -32,12 +32,12 @@ export function HostSwitcherButton(): React.ReactNode { > {selected.name} - + {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} - + {/* Keyed by menu shape: children added to an already-created Menu never reach the native UIMenu, so late-loading models/efforts must remount the chip. */} diff --git a/apps/mobile/src/components/host/new-thread/project-row.ios.tsx b/apps/mobile/src/components/host/new-thread/project-row.ios.tsx index 3dc1d7b81..871eb3882 100644 --- a/apps/mobile/src/components/host/new-thread/project-row.ios.tsx +++ b/apps/mobile/src/components/host/new-thread/project-row.ios.tsx @@ -9,8 +9,8 @@ import { } from '@expo/ui/swift-ui/modifiers'; import { repositoryLabel } from '@linkcode/ui/native'; import type { ProjectRowProps } from '@mobile/components/host/new-thread/project-row.types'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { Color } from 'expo-router'; -import { useThemeColor } from 'heroui-native'; import { FolderIcon } from 'lucide-react-native'; import { TextInput, View } from 'react-native'; import { useTranslations } from 'use-intl'; @@ -34,17 +34,18 @@ export function ProjectRow({ onCustomPathChange, }: ProjectRowProps): React.ReactNode { const t = useTranslations('mobile.sessions'); - const muted = useThemeColor('muted'); + const palette = useNativePalette(); if (workspaces.length === 0) { return ( - + ['onChangeText']> +>[0]; + /** Threads inbox body: grouped sessions under collapsible headers, with the native search bar - * stacked below the navigation bar. */ -export function ThreadsScreen(): React.ReactNode { + * stacked below the navigation bar. The open/close props are the Android toggle; iOS search is + * always available in the header and writes back into the route-owned query. */ +export function ThreadsScreen({ + query = '', + onQueryChange = noop, +}: { + searchOpen?: boolean; + onCloseSearch?: () => void; + query?: string; + onQueryChange?: (query: string) => void; +}): React.ReactNode { const t = useTranslations('mobile.sessions'); const router = useRouter(); - const { loading, groups, hasQuery, onSearchChange, groupLabel, onRefresh } = useThreadInbox(); + const { loading, groups, hasQuery, groupLabel, onRefresh } = useThreadInbox(query); + + // Stable so the search bar's options object survives a keystroke without re-registering. + const onSearchChange = useCallback( + (event: SearchBarChangeEvent) => onQueryChange(event.nativeEvent.text), + [onQueryChange], + ); return ( <> diff --git a/apps/mobile/src/components/host/threads-screen.tsx b/apps/mobile/src/components/host/threads-screen.tsx index 3dd883b8b..ddab66522 100644 --- a/apps/mobile/src/components/host/threads-screen.tsx +++ b/apps/mobile/src/components/host/threads-screen.tsx @@ -5,23 +5,41 @@ import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; import { FormSection } from '@mobile/components/form/section.android'; import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; import { useThreadInbox } from '@mobile/components/host/use-thread-inbox'; -import { Stack, useRouter } from 'expo-router'; +import { useRouter } from 'expo-router'; +import { noop } from 'foxact/noop'; +import { useEffect } from 'react'; +import { BackHandler } from 'react-native'; import { useTranslations } from 'use-intl'; -/** Android threads inbox body. The search bar is the toolbar's collapsing magnifier field — - * the iOS-only placement props are left off. */ -export function ThreadsScreen(): React.ReactNode { +/** Android threads inbox body. The search field lives in the route's header (the M3 search view + * swaps the top app bar's content via `useSearchHeaderOptions`); this screen only filters by the + * route-owned query and collapses the search on hardware back. */ +export function ThreadsScreen({ + searchOpen = false, + onCloseSearch = noop, + query = '', +}: { + searchOpen?: boolean; + onCloseSearch?: () => void; + query?: string; + onQueryChange?: (query: string) => void; +}): React.ReactNode { const t = useTranslations('mobile.sessions'); const router = useRouter(); - const { loading, groups, hasQuery, onSearchChange, groupLabel, onRefresh } = useThreadInbox(); + const { loading, groups, hasQuery, groupLabel, onRefresh } = useThreadInbox(query); + + // The hardware back gesture collapses the search first, as every Android search view does. + useEffect(() => { + if (!searchOpen) return; + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + onCloseSearch(); + return true; + }); + return () => subscription.remove(); + }, [searchOpen, onCloseSearch]); return ( <> - {loading ? ( diff --git a/apps/mobile/src/components/host/use-thread-inbox.ts b/apps/mobile/src/components/host/use-thread-inbox.ts index f15d73532..e50e4c3db 100644 --- a/apps/mobile/src/components/host/use-thread-inbox.ts +++ b/apps/mobile/src/components/host/use-thread-inbox.ts @@ -7,22 +7,16 @@ import { } from '@linkcode/ui/native'; import { threadTitle } from '@mobile/components/host/thread-list/thread-title'; import { useWorkspaces } from '@mobile/runtime/use-workspaces'; -import type { Stack } from 'expo-router'; -import { useCallback, useState } from 'react'; import { useTranslations } from 'use-intl'; -/** Taken from the search bar itself: RN's own replacement for the event it declares carries no text. */ -type SearchBarChangeEvent = Parameters< - NonNullable['onChangeText']> ->[0]; - /** The threads inbox view model: sessions grouped by workspace, filtered by the search query. - * Empty workspace groups are hidden — the new-thread page is where they surface. */ -export function useThreadInbox(): { + * The query is owned by the route — on Android it feeds the toolbar search field, on iOS the + * native search bar. Empty workspace groups are hidden — the new-thread page is where they + * surface. */ +export function useThreadInbox(query: string): { loading: boolean; groups: ThreadGroup[]; hasQuery: boolean; - onSearchChange: (event: SearchBarChangeEvent) => void; groupLabel: (group: ThreadGroup) => string; onRefresh: () => Promise; } { @@ -30,14 +24,6 @@ export function useThreadInbox(): { const { sessions, refresh, loading } = useSessions(); const { workspaces, refresh: refreshWorkspaces } = useWorkspaces(); - const [query, setQuery] = useState(''); - - // Stable so the search bar's options object survives a keystroke without re-registering. - const onSearchChange = useCallback( - (event: SearchBarChangeEvent) => setQuery(event.nativeEvent.text), - [], - ); - const needle = query.trim().toLowerCase(); const groups = groupThreadsByWorkspace(withoutAutomationSessions(sessions), workspaces).reduce< ThreadGroup[] @@ -60,5 +46,5 @@ export function useThreadInbox(): { await Promise.all([refresh(), refreshWorkspaces()]); }; - return { loading, groups, hasQuery: needle !== '', onSearchChange, groupLabel, onRefresh }; + return { loading, groups, hasQuery: needle !== '', groupLabel, onRefresh }; } diff --git a/apps/mobile/src/components/shell/use-chrome-colors.ios.ts b/apps/mobile/src/components/shell/use-chrome-colors.ios.ts deleted file mode 100644 index ee3781c7f..000000000 --- a/apps/mobile/src/components/shell/use-chrome-colors.ios.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ChromeColors } from '@mobile/components/shell/use-chrome-colors.types'; -import { useThemeColor } from 'heroui-native'; - -/** iOS chrome keeps the app's own theme tokens; the contract exists for Android's Material You. */ -export function useChromeColors(): ChromeColors { - const [background, foreground, accent, muted] = useThemeColor([ - 'background', - 'foreground', - 'accent', - 'muted', - ]); - - return { background, title: foreground, tint: accent, subtle: muted }; -} diff --git a/apps/mobile/src/components/shell/use-chrome-colors.ts b/apps/mobile/src/components/shell/use-chrome-colors.ts deleted file mode 100644 index 2b4f81f34..000000000 --- a/apps/mobile/src/components/shell/use-chrome-colors.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; -import type { ChromeColors } from '@mobile/components/shell/use-chrome-colors.types'; - -/** Android chrome reads Material You dynamic roles, so RN-drawn headers and backgrounds match - * the Compose surfaces instead of the app's own brand tokens. */ -export function useChromeColors(): ChromeColors { - const colors = useAppMaterialColors(); - - return { - background: colors.surface, - title: colors.onSurface, - tint: colors.primary, - subtle: colors.onSurfaceVariant, - }; -} diff --git a/apps/mobile/src/components/shell/use-chrome-colors.types.ts b/apps/mobile/src/components/shell/use-chrome-colors.types.ts deleted file mode 100644 index b41555bfd..000000000 --- a/apps/mobile/src/components/shell/use-chrome-colors.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Navigation-chrome palette: headers, screen backgrounds, and header controls. */ -export interface ChromeColors { - /** Screen + header background. */ - background: string; - /** Titles and primary glyphs. */ - title: string; - /** Interactive tint (back chevron, header actions). */ - tint: string; - /** Quiet glyphs and secondary text. */ - subtle: string; -} diff --git a/apps/mobile/src/components/shell/use-stack-screen-options.ts b/apps/mobile/src/components/shell/use-stack-screen-options.ts index e0bf1f8ef..58f6d4aa8 100644 --- a/apps/mobile/src/components/shell/use-stack-screen-options.ts +++ b/apps/mobile/src/components/shell/use-stack-screen-options.ts @@ -1,4 +1,4 @@ -import { useChromeColors } from '@mobile/components/shell/use-chrome-colors'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import type { Stack } from 'expo-router'; import { NavigationBarBackdrop } from '../../../modules/linkcode-navigation-bar-backdrop'; import { USES_IOS_26_NAVIGATION } from './ios-26-navigation'; @@ -29,25 +29,25 @@ export const LARGE_TITLE_HEADER_OPTIONS: StackScreenOptions = { headerShown: true, }; -/** Theme-synced native-stack chrome; every Stack in the app spreads these defaults. - * Screens opt into a header per route via `Stack.Screen` options. Colors come from the - * platform-split chrome palette: app tokens on iOS, Material You roles on Android. */ +/** Native-color stack chrome; every Stack in the app spreads these defaults. Screens opt into a + * header per route via `Stack.Screen` options. Colors come from the platform-split native + * palette: UIKit system colors on iOS, Material You roles on Android. */ export function useStackScreenOptions(): StackScreenOptions { - const chrome = useChromeColors(); + const palette = useNativePalette(); return { headerShown: false, headerLargeTitleEnabled: false, - headerTintColor: chrome.tint, - headerTitleStyle: { color: chrome.title }, - headerLargeTitleStyle: { color: chrome.title }, + headerTintColor: palette.tint, + headerTitleStyle: { color: palette.text }, + headerLargeTitleStyle: { color: palette.text }, // Android has no header blur; painting the header the content color keeps the screen // reading as one surface (the shadow is already off). ...(process.env.EXPO_OS !== 'ios' && { - headerStyle: { backgroundColor: chrome.background }, + headerStyle: { backgroundColor: palette.background }, }), headerShadowVisible: false, headerBackButtonDisplayMode: 'minimal', - contentStyle: { backgroundColor: chrome.background }, + contentStyle: { backgroundColor: palette.background }, }; } diff --git a/apps/mobile/src/components/shell/use-trailing-actions.tsx b/apps/mobile/src/components/shell/use-trailing-actions.tsx index 99d6fa0fb..b50aa4c87 100644 --- a/apps/mobile/src/components/shell/use-trailing-actions.tsx +++ b/apps/mobile/src/components/shell/use-trailing-actions.tsx @@ -1,9 +1,11 @@ import { HeaderMenuButton } from '@mobile/components/shell/header-menu-button'; import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import type { NativeStackHeaderItem, NativeStackNavigationOptions } from 'expo-router'; import { useRouter } from 'expo-router'; -import { Platform } from 'react-native'; +import { SearchIcon } from 'lucide-react-native'; +import { Platform, Pressable, View } from 'react-native'; import { useTranslations } from 'use-intl'; type TrailingHeaderOptions = Pick< @@ -14,10 +16,17 @@ type TrailingHeaderOptions = Pick< /** Trailing navigation-bar chrome for the tab screens: on iOS the screen's primary action — * except on iOS 26, whose tab-bar slot already carries it — then the overflow menu that leads to * Settings. Native bar items are iOS-only; Android renders the overflow as a Compose - * `DropdownMenu` and carries the primary action as a floating action button (`PrimaryActionFab`). */ -export function useTrailingActions(primary: PrimaryAction | null): TrailingHeaderOptions { + * `DropdownMenu` (right-most) and carries the primary action as a floating action button + * (`PrimaryActionFab`). `onSearchPress` adds an Android search toggle before the overflow — iOS + * search lives in the native search bar instead. */ +export function useTrailingActions( + primary: PrimaryAction | null, + { onSearchPress }: { onSearchPress?: () => void } = {}, +): TrailingHeaderOptions { const t = useTranslations('mobile.settings'); + const tSessions = useTranslations('mobile.sessions'); const router = useRouter(); + const palette = useNativePalette(); if (Platform.OS === 'ios') { const items: NativeStackHeaderItem[] = []; @@ -49,10 +58,24 @@ export function useTrailingActions(primary: PrimaryAction | null): TrailingHeade return { headerRight: () => ( - router.push('/settings') }]} - /> + + {onSearchPress ? ( + ({ opacity: pressed ? 0.4 : 1 })} + > + + + ) : null} + router.push('/settings') }]} + /> + ), }; } diff --git a/apps/mobile/src/components/theme/native-palette.ios.ts b/apps/mobile/src/components/theme/native-palette.ios.ts new file mode 100644 index 000000000..2f9fdb505 --- /dev/null +++ b/apps/mobile/src/components/theme/native-palette.ios.ts @@ -0,0 +1,23 @@ +import type { NativePalette } from '@mobile/components/theme/native-palette.types'; +import { Color } from 'expo-router'; + +/** iOS resolves the palette from UIKit semantic colors — `PlatformColor` values that adapt to + * light/dark natively, so no hook state is needed. White-on-blue matches UIKit's own filled + * controls, which never invert their glyph. */ +const IOS_PALETTE: NativePalette = { + background: Color.ios.systemBackground, + surface: Color.ios.secondarySystemBackground, + text: Color.ios.label, + textSecondary: Color.ios.secondaryLabel, + tint: Color.ios.systemBlue, + onTint: '#FFFFFF', + outline: Color.ios.separator, + danger: Color.ios.systemRed, + success: Color.ios.systemGreen, + warning: Color.ios.systemOrange, +}; + +// eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix -- the Android twin resolves its palette through hooks; the shared contract keeps the hook name +export function useNativePalette(): NativePalette { + return IOS_PALETTE; +} diff --git a/apps/mobile/src/components/theme/native-palette.ts b/apps/mobile/src/components/theme/native-palette.ts new file mode 100644 index 000000000..b290fcfe3 --- /dev/null +++ b/apps/mobile/src/components/theme/native-palette.ts @@ -0,0 +1,21 @@ +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import type { NativePalette } from '@mobile/components/theme/native-palette.types'; + +/** Android resolves the palette from Material You dynamic roles (the same scheme the Compose + * surfaces render), pinned to the app theme. */ +export function useNativePalette(): NativePalette { + const colors = useAppMaterialColors(); + + return { + background: colors.surface, + surface: colors.surfaceContainerHigh, + text: colors.onSurface, + textSecondary: colors.onSurfaceVariant, + tint: colors.primary, + onTint: colors.onPrimary, + outline: colors.outlineVariant, + danger: colors.error, + success: colors.primary, + warning: colors.tertiary, + }; +} diff --git a/apps/mobile/src/components/theme/native-palette.types.ts b/apps/mobile/src/components/theme/native-palette.types.ts new file mode 100644 index 000000000..3cf439258 --- /dev/null +++ b/apps/mobile/src/components/theme/native-palette.types.ts @@ -0,0 +1,23 @@ +import type { ColorValue } from 'react-native'; + +/** Semantic palette bridging RN-drawn surfaces to each platform's NATIVE colors: adaptive + * `PlatformColor` system colors on iOS, Material You dynamic roles on Android. Components take + * roles from here instead of the app's own brand tokens. */ +export interface NativePalette { + /** Screens and navigation chrome. */ + background: ColorValue; + /** Raised cards: composer, bubbles, prompt cards. */ + surface: ColorValue; + text: ColorValue; + textSecondary: ColorValue; + /** Interactive accent (send, header tint). */ + tint: ColorValue; + /** Content drawn on top of `tint`. */ + onTint: ColorValue; + /** Hairline borders and idle indicators. */ + outline: ColorValue; + danger: ColorValue; + /** Status accents; Android maps to primary/tertiary — MD3 has no success/warning roles. */ + success: ColorValue; + warning: ColorValue; +} diff --git a/packages/presentation/ui/src/native/agent-icon.tsx b/packages/presentation/ui/src/native/agent-icon.tsx index 9c5886b5a..462ecf018 100644 --- a/packages/presentation/ui/src/native/agent-icon.tsx +++ b/packages/presentation/ui/src/native/agent-icon.tsx @@ -1,5 +1,6 @@ import type { AgentKind } from '@linkcode/schema'; import { useThemeColor } from 'heroui-native'; +import type { ColorValue } from 'react-native'; import { Text, View } from 'react-native'; import Svg, { Path } from 'react-native-svg'; import { AGENT_INITIALS } from '../agent-meta'; @@ -24,7 +25,7 @@ export interface AgentIconProps { /** Outer box size in dp (the glyph scales inside it). */ size?: number; /** Glyph tint override; defaults to accent-foreground (solid) / foreground (ghost). */ - color?: string; + color?: ColorValue; } /** Agent brand chip — the native counterpart of chat/agent-icon.tsx. */ From 16014800a2692d14e9ebbcad9b19b3e81c9a7dea Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Wed, 19 Aug 2026 00:22:57 +0800 Subject: [PATCH 16/37] fix(mobile): fit android adaptive icon layers to the safe zone --- .../mobile/assets/android-icon-foreground.png | Bin 15171 -> 13902 bytes .../mobile/assets/android-icon-monochrome.png | Bin 15762 -> 13107 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png index c3e2ca447c9aeef906f223d3f1852521a87abac6..0fb6714701f4be3bd018bbf8b4382f985c7f72e6 100644 GIT binary patch literal 13902 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 literal 15171 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 diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png index 8f5dc150f8d8b4dfa70853766aecbe632d6d594e..ed5e3132f498889f35f61e4595d044c38206cb6a 100644 GIT binary patch literal 13107 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 From e1e0eb249be1ce69fd97dad84ee8d65ee05c43b4 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 21 Aug 2026 18:53:17 +0800 Subject: [PATCH 17/37] feat(mobile,client-core): reach the dev mock host through mock:// hosts --- apps/mobile/src/runtime/create-host-transport.ts | 14 +++++++++++++- apps/mobile/src/stores/host-store.ts | 7 +++++-- packages/client/core/package.json | 3 ++- .../{workbench => core}/src/mock/data/commands.ts | 0 .../{workbench => core}/src/mock/data/files.ts | 0 .../{workbench => core}/src/mock/data/git.ts | 0 .../{workbench => core}/src/mock/data/history.ts | 0 .../src/mock/data/long-thread.ts | 0 .../{workbench => core}/src/mock/data/models.ts | 0 .../{workbench => core}/src/mock/data/plugins.ts | 0 .../{workbench => core}/src/mock/data/prompt.ts | 0 .../{workbench => core}/src/mock/data/scripts.ts | 0 .../{workbench => core}/src/mock/data/sessions.ts | 0 .../{workbench => core}/src/mock/data/showcase.ts | 12 ++++++------ .../{workbench => core}/src/mock/dev-mock-host.ts | 2 +- .../src/mock/dev-mock-transport.ts | 0 .../tests/integration/dev-mock-transport.test.ts | 4 ++-- .../workbench/src/runtime/daemon-transport.ts | 2 +- 18 files changed, 30 insertions(+), 14 deletions(-) rename packages/client/{workbench => core}/src/mock/data/commands.ts (100%) rename packages/client/{workbench => core}/src/mock/data/files.ts (100%) rename packages/client/{workbench => core}/src/mock/data/git.ts (100%) rename packages/client/{workbench => core}/src/mock/data/history.ts (100%) rename packages/client/{workbench => core}/src/mock/data/long-thread.ts (100%) rename packages/client/{workbench => core}/src/mock/data/models.ts (100%) rename packages/client/{workbench => core}/src/mock/data/plugins.ts (100%) rename packages/client/{workbench => core}/src/mock/data/prompt.ts (100%) rename packages/client/{workbench => core}/src/mock/data/scripts.ts (100%) rename packages/client/{workbench => core}/src/mock/data/sessions.ts (100%) rename packages/client/{workbench => core}/src/mock/data/showcase.ts (98%) rename packages/client/{workbench => core}/src/mock/dev-mock-host.ts (99%) rename packages/client/{workbench => core}/src/mock/dev-mock-transport.ts (100%) rename packages/client/{workbench => core}/tests/integration/dev-mock-transport.test.ts (99%) diff --git a/apps/mobile/src/runtime/create-host-transport.ts b/apps/mobile/src/runtime/create-host-transport.ts index d213c4c44..dcbe064b0 100644 --- a/apps/mobile/src/runtime/create-host-transport.ts +++ b/apps/mobile/src/runtime/create-host-transport.ts @@ -3,10 +3,14 @@ import { SocketIoTransport, TunnelTransport, WsTransport } from '@linkcode/trans import type { HostProfile } from '@mobile/stores/host-store'; import { CLOUD_URL, fetchTunnelJwt } from './cloud/client'; +type MockModule = typeof import('@linkcode/client-core/mock'); + /** * Transport by host kind: tunnel entries dial the cloud relay; direct entries pick by * URL scheme — http(s) is the daemon's default Socket.IO listener (same as - * webview/desktop), ws(s) a raw WebSocket listener. + * webview/desktop), ws(s) a raw WebSocket listener, and `mock:` the in-process mock host + * (webview's `dev:mock` showcase). Dev builds only: the `__DEV__` guard keeps Metro from + * bundling the mock into release, where the throw surfaces as a plain connection failure. */ export function createHostTransport(host: HostProfile): Transport { if ('tunnelHostId' in host) { @@ -18,6 +22,14 @@ export function createHostTransport(host: HostProfile): Transport { }); } const { protocol } = new URL(host.url); + if (protocol === 'mock:') { + if (__DEV__) { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- static import would bundle the mock into release builds + const mock = require('@linkcode/client-core/mock') as MockModule; + return mock.createDevMockTransport(); + } + throw new Error('mock:// hosts are only available in development builds'); + } return protocol === 'ws:' || protocol === 'wss:' ? new WsTransport({ url: host.url }) : new SocketIoTransport({ url: host.url }); diff --git a/apps/mobile/src/stores/host-store.ts b/apps/mobile/src/stores/host-store.ts index 637f7ca8c..1d06cb2d7 100644 --- a/apps/mobile/src/stores/host-store.ts +++ b/apps/mobile/src/stores/host-store.ts @@ -5,9 +5,12 @@ import { z } from 'zod'; import { create } from 'zustand'; import { createJSONStorage } from 'zustand/middleware'; -const HOST_URL_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:']); +// `mock:` stays accepted in release schemas so a dev-added mock host never poisons the persisted +// registry blob; its transport is what refuses to dial outside dev builds. +const HOST_URL_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:', 'mock:']); -/** http(s) dials the daemon's Socket.IO listener; ws(s) dials a raw WebSocket listener. */ +/** http(s) dials the daemon's Socket.IO listener; ws(s) dials a raw WebSocket listener; `mock:` + * runs the in-process mock host (dev builds only). */ export const HostUrlSchema = z.string().refine((value) => { try { return HOST_URL_PROTOCOLS.has(new URL(value).protocol); diff --git a/packages/client/core/package.json b/packages/client/core/package.json index f9c6fd31e..8b963b845 100644 --- a/packages/client/core/package.json +++ b/packages/client/core/package.json @@ -6,7 +6,8 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./mock": "./src/mock/dev-mock-transport.ts" }, "scripts": { "lint": "eslint --format=sukka ." diff --git a/packages/client/workbench/src/mock/data/commands.ts b/packages/client/core/src/mock/data/commands.ts similarity index 100% rename from packages/client/workbench/src/mock/data/commands.ts rename to packages/client/core/src/mock/data/commands.ts diff --git a/packages/client/workbench/src/mock/data/files.ts b/packages/client/core/src/mock/data/files.ts similarity index 100% rename from packages/client/workbench/src/mock/data/files.ts rename to packages/client/core/src/mock/data/files.ts diff --git a/packages/client/workbench/src/mock/data/git.ts b/packages/client/core/src/mock/data/git.ts similarity index 100% rename from packages/client/workbench/src/mock/data/git.ts rename to packages/client/core/src/mock/data/git.ts diff --git a/packages/client/workbench/src/mock/data/history.ts b/packages/client/core/src/mock/data/history.ts similarity index 100% rename from packages/client/workbench/src/mock/data/history.ts rename to packages/client/core/src/mock/data/history.ts diff --git a/packages/client/workbench/src/mock/data/long-thread.ts b/packages/client/core/src/mock/data/long-thread.ts similarity index 100% rename from packages/client/workbench/src/mock/data/long-thread.ts rename to packages/client/core/src/mock/data/long-thread.ts diff --git a/packages/client/workbench/src/mock/data/models.ts b/packages/client/core/src/mock/data/models.ts similarity index 100% rename from packages/client/workbench/src/mock/data/models.ts rename to packages/client/core/src/mock/data/models.ts diff --git a/packages/client/workbench/src/mock/data/plugins.ts b/packages/client/core/src/mock/data/plugins.ts similarity index 100% rename from packages/client/workbench/src/mock/data/plugins.ts rename to packages/client/core/src/mock/data/plugins.ts diff --git a/packages/client/workbench/src/mock/data/prompt.ts b/packages/client/core/src/mock/data/prompt.ts similarity index 100% rename from packages/client/workbench/src/mock/data/prompt.ts rename to packages/client/core/src/mock/data/prompt.ts diff --git a/packages/client/workbench/src/mock/data/scripts.ts b/packages/client/core/src/mock/data/scripts.ts similarity index 100% rename from packages/client/workbench/src/mock/data/scripts.ts rename to packages/client/core/src/mock/data/scripts.ts diff --git a/packages/client/workbench/src/mock/data/sessions.ts b/packages/client/core/src/mock/data/sessions.ts similarity index 100% rename from packages/client/workbench/src/mock/data/sessions.ts rename to packages/client/core/src/mock/data/sessions.ts diff --git a/packages/client/workbench/src/mock/data/showcase.ts b/packages/client/core/src/mock/data/showcase.ts similarity index 98% rename from packages/client/workbench/src/mock/data/showcase.ts rename to packages/client/core/src/mock/data/showcase.ts index 84d811647..763552d17 100644 --- a/packages/client/workbench/src/mock/data/showcase.ts +++ b/packages/client/core/src/mock/data/showcase.ts @@ -126,7 +126,7 @@ export const SHOWCASE_PLAN: Plan = { export const SHOWCASE_PERMISSION_DIFF: Extract = { type: 'diff', - path: 'packages/client/workbench/src/mock/dev-mock-transport.ts', + path: 'packages/client/core/src/mock/dev-mock-transport.ts', oldText: "const coverage = 'thin';\n", newText: "const coverage = 'rich';\n", }; @@ -271,7 +271,7 @@ export const SHOWCASE_STREAM_START_DELAY_MS = 1000; export const SHOWCASE_STREAM_CHUNK_LATENCY_MS = 220; export const SHOWCASE_TERMINAL_START_OUTPUT = - '$ pnpm vitest run packages/client/workbench/src/mock\n' + + '$ pnpm vitest run packages/client/core/src/mock\n' + '\u{1B}[32mPASS\u{1B}[0m dev mock transport (4)\n' + '\u{1B}[33mWARN\u{1B}[0m retry fixture exercised\n' + '\u{1B}[31mFAIL\u{1B}[0m expected negative-path fixture\n'; @@ -552,7 +552,7 @@ export function createShowcaseToolBursts(terminalId = SHOWCASE_TERMINAL_ID): Sho kind: 'execute', status: 'completed', content: [{ type: 'terminal', terminalId }], - rawInput: { command: 'pnpm vitest run packages/client/workbench/src/mock' }, + rawInput: { command: 'pnpm vitest run packages/client/core/src/mock' }, }, { toolCallId: 'mock-tool-execute-lint', @@ -781,11 +781,11 @@ export function createShowcaseToolBursts(terminalId = SHOWCASE_TERMINAL_ID): Sho title: 'Move activity fixture', kind: 'move', status: 'completed', - locations: [{ path: 'packages/client/workbench/src/mock/data/showcase.ts' }], + locations: [{ path: 'packages/client/core/src/mock/data/showcase.ts' }], content: [], rawInput: { - path: 'packages/client/workbench/src/mock/data/activity.ts', - move_path: 'packages/client/workbench/src/mock/data/showcase.ts', + path: 'packages/client/core/src/mock/data/activity.ts', + move_path: 'packages/client/core/src/mock/data/showcase.ts', }, }, { diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/core/src/mock/dev-mock-host.ts similarity index 99% rename from packages/client/workbench/src/mock/dev-mock-host.ts rename to packages/client/core/src/mock/dev-mock-host.ts index dd9155e0c..d7e48564c 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/core/src/mock/dev-mock-host.ts @@ -561,7 +561,7 @@ export class DevMockHost { ? { status: 'conflict', files: [ - { path: 'packages/client/workbench/src/mock.ts', additions: 12, deletions: 4 }, + { path: 'packages/client/core/src/mock.ts', additions: 12, deletions: 4 }, { path: 'packages/presentation/ui/src/shell.tsx', additions: 3, deletions: 1 }, ], } diff --git a/packages/client/workbench/src/mock/dev-mock-transport.ts b/packages/client/core/src/mock/dev-mock-transport.ts similarity index 100% rename from packages/client/workbench/src/mock/dev-mock-transport.ts rename to packages/client/core/src/mock/dev-mock-transport.ts diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/core/tests/integration/dev-mock-transport.test.ts similarity index 99% rename from packages/client/workbench/tests/integration/dev-mock-transport.test.ts rename to packages/client/core/tests/integration/dev-mock-transport.test.ts index 3febd842e..2540b2b97 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/core/tests/integration/dev-mock-transport.test.ts @@ -1,4 +1,3 @@ -import { LinkCodeClient } from '@linkcode/client-core'; import type { Accounts, AgentEvent, @@ -10,6 +9,7 @@ import type { import { nullthrow } from 'foxts/guard'; import { wait } from 'foxts/wait'; import { describe, expect, it } from 'vitest'; +import { LinkCodeClient } from '../../src'; import { createDevMockTransport } from '../../src/mock/dev-mock-transport'; const rBlobUrl = /^blob:/; @@ -636,7 +636,7 @@ describe('dev mock transport', () => { true, ); expect(tools.some((tool) => tool.status === 'failed')).toBe(true); - expect(terminalOutput).toContain('pnpm vitest run packages/client/workbench/src/mock'); + expect(terminalOutput).toContain('pnpm vitest run packages/client/core/src/mock'); expect(terminalOutput).toContain('mock terminal stream finished'); const streamChunks = events.filter( diff --git a/packages/client/workbench/src/runtime/daemon-transport.ts b/packages/client/workbench/src/runtime/daemon-transport.ts index 50d7d2a59..e8046e62f 100644 --- a/packages/client/workbench/src/runtime/daemon-transport.ts +++ b/packages/client/workbench/src/runtime/daemon-transport.ts @@ -1,6 +1,6 @@ +import { createDevMockTransport } from '@linkcode/client-core/mock'; import type { Transport } from '@linkcode/transport'; import { SocketIoTransport } from '@linkcode/transport'; -import { createDevMockTransport } from '../mock/dev-mock-transport'; /** * Construct the transport the workbench data plane rides on. `--mode mock` (dev only) swaps the From c55ec2bbc668d6d210fc1b442faf9368128a09c5 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 21 Aug 2026 19:23:43 +0800 Subject: [PATCH 18/37] fix(mobile): give android prompt-dock cards a bounded compose width --- .../conversation/prompt-dock/permission-prompt.tsx | 2 +- .../components/conversation/prompt-dock/plan-tracker.tsx | 2 +- .../components/conversation/prompt-dock/question-page.tsx | 4 ++-- apps/mobile/src/components/form/themed-host.android.tsx | 6 +++++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx index 66e9cdcc2..195fa3f2a 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx @@ -57,7 +57,7 @@ export function PermissionPrompt({ {row.value} ))} - + {options.map((option) => ( - + - + - + {question.options.map((option) => { const selected = draft.selected.includes(option.optionId); return ( diff --git a/apps/mobile/src/components/form/themed-host.android.tsx b/apps/mobile/src/components/form/themed-host.android.tsx index ac23ba737..def111e8a 100644 --- a/apps/mobile/src/components/form/themed-host.android.tsx +++ b/apps/mobile/src/components/form/themed-host.android.tsx @@ -3,7 +3,11 @@ import { useResolvedColorScheme } from '@mobile/components/form/compose-theme.an /** `Host` pinned to the APP theme. A bare Host follows the system scheme, which diverges from * the RN surfaces the moment the in-app appearance preference overrides it — dark text on a - * dark background. Android components must use this, never `Host` directly. */ + * dark background. Android components must use this, never `Host` directly. + * + * Sizing to content needs `matchContents={{ vertical: true }}`, never bare `matchContents`: the + * horizontal axis then measures Compose unbounded, so `fillMaxWidth()` no-ops and a `weight(1)` + * child collapses to zero width (labels wrapping one character per line, text vanishing). */ export function ThemedHost(props: React.ComponentProps): React.ReactNode { const colorScheme = useResolvedColorScheme(); return ; From ae4102793ed36b69fae520b88f68c9f49143053a Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 21 Aug 2026 20:12:28 +0800 Subject: [PATCH 19/37] fix(mobile): push the android add-host route with a real toolbar --- .../src/components/connect/add-host-screen.tsx | 6 ++++-- apps/mobile/src/components/shell/root-navigator.tsx | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/components/connect/add-host-screen.tsx b/apps/mobile/src/components/connect/add-host-screen.tsx index b666ad559..30881a3da 100644 --- a/apps/mobile/src/components/connect/add-host-screen.tsx +++ b/apps/mobile/src/components/connect/add-host-screen.tsx @@ -6,8 +6,10 @@ import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { Stack } from 'expo-router'; import { useTranslations } from 'use-intl'; -/** Android add-host form. The iOS header bar items (`unstable_header*Items`) don't exist on - * Android, so submit is an in-form button and dismissal is the sheet's own back/swipe. */ +/** Android add-host form, reached only by deep link — the connect screen opens the in-place + * bottom sheet instead. Pushed with the standard toolbar (Android's formSheet drops the header); + * the iOS header bar items (`unstable_header*Items`) don't exist here, so submit is an in-form + * button and dismissal is the toolbar back arrow. */ export function AddHostScreen(): React.ReactNode { const t = useTranslations('mobile.connect'); const colors = useAppMaterialColors(); diff --git a/apps/mobile/src/components/shell/root-navigator.tsx b/apps/mobile/src/components/shell/root-navigator.tsx index 2f5a79f98..70c6169ef 100644 --- a/apps/mobile/src/components/shell/root-navigator.tsx +++ b/apps/mobile/src/components/shell/root-navigator.tsx @@ -19,11 +19,15 @@ export function RootNavigator(): React.ReactNode { name="add-host" options={{ ...VISIBLE_HEADER_OPTIONS, - presentation: 'formSheet', - sheetAllowedDetents: [1], - sheetGrabberVisible: false, - headerBackVisible: false, title: '', + // iOS-only: Android's formSheet drops the native header (no title, no dismissal), + // so the deep-linked route pushes with the standard toolbar there instead. + ...(process.env.EXPO_OS === 'ios' && { + presentation: 'formSheet' as const, + sheetAllowedDetents: [1], + sheetGrabberVisible: false, + headerBackVisible: false, + }), }} /> From 5c3426ee9627f828a121c09989a9882de364bd61 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 21 Aug 2026 21:19:13 +0800 Subject: [PATCH 20/37] fix(mobile): give thread groups material chevrons and expand motion --- apps/mobile/assets/icons/expand-less.xml | 9 ++++ apps/mobile/assets/icons/expand-more.xml | 9 ++++ .../host/thread-list/thread-list.tsx | 44 ++++++++++++++----- 3 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/assets/icons/expand-less.xml create mode 100644 apps/mobile/assets/icons/expand-more.xml diff --git a/apps/mobile/assets/icons/expand-less.xml b/apps/mobile/assets/icons/expand-less.xml new file mode 100644 index 000000000..e08b770eb --- /dev/null +++ b/apps/mobile/assets/icons/expand-less.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/assets/icons/expand-more.xml b/apps/mobile/assets/icons/expand-more.xml new file mode 100644 index 000000000..93d859cfe --- /dev/null +++ b/apps/mobile/assets/icons/expand-more.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/src/components/host/thread-list/thread-list.tsx b/apps/mobile/src/components/host/thread-list/thread-list.tsx index e5afb53ce..54787cba3 100644 --- a/apps/mobile/src/components/host/thread-list/thread-list.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-list.tsx @@ -1,15 +1,29 @@ -import { LazyColumn, PullToRefreshBox, Row, Spacer, Text } from '@expo/ui/jetpack-compose'; +import { + AnimatedVisibility, + Column, + EnterTransition, + ExitTransition, + Icon, + LazyColumn, + PullToRefreshBox, + Row, + Spacer, + Text, +} from '@expo/ui/jetpack-compose'; import { clickable, fillMaxWidth, padding, weight } from '@expo/ui/jetpack-compose/modifiers'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { Fragment, useState } from 'react'; +import expandLessGlyph from '../../../../assets/icons/expand-less.xml'; +import expandMoreGlyph from '../../../../assets/icons/expand-more.xml'; import type { ThreadListProps } from './thread-list.types'; import { ThreadRow } from './thread-row'; import { useThreadListState } from './use-thread-list-state'; /** Android thread inbox body. Compose has no collapsible Section: each group renders a clickable - * MD3 subheader row and conditionally its rows. PullToRefreshBox takes a controlled flag instead - * of awaiting the promise, so the spinner state is adapted here. */ + * MD3 subheader row with an expand chevron, and its rows collapse through `AnimatedVisibility` + * (the M3 expand/shrink motion; rows stay mounted). PullToRefreshBox takes a controlled flag + * instead of awaiting the promise, so the spinner state is adapted here. */ export function ThreadList({ groups, labelFor, @@ -45,20 +59,30 @@ export function ThreadList({ {labelFor(group)} - - {expanded ? '▾' : '▸'} - + - {expanded - ? group.sessions.map((session) => ( + + + {group.sessions.map((session) => ( onOpenThread(session.sessionId)} /> - )) - : null} + ))} + + ); })} From ae659ec2cdbf46d0aa76dc4496b06b877ba735f3 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 24 Aug 2026 13:14:57 +0800 Subject: [PATCH 21/37] fix(mobile): center the android pull-to-refresh indicator --- .../mobile/src/components/host/thread-list/thread-list.tsx | 4 +++- apps/mobile/src/components/terminal/terminals-screen.tsx | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/host/thread-list/thread-list.tsx b/apps/mobile/src/components/host/thread-list/thread-list.tsx index 54787cba3..c70e4af19 100644 --- a/apps/mobile/src/components/host/thread-list/thread-list.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-list.tsx @@ -41,7 +41,9 @@ export function ThreadList({ return ( - + {/* expo-ui's indicator slot drops Compose's align(TopCenter); topCenter restores it, and the + * fillMaxWidth child is unaffected. */} + {groups.map((group) => { const expanded = !collapsed.has(group.key); diff --git a/apps/mobile/src/components/terminal/terminals-screen.tsx b/apps/mobile/src/components/terminal/terminals-screen.tsx index fedd7f2aa..a56a89acf 100644 --- a/apps/mobile/src/components/terminal/terminals-screen.tsx +++ b/apps/mobile/src/components/terminal/terminals-screen.tsx @@ -53,7 +53,12 @@ export function TerminalsScreen({ ) : ( - + {/* topCenter: see thread-list.tsx — restores the indicator alignment expo-ui drops. */} + {loadError ? ( {t('loadError', { error: loadError })} From 12ee98232884516e56fb43f6b91b717ec2e9d089 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 24 Aug 2026 14:10:21 +0800 Subject: [PATCH 22/37] feat(mobile): structure the android selector sheet by axis --- apps/mobile/assets/icons/check.xml | 9 + .../components/conversation/session-tools.tsx | 54 +++--- .../form/selector-sheet.android.tsx | 155 ++++++++++++++++++ .../components/form/selector-sheet.types.ts | 39 +++++ .../host/new-thread/draft-tools.tsx | 80 +++++---- 5 files changed, 266 insertions(+), 71 deletions(-) create mode 100644 apps/mobile/assets/icons/check.xml create mode 100644 apps/mobile/src/components/form/selector-sheet.android.tsx create mode 100644 apps/mobile/src/components/form/selector-sheet.types.ts diff --git a/apps/mobile/assets/icons/check.xml b/apps/mobile/assets/icons/check.xml new file mode 100644 index 000000000..935007004 --- /dev/null +++ b/apps/mobile/assets/icons/check.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/src/components/conversation/session-tools.tsx b/apps/mobile/src/components/conversation/session-tools.tsx index be45bcef3..f7eb2d807 100644 --- a/apps/mobile/src/components/conversation/session-tools.tsx +++ b/apps/mobile/src/components/conversation/session-tools.tsx @@ -1,11 +1,16 @@ -import { AgentIcon, groupModelsByProvider, modelChoiceKey } from '@linkcode/ui/native'; +import { AgentIcon, modelChoiceKey } from '@linkcode/ui/native'; import type { SessionApprovalChipProps, SessionSelectorChipProps, } from '@mobile/components/conversation/session-tools.types'; import { ToolChip } from '@mobile/components/conversation/tool-chip.android'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; -import type { SheetPickerSection } from '@mobile/components/form/sheet-picker.android'; +import { SelectorSheet } from '@mobile/components/form/selector-sheet.android'; +import type { + SelectorChipAxis, + SelectorModelAxis, +} from '@mobile/components/form/selector-sheet.types'; +import { modelAxisGroups } from '@mobile/components/form/selector-sheet.types'; import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; import { ShieldIcon } from 'lucide-react-native'; import { useState } from 'react'; @@ -57,8 +62,9 @@ export function SessionApprovalChip({ ); } -/** The live session's selector on Android: Model / Effort sections, no harness section (the agent - * is fixed) and no "Default" entries — a running session is always on a concrete value. */ +/** The live session's selector on Android: the structured `SelectorSheet` with Model / Effort + * axes, no harness axis (the agent is fixed) and no "Default" entries — a running session is + * always on a concrete value. Selections are server-reflected like the approval chip. */ export function SessionSelectorChip({ kind, selectorValue, @@ -72,36 +78,22 @@ export function SessionSelectorChip({ const t = useTranslations('mobile.sessions'); const colors = useAppMaterialColors(); const [open, setOpen] = useState(false); - const hasModels = models !== null && models.length > 0; - const hasEfforts = effortOptions !== undefined && effortOptions.length > 0; - if (!hasModels && !hasEfforts) return null; - - // The account label joins a row only when the list spans several accounts — the same threshold - // as the web's provider grouping; a single-account list repeating its account is noise. - const spansAccounts = models !== null && groupModelsByProvider(models) !== null; - const sections: SheetPickerSection[] = []; - if (hasModels) { - sections.push({ - id: 'model', + let modelAxis: SelectorModelAxis | undefined; + if (models !== null && models.length > 0) { + modelAxis = { title: t('modelLabel'), selection: currentModelKey, - options: models.map((model) => ({ - id: modelChoiceKey(model), - label: - spansAccounts && model.description - ? `${model.label} — ${model.description}` - : model.label, - })), + groups: modelAxisGroups(models), onSelect(key) { const option = models.find((model) => modelChoiceKey(model) === key); if (option) onModelChange(option); }, - }); + }; } - if (hasEfforts) { - sections.push({ - id: 'effort', + let effortAxis: SelectorChipAxis | undefined; + if (effortOptions !== undefined && effortOptions.length > 0) { + effortAxis = { title: t('effortLabel'), selection: currentEffort, options: effortOptions.map((option) => ({ id: option.id, label: option.label })), @@ -109,8 +101,9 @@ export function SessionSelectorChip({ const option = effortOptions.find((candidate) => candidate.id === value); if (option) onEffortChange(option.id); }, - }); + }; } + if (modelAxis === undefined && effortAxis === undefined) return null; return ( @@ -122,7 +115,12 @@ export function SessionSelectorChip({ maxValueWidth={150} onPress={() => setOpen(true)} /> - setOpen(false)} sections={sections} /> + setOpen(false)} + model={modelAxis} + effort={effortAxis} + /> ); } diff --git a/apps/mobile/src/components/form/selector-sheet.android.tsx b/apps/mobile/src/components/form/selector-sheet.android.tsx new file mode 100644 index 000000000..81f26403b --- /dev/null +++ b/apps/mobile/src/components/form/selector-sheet.android.tsx @@ -0,0 +1,155 @@ +import { + Column, + FilterChip, + FlowRow, + Icon, + ListItem, + ModalBottomSheet, + Text, +} from '@expo/ui/jetpack-compose'; +import { + animateContentSize, + clickable, + fillMaxWidth, + padding, + verticalScroll, +} from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import type { + SelectorChipAxis, + SelectorModelAxis, +} from '@mobile/components/form/selector-sheet.types'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import checkGlyph from '../../../assets/icons/check.xml'; + +function AxisTitle({ children }: { children: string }): React.ReactNode { + const colors = useAppMaterialColors(); + return ( + + {children} + + ); +} + +/** Single-select chip set for a compact axis (harness, effort). The M3 filter chip draws no + * checkmark of its own, so the selected chip supplies one as its leading icon. */ +function ChipAxisSection({ + axis, + onPicked, +}: { + axis: SelectorChipAxis; + /** Runs after a chip selection; the harness axis omits it to keep the sheet open. */ + onPicked?: () => void; +}): React.ReactNode { + return ( + + {axis.title} + + {axis.options.map((option) => { + const selected = axis.selection === option.id; + return ( + { + axis.onSelect(option.id); + onPicked?.(); + }} + modifiers={[animateContentSize()]} + > + {selected ? ( + + + + ) : null} + + {option.label} + + + ); + })} + + + ); +} + +/** The harness/model/effort picker on Android — the MD3 shape of the web `ModelSelectorMenu`: + * one modal bottom sheet with chip sets for the compact axes and a grouped list for models. + * Picking a harness keeps the sheet open (it re-scopes the axes below); picking a model or an + * effort is terminal and closes, matching the other platforms. */ +export function SelectorSheet({ + open, + onClose, + harness, + model, + effort, +}: { + open: boolean; + onClose: () => void; + /** Draft-only harness chips; absent on a live session where the agent is fixed. */ + harness?: SelectorChipAxis; + model?: SelectorModelAxis; + effort?: SelectorChipAxis; +}): React.ReactNode { + const colors = useAppMaterialColors(); + + if (!open) return null; + + return ( + + + + {harness === undefined ? null : } + {model === undefined ? null : ( + + {model.title} + {model.groups.map((group) => ( + + {group.label === null ? null : ( + + {group.label} + + )} + {group.options.map((option) => ( + { + model.onSelect(option.id); + onClose(); + }), + ]} + > + + {option.label} + + {model.selection === option.id ? ( + + + + ) : null} + + ))} + + ))} + + )} + {effort === undefined ? null : } + + + + ); +} diff --git a/apps/mobile/src/components/form/selector-sheet.types.ts b/apps/mobile/src/components/form/selector-sheet.types.ts new file mode 100644 index 000000000..52c1f999e --- /dev/null +++ b/apps/mobile/src/components/form/selector-sheet.types.ts @@ -0,0 +1,39 @@ +import type { ModelOption } from '@linkcode/ui/native'; +import { groupModelsByProvider, modelChoiceKey } from '@linkcode/ui/native'; + +export interface SelectorChipAxis { + title: string; + /** Option id drawn selected, or null for none. */ + selection: string | null; + options: Array<{ id: string; label: string }>; + onSelect: (id: string) => void; +} + +export interface SelectorModelGroup { + /** Provider header above the rows, or null for headerless rows. */ + label: string | null; + options: Array<{ id: string; label: string }>; +} + +export interface SelectorModelAxis { + title: string; + selection: string | null; + groups: SelectorModelGroup[]; + onSelect: (id: string) => void; +} + +/** Model rows for the sheet: flat for a single-account list, provider-headed groups when the list + * spans accounts — the headers replace the per-row account subtitle a flat list would need. */ +export function modelAxisGroups(models: ModelOption[]): SelectorModelGroup[] { + const toOption = (model: ModelOption) => ({ id: modelChoiceKey(model), label: model.label }); + const providerGroups = groupModelsByProvider(models); + if (providerGroups === null) return [{ label: null, options: models.map(toOption) }]; + const groups: SelectorModelGroup[] = []; + if (providerGroups.ungrouped.length > 0) { + groups.push({ label: null, options: providerGroups.ungrouped.map(toOption) }); + } + for (const group of providerGroups.groups) { + groups.push({ label: group.label, options: group.options.map(toOption) }); + } + return groups; +} diff --git a/apps/mobile/src/components/host/new-thread/draft-tools.tsx b/apps/mobile/src/components/host/new-thread/draft-tools.tsx index 314423deb..22521762d 100644 --- a/apps/mobile/src/components/host/new-thread/draft-tools.tsx +++ b/apps/mobile/src/components/host/new-thread/draft-tools.tsx @@ -1,13 +1,13 @@ import { AgentKindSchema } from '@linkcode/schema'; -import { - AGENT_LABELS, - AgentIcon, - groupModelsByProvider, - modelChoiceKey, -} from '@linkcode/ui/native'; +import { AGENT_LABELS, AgentIcon } from '@linkcode/ui/native'; import { ToolChip } from '@mobile/components/conversation/tool-chip.android'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; -import type { SheetPickerSection } from '@mobile/components/form/sheet-picker.android'; +import { SelectorSheet } from '@mobile/components/form/selector-sheet.android'; +import type { + SelectorChipAxis, + SelectorModelAxis, +} from '@mobile/components/form/selector-sheet.types'; +import { modelAxisGroups } from '@mobile/components/form/selector-sheet.types'; import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; import type { AgentSelectorChipProps, @@ -59,9 +59,9 @@ export function ApprovalChip({ ); } -/** The draft's combined selector on Android: the harness brand mark beside one chip opening a - * sheet with Agent / Model / Effort sections — the same groups the iOS UIMenu nests as submenus, - * flattened because bottom sheets don't nest. */ +/** The draft's combined selector on Android: the harness brand mark beside one chip opening the + * structured `SelectorSheet` — the same Agent / Model / Effort axes the iOS UIMenu nests as + * submenus. A "Default" entry leads the model and effort axes; picking it clears the pick. */ export function AgentSelectorChip({ kind, onKindChange, @@ -76,46 +76,34 @@ export function AgentSelectorChip({ const t = useTranslations('mobile.sessions'); const colors = useAppMaterialColors(); const [open, setOpen] = useState(false); - // The account label joins a row only when the list spans several accounts — the same threshold - // as the web's provider grouping; a single-account list repeating its account is noise. - const spansAccounts = models !== null && groupModelsByProvider(models) !== null; - const sections: SheetPickerSection[] = [ - { - id: 'kind', - title: t('kindLabel'), - selection: kind, - options: AgentKindSchema.options.map((option) => ({ - id: option, - label: AGENT_LABELS[option], - })), - onSelect(id) { - const parsed = AgentKindSchema.safeParse(id); - if (parsed.success) onKindChange(parsed.data); - }, + const harnessAxis: SelectorChipAxis = { + title: t('kindLabel'), + selection: kind, + options: AgentKindSchema.options.map((option) => ({ + id: option, + label: AGENT_LABELS[option], + })), + onSelect(id) { + const parsed = AgentKindSchema.safeParse(id); + if (parsed.success) onKindChange(parsed.data); }, - ]; + }; + let modelAxis: SelectorModelAxis | undefined; if (models !== null && models.length > 0) { - sections.push({ - id: 'model', + modelAxis = { title: t('modelLabel'), selection: modelKey ?? DEFAULT_TAG, - options: [ - { id: DEFAULT_TAG, label: t('defaultOption') }, - ...models.map((model) => ({ - id: modelChoiceKey(model), - label: - spansAccounts && model.description - ? `${model.label} — ${model.description}` - : model.label, - })), + groups: [ + { label: null, options: [{ id: DEFAULT_TAG, label: t('defaultOption') }] }, + ...modelAxisGroups(models), ], onSelect: clearable(onModelKeyChange), - }); + }; } + let effortAxis: SelectorChipAxis | undefined; if (effortOptions !== undefined && effortOptions.length > 0) { - sections.push({ - id: 'effort', + effortAxis = { title: t('effortLabel'), selection: effort ?? DEFAULT_TAG, options: [ @@ -123,7 +111,7 @@ export function AgentSelectorChip({ ...effortOptions.map((option) => ({ id: option.id, label: option.label })), ], onSelect: clearable(onEffortChange), - }); + }; } return ( @@ -136,7 +124,13 @@ export function AgentSelectorChip({ maxValueWidth={150} onPress={() => setOpen(true)} /> - setOpen(false)} sections={sections} /> + setOpen(false)} + harness={harnessAxis} + model={modelAxis} + effort={effortAxis} + /> ); } From 26257bbd50e54d9e9995266524711254b6bd92ed Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 24 Aug 2026 16:04:45 +0800 Subject: [PATCH 23/37] fix(mobile): reuse the refresh indicator for load and connect states --- .../components/account/account-screen.ios.tsx | 43 +++++++------ .../src/components/account/account-screen.tsx | 44 ++++++------- .../account/devices-section.ios.tsx | 14 +---- .../connect/discovered-hosts-section.tsx | 6 +- .../connect/my-machines-section.ios.tsx | 5 +- .../components/form/loading-view.android.tsx | 16 +++++ .../src/components/form/loading-view.ios.tsx | 30 +++++++++ .../src/components/form/rows.android.tsx | 16 +++-- .../host/host-connection-state.ios.tsx | 54 +++++++--------- .../components/host/host-connection-state.tsx | 63 ++++++++----------- .../components/host/threads-screen.ios.tsx | 16 +---- .../src/components/host/threads-screen.tsx | 7 +-- .../terminal/terminals-screen.ios.tsx | 13 +--- .../components/terminal/terminals-screen.tsx | 7 +-- .../components/theme/native-palette.ios.ts | 1 + .../src/components/theme/native-palette.ts | 1 + .../components/theme/native-palette.types.ts | 2 + packages/presentation/i18n/src/locales/en.ts | 1 - .../presentation/i18n/src/locales/zh-cn.ts | 1 - 19 files changed, 173 insertions(+), 167 deletions(-) create mode 100644 apps/mobile/src/components/form/loading-view.android.tsx create mode 100644 apps/mobile/src/components/form/loading-view.ios.tsx diff --git a/apps/mobile/src/components/account/account-screen.ios.tsx b/apps/mobile/src/components/account/account-screen.ios.tsx index 2f71a80f0..646bec370 100644 --- a/apps/mobile/src/components/account/account-screen.ios.tsx +++ b/apps/mobile/src/components/account/account-screen.ios.tsx @@ -1,6 +1,7 @@ -import { Button, Form, Host, ProgressView, Section } from '@expo/ui/swift-ui'; +import { Button, Form, Host, Section } from '@expo/ui/swift-ui'; import { DevicesSection } from '@mobile/components/account/devices-section'; import { ProfileRow } from '@mobile/components/account/profile-row'; +import { LoadingView } from '@mobile/components/form/loading-view.ios'; import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; import { useTranslations } from 'use-intl'; @@ -13,27 +14,25 @@ export function AccountScreen(): React.ReactNode { return ( // Form needs the viewport as its proposed size, otherwise it collapses to its content. -
- {account.status !== 'signed-in' ? ( - - ) : ( - <> -
- -
- -
-
- - )} - + {account.status !== 'signed-in' ? ( + + ) : ( +
+
+ +
+ +
+
+ + )}
); } diff --git a/apps/mobile/src/components/account/account-screen.tsx b/apps/mobile/src/components/account/account-screen.tsx index 1f396f002..2eaf82df8 100644 --- a/apps/mobile/src/components/account/account-screen.tsx +++ b/apps/mobile/src/components/account/account-screen.tsx @@ -4,7 +4,7 @@ import { DevicesSection } from '@mobile/components/account/devices-section'; import { ProfileRow } from '@mobile/components/account/profile-row'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { FormList } from '@mobile/components/form/list.android'; -import { FormLoadingRow } from '@mobile/components/form/rows.android'; +import { LoadingView } from '@mobile/components/form/loading-view.android'; import { FormSection } from '@mobile/components/form/section.android'; import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; import { useTranslations } from 'use-intl'; @@ -16,31 +16,27 @@ export function AccountScreen(): React.ReactNode { const colors = useAppMaterialColors(); const account = useCloudAccount(); + if (account.status !== 'signed-in') return ; + return ( - {account.status !== 'signed-in' ? ( - - ) : ( - <> - - - - - - { - void signOutOfCloud(); - }), - ]} - > - - {t('signOut')} - - - - - )} + + + + + + { + void signOutOfCloud(); + }), + ]} + > + + {t('signOut')} + + + ); } diff --git a/apps/mobile/src/components/account/devices-section.ios.tsx b/apps/mobile/src/components/account/devices-section.ios.tsx index 8607119fc..cb4fcc4f0 100644 --- a/apps/mobile/src/components/account/devices-section.ios.tsx +++ b/apps/mobile/src/components/account/devices-section.ios.tsx @@ -1,15 +1,7 @@ -import { - Button, - HStack, - ProgressView, - Section, - Spacer, - SwipeActions, - Text, - VStack, -} from '@expo/ui/swift-ui'; +import { Button, HStack, Section, Spacer, SwipeActions, Text, VStack } from '@expo/ui/swift-ui'; import { badge, buttonStyle, disabled, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; import { useDevicesSection } from '@mobile/components/account/use-devices-section'; +import { FormLoadingRow } from '@mobile/components/form/loading-view.ios'; import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; import { useTranslations } from 'use-intl'; @@ -37,7 +29,7 @@ export function DevicesSection(): React.ReactNode { {devicesError ? ( {t('devicesError')} ) : devices === null ? ( - + ) : devices.length === 0 ? ( {t('devicesEmpty')} ) : ( diff --git a/apps/mobile/src/components/connect/discovered-hosts-section.tsx b/apps/mobile/src/components/connect/discovered-hosts-section.tsx index 7893c4ece..48176a188 100644 --- a/apps/mobile/src/components/connect/discovered-hosts-section.tsx +++ b/apps/mobile/src/components/connect/discovered-hosts-section.tsx @@ -1,4 +1,4 @@ -import { CircularProgressIndicator } from '@expo/ui/jetpack-compose'; +import { LoadingIndicator } from '@expo/ui/jetpack-compose'; import { size } from '@expo/ui/jetpack-compose/modifiers'; import { ManualHostRow } from '@mobile/components/connect/manual-host-row'; import { useDiscoveredHosts } from '@mobile/components/connect/use-discovered-hosts'; @@ -14,9 +14,7 @@ export function DiscoveredHostsSection(): React.ReactNode { return ( - } + trailing={status === 'error' ? undefined : } > {status === 'error' ? ( {t('discovery.error')} diff --git a/apps/mobile/src/components/connect/my-machines-section.ios.tsx b/apps/mobile/src/components/connect/my-machines-section.ios.tsx index e6e3a6571..45abf5e98 100644 --- a/apps/mobile/src/components/connect/my-machines-section.ios.tsx +++ b/apps/mobile/src/components/connect/my-machines-section.ios.tsx @@ -1,6 +1,7 @@ -import { Button, HStack, ProgressView, Section, Spacer, Text } from '@expo/ui/swift-ui'; +import { Button, HStack, Section, Spacer, Text } from '@expo/ui/swift-ui'; import { buttonStyle, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; import { useMyMachines } from '@mobile/components/connect/use-my-machines'; +import { FormLoadingRow } from '@mobile/components/form/loading-view.ios'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; import { useTranslations } from 'use-intl'; @@ -29,7 +30,7 @@ export function MyMachinesSection({ userId }: { userId: string }): React.ReactNo {hostsError ? ( {t('error')} ) : onlineHosts === null ? ( - + ) : onlineHosts.length === 0 ? ( {t('empty')} ) : ( diff --git a/apps/mobile/src/components/form/loading-view.android.tsx b/apps/mobile/src/components/form/loading-view.android.tsx new file mode 100644 index 000000000..f00215ccb --- /dev/null +++ b/apps/mobile/src/components/form/loading-view.android.tsx @@ -0,0 +1,16 @@ +import { Box, PullToRefreshBox } from '@expo/ui/jetpack-compose'; +import { fillMaxSize } from '@expo/ui/jetpack-compose/modifiers'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; + +/** Full-screen load state: the real pull-to-refresh box held in its refreshing state, so first + * load shows exactly the indicator a drag refresh shows, in the same place. + * topCenter: expo-ui's indicator slot drops Compose's align (see thread-list.tsx). */ +export function LoadingView(): React.ReactNode { + return ( + + + + + + ); +} diff --git a/apps/mobile/src/components/form/loading-view.ios.tsx b/apps/mobile/src/components/form/loading-view.ios.tsx new file mode 100644 index 000000000..b253706f2 --- /dev/null +++ b/apps/mobile/src/components/form/loading-view.ios.tsx @@ -0,0 +1,30 @@ +import { ProgressView } from '@expo/ui/swift-ui'; +import { background, frame, padding, scaleEffect } from '@expo/ui/swift-ui/modifiers'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; + +/** Full-screen load state: the refresh spinner held where a drag refresh shows it — top-center + * just below the header — mirroring the Android `PullToRefreshBox` position. The scale matches + * the refresh control's spinner (larger than a bare ProgressView), and the grouped background + * matches the Form/List screens this state stands in for. */ +export function LoadingView(): React.ReactNode { + const palette = useNativePalette(); + return ( + + ); +} + +/** In-section loading row: the same spinner centered in its form cell. */ +export function FormLoadingRow(): React.ReactNode { + return ; +} diff --git a/apps/mobile/src/components/form/rows.android.tsx b/apps/mobile/src/components/form/rows.android.tsx index 6b60471a8..0ba978fcd 100644 --- a/apps/mobile/src/components/form/rows.android.tsx +++ b/apps/mobile/src/components/form/rows.android.tsx @@ -1,5 +1,5 @@ -import { CircularProgressIndicator, ListItem, Row, Switch, Text } from '@expo/ui/jetpack-compose'; -import { padding, toggleable } from '@expo/ui/jetpack-compose/modifiers'; +import { ContainedLoadingIndicator, ListItem, Row, Switch, Text } from '@expo/ui/jetpack-compose'; +import { fillMaxWidth, padding, toggleable } from '@expo/ui/jetpack-compose/modifiers'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; /** Quiet in-section message row: empty states, hints, and errors. */ @@ -23,11 +23,17 @@ export function FormHint({ ); } -/** Centered in-section loading row, the `ProgressView` stand-in. */ +/** Centered in-section loading row, the `ProgressView` stand-in. Without `fillMaxWidth` the Row + * wraps its content and the "centered" indicator sits at the start edge. The indicator carries + * the refresh indicator's colors (`PullToRefreshDefaults`) so every load state reads as one. */ export function FormLoadingRow(): React.ReactNode { + const colors = useAppMaterialColors(); return ( - - + + ); } diff --git a/apps/mobile/src/components/host/host-connection-state.ios.tsx b/apps/mobile/src/components/host/host-connection-state.ios.tsx index c5a935d11..039443a94 100644 --- a/apps/mobile/src/components/host/host-connection-state.ios.tsx +++ b/apps/mobile/src/components/host/host-connection-state.ios.tsx @@ -1,10 +1,11 @@ -import { Button, Host, Image, ProgressView, Text, VStack } from '@expo/ui/swift-ui'; +import { Button, Host, Image, Text, VStack } from '@expo/ui/swift-ui'; import { buttonStyle, font, multilineTextAlignment, textSelection, } from '@expo/ui/swift-ui/modifiers'; +import { LoadingView } from '@mobile/components/form/loading-view.ios'; import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; import type { HostConnectionStateProps } from '@mobile/components/host/host-connection-state.types'; import { useTranslations } from 'use-intl'; @@ -23,35 +24,28 @@ export function HostConnectionState({ return ( - - {status === 'connecting' ? ( - <> - - {t('connecting')} - - ) : ( - <> - - - {t('unavailableTitle')} - - {t('error', { url })} - - - - - {failure ? ( - - {failure} - - ) : null} - - )} + + + + {t('unavailableTitle')} + + + {t('error', { url })} + + + + + + {failure ? ( + + {failure} + + ) : null}
); } diff --git a/apps/mobile/src/components/host/threads-screen.ios.tsx b/apps/mobile/src/components/host/threads-screen.ios.tsx index b43528f9c..b7042eee2 100644 --- a/apps/mobile/src/components/host/threads-screen.ios.tsx +++ b/apps/mobile/src/components/host/threads-screen.ios.tsx @@ -1,11 +1,5 @@ -import { - Form, - Host, - ProgressView, - Section, - Button as UIButton, - Text as UIText, -} from '@expo/ui/swift-ui'; +import { Form, Host, Section, Button as UIButton, Text as UIText } from '@expo/ui/swift-ui'; +import { LoadingView } from '@mobile/components/form/loading-view.ios'; import { SECONDARY } from '@mobile/components/form/styles.ios'; import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; import { useThreadInbox } from '@mobile/components/host/use-thread-inbox'; @@ -55,11 +49,7 @@ export function ThreadsScreen({ {/* The list needs the viewport as its proposed size, otherwise SwiftUI collapses it. */} {loading ? ( -
-
- -
-
+ ) : groups.length === 0 ? ( // A query that matched nothing is not an empty inbox: saying "no threads yet" there // reads as though the existing threads were lost, and offering to start one is no diff --git a/apps/mobile/src/components/host/threads-screen.tsx b/apps/mobile/src/components/host/threads-screen.tsx index ddab66522..f20d3491f 100644 --- a/apps/mobile/src/components/host/threads-screen.tsx +++ b/apps/mobile/src/components/host/threads-screen.tsx @@ -1,7 +1,8 @@ import { Button, Text } from '@expo/ui/jetpack-compose'; import { padding } from '@expo/ui/jetpack-compose/modifiers'; import { FormList } from '@mobile/components/form/list.android'; -import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; +import { LoadingView } from '@mobile/components/form/loading-view.android'; +import { FormHint } from '@mobile/components/form/rows.android'; import { FormSection } from '@mobile/components/form/section.android'; import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; import { useThreadInbox } from '@mobile/components/host/use-thread-inbox'; @@ -41,9 +42,7 @@ export function ThreadsScreen({ return ( <> {loading ? ( - - - + ) : groups.length === 0 ? ( // A query that matched nothing is not an empty inbox: saying "no threads yet" there // reads as though the existing threads were lost, and offering to start one is no diff --git a/apps/mobile/src/components/terminal/terminals-screen.ios.tsx b/apps/mobile/src/components/terminal/terminals-screen.ios.tsx index b58598537..2633302bb 100644 --- a/apps/mobile/src/components/terminal/terminals-screen.ios.tsx +++ b/apps/mobile/src/components/terminal/terminals-screen.ios.tsx @@ -1,14 +1,7 @@ -import { - Button, - ContentUnavailableView, - Form, - Host, - ProgressView, - Section, - Text, -} from '@expo/ui/swift-ui'; +import { Button, ContentUnavailableView, Form, Host, Section, Text } from '@expo/ui/swift-ui'; import { foregroundStyle, refreshable } from '@expo/ui/swift-ui/modifiers'; import { repositoryLabel } from '@linkcode/ui/native'; +import { LoadingView } from '@mobile/components/form/loading-view.ios'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; import { useTerminalInbox } from '@mobile/components/terminal/use-terminal-inbox'; @@ -45,7 +38,7 @@ export function TerminalsScreen({ {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} {loading ? ( - + ) : loadError && terminals.length === 0 ? (
diff --git a/apps/mobile/src/components/terminal/terminals-screen.tsx b/apps/mobile/src/components/terminal/terminals-screen.tsx index a56a89acf..84ad6f6e6 100644 --- a/apps/mobile/src/components/terminal/terminals-screen.tsx +++ b/apps/mobile/src/components/terminal/terminals-screen.tsx @@ -2,8 +2,9 @@ import { Button, LazyColumn, PullToRefreshBox, Text } from '@expo/ui/jetpack-com import { fillMaxWidth, padding } from '@expo/ui/jetpack-compose/modifiers'; import { repositoryLabel } from '@linkcode/ui/native'; import { FormList } from '@mobile/components/form/list.android'; +import { LoadingView } from '@mobile/components/form/loading-view.android'; import { NavigationRow } from '@mobile/components/form/navigation-row'; -import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; +import { FormHint } from '@mobile/components/form/rows.android'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; import { useTerminalInbox } from '@mobile/components/terminal/use-terminal-inbox'; @@ -41,9 +42,7 @@ export function TerminalsScreen({ return ( <> {loading ? ( - - - + ) : loadError && terminals.length === 0 ? ( {t('loadError', { error: loadError })} diff --git a/apps/mobile/src/components/theme/native-palette.ios.ts b/apps/mobile/src/components/theme/native-palette.ios.ts index 2f9fdb505..857577af8 100644 --- a/apps/mobile/src/components/theme/native-palette.ios.ts +++ b/apps/mobile/src/components/theme/native-palette.ios.ts @@ -6,6 +6,7 @@ import { Color } from 'expo-router'; * controls, which never invert their glyph. */ const IOS_PALETTE: NativePalette = { background: Color.ios.systemBackground, + groupedBackground: Color.ios.systemGroupedBackground, surface: Color.ios.secondarySystemBackground, text: Color.ios.label, textSecondary: Color.ios.secondaryLabel, diff --git a/apps/mobile/src/components/theme/native-palette.ts b/apps/mobile/src/components/theme/native-palette.ts index b290fcfe3..6ee54d4df 100644 --- a/apps/mobile/src/components/theme/native-palette.ts +++ b/apps/mobile/src/components/theme/native-palette.ts @@ -8,6 +8,7 @@ export function useNativePalette(): NativePalette { return { background: colors.surface, + groupedBackground: colors.surface, surface: colors.surfaceContainerHigh, text: colors.onSurface, textSecondary: colors.onSurfaceVariant, diff --git a/apps/mobile/src/components/theme/native-palette.types.ts b/apps/mobile/src/components/theme/native-palette.types.ts index 3cf439258..d9595b3e1 100644 --- a/apps/mobile/src/components/theme/native-palette.types.ts +++ b/apps/mobile/src/components/theme/native-palette.types.ts @@ -6,6 +6,8 @@ import type { ColorValue } from 'react-native'; export interface NativePalette { /** Screens and navigation chrome. */ background: ColorValue; + /** Grouped list/form screens, and the load states that stand in for them. */ + groupedBackground: ColorValue; /** Raised cards: composer, bubbles, prompt cards. */ surface: ColorValue; text: ColorValue; diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 7ca194b4b..0e36a93bf 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1419,7 +1419,6 @@ export const en = { cancel: 'Cancel', }, connection: { - connecting: 'Connecting to the host…', unavailableTitle: 'Host unavailable', error: 'Unable to reach the host at {url}.', retry: 'Retry', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index badd487a0..8c952adb5 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1375,7 +1375,6 @@ export const zhCN = { cancel: '取消', }, connection: { - connecting: '正在连接 host…', unavailableTitle: '无法连接 host', error: '无法连接 host({url})。', retry: '重试', From 071511c78ac4ea471ec06dc642f59d9f5e8a34da Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 24 Aug 2026 21:44:22 +0800 Subject: [PATCH 24/37] feat(mobile): lead thread rows with the harness brand mark --- apps/mobile/AGENTS.md | 8 ++-- .../mobile/assets/icons/agent-claude-code.xml | 10 +++++ apps/mobile/assets/icons/agent-codex.xml | 10 +++++ apps/mobile/assets/icons/agent-opencode.xml | 10 +++++ .../host/thread-list/thread-row.ios.tsx | 27 ++++++++---- .../host/thread-list/thread-row.tsx | 41 +++++++++++++++---- 6 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/assets/icons/agent-claude-code.xml create mode 100644 apps/mobile/assets/icons/agent-codex.xml create mode 100644 apps/mobile/assets/icons/agent-opencode.xml diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 8a79f8a73..4a38dc60b 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -83,10 +83,12 @@ over: **sign-in** (`AppleAuthenticationButton` is an RN view and `@expo/ui` has Apple), the **conversation surface** — timeline, composer, and the screen holding them (excluded by the redesign decision; the composer also rides `react-native-keyboard-controller`), the **terminal canvas** (`expo-libghostty`), the **startup splash** (`BrandMark` is a bundled RN image), and the -**navigation header** (react-navigation). Two smaller losses are accepted rather than worked +**navigation header** (react-navigation). One smaller loss is accepted rather than worked around: `Image` takes SF Symbols, asset-catalog names, and local file URIs but **never a remote -URL**, so the account avatar is an SF Symbol; and the agent brand marks are RN SVG components, so -thread rows and the new-thread picker name the agent in text instead. +URL**, so the account avatar is an SF Symbol. The agent brand marks (RN SVG) do cross into rows +now: the iOS thread row hosts `AgentIcon` through `RNHostView` (`matchContents`, pointer events +off), and the Android row uses vector-drawable copies of the lobe glyphs in `assets/icons/` — +but the new-thread picker and selector sheets still name the agent in text. ## Styling & version pins diff --git a/apps/mobile/assets/icons/agent-claude-code.xml b/apps/mobile/assets/icons/agent-claude-code.xml new file mode 100644 index 000000000..ebe5d9b60 --- /dev/null +++ b/apps/mobile/assets/icons/agent-claude-code.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/mobile/assets/icons/agent-codex.xml b/apps/mobile/assets/icons/agent-codex.xml new file mode 100644 index 000000000..7ad436a4b --- /dev/null +++ b/apps/mobile/assets/icons/agent-codex.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/mobile/assets/icons/agent-opencode.xml b/apps/mobile/assets/icons/agent-opencode.xml new file mode 100644 index 000000000..6d807e4e0 --- /dev/null +++ b/apps/mobile/assets/icons/agent-opencode.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx b/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx index 0f5b0f441..a018eec99 100644 --- a/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx @@ -1,4 +1,4 @@ -import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { HStack, Image, RNHostView, Spacer, Text, VStack } from '@expo/ui/swift-ui'; import { contentShape, foregroundStyle, @@ -7,9 +7,11 @@ import { shapes, } from '@expo/ui/swift-ui/modifiers'; import type { SessionStatus } from '@linkcode/schema'; -import { AGENT_LABELS } from '@linkcode/ui/native'; +import { AgentIcon } from '@linkcode/ui/native'; import { FOOTNOTE, SECONDARY, TERTIARY } from '@mobile/components/form/styles.ios'; +import { useNativePalette } from '@mobile/components/theme/native-palette'; import { formatRelativeShort } from '@mobile/utils/relative-time'; +import { View } from 'react-native'; import type { ThreadRowProps } from './thread-row.types'; import { threadTitle } from './thread-title'; @@ -23,22 +25,29 @@ const STATUS_COLOR = { } as const satisfies Record; const WHOLE_ROW = contentShape(shapes.rectangle()); +const GLYPH_SIZE = 18; -/** One thread row: title (desktop-matching fallback), which agent is driving it and how long ago it +/** One thread row: the harness brand mark (the web sidebar's ghost glyph), title, how long ago it * moved, then a status dot and the chevron `NavigationLink` would have drawn. * - * The subtitle names the agent rather than the project because the list is already grouped by - * project — repeating it there would spend the line on something the section header already says. - * The agent is text, not a glyph: the brand marks are RN SVG components and cannot cross into - * SwiftUI. */ + * The brand mark is the RN `AgentIcon`, carried into the SwiftUI row through `RNHostView` — + * `matchContents` so the row takes the mark's Yoga-computed 18pt box, `pointerEvents="none"` so + * the hosted surface never swallows a tap meant for the row's own gesture. */ export function ThreadRow({ session, now, onPress }: ThreadRowProps): React.ReactNode { - const subtitle = `${AGENT_LABELS[session.kind]} · ${formatRelativeShort(session.updatedAt, now)}`; + const palette = useNativePalette(); return ( + + + + + {threadTitle(session)} - {subtitle} + + {formatRelativeShort(session.updatedAt, now)} + > = { + 'claude-code': agentClaudeCodeGlyph, + codex: agentCodexGlyph, + opencode: agentOpencodeGlyph, +}; + +const GLYPH_SIZE = 18; + +/** Android thread row: MD3 ListItem with the harness brand mark leading (the web sidebar's ghost + * glyph), the status dot drawn as a clipped Box, and no disclosure chevron, per MD3. The dot + * speaks Material roles — MD3 has no success/warning — running reads as the active accent, + * waiting states as tertiary. */ export function ThreadRow({ session, now, onPress }: ThreadRowProps): React.ReactNode { const colors = useAppMaterialColors(); const statusColor = { @@ -19,10 +33,21 @@ export function ThreadRow({ session, now, onPress }: ThreadRowProps): React.Reac 'awaiting-input': colors.tertiary, stopped: colors.outline, } satisfies Record; - const subtitle = `${AGENT_LABELS[session.kind]} · ${formatRelativeShort(session.updatedAt, now)}`; + const glyph = AGENT_GLYPHS[session.kind]; return ( + + {glyph === undefined ? ( + + + {AGENT_INITIALS[session.kind]} + + + ) : ( + + )} + {threadTitle(session)} @@ -30,7 +55,7 @@ export function ThreadRow({ session, now, onPress }: ThreadRowProps): React.Reac - {subtitle} + {formatRelativeShort(session.updatedAt, now)} From 154a7dc0c754102a98546e34e1f4be8d7299ed06 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 7 Sep 2026 16:25:50 +0800 Subject: [PATCH 25/37] fix(mobile): keep in-app account deletion after the android split --- .../components/account/account-screen.ios.tsx | 5 +- .../src/components/account/account-screen.tsx | 5 +- .../account/delete-account-section.ios.tsx | 25 +++++ .../account/delete-account-section.tsx | 102 ++++-------------- .../components/account/use-delete-account.ts | 80 ++++++++++++++ .../components/settings/settings-screen.tsx | 3 +- 6 files changed, 135 insertions(+), 85 deletions(-) create mode 100644 apps/mobile/src/components/account/delete-account-section.ios.tsx create mode 100644 apps/mobile/src/components/account/use-delete-account.ts diff --git a/apps/mobile/src/components/account/account-screen.ios.tsx b/apps/mobile/src/components/account/account-screen.ios.tsx index 646bec370..eb972544d 100644 --- a/apps/mobile/src/components/account/account-screen.ios.tsx +++ b/apps/mobile/src/components/account/account-screen.ios.tsx @@ -1,8 +1,10 @@ import { Button, Form, Host, Section } from '@expo/ui/swift-ui'; +import { DeleteAccountSection } from '@mobile/components/account/delete-account-section'; import { DevicesSection } from '@mobile/components/account/devices-section'; import { ProfileRow } from '@mobile/components/account/profile-row'; import { LoadingView } from '@mobile/components/form/loading-view.ios'; import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; +import { Alert } from 'react-native'; import { useTranslations } from 'use-intl'; /** Account body: profile, the account's device registry, and sign-out. The route shell owns the @@ -27,10 +29,11 @@ export function AccountScreen(): React.ReactNode { role="destructive" label={t('signOut')} onPress={() => { - void signOutOfCloud(); + void signOutOfCloud().catch(() => Alert.alert(t('signOutError'))); }} />
+ )}
diff --git a/apps/mobile/src/components/account/account-screen.tsx b/apps/mobile/src/components/account/account-screen.tsx index 2eaf82df8..244a15381 100644 --- a/apps/mobile/src/components/account/account-screen.tsx +++ b/apps/mobile/src/components/account/account-screen.tsx @@ -1,5 +1,6 @@ import { ListItem, Text } from '@expo/ui/jetpack-compose'; import { clickable } from '@expo/ui/jetpack-compose/modifiers'; +import { DeleteAccountSection } from '@mobile/components/account/delete-account-section'; import { DevicesSection } from '@mobile/components/account/devices-section'; import { ProfileRow } from '@mobile/components/account/profile-row'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; @@ -7,6 +8,7 @@ import { FormList } from '@mobile/components/form/list.android'; import { LoadingView } from '@mobile/components/form/loading-view.android'; import { FormSection } from '@mobile/components/form/section.android'; import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; +import { Alert } from 'react-native'; import { useTranslations } from 'use-intl'; /** Android account body, mirroring `account-screen.ios.tsx`. Sign-out is a plain error-colored @@ -28,7 +30,7 @@ export function AccountScreen(): React.ReactNode { { - void signOutOfCloud(); + void signOutOfCloud().catch(() => Alert.alert(t('signOutError'))); }), ]} > @@ -37,6 +39,7 @@ export function AccountScreen(): React.ReactNode { + ); } diff --git a/apps/mobile/src/components/account/delete-account-section.ios.tsx b/apps/mobile/src/components/account/delete-account-section.ios.tsx new file mode 100644 index 000000000..869040b9d --- /dev/null +++ b/apps/mobile/src/components/account/delete-account-section.ios.tsx @@ -0,0 +1,25 @@ +import { Button, Section } from '@expo/ui/swift-ui'; +import { disabled } from '@expo/ui/swift-ui/modifiers'; +import { useDeleteAccount } from '@mobile/components/account/use-delete-account'; +import { useTranslations } from 'use-intl'; + +/** + * Permanent, in-app account deletion (App Store Guideline 5.1.1(v)). Its own + * Section, below Sign out, `Button role="destructive"` — not hidden behind + * any secondary menu, matching `DevicesSection`'s destructive-row precedent. + */ +export function DeleteAccountSection(): React.ReactNode { + const t = useTranslations('mobile.account'); + const { busy, confirmDelete } = useDeleteAccount(); + + return ( +
+
+ ); +} diff --git a/apps/mobile/src/components/account/delete-account-section.tsx b/apps/mobile/src/components/account/delete-account-section.tsx index aee324d1f..5f204e155 100644 --- a/apps/mobile/src/components/account/delete-account-section.tsx +++ b/apps/mobile/src/components/account/delete-account-section.tsx @@ -1,89 +1,29 @@ -import { Button, Section } from '@expo/ui/swift-ui'; -import { disabled } from '@expo/ui/swift-ui/modifiers'; -import { deleteAccount, runAccountDeletionTeardown } from '@mobile/runtime/cloud/deletion'; -import { useState } from 'react'; -import { Alert } from 'react-native'; +import { ListItem, Text } from '@expo/ui/jetpack-compose'; +import { clickable } from '@expo/ui/jetpack-compose/modifiers'; +import { useDeleteAccount } from '@mobile/components/account/use-delete-account'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { FormSection } from '@mobile/components/form/section.android'; import { useTranslations } from 'use-intl'; -/** - * Permanent, in-app account deletion (App Store Guideline 5.1.1(v)). Its own - * Section, below Sign out, `Button role="destructive"` — not hidden behind - * any secondary menu, matching `DevicesSection`'s destructive-row precedent. - */ +/** Android delete row. Compose has no button `role`, so the label uses the error color. */ export function DeleteAccountSection(): React.ReactNode { const t = useTranslations('mobile.account'); - const [busy, setBusy] = useState(false); - - const failureMessage = (code: string | undefined): string => { - if (code === 'ACCOUNT_DELETION_EMERGENCY_AUDIT_HOLD') return t('deleteEmergencyHold'); - if (code === 'ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER') return t('deleteSoleOwner'); - return t('deleteFailed'); - }; - - const run = async () => { - setBusy(true); - try { - const outcome = await deleteAccount(); - if (outcome.kind === 'reauthentication-failed') { - Alert.alert(t('deleteReauthenticationFailed')); - return; - } - if (outcome.kind === 'apple-device-required') { - Alert.alert(t('deleteAppleDeviceRequired')); - return; - } - if (outcome.kind === 'account-mismatch') { - Alert.alert(t('deleteAccountMismatch')); - return; - } - if (outcome.kind === 'unknown') { - Alert.alert(t('deleteUnknown')); - return; - } - if (outcome.kind === 'failed') { - Alert.alert(failureMessage(outcome.code)); - return; - } - - // Both remaining outcomes (`pending` and `completed`) mean the server - // already accepted the deletion — local teardown runs regardless. - await runAccountDeletionTeardown(); - if (outcome.kind === 'pending') { - Alert.alert(t('deletePending')); - return; - } - // A failed revocation still leaves deletion successful and needs manual Apple follow-up. - if (outcome.authorizationRevocation === 'failed') { - Alert.alert(t('deleteRevocationFailedTitle'), t('deleteRevocationFailedMessage')); - } else { - Alert.alert(t('deleteCompleted')); - } - } finally { - setBusy(false); - } - }; - - const confirmDelete = () => { - Alert.alert(t('deleteTitle'), t('deleteMessage'), [ - { text: t('deleteCancel'), style: 'cancel' }, - { - text: t('deleteConfirm'), - style: 'destructive', - onPress() { - void run(); - }, - }, - ]); - }; + const colors = useAppMaterialColors(); + const { busy, confirmDelete } = useDeleteAccount(); return ( -
-
+ + { + if (!busy) confirmDelete(); + }), + ]} + > + + {t('deleteAccount')} + + + ); } diff --git a/apps/mobile/src/components/account/use-delete-account.ts b/apps/mobile/src/components/account/use-delete-account.ts new file mode 100644 index 000000000..b3edbbf91 --- /dev/null +++ b/apps/mobile/src/components/account/use-delete-account.ts @@ -0,0 +1,80 @@ +import { deleteAccount, runAccountDeletionTeardown } from '@mobile/runtime/cloud/deletion'; +import { useState } from 'react'; +import { Alert } from 'react-native'; +import { useTranslations } from 'use-intl'; + +export interface DeleteAccountState { + busy: boolean; + confirmDelete: () => void; +} + +/** Shared deletion flow for both platform account screens. Confirmation is an RN `Alert` + * because that already is the native alert on iOS and Android. */ +export function useDeleteAccount(): DeleteAccountState { + const t = useTranslations('mobile.account'); + const [busy, setBusy] = useState(false); + + const failureMessage = (code: string | undefined): string => { + if (code === 'ACCOUNT_DELETION_EMERGENCY_AUDIT_HOLD') return t('deleteEmergencyHold'); + if (code === 'ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER') return t('deleteSoleOwner'); + return t('deleteFailed'); + }; + + const run = async () => { + setBusy(true); + try { + const outcome = await deleteAccount(); + if (outcome.kind === 'reauthentication-failed') { + Alert.alert(t('deleteReauthenticationFailed')); + return; + } + if (outcome.kind === 'apple-device-required') { + Alert.alert(t('deleteAppleDeviceRequired')); + return; + } + if (outcome.kind === 'account-mismatch') { + Alert.alert(t('deleteAccountMismatch')); + return; + } + if (outcome.kind === 'unknown') { + Alert.alert(t('deleteUnknown')); + return; + } + if (outcome.kind === 'failed') { + Alert.alert(failureMessage(outcome.code)); + return; + } + + // Both remaining outcomes (`pending` and `completed`) mean the server + // already accepted the deletion — local teardown runs regardless. + await runAccountDeletionTeardown(); + if (outcome.kind === 'pending') { + Alert.alert(t('deletePending')); + return; + } + // A failed revocation still leaves deletion successful and needs manual Apple follow-up. + if (outcome.authorizationRevocation === 'failed') { + Alert.alert(t('deleteRevocationFailedTitle'), t('deleteRevocationFailedMessage')); + } else { + Alert.alert(t('deleteCompleted')); + } + } finally { + setBusy(false); + } + }; + + const confirmDelete = () => { + Alert.alert(t('deleteTitle'), t('deleteMessage'), [ + { text: t('deleteCancel'), style: 'cancel' }, + { + text: t('deleteConfirm'), + style: 'destructive', + onPress() { + void run(); + }, + }, + ]); + }; + + return { busy, confirmDelete }; +} diff --git a/apps/mobile/src/components/settings/settings-screen.tsx b/apps/mobile/src/components/settings/settings-screen.tsx index ac13db59f..6f1c4175f 100644 --- a/apps/mobile/src/components/settings/settings-screen.tsx +++ b/apps/mobile/src/components/settings/settings-screen.tsx @@ -53,8 +53,7 @@ export function SettingsScreen(): React.ReactNode { const setKeepHostsConnected = useSettingsStore((state) => state.setKeepHostsConnected); const [notificationUpdatePending, setNotificationUpdatePending] = useState(false); const notificationUpdatePendingRef = useRef(false); - const notificationsToggleEnabled = - account.status === 'signed-in' && !notificationUpdatePending; + const notificationsToggleEnabled = account.status === 'signed-in' && !notificationUpdatePending; const updateNotifications = async (enabled: boolean) => { if (account.status !== 'signed-in' || notificationUpdatePendingRef.current) return; From 74f095a06e00182dd0335e16a6960f637a6761f5 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 7 Sep 2026 19:07:28 +0800 Subject: [PATCH 26/37] fix(mobile): share platform state and repair native controls --- apps/mobile/AGENTS.md | 11 +- apps/mobile/src/app/new-thread.tsx | 38 +- .../components/account/account-screen.ios.tsx | 6 +- .../account/delete-account-section.ios.tsx | 2 +- .../account/delete-account-section.tsx | 2 +- .../account/devices-section.ios.tsx | 2 +- .../components/account/devices-section.tsx | 2 +- .../connect/add-host-screen.ios.tsx | 2 +- .../components/connect/add-host-screen.tsx | 2 +- .../connect/add-host-sheet.android.tsx | 2 +- .../connect/discovered-hosts-section.ios.tsx | 2 +- .../connect/discovered-hosts-section.tsx | 2 +- .../connect/my-machines-section.ios.tsx | 2 +- .../connect/my-machines-section.tsx | 2 +- .../src/components/conversation/composer.tsx | 2 +- .../conversation/session-tools.ios.tsx | 2 +- .../src/components/form/rows.android.tsx | 17 +- .../form/selector-sheet.android.tsx | 17 +- .../components/form/selector-sheet.types.ts | 3 +- .../components/form/sheet-picker.android.tsx | 22 +- .../host/new-thread/new-thread-screen.tsx | 37 ++ .../host/thread-list/thread-row.ios.tsx | 2 +- .../host/thread-list/thread-row.tsx | 2 +- .../components/host/threads-screen.ios.tsx | 2 +- .../src/components/host/threads-screen.tsx | 2 +- .../settings/settings-screen.ios.tsx | 45 +- .../components/settings/settings-screen.tsx | 50 +- .../terminal/terminals-screen.ios.tsx | 2 +- .../components/terminal/terminals-screen.tsx | 2 +- .../connect => runtime}/use-add-host.ts | 0 .../account => runtime}/use-delete-account.ts | 0 .../use-devices-section.ts | 0 .../use-discovered-hosts.ts | 0 .../connect => runtime}/use-my-machines.ts | 0 .../src/runtime/use-notification-settings.ts | 46 ++ .../use-terminal-inbox.ts | 0 .../host => runtime}/use-thread-inbox.ts | 2 +- .../thread-list => utils}/thread-title.ts | 0 pnpm-lock.yaml | 582 +++--------------- 39 files changed, 269 insertions(+), 645 deletions(-) create mode 100644 apps/mobile/src/components/host/new-thread/new-thread-screen.tsx rename apps/mobile/src/{components/connect => runtime}/use-add-host.ts (100%) rename apps/mobile/src/{components/account => runtime}/use-delete-account.ts (100%) rename apps/mobile/src/{components/account => runtime}/use-devices-section.ts (100%) rename apps/mobile/src/{components/connect => runtime}/use-discovered-hosts.ts (100%) rename apps/mobile/src/{components/connect => runtime}/use-my-machines.ts (100%) create mode 100644 apps/mobile/src/runtime/use-notification-settings.ts rename apps/mobile/src/{components/terminal => runtime}/use-terminal-inbox.ts (100%) rename apps/mobile/src/{components/host => runtime}/use-thread-inbox.ts (95%) rename apps/mobile/src/{components/host/thread-list => utils}/thread-title.ts (100%) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 4a38dc60b..9fa1f534c 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -1,6 +1,6 @@ # apps/mobile — Expo / React Native client -Expo + React Native. List/form screens render **`@expo/ui`** (real SwiftUI); **HeroUI Native** +Expo + React Native. List/form screens render **`@expo/ui`** (SwiftUI on iOS, Jetpack Compose on Android); **HeroUI Native** covers the RN surfaces that cannot cross over ("What deliberately stays React Native" below). Reaches the host through the `server` tunnel; business data still travels over `transport` + `@linkcode/schema`, the same contract as every other client. @@ -46,6 +46,15 @@ Native. Mobile consumes `@linkcode/ui` only through its **native** components ## `@expo/ui` (SwiftUI) — its layout rules are not RN's +Platform views share `runtime/` hooks for data and actions. Keep `.ios.tsx` responsible for +SwiftUI and the Android implementation responsible for Compose; shared routes and runtime must +not import either platform's UI package. Android hosts use `ThemedHost` so Material colors follow +the app's appearance preference. Each visible list row must be a direct `LazyColumn` child. + +Expo's Compose `Switch` always receives a native callback, even without a JS handler. Wire +`onCheckedChange` alongside a toggleable row; a tap on the thumb is consumed by the switch. +Single-choice rows use `selectable` and their parent uses `selectableGroup` for TalkBack. + Settings, terminal appearance, and connect render a real `Form` inside a `Host` (`style={{flex:1}}` + `useViewportSizeMeasurement`, or the Form collapses to its content). Each trap below was found only by driving the simulator: diff --git a/apps/mobile/src/app/new-thread.tsx b/apps/mobile/src/app/new-thread.tsx index 397bd25e9..2cfa8b4f0 100644 --- a/apps/mobile/src/app/new-thread.tsx +++ b/apps/mobile/src/app/new-thread.tsx @@ -1,14 +1,9 @@ -import { Composer } from '@mobile/components/conversation/composer'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; -import { AgentSelectorChip, ApprovalChip } from '@mobile/components/host/new-thread/draft-tools'; -import { ProjectRow } from '@mobile/components/host/new-thread/project-row'; +import { NewThreadScreen } from '@mobile/components/host/new-thread/new-thread-screen'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useNativePalette } from '@mobile/components/theme/native-palette'; -import { useNewThreadDraft } from '@mobile/runtime/use-new-thread-draft'; -import { Stack, useRouter } from 'expo-router'; -import { noop } from 'foxact/noop'; +import { Stack } from 'expo-router'; import { View } from 'react-native'; -import { KeyboardStickyView } from 'react-native-keyboard-controller'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; /** Composer-first new-thread page, the mobile shape of the desktop draft surface: the start @@ -32,32 +27,3 @@ export default function NewThreadRoute(): React.ReactNode {
); } - -function NewThreadScreen(): React.ReactNode { - const router = useRouter(); - const { text, setText, start, creating, sendBlocked, error, project, approval, selector } = - useNewThreadDraft((sessionId) => router.replace(`/session/${sessionId}`)); - - return ( - <> - - - - { - void start(text); - }} - onStop={noop} - isRunning={false} - disabled={creating} - sendBlocked={sendBlocked} - error={error} - tools={} - trailing={} - /> - - - ); -} diff --git a/apps/mobile/src/components/account/account-screen.ios.tsx b/apps/mobile/src/components/account/account-screen.ios.tsx index eb972544d..a41ac800f 100644 --- a/apps/mobile/src/components/account/account-screen.ios.tsx +++ b/apps/mobile/src/components/account/account-screen.ios.tsx @@ -16,9 +16,7 @@ export function AccountScreen(): React.ReactNode { return ( // Form needs the viewport as its proposed size, otherwise it collapses to its content. - {account.status !== 'signed-in' ? ( - - ) : ( + {account.status === 'signed-in' ? (
@@ -35,6 +33,8 @@ export function AccountScreen(): React.ReactNode {
+ ) : ( + )}
); diff --git a/apps/mobile/src/components/account/delete-account-section.ios.tsx b/apps/mobile/src/components/account/delete-account-section.ios.tsx index 869040b9d..a81e3a0c0 100644 --- a/apps/mobile/src/components/account/delete-account-section.ios.tsx +++ b/apps/mobile/src/components/account/delete-account-section.ios.tsx @@ -1,6 +1,6 @@ import { Button, Section } from '@expo/ui/swift-ui'; import { disabled } from '@expo/ui/swift-ui/modifiers'; -import { useDeleteAccount } from '@mobile/components/account/use-delete-account'; +import { useDeleteAccount } from '@mobile/runtime/use-delete-account'; import { useTranslations } from 'use-intl'; /** diff --git a/apps/mobile/src/components/account/delete-account-section.tsx b/apps/mobile/src/components/account/delete-account-section.tsx index 5f204e155..afbeeeada 100644 --- a/apps/mobile/src/components/account/delete-account-section.tsx +++ b/apps/mobile/src/components/account/delete-account-section.tsx @@ -1,8 +1,8 @@ import { ListItem, Text } from '@expo/ui/jetpack-compose'; import { clickable } from '@expo/ui/jetpack-compose/modifiers'; -import { useDeleteAccount } from '@mobile/components/account/use-delete-account'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { FormSection } from '@mobile/components/form/section.android'; +import { useDeleteAccount } from '@mobile/runtime/use-delete-account'; import { useTranslations } from 'use-intl'; /** Android delete row. Compose has no button `role`, so the label uses the error color. */ diff --git a/apps/mobile/src/components/account/devices-section.ios.tsx b/apps/mobile/src/components/account/devices-section.ios.tsx index cb4fcc4f0..e27180e3e 100644 --- a/apps/mobile/src/components/account/devices-section.ios.tsx +++ b/apps/mobile/src/components/account/devices-section.ios.tsx @@ -1,8 +1,8 @@ import { Button, HStack, Section, Spacer, SwipeActions, Text, VStack } from '@expo/ui/swift-ui'; import { badge, buttonStyle, disabled, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; -import { useDevicesSection } from '@mobile/components/account/use-devices-section'; import { FormLoadingRow } from '@mobile/components/form/loading-view.ios'; import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; +import { useDevicesSection } from '@mobile/runtime/use-devices-section'; import { useTranslations } from 'use-intl'; /** The account's registered devices; the view model lives in `use-devices-section`. */ diff --git a/apps/mobile/src/components/account/devices-section.tsx b/apps/mobile/src/components/account/devices-section.tsx index 46c41b103..b8a540010 100644 --- a/apps/mobile/src/components/account/devices-section.tsx +++ b/apps/mobile/src/components/account/devices-section.tsx @@ -1,8 +1,8 @@ import { ListItem, Text, TextButton } from '@expo/ui/jetpack-compose'; -import { useDevicesSection } from '@mobile/components/account/use-devices-section'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; import { FormSection } from '@mobile/components/form/section.android'; +import { useDevicesSection } from '@mobile/runtime/use-devices-section'; import { useTranslations } from 'use-intl'; /** Android device registry; the view model lives in `use-devices-section`. Compose has no diff --git a/apps/mobile/src/components/connect/add-host-screen.ios.tsx b/apps/mobile/src/components/connect/add-host-screen.ios.tsx index d3e390260..4f9f6edc1 100644 --- a/apps/mobile/src/components/connect/add-host-screen.ios.tsx +++ b/apps/mobile/src/components/connect/add-host-screen.ios.tsx @@ -7,7 +7,7 @@ import { textContentType, textInputAutocapitalization, } from '@expo/ui/swift-ui/modifiers'; -import { useAddHost } from '@mobile/components/connect/use-add-host'; +import { useAddHost } from '@mobile/runtime/use-add-host'; import { Stack, useRouter } from 'expo-router'; import { useTranslations } from 'use-intl'; diff --git a/apps/mobile/src/components/connect/add-host-screen.tsx b/apps/mobile/src/components/connect/add-host-screen.tsx index 30881a3da..48d8206f4 100644 --- a/apps/mobile/src/components/connect/add-host-screen.tsx +++ b/apps/mobile/src/components/connect/add-host-screen.tsx @@ -1,8 +1,8 @@ import { Button, Column, OutlinedTextField, Text, useNativeState } from '@expo/ui/jetpack-compose'; import { fillMaxWidth, padding, testID } from '@expo/ui/jetpack-compose/modifiers'; -import { useAddHost } from '@mobile/components/connect/use-add-host'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import { useAddHost } from '@mobile/runtime/use-add-host'; import { Stack } from 'expo-router'; import { useTranslations } from 'use-intl'; diff --git a/apps/mobile/src/components/connect/add-host-sheet.android.tsx b/apps/mobile/src/components/connect/add-host-sheet.android.tsx index 24810920e..5dc9f8cbd 100644 --- a/apps/mobile/src/components/connect/add-host-sheet.android.tsx +++ b/apps/mobile/src/components/connect/add-host-sheet.android.tsx @@ -7,9 +7,9 @@ import { useNativeState, } from '@expo/ui/jetpack-compose'; import { fillMaxWidth, imePadding, padding, testID } from '@expo/ui/jetpack-compose/modifiers'; -import { useAddHost } from '@mobile/components/connect/use-add-host'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import { useAddHost } from '@mobile/runtime/use-add-host'; import { useTranslations } from 'use-intl'; /** Android add-host form as an MD3 bottom sheet — the same modal family as the host and model diff --git a/apps/mobile/src/components/connect/discovered-hosts-section.ios.tsx b/apps/mobile/src/components/connect/discovered-hosts-section.ios.tsx index 965883eae..ea07aa40e 100644 --- a/apps/mobile/src/components/connect/discovered-hosts-section.ios.tsx +++ b/apps/mobile/src/components/connect/discovered-hosts-section.ios.tsx @@ -1,8 +1,8 @@ import { HStack, ProgressView, Section, Text } from '@expo/ui/swift-ui'; import { accessibilityLabel, controlSize, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; import { ManualHostRow } from '@mobile/components/connect/manual-host-row'; -import { useDiscoveredHosts } from '@mobile/components/connect/use-discovered-hosts'; import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { useDiscoveredHosts } from '@mobile/runtime/use-discovered-hosts'; import { useTranslations } from 'use-intl'; export function DiscoveredHostsSection(): React.ReactNode { diff --git a/apps/mobile/src/components/connect/discovered-hosts-section.tsx b/apps/mobile/src/components/connect/discovered-hosts-section.tsx index 48176a188..31721a4f2 100644 --- a/apps/mobile/src/components/connect/discovered-hosts-section.tsx +++ b/apps/mobile/src/components/connect/discovered-hosts-section.tsx @@ -1,10 +1,10 @@ import { LoadingIndicator } from '@expo/ui/jetpack-compose'; import { size } from '@expo/ui/jetpack-compose/modifiers'; import { ManualHostRow } from '@mobile/components/connect/manual-host-row'; -import { useDiscoveredHosts } from '@mobile/components/connect/use-discovered-hosts'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { FormHint } from '@mobile/components/form/rows.android'; import { FormSection } from '@mobile/components/form/section.android'; +import { useDiscoveredHosts } from '@mobile/runtime/use-discovered-hosts'; import { useTranslations } from 'use-intl'; export function DiscoveredHostsSection(): React.ReactNode { diff --git a/apps/mobile/src/components/connect/my-machines-section.ios.tsx b/apps/mobile/src/components/connect/my-machines-section.ios.tsx index 45abf5e98..a0d6a675c 100644 --- a/apps/mobile/src/components/connect/my-machines-section.ios.tsx +++ b/apps/mobile/src/components/connect/my-machines-section.ios.tsx @@ -1,9 +1,9 @@ import { Button, HStack, Section, Spacer, Text } from '@expo/ui/swift-ui'; import { buttonStyle, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; -import { useMyMachines } from '@mobile/components/connect/use-my-machines'; import { FormLoadingRow } from '@mobile/components/form/loading-view.ios'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { FOOTNOTE, SECONDARY } from '@mobile/components/form/styles.ios'; +import { useMyMachines } from '@mobile/runtime/use-my-machines'; import { useTranslations } from 'use-intl'; /** Online machines; the view model lives in `use-my-machines`. */ diff --git a/apps/mobile/src/components/connect/my-machines-section.tsx b/apps/mobile/src/components/connect/my-machines-section.tsx index 5de489f26..2ec610245 100644 --- a/apps/mobile/src/components/connect/my-machines-section.tsx +++ b/apps/mobile/src/components/connect/my-machines-section.tsx @@ -1,8 +1,8 @@ import { Text, TextButton } from '@expo/ui/jetpack-compose'; -import { useMyMachines } from '@mobile/components/connect/use-my-machines'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; import { FormSection } from '@mobile/components/form/section.android'; +import { useMyMachines } from '@mobile/runtime/use-my-machines'; import { useTranslations } from 'use-intl'; /** Android online machines; the view model lives in `use-my-machines`. */ diff --git a/apps/mobile/src/components/conversation/composer.tsx b/apps/mobile/src/components/conversation/composer.tsx index 01f2cfa3a..01d821ac6 100644 --- a/apps/mobile/src/components/conversation/composer.tsx +++ b/apps/mobile/src/components/conversation/composer.tsx @@ -24,7 +24,7 @@ const GLASS = [ /** The message composer card, shaped like the web composer: the editor on top and a footer row * below it — optional tool slots left and trailing, then the circular send/stop action. Chat * screens pass no tools; the new-thread draft fills the slots with its start-option chips. - * Send and stop are wired by the screen; this owns nothing but the draft text. */ + * Draft state and send/stop behavior belong to the screen's runtime hook. */ export function Composer({ onSend, onStop, diff --git a/apps/mobile/src/components/conversation/session-tools.ios.tsx b/apps/mobile/src/components/conversation/session-tools.ios.tsx index d80c051ec..83575408f 100644 --- a/apps/mobile/src/components/conversation/session-tools.ios.tsx +++ b/apps/mobile/src/components/conversation/session-tools.ios.tsx @@ -20,7 +20,7 @@ export function SessionApprovalChip({ const t = useTranslations('mobile.sessions'); if (!approvalPolicy || approvalPolicy.availablePolicies.length === 0) return null; - // eslint-disable-next-line sukka/react-no-performance-impacting-array-find -- one lookup against a handful of policies per render + // eslint-disable-next-line vibe-proof/react-no-performance-impacting-array-find -- one lookup against a handful of policies per render const current = approvalPolicy.availablePolicies.find( (policy) => policy.policyId === approvalPolicy.currentPolicyId, ); diff --git a/apps/mobile/src/components/form/rows.android.tsx b/apps/mobile/src/components/form/rows.android.tsx index 0ba978fcd..586faee66 100644 --- a/apps/mobile/src/components/form/rows.android.tsx +++ b/apps/mobile/src/components/form/rows.android.tsx @@ -1,5 +1,5 @@ import { ContainedLoadingIndicator, ListItem, Row, Switch, Text } from '@expo/ui/jetpack-compose'; -import { fillMaxWidth, padding, toggleable } from '@expo/ui/jetpack-compose/modifiers'; +import { alpha, fillMaxWidth, padding, toggleable } from '@expo/ui/jetpack-compose/modifiers'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; /** Quiet in-section message row: empty states, hints, and errors. */ @@ -38,24 +38,29 @@ export function FormLoadingRow(): React.ReactNode { ); } -/** Labeled switch row. The row owns the tap (`toggleable`), the Switch only displays the state — - * giving it its own handler would double-toggle a tap that lands on the thumb. */ +// Expo always installs the native Switch callback, so thumb taps do not reach the row handler. export function ToggleRow({ label, value, onValueChange, + enabled = true, }: { label: string; value: boolean; onValueChange: (value: boolean) => void; + enabled?: boolean; }): React.ReactNode { return ( - onValueChange(!value), { role: 'switch' })]}> + onValueChange(!value), { role: 'switch' })] : [] + } + > - {label} + {label} - + ); diff --git a/apps/mobile/src/components/form/selector-sheet.android.tsx b/apps/mobile/src/components/form/selector-sheet.android.tsx index 81f26403b..5c1e2fe94 100644 --- a/apps/mobile/src/components/form/selector-sheet.android.tsx +++ b/apps/mobile/src/components/form/selector-sheet.android.tsx @@ -9,9 +9,10 @@ import { } from '@expo/ui/jetpack-compose'; import { animateContentSize, - clickable, fillMaxWidth, padding, + selectable, + selectableGroup, verticalScroll, } from '@expo/ui/jetpack-compose/modifiers'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; @@ -109,7 +110,7 @@ export function SelectorSheet({ {harness === undefined ? null : } {model === undefined ? null : ( - + {model.title} {model.groups.map((group) => ( @@ -127,10 +128,14 @@ export function SelectorSheet({ key={option.id} colors={{ containerColor: '#00000000' }} modifiers={[ - clickable(() => { - model.onSelect(option.id); - onClose(); - }), + selectable( + model.selection === option.id, + () => { + model.onSelect(option.id); + onClose(); + }, + 'radioButton', + ), ]} > diff --git a/apps/mobile/src/components/form/selector-sheet.types.ts b/apps/mobile/src/components/form/selector-sheet.types.ts index 52c1f999e..6349c34e9 100644 --- a/apps/mobile/src/components/form/selector-sheet.types.ts +++ b/apps/mobile/src/components/form/selector-sheet.types.ts @@ -32,7 +32,8 @@ export function modelAxisGroups(models: ModelOption[]): SelectorModelGroup[] { if (providerGroups.ungrouped.length > 0) { groups.push({ label: null, options: providerGroups.ungrouped.map(toOption) }); } - for (const group of providerGroups.groups) { + for (let i = 0, len = providerGroups.groups.length; i < len; i++) { + const group = providerGroups.groups[i]; groups.push({ label: group.label, options: group.options.map(toOption) }); } return groups; diff --git a/apps/mobile/src/components/form/sheet-picker.android.tsx b/apps/mobile/src/components/form/sheet-picker.android.tsx index 856cca6de..0f7f1ba75 100644 --- a/apps/mobile/src/components/form/sheet-picker.android.tsx +++ b/apps/mobile/src/components/form/sheet-picker.android.tsx @@ -1,5 +1,11 @@ import { Column, ListItem, ModalBottomSheet, RadioButton, Text } from '@expo/ui/jetpack-compose'; -import { clickable, padding, verticalScroll } from '@expo/ui/jetpack-compose/modifiers'; +import { + clickable, + padding, + selectable, + selectableGroup, + verticalScroll, +} from '@expo/ui/jetpack-compose/modifiers'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; @@ -49,7 +55,7 @@ export function SheetPicker({ {sections.map((section) => ( - + {section.title === undefined ? null : ( { - section.onSelect(option.id); - onClose(); - }), + selectable( + section.selection === option.id, + () => { + section.onSelect(option.id); + onClose(); + }, + 'radioButton', + ), ]} > diff --git a/apps/mobile/src/components/host/new-thread/new-thread-screen.tsx b/apps/mobile/src/components/host/new-thread/new-thread-screen.tsx new file mode 100644 index 000000000..6baf28702 --- /dev/null +++ b/apps/mobile/src/components/host/new-thread/new-thread-screen.tsx @@ -0,0 +1,37 @@ +import { Composer } from '@mobile/components/conversation/composer'; +import { AgentSelectorChip, ApprovalChip } from '@mobile/components/host/new-thread/draft-tools'; +import { ProjectRow } from '@mobile/components/host/new-thread/project-row'; +import { useNewThreadDraft } from '@mobile/runtime/use-new-thread-draft'; +import { useRouter } from 'expo-router'; +import { noop } from 'foxact/noop'; +import { View } from 'react-native'; +import { KeyboardStickyView } from 'react-native-keyboard-controller'; + +export function NewThreadScreen(): React.ReactNode { + const router = useRouter(); + const { text, setText, start, creating, sendBlocked, error, project, approval, selector } = + useNewThreadDraft((sessionId) => router.replace(`/session/${sessionId}`)); + + return ( + <> + + + + { + void start(text); + }} + onStop={noop} + isRunning={false} + disabled={creating} + sendBlocked={sendBlocked} + error={error} + tools={} + trailing={} + /> + + + ); +} diff --git a/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx b/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx index a018eec99..748497d42 100644 --- a/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx @@ -11,9 +11,9 @@ import { AgentIcon } from '@linkcode/ui/native'; import { FOOTNOTE, SECONDARY, TERTIARY } from '@mobile/components/form/styles.ios'; import { useNativePalette } from '@mobile/components/theme/native-palette'; import { formatRelativeShort } from '@mobile/utils/relative-time'; +import { threadTitle } from '@mobile/utils/thread-title'; import { View } from 'react-native'; import type { ThreadRowProps } from './thread-row.types'; -import { threadTitle } from './thread-title'; /** SwiftUI's semantic colours standing in for the `bg-*` tokens the RN dot used. */ const STATUS_COLOR = { diff --git a/apps/mobile/src/components/host/thread-list/thread-row.tsx b/apps/mobile/src/components/host/thread-list/thread-row.tsx index 9730005e6..21cce93ef 100644 --- a/apps/mobile/src/components/host/thread-list/thread-row.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-row.tsx @@ -4,11 +4,11 @@ import type { AgentKind, SessionStatus } from '@linkcode/schema'; import { AGENT_INITIALS } from '@linkcode/ui/native'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { formatRelativeShort } from '@mobile/utils/relative-time'; +import { threadTitle } from '@mobile/utils/thread-title'; import agentClaudeCodeGlyph from '../../../../assets/icons/agent-claude-code.xml'; import agentCodexGlyph from '../../../../assets/icons/agent-codex.xml'; import agentOpencodeGlyph from '../../../../assets/icons/agent-opencode.xml'; import type { ThreadRowProps } from './thread-row.types'; -import { threadTitle } from './thread-title'; /** Brand marks vendored as vector drawables from `@proj-airi/lobe-icons` (the web glyph set); * kinds without a lobe glyph fall back to initials, matching the web sidebar. */ diff --git a/apps/mobile/src/components/host/threads-screen.ios.tsx b/apps/mobile/src/components/host/threads-screen.ios.tsx index b7042eee2..dbd7016f4 100644 --- a/apps/mobile/src/components/host/threads-screen.ios.tsx +++ b/apps/mobile/src/components/host/threads-screen.ios.tsx @@ -2,7 +2,7 @@ import { Form, Host, Section, Button as UIButton, Text as UIText } from '@expo/u import { LoadingView } from '@mobile/components/form/loading-view.ios'; import { SECONDARY } from '@mobile/components/form/styles.ios'; import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; -import { useThreadInbox } from '@mobile/components/host/use-thread-inbox'; +import { useThreadInbox } from '@mobile/runtime/use-thread-inbox'; import { Stack, useRouter } from 'expo-router'; import { noop } from 'foxact/noop'; import { useCallback } from 'react'; diff --git a/apps/mobile/src/components/host/threads-screen.tsx b/apps/mobile/src/components/host/threads-screen.tsx index f20d3491f..e285a072a 100644 --- a/apps/mobile/src/components/host/threads-screen.tsx +++ b/apps/mobile/src/components/host/threads-screen.tsx @@ -5,7 +5,7 @@ import { LoadingView } from '@mobile/components/form/loading-view.android'; import { FormHint } from '@mobile/components/form/rows.android'; import { FormSection } from '@mobile/components/form/section.android'; import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; -import { useThreadInbox } from '@mobile/components/host/use-thread-inbox'; +import { useThreadInbox } from '@mobile/runtime/use-thread-inbox'; import { useRouter } from 'expo-router'; import { noop } from 'foxact/noop'; import { useEffect } from 'react'; diff --git a/apps/mobile/src/components/settings/settings-screen.ios.tsx b/apps/mobile/src/components/settings/settings-screen.ios.tsx index 65ccba0b3..2d7c572bd 100644 --- a/apps/mobile/src/components/settings/settings-screen.ios.tsx +++ b/apps/mobile/src/components/settings/settings-screen.ios.tsx @@ -11,16 +11,12 @@ import { } from '@mobile/components/settings/settings-screen.shared'; import { LARGE_TITLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useCloudAccount } from '@mobile/runtime/cloud/account'; -import { - disableDeviceNotifications, - enableDeviceNotifications, -} from '@mobile/runtime/notifications'; import { setMobileProductAnalyticsEnabled } from '@mobile/runtime/product-analytics'; +import { useNotificationSettings } from '@mobile/runtime/use-notification-settings'; import { useAnalyticsPreferenceStore } from '@mobile/stores/analytics-store'; import { useSettingsStore } from '@mobile/stores/settings-store'; import { Stack, useRouter } from 'expo-router'; -import { useRef, useState } from 'react'; -import { Alert, Linking, View } from 'react-native'; +import { View } from 'react-native'; import { useTranslations } from 'use-intl'; const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); @@ -35,39 +31,14 @@ export function SettingsScreen(): React.ReactNode { const account = useCloudAccount(); const productAnalyticsEnabled = useAnalyticsPreferenceStore((state) => state.enabled); const themePreference = useSettingsStore((state) => state.themePreference); - const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled); + const { + enabled: notificationsEnabled, + canUpdate: notificationsToggleEnabled, + update: updateNotifications, + } = useNotificationSettings(); const setThemePreference = useSettingsStore((state) => state.setThemePreference); const keepHostsConnected = useSettingsStore((state) => state.keepHostsConnected); const setKeepHostsConnected = useSettingsStore((state) => state.setKeepHostsConnected); - const [notificationUpdatePending, setNotificationUpdatePending] = useState(false); - const notificationUpdatePendingRef = useRef(false); - - const updateNotifications = async (enabled: boolean) => { - if (account.status !== 'signed-in' || notificationUpdatePendingRef.current) return; - notificationUpdatePendingRef.current = true; - setNotificationUpdatePending(true); - try { - if (!enabled) { - await disableDeviceNotifications(); - return; - } - if (await enableDeviceNotifications(account.user.id)) return; - Alert.alert(t('notificationsDeniedTitle'), t('notificationsDenied'), [ - { text: t('cancel'), style: 'cancel' }, - { - text: t('openSettings'), - onPress() { - void Linking.openSettings(); - }, - }, - ]); - } catch { - Alert.alert(t('notificationsErrorTitle'), t('notificationsError')); - } finally { - notificationUpdatePendingRef.current = false; - setNotificationUpdatePending(false); - } - }; // The flex container is load-bearing: a SwiftUI host left as the screen's direct child is // proposed the whole window and paints straight over the large title. @@ -143,7 +114,7 @@ export function SettingsScreen(): React.ReactNode { isOn={notificationsEnabled} onIsOnChange={updateNotifications} label={t('notifications')} - modifiers={[disabled(account.status !== 'signed-in' || notificationUpdatePending)]} + modifiers={[disabled(!notificationsToggleEnabled)]} /> diff --git a/apps/mobile/src/components/settings/settings-screen.tsx b/apps/mobile/src/components/settings/settings-screen.tsx index 6f1c4175f..69b66a7bc 100644 --- a/apps/mobile/src/components/settings/settings-screen.tsx +++ b/apps/mobile/src/components/settings/settings-screen.tsx @@ -20,17 +20,13 @@ import { } from '@mobile/components/settings/settings-screen.shared'; import { LARGE_TITLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useCloudAccount } from '@mobile/runtime/cloud/account'; -import { - disableDeviceNotifications, - enableDeviceNotifications, -} from '@mobile/runtime/notifications'; import { setMobileProductAnalyticsEnabled } from '@mobile/runtime/product-analytics'; +import { useNotificationSettings } from '@mobile/runtime/use-notification-settings'; import { useAnalyticsPreferenceStore } from '@mobile/stores/analytics-store'; import { useSettingsStore } from '@mobile/stores/settings-store'; import { Stack, useRouter } from 'expo-router'; import { noop } from 'foxact/noop'; -import { useRef, useState } from 'react'; -import { Alert, Linking, View } from 'react-native'; +import { Linking, View } from 'react-native'; import { useTranslations } from 'use-intl'; /** Compose has no `Link` row, so legal rows open the URL through RN `Linking`. */ @@ -47,40 +43,14 @@ export function SettingsScreen(): React.ReactNode { const account = useCloudAccount(); const productAnalyticsEnabled = useAnalyticsPreferenceStore((state) => state.enabled); const themePreference = useSettingsStore((state) => state.themePreference); - const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled); + const { + enabled: notificationsEnabled, + canUpdate: notificationsToggleEnabled, + update: updateNotifications, + } = useNotificationSettings(); const setThemePreference = useSettingsStore((state) => state.setThemePreference); const keepHostsConnected = useSettingsStore((state) => state.keepHostsConnected); const setKeepHostsConnected = useSettingsStore((state) => state.setKeepHostsConnected); - const [notificationUpdatePending, setNotificationUpdatePending] = useState(false); - const notificationUpdatePendingRef = useRef(false); - const notificationsToggleEnabled = account.status === 'signed-in' && !notificationUpdatePending; - - const updateNotifications = async (enabled: boolean) => { - if (account.status !== 'signed-in' || notificationUpdatePendingRef.current) return; - notificationUpdatePendingRef.current = true; - setNotificationUpdatePending(true); - try { - if (!enabled) { - await disableDeviceNotifications(); - return; - } - if (await enableDeviceNotifications(account.user.id)) return; - Alert.alert(t('notificationsDeniedTitle'), t('notificationsDenied'), [ - { text: t('cancel'), style: 'cancel' }, - { - text: t('openSettings'), - onPress() { - void Linking.openSettings(); - }, - }, - ]); - } catch { - Alert.alert(t('notificationsErrorTitle'), t('notificationsError')); - } finally { - notificationUpdatePendingRef.current = false; - setNotificationUpdatePending(false); - } - }; return ( @@ -151,10 +121,8 @@ export function SettingsScreen(): React.ReactNode { { - if (!notificationsToggleEnabled) return; - void updateNotifications(enabled); - }} + enabled={notificationsToggleEnabled} + onValueChange={updateNotifications} /> diff --git a/apps/mobile/src/components/terminal/terminals-screen.ios.tsx b/apps/mobile/src/components/terminal/terminals-screen.ios.tsx index 2633302bb..c3bf6be7e 100644 --- a/apps/mobile/src/components/terminal/terminals-screen.ios.tsx +++ b/apps/mobile/src/components/terminal/terminals-screen.ios.tsx @@ -4,7 +4,7 @@ import { repositoryLabel } from '@linkcode/ui/native'; import { LoadingView } from '@mobile/components/form/loading-view.ios'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; -import { useTerminalInbox } from '@mobile/components/terminal/use-terminal-inbox'; +import { useTerminalInbox } from '@mobile/runtime/use-terminal-inbox'; import { Platform } from 'react-native'; import { useTranslations } from 'use-intl'; diff --git a/apps/mobile/src/components/terminal/terminals-screen.tsx b/apps/mobile/src/components/terminal/terminals-screen.tsx index 84ad6f6e6..a6d6bc827 100644 --- a/apps/mobile/src/components/terminal/terminals-screen.tsx +++ b/apps/mobile/src/components/terminal/terminals-screen.tsx @@ -7,7 +7,7 @@ import { NavigationRow } from '@mobile/components/form/navigation-row'; import { FormHint } from '@mobile/components/form/rows.android'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; -import { useTerminalInbox } from '@mobile/components/terminal/use-terminal-inbox'; +import { useTerminalInbox } from '@mobile/runtime/use-terminal-inbox'; import { useState } from 'react'; import { useTranslations } from 'use-intl'; diff --git a/apps/mobile/src/components/connect/use-add-host.ts b/apps/mobile/src/runtime/use-add-host.ts similarity index 100% rename from apps/mobile/src/components/connect/use-add-host.ts rename to apps/mobile/src/runtime/use-add-host.ts diff --git a/apps/mobile/src/components/account/use-delete-account.ts b/apps/mobile/src/runtime/use-delete-account.ts similarity index 100% rename from apps/mobile/src/components/account/use-delete-account.ts rename to apps/mobile/src/runtime/use-delete-account.ts diff --git a/apps/mobile/src/components/account/use-devices-section.ts b/apps/mobile/src/runtime/use-devices-section.ts similarity index 100% rename from apps/mobile/src/components/account/use-devices-section.ts rename to apps/mobile/src/runtime/use-devices-section.ts diff --git a/apps/mobile/src/components/connect/use-discovered-hosts.ts b/apps/mobile/src/runtime/use-discovered-hosts.ts similarity index 100% rename from apps/mobile/src/components/connect/use-discovered-hosts.ts rename to apps/mobile/src/runtime/use-discovered-hosts.ts diff --git a/apps/mobile/src/components/connect/use-my-machines.ts b/apps/mobile/src/runtime/use-my-machines.ts similarity index 100% rename from apps/mobile/src/components/connect/use-my-machines.ts rename to apps/mobile/src/runtime/use-my-machines.ts diff --git a/apps/mobile/src/runtime/use-notification-settings.ts b/apps/mobile/src/runtime/use-notification-settings.ts new file mode 100644 index 000000000..31d780252 --- /dev/null +++ b/apps/mobile/src/runtime/use-notification-settings.ts @@ -0,0 +1,46 @@ +import { useCloudAccount } from '@mobile/runtime/cloud/account'; +import { + disableDeviceNotifications, + enableDeviceNotifications, +} from '@mobile/runtime/notifications'; +import { useSettingsStore } from '@mobile/stores/settings-store'; +import { useRef, useState } from 'react'; +import { Alert, Linking } from 'react-native'; +import { useTranslations } from 'use-intl'; + +export function useNotificationSettings() { + const t = useTranslations('mobile.settings'); + const account = useCloudAccount(); + const enabled = useSettingsStore((state) => state.notificationsEnabled); + const [pending, setPending] = useState(false); + const pendingRef = useRef(false); + + const update = async (enabled: boolean) => { + if (account.status !== 'signed-in' || pendingRef.current) return; + pendingRef.current = true; + setPending(true); + try { + if (!enabled) { + await disableDeviceNotifications(); + return; + } + if (await enableDeviceNotifications(account.user.id)) return; + Alert.alert(t('notificationsDeniedTitle'), t('notificationsDenied'), [ + { text: t('cancel'), style: 'cancel' }, + { + text: t('openSettings'), + onPress() { + void Linking.openSettings(); + }, + }, + ]); + } catch { + Alert.alert(t('notificationsErrorTitle'), t('notificationsError')); + } finally { + pendingRef.current = false; + setPending(false); + } + }; + + return { enabled, canUpdate: account.status === 'signed-in' && !pending, update }; +} diff --git a/apps/mobile/src/components/terminal/use-terminal-inbox.ts b/apps/mobile/src/runtime/use-terminal-inbox.ts similarity index 100% rename from apps/mobile/src/components/terminal/use-terminal-inbox.ts rename to apps/mobile/src/runtime/use-terminal-inbox.ts diff --git a/apps/mobile/src/components/host/use-thread-inbox.ts b/apps/mobile/src/runtime/use-thread-inbox.ts similarity index 95% rename from apps/mobile/src/components/host/use-thread-inbox.ts rename to apps/mobile/src/runtime/use-thread-inbox.ts index e50e4c3db..5e436234a 100644 --- a/apps/mobile/src/components/host/use-thread-inbox.ts +++ b/apps/mobile/src/runtime/use-thread-inbox.ts @@ -5,8 +5,8 @@ import { repositoryLabel, withoutAutomationSessions, } from '@linkcode/ui/native'; -import { threadTitle } from '@mobile/components/host/thread-list/thread-title'; import { useWorkspaces } from '@mobile/runtime/use-workspaces'; +import { threadTitle } from '@mobile/utils/thread-title'; import { useTranslations } from 'use-intl'; /** The threads inbox view model: sessions grouped by workspace, filtered by the search query. diff --git a/apps/mobile/src/components/host/thread-list/thread-title.ts b/apps/mobile/src/utils/thread-title.ts similarity index 100% rename from apps/mobile/src/components/host/thread-list/thread-title.ts rename to apps/mobile/src/utils/thread-title.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65f4f507b..5a593f200 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1268,10 +1268,10 @@ importers: version: 9.3.0 react-native: specifier: '>=0.80' - version: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + version: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) react-native-safe-area-context: specifier: '>=5.0.0' - version: 5.7.0(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + version: 5.7.0(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) rehype-slug: specifier: ^6.0.0 version: 6.0.0 @@ -1329,7 +1329,7 @@ importers: version: 19.2.3(@types/react@19.2.17) heroui-native: specifier: ^1.0.6 - version: 1.0.6(f14941eff73abd8af92ac9e02ccd9b8e) + version: 1.0.6(759903ba85becadd460e21d1d0660b91) react: specifier: 'catalog:' version: 19.3.0-canary-96fcba90-20260728 @@ -1338,7 +1338,7 @@ importers: version: 19.3.0-canary-96fcba90-20260728(react@19.3.0-canary-96fcba90-20260728) react-native-svg: specifier: ^15.15.4 - version: 15.15.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + version: 15.15.4(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' @@ -4281,10 +4281,6 @@ packages: react: '>=16' react-native: '>=0.57' - '@react-native/assets-registry@0.86.0': - resolution: {integrity: sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/assets-registry@0.86.2': resolution: {integrity: sha512-vcX/mBjWAVnWofu7KecotquI2unZ/tITwA7OGdq/mdY/zmGXIEvYhfEYyOQij/LRqi9WAL+iizInTBWnxDhK/Q==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -4303,12 +4299,6 @@ packages: peerDependencies: '@babel/core': '*' - '@react-native/codegen@0.86.0': - resolution: {integrity: sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - peerDependencies: - '@babel/core': '*' - '@react-native/codegen@0.86.2': resolution: {integrity: sha512-xKkudsahUJ1n//55g4fXk5BStVqqmZlz8HQveL45ZxcfDnwvhuYe2GymksQANFsSN+slvrarjrfq8kIxJzbceA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -4321,18 +4311,6 @@ packages: peerDependencies: '@babel/core': '*' - '@react-native/community-cli-plugin@0.86.0': - resolution: {integrity: sha512-Jv8p1ebEPfTzs8gmrjsdT2XMXFfeAg45Pman+XPLFGaSeGAZkutRFRyX9Cs9aGTSOyIA9YPJ6vDNb1ayTf1FKQ==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - peerDependencies: - '@react-native-community/cli': '*' - '@react-native/metro-config': 0.86.0 - peerDependenciesMeta: - '@react-native-community/cli': - optional: true - '@react-native/metro-config': - optional: true - '@react-native/community-cli-plugin@0.86.2': resolution: {integrity: sha512-YHXNKoM6Y/HjREySZ5arET2xgiHgg67r1MdwJB//MPJAJ0Xc5g0u6UHxY9VzsHO3Y07dre6s0BinYwjt1SEWvQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -4345,10 +4323,6 @@ packages: '@react-native/metro-config': optional: true - '@react-native/debugger-frontend@0.86.0': - resolution: {integrity: sha512-7Mb3nDfyJeys+ELF75Ageu7VKERlnIMoO+aNPoXqTXvz+b41L6l2CqMyLpDHxkBSlenij6gEepPNgaIyWHbJZw==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/debugger-frontend@0.86.2': resolution: {integrity: sha512-KGS1aV5F6cIqpnoIUhLBXyVzy1oAj8jBFGau6vX4Vy0HXRJN7p+68RU7x6NuyraHvQcR14ccMGT5TkFuNjQ4gA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -4357,10 +4331,6 @@ packages: resolution: {integrity: sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/debugger-shell@0.86.0': - resolution: {integrity: sha512-Y0zEkZzLz8ou6o/VLml1A31X/rMgc6DRjwxwzPMa94qRTMY070WeBCNTITQo4kKTBAUgbxh07oXPQqp0Tpja8w==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/debugger-shell@0.86.2': resolution: {integrity: sha512-/TaVJ2+gGajZPJGrFaObUQmHmlaxAlfmOPZicl6pNKDUjzSgFMpcLkdTOExvb+USYTVdGX1XwxXyvjQdUO2bvg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -4369,10 +4339,6 @@ packages: resolution: {integrity: sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/dev-middleware@0.86.0': - resolution: {integrity: sha512-20pTO6yTybmvXvro520H6C7jydIQnLKOl5qFtVEcHSdFrY63r3OGei+Rx9bILgSRmH6jgnfEcijcMx7pwWuQtw==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/dev-middleware@0.86.2': resolution: {integrity: sha512-B7L0vKvg+IcEElT7Vpqh1xj5yJAqWUegjbP+bQRaorJMAYnv11GkliTnZV2AdTDfZQJWgOEx8i8LGkHkUg7bnA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -4381,18 +4347,10 @@ packages: resolution: {integrity: sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/gradle-plugin@0.86.0': - resolution: {integrity: sha512-a1RcfaEDqWExCGfCwadIxt4l8FvKYgFqeMf2uzeKyAOnb+vTGNIeCvifFL2MqvgaeYxlER437HbMIajGcuJ1pQ==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/gradle-plugin@0.86.2': resolution: {integrity: sha512-2F6x14NcHMpVmfTTFKfMkpV5dZedZrLiv6PE+c3vgnesV2bjleUBydr4U+NI8VkI7OwW71L0A5qQ76I9LCrfoQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/js-polyfills@0.86.0': - resolution: {integrity: sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/js-polyfills@0.86.2': resolution: {integrity: sha512-bIwNcGBaQ74shB5z1mRkxOpjikimuwsnOCEkZSzL67Z1FTyK1ObpENfyd2QvcvVW9Cjl+tHuw9ynpBnb2jPoJQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -4413,26 +4371,12 @@ packages: '@react-native/normalize-colors@0.79.7': resolution: {integrity: sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ==} - '@react-native/normalize-colors@0.86.0': - resolution: {integrity: sha512-kG0wfCGghUKlfxkJyyHCDVutWVYWK7/DG58ojA/4v9EfulgF+osuSQmlbNb3rcKX58qutm7JcldSeVLgGFha9g==} - '@react-native/normalize-colors@0.86.2': resolution: {integrity: sha512-EzPFc9Y6lzYOWeso2almwXI7f8+qReHxWvT+algsOczb2UhWXIWXDoSvkdwoSfiwwmGt/ijJgKJoeHlzPkLwRg==} '@react-native/normalize-colors@0.86.3': resolution: {integrity: sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==} - '@react-native/virtualized-lists@0.86.0': - resolution: {integrity: sha512-4/ZLXdf/OSpPDVO0AsQ1SJdRIzt5t9BNQ46QwGgxvX7/cirYR5k8KXctNGGgW8lQo2gZChEfY2zFCZg9nM/jiw==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - peerDependencies: - '@types/react': ^19.2.0 - react: '*' - react-native: 0.86.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@react-native/virtualized-lists@0.86.2': resolution: {integrity: sha512-uO0J72gh3EvE+1/GHRk18QRyBDTRHRB0AraAfojsRjbT7VMuJwKrZYaKGshavoaEud6aw00ZB9/8mTMIKjjcAw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -8179,9 +8123,6 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - hermes-compiler@250829098.0.14: - resolution: {integrity: sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA==} - hermes-compiler@250829098.0.16: resolution: {integrity: sha512-xsgzk+mUyvt9t1nUbF8USBlYxajTUtPJhVZ86q85s/SEoMKCF+52YZcudb0ENSnV3T3lV9mgB3s6R7+pH90zgw==} @@ -8317,11 +8258,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - image-size@1.2.1: - resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} - engines: {node: '>=16.x'} - hasBin: true - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -9022,117 +8958,59 @@ packages: mermaid@11.16.0: resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} - metro-babel-transformer@0.84.4: - resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-babel-transformer@0.84.5: resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-cache-key@0.84.4: - resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-cache-key@0.84.5: resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-cache@0.84.4: - resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-cache@0.84.5: resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-config@0.84.4: - resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-config@0.84.5: resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-core@0.84.4: - resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-core@0.84.5: resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-file-map@0.84.4: - resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-file-map@0.84.5: resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-minify-terser@0.84.4: - resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-minify-terser@0.84.5: resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-resolver@0.84.4: - resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-resolver@0.84.5: resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-runtime@0.84.4: - resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-runtime@0.84.5: resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-source-map@0.84.4: - resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-source-map@0.84.5: resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-symbolicate@0.84.4: - resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - hasBin: true - metro-symbolicate@0.84.5: resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true - metro-transform-plugins@0.84.4: - resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-transform-plugins@0.84.5: resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-transform-worker@0.84.4: - resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-transform-worker@0.84.5: resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro@0.84.4: - resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - hasBin: true - metro@0.84.5: resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -9524,10 +9402,6 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} - ob1@0.84.4: - resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - ob1@0.84.5: resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -9980,9 +9854,6 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - queue@6.0.2: - resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} - quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -10124,20 +9995,6 @@ packages: react: '*' react-native: 0.83 - 0.86 - react-native@0.86.0: - resolution: {integrity: sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==} - engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - hasBin: true - peerDependencies: - '@react-native/jest-preset': 0.86.0 - '@types/react': ^19.1.1 - react: ^19.2.3 - peerDependenciesMeta: - '@react-native/jest-preset': - optional: true - '@types/react': - optional: true - react-native@0.86.2: resolution: {integrity: sha512-zbJXGZpwfZGA79Z9ob6Atvfx4nAQL8yJBa35s58E4Oo+khPykfQP2sTeumkKbjwajFYfVayg8pj7Il9nIfTk7A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -13706,6 +13563,18 @@ snapshots: - supports-color - utf-8-validate + '@gorhom/bottom-sheet@5.2.14(50aa6fff1dfa2319f41b6b41f4bd94c3)': + dependencies: + '@gorhom/portal': 1.0.14(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + invariant: 2.2.4 + react: 19.3.0-canary-96fcba90-20260728 + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react-native-gesture-handler: 2.32.0(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + optionalDependencies: + '@types/react': 19.2.17 + optional: true + '@gorhom/bottom-sheet@5.2.14(b3053a3c63c272f26bedf4b267721ee5)': dependencies: '@gorhom/portal': 1.0.14(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) @@ -13717,31 +13586,19 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@gorhom/bottom-sheet@5.2.14(d63eeb62e36d83b5df9dbf1ed21b9fa2)': + '@gorhom/portal@1.0.14(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)': dependencies: - '@gorhom/portal': 1.0.14(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) - invariant: 2.2.4 - react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) - react-native-gesture-handler: 2.32.0(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) - react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) - optionalDependencies: - '@types/react': 19.2.17 - optional: true + nanoid: 3.3.16 + react: 19.2.3 + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) - '@gorhom/portal@1.0.14(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)': + '@gorhom/portal@1.0.14(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)': dependencies: nanoid: 3.3.16 react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) optional: true - '@gorhom/portal@1.0.14(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)': - dependencies: - nanoid: 3.3.16 - react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) - '@hono/node-server@2.0.12(hono@4.12.32)': dependencies: hono: 4.12.32 @@ -14744,8 +14601,6 @@ snapshots: react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) - '@react-native/assets-registry@0.86.0': {} - '@react-native/assets-registry@0.86.2': {} '@react-native/babel-plugin-codegen@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': @@ -14802,16 +14657,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/codegen@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))': - dependencies: - '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/parser': 7.29.7 - hermes-parser: 0.36.0 - invariant: 2.2.4 - nullthrows: 1.1.1 - tinyglobby: 0.2.17 - yargs: 17.7.3 - '@react-native/codegen@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) @@ -14832,30 +14677,14 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.3 - '@react-native/community-cli-plugin@0.86.0(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)': - dependencies: - '@react-native/dev-middleware': 0.86.0(supports-color@8.1.1) - debug: 4.4.3(supports-color@8.1.1) - invariant: 2.2.4 - metro: 0.84.4(supports-color@8.1.1) - metro-config: 0.84.4(supports-color@8.1.1) - metro-core: 0.84.4 - semver: 7.8.5 - optionalDependencies: - '@react-native/metro-config': 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@react-native/community-cli-plugin@0.86.2(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@react-native/dev-middleware': 0.86.2(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1) invariant: 2.2.4 - metro: 0.84.4(supports-color@8.1.1) - metro-config: 0.84.4(supports-color@8.1.1) - metro-core: 0.84.4 + metro: 0.84.5(supports-color@8.1.1) + metro-config: 0.84.5(supports-color@8.1.1) + metro-core: 0.84.5 semver: 7.8.5 optionalDependencies: '@react-native/metro-config': 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) @@ -14864,20 +14693,10 @@ snapshots: - supports-color - utf-8-validate - '@react-native/debugger-frontend@0.86.0': {} - '@react-native/debugger-frontend@0.86.2': {} '@react-native/debugger-frontend@0.86.3': {} - '@react-native/debugger-shell@0.86.0(supports-color@8.1.1)': - dependencies: - cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) - fb-dotslash: 0.5.8 - transitivePeerDependencies: - - supports-color - '@react-native/debugger-shell@0.86.2(supports-color@8.1.1)': dependencies: cross-spawn: 7.0.6 @@ -14894,25 +14713,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/dev-middleware@0.86.0(supports-color@8.1.1)': - dependencies: - '@isaacs/ttlcache': 1.4.1 - '@react-native/debugger-frontend': 0.86.0 - '@react-native/debugger-shell': 0.86.0(supports-color@8.1.1) - chrome-launcher: 0.15.2(supports-color@8.1.1) - chromium-edge-launcher: 0.3.0(supports-color@8.1.1) - connect: 3.7.0(supports-color@8.1.1) - debug: 4.4.3(supports-color@8.1.1) - invariant: 2.2.4 - nullthrows: 1.1.1 - open: 7.4.2 - serve-static: 1.16.3(supports-color@8.1.1) - ws: 7.5.11 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@react-native/dev-middleware@0.86.2(supports-color@8.1.1)': dependencies: '@isaacs/ttlcache': 1.4.1 @@ -14951,12 +14751,8 @@ snapshots: - supports-color - utf-8-validate - '@react-native/gradle-plugin@0.86.0': {} - '@react-native/gradle-plugin@0.86.2': {} - '@react-native/js-polyfills@0.86.0': {} - '@react-native/js-polyfills@0.86.2': {} '@react-native/metro-babel-transformer@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': @@ -14972,8 +14768,8 @@ snapshots: dependencies: '@react-native/js-polyfills': 0.86.2 '@react-native/metro-babel-transformer': 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) - metro-config: 0.84.4(supports-color@8.1.1) - metro-runtime: 0.84.4 + metro-config: 0.84.5(supports-color@8.1.1) + metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' - bufferutil @@ -14984,27 +14780,25 @@ snapshots: '@react-native/normalize-colors@0.79.7': {} - '@react-native/normalize-colors@0.86.0': {} - '@react-native/normalize-colors@0.86.2': {} '@react-native/normalize-colors@0.86.3': {} - '@react-native/virtualized-lists@0.86.0(@types/react@19.2.17)(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)': + '@react-native/virtualized-lists@0.86.2(@types/react@19.2.17)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 - react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react: 19.2.3 + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) optionalDependencies: '@types/react': 19.2.17 - '@react-native/virtualized-lists@0.86.2(@types/react@19.2.17)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)': + '@react-native/virtualized-lists@0.86.2(@types/react@19.2.17)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 - react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) + react: 19.3.0-canary-96fcba90-20260728 + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) optionalDependencies: '@types/react': 19.2.17 @@ -18978,8 +18772,6 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 - hermes-compiler@250829098.0.14: {} - hermes-compiler@250829098.0.16: {} hermes-estree@0.25.1: {} @@ -19021,20 +18813,20 @@ snapshots: '@gorhom/bottom-sheet': 5.2.14(b3053a3c63c272f26bedf4b267721ee5) react-native-screens: 4.26.2(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) - heroui-native@1.0.6(f14941eff73abd8af92ac9e02ccd9b8e): + heroui-native@1.0.6(759903ba85becadd460e21d1d0660b91): dependencies: react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) - react-native-gesture-handler: 2.32.0(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) - react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) - react-native-safe-area-context: 5.7.0(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) - react-native-svg: 15.15.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) - react-native-worklets: 0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react-native-gesture-handler: 2.32.0(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + react-native-safe-area-context: 5.7.0(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + react-native-svg: 15.15.4(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + react-native-worklets: 0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) tailwind-merge: 3.6.0 tailwind-variants: 3.3.0(tailwind-merge@3.6.0)(tailwindcss@4.3.3) optionalDependencies: - '@gorhom/bottom-sheet': 5.2.14(d63eeb62e36d83b5df9dbf1ed21b9fa2) - react-native-screens: 4.26.2(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + '@gorhom/bottom-sheet': 5.2.14(50aa6fff1dfa2319f41b6b41f4bd94c3) + react-native-screens: 4.26.2(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) highlight.js@10.7.3: {} @@ -19141,10 +18933,6 @@ snapshots: ignore@7.0.5: {} - image-size@1.2.1: - dependencies: - queue: 6.0.2 - import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -19890,16 +19678,6 @@ snapshots: ts-dedent: 2.3.0 uuid: 11.1.1 - metro-babel-transformer@0.84.4(supports-color@8.1.1): - dependencies: - '@babel/core': 7.29.7(supports-color@8.1.1) - flow-enums-runtime: 0.0.6 - hermes-parser: 0.35.0 - metro-cache-key: 0.84.4 - nullthrows: 1.1.1 - transitivePeerDependencies: - - supports-color - metro-babel-transformer@0.84.5(supports-color@8.1.1): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) @@ -19910,23 +19688,10 @@ snapshots: transitivePeerDependencies: - supports-color - metro-cache-key@0.84.4: - dependencies: - flow-enums-runtime: 0.0.6 - metro-cache-key@0.84.5: dependencies: flow-enums-runtime: 0.0.6 - metro-cache@0.84.4(supports-color@8.1.1): - dependencies: - exponential-backoff: 3.1.3 - flow-enums-runtime: 0.0.6 - https-proxy-agent: 7.0.6(supports-color@8.1.1) - metro-core: 0.84.4 - transitivePeerDependencies: - - supports-color - metro-cache@0.84.5(supports-color@8.1.1): dependencies: exponential-backoff: 3.1.3 @@ -19936,21 +19701,6 @@ snapshots: transitivePeerDependencies: - supports-color - metro-config@0.84.4(supports-color@8.1.1): - dependencies: - connect: 3.7.0(supports-color@8.1.1) - flow-enums-runtime: 0.0.6 - jest-validate: 29.7.0 - metro: 0.84.4(supports-color@8.1.1) - metro-cache: 0.84.4(supports-color@8.1.1) - metro-core: 0.84.4 - metro-runtime: 0.84.4 - yaml: 2.9.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - metro-config@0.84.5(supports-color@8.1.1): dependencies: connect: 3.7.0(supports-color@8.1.1) @@ -19966,32 +19716,12 @@ snapshots: - supports-color - utf-8-validate - metro-core@0.84.4: - dependencies: - flow-enums-runtime: 0.0.6 - lodash.throttle: 4.1.1 - metro-resolver: 0.84.4 - metro-core@0.84.5: dependencies: flow-enums-runtime: 0.0.6 lodash.throttle: 4.1.1 metro-resolver: 0.84.5 - metro-file-map@0.84.4(supports-color@8.1.1): - dependencies: - debug: 4.4.3(supports-color@8.1.1) - fb-watchman: 2.0.2 - flow-enums-runtime: 0.0.6 - graceful-fs: 4.2.11 - invariant: 2.2.4 - jest-worker: 29.7.0 - micromatch: 4.0.8 - nullthrows: 1.1.1 - walker: 1.0.8 - transitivePeerDependencies: - - supports-color - metro-file-map@0.84.5(supports-color@8.1.1): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -20006,48 +19736,20 @@ snapshots: transitivePeerDependencies: - supports-color - metro-minify-terser@0.84.4: - dependencies: - flow-enums-runtime: 0.0.6 - terser: 5.48.0 - metro-minify-terser@0.84.5: dependencies: flow-enums-runtime: 0.0.6 terser: 5.48.0 - metro-resolver@0.84.4: - dependencies: - flow-enums-runtime: 0.0.6 - metro-resolver@0.84.5: dependencies: flow-enums-runtime: 0.0.6 - metro-runtime@0.84.4: - dependencies: - '@babel/runtime': 7.29.7 - flow-enums-runtime: 0.0.6 - metro-runtime@0.84.5: dependencies: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 - metro-source-map@0.84.4(supports-color@8.1.1): - dependencies: - '@babel/traverse': 7.29.7(supports-color@8.1.1) - '@babel/types': 7.29.7 - flow-enums-runtime: 0.0.6 - invariant: 2.2.4 - metro-symbolicate: 0.84.4(supports-color@8.1.1) - nullthrows: 1.1.1 - ob1: 0.84.4 - source-map: 0.5.7 - vlq: 1.0.1 - transitivePeerDependencies: - - supports-color - metro-source-map@0.84.5(supports-color@8.1.1): dependencies: '@babel/traverse': 7.29.7(supports-color@8.1.1) @@ -20062,17 +19764,6 @@ snapshots: transitivePeerDependencies: - supports-color - metro-symbolicate@0.84.4(supports-color@8.1.1): - dependencies: - flow-enums-runtime: 0.0.6 - invariant: 2.2.4 - metro-source-map: 0.84.4(supports-color@8.1.1) - nullthrows: 1.1.1 - source-map: 0.5.7 - vlq: 1.0.1 - transitivePeerDependencies: - - supports-color - metro-symbolicate@0.84.5(supports-color@8.1.1): dependencies: flow-enums-runtime: 0.0.6 @@ -20084,17 +19775,6 @@ snapshots: transitivePeerDependencies: - supports-color - metro-transform-plugins@0.84.4(supports-color@8.1.1): - dependencies: - '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/generator': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@8.1.1) - flow-enums-runtime: 0.0.6 - nullthrows: 1.1.1 - transitivePeerDependencies: - - supports-color - metro-transform-plugins@0.84.5(supports-color@8.1.1): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) @@ -20106,26 +19786,6 @@ snapshots: transitivePeerDependencies: - supports-color - metro-transform-worker@0.84.4(supports-color@8.1.1): - dependencies: - '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - flow-enums-runtime: 0.0.6 - metro: 0.84.4(supports-color@8.1.1) - metro-babel-transformer: 0.84.4(supports-color@8.1.1) - metro-cache: 0.84.4(supports-color@8.1.1) - metro-cache-key: 0.84.4 - metro-minify-terser: 0.84.4 - metro-source-map: 0.84.4(supports-color@8.1.1) - metro-transform-plugins: 0.84.4(supports-color@8.1.1) - nullthrows: 1.1.1 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - metro-transform-worker@0.84.5(supports-color@8.1.1): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) @@ -20146,52 +19806,6 @@ snapshots: - supports-color - utf-8-validate - metro@0.84.4(supports-color@8.1.1): - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@8.1.1) - '@babel/types': 7.29.7 - accepts: 2.0.0 - ci-info: 2.0.0 - connect: 3.7.0(supports-color@8.1.1) - debug: 4.4.3(supports-color@8.1.1) - error-stack-parser: 2.1.4 - flow-enums-runtime: 0.0.6 - graceful-fs: 4.2.11 - hermes-parser: 0.35.0 - image-size: 1.2.1 - invariant: 2.2.4 - jest-worker: 29.7.0 - jsc-safe-url: 0.2.4 - lodash.throttle: 4.1.1 - metro-babel-transformer: 0.84.4(supports-color@8.1.1) - metro-cache: 0.84.4(supports-color@8.1.1) - metro-cache-key: 0.84.4 - metro-config: 0.84.4(supports-color@8.1.1) - metro-core: 0.84.4 - metro-file-map: 0.84.4(supports-color@8.1.1) - metro-resolver: 0.84.4 - metro-runtime: 0.84.4 - metro-source-map: 0.84.4(supports-color@8.1.1) - metro-symbolicate: 0.84.4(supports-color@8.1.1) - metro-transform-plugins: 0.84.4(supports-color@8.1.1) - metro-transform-worker: 0.84.4(supports-color@8.1.1) - mime-types: 3.0.2 - nullthrows: 1.1.1 - serialize-error: 2.1.0 - source-map: 0.5.7 - throat: 5.0.0 - ws: 7.5.11 - yargs: 17.7.3 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - metro@0.84.5(supports-color@8.1.1): dependencies: '@babel/code-frame': 7.29.7 @@ -20700,10 +20314,6 @@ snapshots: nullthrows@1.1.1: {} - ob1@0.84.4: - dependencies: - flow-enums-runtime: 0.0.6 - ob1@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -21125,10 +20735,6 @@ snapshots: queue-microtask@1.2.3: {} - queue@6.0.2: - dependencies: - inherits: 2.0.4 - quick-format-unescaped@4.0.4: {} quick-lru@5.1.1: {} @@ -21210,34 +20816,34 @@ snapshots: react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) - react-native-gesture-handler@2.32.0(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): + react-native-gesture-handler@2.32.0(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 - react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react: 19.2.3 + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) - react-native-gesture-handler@2.32.0(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): + react-native-gesture-handler@2.32.0(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 - react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) - - react-native-is-edge-to-edge@1.3.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): - dependencies: react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) react-native-is-edge-to-edge@1.3.1(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) + react-native-is-edge-to-edge@1.3.1(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): + dependencies: + react: 19.3.0-canary-96fcba90-20260728 + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react-native-keyboard-controller@1.21.9(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: react: 19.2.3 @@ -21245,14 +20851,6 @@ snapshots: react-native-is-edge-to-edge: 1.3.1(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) - react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): - dependencies: - react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) - react-native-is-edge-to-edge: 1.3.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) - react-native-worklets: 0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) - semver: 7.8.5 - react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: react: 19.2.3 @@ -21261,23 +20859,23 @@ snapshots: react-native-worklets: 0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1) semver: 7.8.5 - react-native-safe-area-context@5.7.0(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): + react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): dependencies: react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react-native-is-edge-to-edge: 1.3.1(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + react-native-worklets: 0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + semver: 7.8.5 react-native-safe-area-context@5.7.0(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) - react-native-screens@4.26.2(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): + react-native-safe-area-context@5.7.0(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): dependencies: react: 19.3.0-canary-96fcba90-20260728 - react-freeze: 1.0.4(react@19.3.0-canary-96fcba90-20260728) - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) - warn-once: 0.1.1 - optional: true + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) react-native-screens@4.26.2(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: @@ -21286,13 +20884,13 @@ snapshots: react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) warn-once: 0.1.1 - react-native-svg@15.15.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): + react-native-screens@4.26.2(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): dependencies: - css-select: 5.2.2 - css-tree: 1.1.3 react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react-freeze: 1.0.4(react@19.3.0-canary-96fcba90-20260728) + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) warn-once: 0.1.1 + optional: true react-native-svg@15.15.4(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: @@ -21302,6 +20900,14 @@ snapshots: react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) warn-once: 0.1.1 + react-native-svg@15.15.4(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.3.0-canary-96fcba90-20260728 + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + warn-once: 0.1.1 + react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.29.7 @@ -21324,7 +20930,7 @@ snapshots: react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) - react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1): + react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) @@ -21339,13 +20945,13 @@ snapshots: '@babel/types': 7.29.7 '@react-native/metro-config': 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) convert-source-map: 2.0.0 - react: 19.3.0-canary-96fcba90-20260728 - react-native: 0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) + react: 19.2.3 + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) semver: 7.8.5 transitivePeerDependencies: - supports-color - react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1): + react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) @@ -21360,21 +20966,21 @@ snapshots: '@babel/types': 7.29.7 '@react-native/metro-config': 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) convert-source-map: 2.0.0 - react: 19.2.3 - react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1) + react: 19.3.0-canary-96fcba90-20260728 + react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1) semver: 7.8.5 transitivePeerDependencies: - supports-color - react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1): + react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1): dependencies: - '@react-native/assets-registry': 0.86.0 - '@react-native/codegen': 0.86.0(@babel/core@7.29.7(supports-color@8.1.1)) - '@react-native/community-cli-plugin': 0.86.0(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1) - '@react-native/gradle-plugin': 0.86.0 - '@react-native/js-polyfills': 0.86.0 - '@react-native/normalize-colors': 0.86.0 - '@react-native/virtualized-lists': 0.86.0(@types/react@19.2.17)(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) + '@react-native/assets-registry': 0.86.2 + '@react-native/codegen': 0.86.2(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/community-cli-plugin': 0.86.2(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1) + '@react-native/gradle-plugin': 0.86.2 + '@react-native/js-polyfills': 0.86.2 + '@react-native/normalize-colors': 0.86.2 + '@react-native/virtualized-lists': 0.86.2(@types/react@19.2.17)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -21382,15 +20988,15 @@ snapshots: base64-js: 1.5.1 commander: 12.1.0 flow-enums-runtime: 0.0.6 - hermes-compiler: 250829098.0.14 + hermes-compiler: 250829098.0.16 invariant: 2.2.4 memoize-one: 5.2.1 - metro-runtime: 0.84.4 - metro-source-map: 0.84.4(supports-color@8.1.1) + metro-runtime: 0.84.5 + metro-source-map: 0.84.5(supports-color@8.1.1) nullthrows: 1.1.1 pretty-format: 29.7.0 promise: 8.3.0 - react: 19.3.0-canary-96fcba90-20260728 + react: 19.2.3 react-devtools-core: 6.1.5 react-refresh: 0.14.2 regenerator-runtime: 0.13.11 @@ -21411,7 +21017,7 @@ snapshots: - supports-color - utf-8-validate - react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1): + react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1): dependencies: '@react-native/assets-registry': 0.86.2 '@react-native/codegen': 0.86.2(@babel/core@7.29.7(supports-color@8.1.1)) @@ -21419,7 +21025,7 @@ snapshots: '@react-native/gradle-plugin': 0.86.2 '@react-native/js-polyfills': 0.86.2 '@react-native/normalize-colors': 0.86.2 - '@react-native/virtualized-lists': 0.86.2(@types/react@19.2.17)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) + '@react-native/virtualized-lists': 0.86.2(@types/react@19.2.17)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.3.0-canary-96fcba90-20260728)(supports-color@8.1.1))(react@19.3.0-canary-96fcba90-20260728) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -21430,12 +21036,12 @@ snapshots: hermes-compiler: 250829098.0.16 invariant: 2.2.4 memoize-one: 5.2.1 - metro-runtime: 0.84.4 - metro-source-map: 0.84.4(supports-color@8.1.1) + metro-runtime: 0.84.5 + metro-source-map: 0.84.5(supports-color@8.1.1) nullthrows: 1.1.1 pretty-format: 29.7.0 promise: 8.3.0 - react: 19.2.3 + react: 19.3.0-canary-96fcba90-20260728 react-devtools-core: 6.1.5 react-refresh: 0.14.2 regenerator-runtime: 0.13.11 From 4a1f10567f55feadc566e99c768129af52da861d Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 7 Sep 2026 19:54:31 +0800 Subject: [PATCH 27/37] refactor(mobile): use native controls for app chrome --- apps/mobile/AGENTS.md | 4 +- apps/mobile/src/app/index.tsx | 16 +-- apps/mobile/src/app/sign-in.tsx | 89 ++++----------- apps/mobile/src/app/terminal/[terminalId].tsx | 57 ++++++---- .../src/components/form/action-button.ios.tsx | 47 ++++++++ .../src/components/form/action-button.tsx | 33 ++++++ .../components/form/action-button.types.ts | 7 ++ .../src/components/form/list.android.tsx | 4 +- .../components/host/host-switcher-button.tsx | 4 +- .../host/thread-list/thread-list.tsx | 103 ++++++++---------- .../components/shell/app-loading-screen.tsx | 16 +++ .../components/shell/header-menu-button.tsx | 2 +- .../src/components/shell/root-navigator.tsx | 3 +- 13 files changed, 219 insertions(+), 166 deletions(-) create mode 100644 apps/mobile/src/components/form/action-button.ios.tsx create mode 100644 apps/mobile/src/components/form/action-button.tsx create mode 100644 apps/mobile/src/components/form/action-button.types.ts create mode 100644 apps/mobile/src/components/shell/app-loading-screen.tsx diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 9fa1f534c..e5a7d6bdb 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -44,7 +44,7 @@ Native. Mobile consumes `@linkcode/ui` only through its **native** components under Node, suspect this class first and probe the runtime rather than reasoning from the API's documentation. -## `@expo/ui` (SwiftUI) — its layout rules are not RN's +## Native UI boundaries Platform views share `runtime/` hooks for data and actions. Keep `.ios.tsx` responsible for SwiftUI and the Android implementation responsible for Compose; shared routes and runtime must @@ -55,6 +55,8 @@ Expo's Compose `Switch` always receives a native callback, even without a JS han `onCheckedChange` alongside a toggleable row; a tap on the thumb is consumed by the switch. Single-choice rows use `selectable` and their parent uses `selectableGroup` for TalkBack. +## `@expo/ui` (SwiftUI) — its layout rules are not RN's + Settings, terminal appearance, and connect render a real `Form` inside a `Host` (`style={{flex:1}}` + `useViewportSizeMeasurement`, or the Form collapses to its content). Each trap below was found only by driving the simulator: diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 386c2cc50..11c64fe31 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,11 +1,8 @@ -import { BrandMark } from '@mobile/components/shell/brand-mark'; -import { useNativePalette } from '@mobile/components/theme/native-palette'; +import { AppLoadingScreen } from '@mobile/components/shell/app-loading-screen'; import { useCloudAccount } from '@mobile/runtime/cloud/account'; import { resolveStartupTarget } from '@mobile/runtime/startup'; import { useHostRegistryHydrated, useHostRegistryStore } from '@mobile/stores/host-store'; import { Redirect } from 'expo-router'; -import { Spinner } from 'heroui-native'; -import { View } from 'react-native'; /** * Startup router: waits for the persisted registry and the account state, @@ -13,7 +10,6 @@ import { View } from 'react-native'; */ export default function StartupScreen() { const hydrated = useHostRegistryHydrated(); - const palette = useNativePalette(); const account = useCloudAccount(); const hosts = useHostRegistryStore((state) => state.hosts); const lastActiveHostId = useHostRegistryStore((state) => state.lastActiveHostId); @@ -21,15 +17,7 @@ export default function StartupScreen() { // Only first-run routing waits on account state — LAN/direct users with // saved hosts must not block on a slow or offline cloud session check. if (!hydrated || (hosts.length === 0 && account.status === 'loading')) { - return ( - - - - - ); + return ; } const target = resolveStartupTarget({ diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 759dd0a4b..b98fef1b6 100644 --- a/apps/mobile/src/app/sign-in.tsx +++ b/apps/mobile/src/app/sign-in.tsx @@ -1,3 +1,5 @@ +import { ActionButton } from '@mobile/components/form/action-button'; +import { AppLoadingScreen } from '@mobile/components/shell/app-loading-screen'; import { BrandMark } from '@mobile/components/shell/brand-mark'; import { useNativePalette } from '@mobile/components/theme/native-palette'; import { signInToCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; @@ -5,11 +7,11 @@ import { isAppleSignInCancel, signInWithApple } from '@mobile/runtime/cloud/idp' import * as AppleAuthentication from 'expo-apple-authentication'; import { Redirect, useRouter } from 'expo-router'; import { useEffect } from 'foxact/use-abortable-effect'; -import { Button } from 'heroui-native'; import { useState } from 'react'; import { AccessibilityInfo, ActivityIndicator, + Platform, ScrollView, StyleSheet, Text, @@ -36,13 +38,13 @@ const styles = StyleSheet.create({ gap: 12, }, title: { - fontSize: 34, - fontWeight: '700', - lineHeight: 41, + fontSize: Platform.OS === 'ios' ? 34 : 32, + fontWeight: Platform.OS === 'ios' ? '700' : '400', + lineHeight: Platform.OS === 'ios' ? 41 : 40, }, tagline: { - fontSize: 17, - lineHeight: 22, + fontSize: Platform.OS === 'ios' ? 17 : 16, + lineHeight: Platform.OS === 'ios' ? 22 : 24, maxWidth: 320, }, actions: { @@ -54,31 +56,13 @@ const styles = StyleSheet.create({ minHeight: 18, }, error: { - fontSize: 13, - lineHeight: 18, + fontSize: Platform.OS === 'ios' ? 13 : 12, + lineHeight: Platform.OS === 'ios' ? 18 : 16, }, appleButton: { height: 50, width: '100%', }, - authButton: { - borderRadius: 13, - height: 'auto', - minHeight: 50, - paddingHorizontal: 16, - paddingVertical: 13, - }, - manualButton: { - borderRadius: 13, - height: 'auto', - minHeight: 44, - paddingHorizontal: 16, - paddingVertical: 10, - }, - buttonLabel: { - fontSize: 17, - lineHeight: 22, - }, disabled: { opacity: 0.5, }, @@ -111,15 +95,7 @@ export default function SignInScreen() { if (account.status === 'signed-in') return ; if (appleAvailable === null || account.status === 'loading') { - return ( - - - - - ); + return ; } const run = async (flow: () => Promise) => { @@ -155,7 +131,6 @@ export default function SignInScreen() { @@ -208,42 +183,22 @@ export default function SignInScreen() { }} /> ) : null} - - + /> diff --git a/apps/mobile/src/app/terminal/[terminalId].tsx b/apps/mobile/src/app/terminal/[terminalId].tsx index 92c039818..2d42a0fca 100644 --- a/apps/mobile/src/app/terminal/[terminalId].tsx +++ b/apps/mobile/src/app/terminal/[terminalId].tsx @@ -1,12 +1,12 @@ import { TerminalIdSchema } from '@linkcode/schema'; +import { ActionButton } from '@mobile/components/form/action-button'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; import TerminalRenderer from '@mobile/components/terminal/terminal-renderer'; import { useNativePalette } from '@mobile/components/theme/native-palette'; import { useTerminalSession } from '@mobile/runtime/use-terminal-session'; import { resolveTerminalTheme, useTerminalPrefsStore } from '@mobile/stores/terminal-prefs-store'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Button, Chip, Spinner } from 'heroui-native'; -import { Text, View } from 'react-native'; +import { ActivityIndicator, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslations } from 'use-intl'; @@ -65,33 +65,40 @@ function TerminalScreen(): React.ReactNode { }} > - + {terminal?.cwd ?? t('title')} {status === 'ready' ? ( - - {canControl ? t('controlling') : t('readOnly')} - + + {canControl ? t('controlling') : t('readOnly')} + ) : null} {truncated ? ( - {t('truncated')} + + {t('truncated')} + ) : null} {error ? ( - + {t('error', { error })} ) : null} {exit ? ( {exit.code === null ? t('exitedSignal') : t('exited', { code: exit.code })} @@ -99,16 +106,17 @@ function TerminalScreen(): React.ReactNode { {status === 'attaching' ? ( - + {t('attaching')} ) : status === 'error' ? ( - + ) : ( {canControl ? ( - + + + ) : terminal?.managed ? ( ) : ( - + + + )}
) : null} diff --git a/apps/mobile/src/components/form/action-button.ios.tsx b/apps/mobile/src/components/form/action-button.ios.tsx new file mode 100644 index 000000000..c36c17bba --- /dev/null +++ b/apps/mobile/src/components/form/action-button.ios.tsx @@ -0,0 +1,47 @@ +import { Button, Host, Text } from '@expo/ui/swift-ui'; +import { + buttonStyle, + controlSize, + disabled as disabledModifier, + frame, +} from '@expo/ui/swift-ui/modifiers'; +import type { ActionButtonProps } from './action-button.types'; + +export function ActionButton({ + label, + onPress, + disabled = false, + variant = 'primary', + fullWidth = false, +}: ActionButtonProps): React.ReactNode { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/form/action-button.tsx b/apps/mobile/src/components/form/action-button.tsx new file mode 100644 index 000000000..6f1064027 --- /dev/null +++ b/apps/mobile/src/components/form/action-button.tsx @@ -0,0 +1,33 @@ +import { Button, FilledTonalButton, Text, TextButton } from '@expo/ui/jetpack-compose'; +import { defaultMinSize, fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers'; +import type { ActionButtonProps } from './action-button.types'; +import { useAppMaterialColors } from './compose-theme.android'; +import { ThemedHost } from './themed-host.android'; + +export function ActionButton({ + label, + onPress, + disabled = false, + variant = 'primary', + fullWidth = false, +}: ActionButtonProps): React.ReactNode { + const colors = useAppMaterialColors(); + const Control = + variant === 'text' ? TextButton : variant === 'primary' ? Button : FilledTonalButton; + return ( + + + {label} + + + ); +} diff --git a/apps/mobile/src/components/form/action-button.types.ts b/apps/mobile/src/components/form/action-button.types.ts new file mode 100644 index 000000000..381eeeabe --- /dev/null +++ b/apps/mobile/src/components/form/action-button.types.ts @@ -0,0 +1,7 @@ +export interface ActionButtonProps { + label: string; + onPress: () => void; + disabled?: boolean; + variant?: 'primary' | 'secondary' | 'text' | 'destructive'; + fullWidth?: boolean; +} diff --git a/apps/mobile/src/components/form/list.android.tsx b/apps/mobile/src/components/form/list.android.tsx index a63a7a007..f21950619 100644 --- a/apps/mobile/src/components/form/list.android.tsx +++ b/apps/mobile/src/components/form/list.android.tsx @@ -1,13 +1,15 @@ import { LazyColumn } from '@expo/ui/jetpack-compose'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; /** Android form scaffold — the Compose counterpart of the iOS `Host`+`Form` pair. The Host needs * the viewport as its proposed size or the column collapses to its content, same trap as SwiftUI. * LazyColumn maps each direct child to one lazy item, so keep children at section granularity. */ export function FormList({ children }: React.PropsWithChildren): React.ReactNode { + const insets = useSafeAreaInsets(); return ( - {children} + {children} ); } diff --git a/apps/mobile/src/components/host/host-switcher-button.tsx b/apps/mobile/src/components/host/host-switcher-button.tsx index eeae6de5d..9117d1bd5 100644 --- a/apps/mobile/src/components/host/host-switcher-button.tsx +++ b/apps/mobile/src/components/host/host-switcher-button.tsx @@ -28,10 +28,10 @@ export function HostSwitcherButton(): React.ReactNode { accessibilityRole="button" accessibilityLabel={selected.name} onPress={() => setOpen(true)} - className="flex-row items-center gap-1 py-1 pr-3" + className="min-h-12 min-w-12 max-w-52 flex-row items-center gap-1 pr-3" > diff --git a/apps/mobile/src/components/host/thread-list/thread-list.tsx b/apps/mobile/src/components/host/thread-list/thread-list.tsx index c70e4af19..f3f5ea29a 100644 --- a/apps/mobile/src/components/host/thread-list/thread-list.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-list.tsx @@ -1,29 +1,21 @@ +import { Icon, LazyColumn, PullToRefreshBox, Row, Text } from '@expo/ui/jetpack-compose'; import { - AnimatedVisibility, - Column, - EnterTransition, - ExitTransition, - Icon, - LazyColumn, - PullToRefreshBox, - Row, - Spacer, - Text, -} from '@expo/ui/jetpack-compose'; -import { clickable, fillMaxWidth, padding, weight } from '@expo/ui/jetpack-compose/modifiers'; + clickable, + defaultMinSize, + fillMaxWidth, + padding, + weight, +} from '@expo/ui/jetpack-compose/modifiers'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; -import { Fragment, useState } from 'react'; +import { useState } from 'react'; import expandLessGlyph from '../../../../assets/icons/expand-less.xml'; import expandMoreGlyph from '../../../../assets/icons/expand-more.xml'; import type { ThreadListProps } from './thread-list.types'; import { ThreadRow } from './thread-row'; import { useThreadListState } from './use-thread-list-state'; -/** Android thread inbox body. Compose has no collapsible Section: each group renders a clickable - * MD3 subheader row with an expand chevron, and its rows collapse through `AnimatedVisibility` - * (the M3 expand/shrink motion; rows stay mounted). PullToRefreshBox takes a controlled flag - * instead of awaiting the promise, so the spinner state is adapted here. */ +// Each visible thread must be a direct LazyColumn child; grouping rows in a Column defeats laziness. export function ThreadList({ groups, labelFor, @@ -45,48 +37,45 @@ export function ThreadList({ * fillMaxWidth child is unaffected. */} - {groups.map((group) => { + {groups.flatMap((group) => { const expanded = !collapsed.has(group.key); - return ( - - setGroupExpanded(group.key, !expanded)), - fillMaxWidth(), - padding(16, 14, 16, 4), - ]} + return [ + setGroupExpanded(group.key, !expanded)), + fillMaxWidth(), + defaultMinSize({ minHeight: 48 }), + padding(16, 14, 16, 4), + ]} + > + - - {labelFor(group)} - - - - - - - {group.sessions.map((session) => ( - onOpenThread(session.sessionId)} - /> - ))} - - - - ); + {labelFor(group)} + + + , + ...(expanded + ? group.sessions.map((session) => ( + onOpenThread(session.sessionId)} + /> + )) + : []), + ]; })} diff --git a/apps/mobile/src/components/shell/app-loading-screen.tsx b/apps/mobile/src/components/shell/app-loading-screen.tsx new file mode 100644 index 000000000..f0cb66647 --- /dev/null +++ b/apps/mobile/src/components/shell/app-loading-screen.tsx @@ -0,0 +1,16 @@ +import { useNativePalette } from '@mobile/components/theme/native-palette'; +import { ActivityIndicator, View } from 'react-native'; +import { BrandMark } from './brand-mark'; + +export function AppLoadingScreen(): React.ReactNode { + const palette = useNativePalette(); + return ( + + + + + ); +} diff --git a/apps/mobile/src/components/shell/header-menu-button.tsx b/apps/mobile/src/components/shell/header-menu-button.tsx index d6e19a142..602b09d20 100644 --- a/apps/mobile/src/components/shell/header-menu-button.tsx +++ b/apps/mobile/src/components/shell/header-menu-button.tsx @@ -16,7 +16,7 @@ export function HeaderMenuButton({ label, actions }: HeaderMenuButtonProps): Rea setExpanded(false)}> setExpanded(true)}> - + {actions.map((action) => ( diff --git a/apps/mobile/src/components/shell/root-navigator.tsx b/apps/mobile/src/components/shell/root-navigator.tsx index 70c6169ef..5471b167b 100644 --- a/apps/mobile/src/components/shell/root-navigator.tsx +++ b/apps/mobile/src/components/shell/root-navigator.tsx @@ -1,3 +1,4 @@ +import { AppLoadingScreen } from '@mobile/components/shell/app-loading-screen'; import { HostConnectionScope } from '@mobile/components/shell/host-connection-scope'; import { useStackScreenOptions, @@ -11,7 +12,7 @@ import { Stack } from 'expo-router'; export function RootNavigator(): React.ReactNode { const configurationReady = useMobileConfiguration(); const screenOptions = useStackScreenOptions(); - if (!configurationReady) return null; + if (!configurationReady) return ; return ( From 49fe7749b7983672c8be105907d6ad8ca3e59295 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 7 Sep 2026 21:58:17 +0800 Subject: [PATCH 28/37] fix(mobile): use native long-press menus for Android list actions --- apps/mobile/AGENTS.md | 6 +++ .../components/account/devices-section.tsx | 49 ++++++++--------- .../connect/saved-hosts-section.tsx | 37 +++++++------ .../components/form/row-actions.android.tsx | 53 +++++++++++++++++++ 4 files changed, 102 insertions(+), 43 deletions(-) create mode 100644 apps/mobile/src/components/form/row-actions.android.tsx diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index e5a7d6bdb..dc66866c5 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -55,6 +55,12 @@ Expo's Compose `Switch` always receives a native callback, even without a JS han `onCheckedChange` alongside a toggleable row; a tap on the thumb is consumed by the switch. Single-choice rows use `selectable` and their parent uses `selectableGroup` for TalkBack. +List-item management actions use SwiftUI `SwipeActions` on iOS (horizontal row swipes) and +`RowActions` on Android (Compose long-press dropdown menus under `MaterialExpressiveTheme`). +Keep a row's normal tap for navigation; rows without a primary action may also open the menu on +tap. Destructive actions belong in these native action surfaces, not permanent trailing buttons. +Children of `RowActions` must not add their own click handler, which would consume the gesture. + ## `@expo/ui` (SwiftUI) — its layout rules are not RN's Settings, terminal appearance, and connect render a real `Form` inside a `Host` (`style={{flex:1}}` diff --git a/apps/mobile/src/components/account/devices-section.tsx b/apps/mobile/src/components/account/devices-section.tsx index b8a540010..b1cb0db53 100644 --- a/apps/mobile/src/components/account/devices-section.tsx +++ b/apps/mobile/src/components/account/devices-section.tsx @@ -1,13 +1,11 @@ import { ListItem, Text, TextButton } from '@expo/ui/jetpack-compose'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { RowActions } from '@mobile/components/form/row-actions.android'; import { FormHint, FormLoadingRow } from '@mobile/components/form/rows.android'; import { FormSection } from '@mobile/components/form/section.android'; import { useDevicesSection } from '@mobile/runtime/use-devices-section'; import { useTranslations } from 'use-intl'; -/** Android device registry; the view model lives in `use-devices-section`. Compose has no - * SwipeActions, so revoke is a visible trailing text button — the MD3-discoverable shape for a - * destructive-only row action. */ export function DevicesSection(): React.ReactNode { const t = useTranslations('mobile.account'); const colors = useAppMaterialColors(); @@ -31,27 +29,30 @@ export function DevicesSection(): React.ReactNode { {t('devicesEmpty')} ) : ( devices.map((device) => ( - - - {device.name} - - - - {device.id === enrolledId - ? `${describeDevice(device)} · ${t('thisDevice')}` - : describeDevice(device)} - - - - confirmRevoke(device)} - > - {t('revoke')} - - - + confirmRevoke(device), + }, + ]} + > + + + {device.name} + + + + {device.id === enrolledId + ? `${describeDevice(device)} · ${t('thisDevice')}` + : describeDevice(device)} + + + + )) )} diff --git a/apps/mobile/src/components/connect/saved-hosts-section.tsx b/apps/mobile/src/components/connect/saved-hosts-section.tsx index 854608109..c55bb9369 100644 --- a/apps/mobile/src/components/connect/saved-hosts-section.tsx +++ b/apps/mobile/src/components/connect/saved-hosts-section.tsx @@ -1,13 +1,11 @@ -import { ListItem, Text, TextButton } from '@expo/ui/jetpack-compose'; -import { clickable } from '@expo/ui/jetpack-compose/modifiers'; +import { ListItem, Text } from '@expo/ui/jetpack-compose'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { RowActions } from '@mobile/components/form/row-actions.android'; import { FormSection } from '@mobile/components/form/section.android'; import { useOpenHost } from '@mobile/runtime/use-open-host'; import { useHostRegistryStore } from '@mobile/stores/host-store'; import { useTranslations } from 'use-intl'; -/** Android saved hosts. Compose has no SwipeActions, so removal is a visible trailing text - * button; the rest of the row stays a single tap target for opening the host. */ export function SavedHostsSection(): React.ReactNode { const t = useTranslations('mobile.connect'); const colors = useAppMaterialColors(); @@ -18,21 +16,22 @@ export function SavedHostsSection(): React.ReactNode { return ( {hosts.map((host) => ( - openHost(host.id))]}> - - {host.name} - - - - {'url' in host ? host.url : t('viaTunnel')} - - - - removeHost(host.id)}> - {t('remove')} - - - + openHost(host.id)} + actions={[{ label: t('remove'), destructive: true, onPress: () => removeHost(host.id) }]} + > + + + {host.name} + + + + {'url' in host ? host.url : t('viaTunnel')} + + + + ))} ); diff --git a/apps/mobile/src/components/form/row-actions.android.tsx b/apps/mobile/src/components/form/row-actions.android.tsx new file mode 100644 index 000000000..36b17af43 --- /dev/null +++ b/apps/mobile/src/components/form/row-actions.android.tsx @@ -0,0 +1,53 @@ +import { DropdownMenu, DropdownMenuItem, Text } from '@expo/ui/jetpack-compose'; +import { combinedClickable, fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers'; +import { useState } from 'react'; +import { useAppMaterialColors } from './compose-theme.android'; + +export function RowActions({ + children, + onPress, + actions, +}: { + children: React.ReactNode; + onPress?: () => void; + actions: Array<{ + label: string; + onPress: () => void; + destructive?: boolean; + disabled?: boolean; + }>; +}): React.ReactNode { + const [expanded, setExpanded] = useState(false); + const colors = useAppMaterialColors(); + const openMenu = () => setExpanded(true); + + return ( + setExpanded(false)} + modifiers={[ + fillMaxWidth(), + combinedClickable({ onClick: onPress ?? openMenu, onLongClick: openMenu }), + ]} + > + {children} + + {actions.map((action) => ( + { + setExpanded(false); + action.onPress(); + }} + > + + {action.label} + + + ))} + + + ); +} From 83fea7669755c33dfcc1841b60c97cb66aa6d444 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 7 Sep 2026 23:17:28 +0800 Subject: [PATCH 29/37] refactor(mobile): use native controls in the chat composer --- apps/mobile/assets/icons/arrow-up.xml | 1 + apps/mobile/assets/icons/chevron-left.xml | 1 + apps/mobile/assets/icons/chevron-right.xml | 1 + apps/mobile/assets/icons/close.xml | 1 + apps/mobile/assets/icons/shield.xml | 1 + apps/mobile/assets/icons/stop.xml | 1 + .../conversation/option-chip.ios.tsx | 1 + .../prompt-dock/permission-prompt.tsx | 14 +++--- .../conversation/prompt-dock/prompt-dock.tsx | 25 +++++------ .../prompt-dock/question-page.tsx | 13 +++--- .../components/conversation/send-button.tsx | 29 +++--------- .../components/conversation/session-tools.tsx | 2 - .../conversation/tool-chip.android.tsx | 41 +++++++++-------- .../src/components/form/icon-button.ios.tsx | 44 +++++++++++++++++++ .../src/components/form/icon-button.tsx | 36 +++++++++++++++ .../src/components/form/icon-button.types.ts | 7 +++ .../host/new-thread/draft-tools.tsx | 2 - 17 files changed, 145 insertions(+), 75 deletions(-) create mode 100644 apps/mobile/assets/icons/arrow-up.xml create mode 100644 apps/mobile/assets/icons/chevron-left.xml create mode 100644 apps/mobile/assets/icons/chevron-right.xml create mode 100644 apps/mobile/assets/icons/close.xml create mode 100644 apps/mobile/assets/icons/shield.xml create mode 100644 apps/mobile/assets/icons/stop.xml create mode 100644 apps/mobile/src/components/form/icon-button.ios.tsx create mode 100644 apps/mobile/src/components/form/icon-button.tsx create mode 100644 apps/mobile/src/components/form/icon-button.types.ts diff --git a/apps/mobile/assets/icons/arrow-up.xml b/apps/mobile/assets/icons/arrow-up.xml new file mode 100644 index 000000000..496043063 --- /dev/null +++ b/apps/mobile/assets/icons/arrow-up.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/assets/icons/chevron-left.xml b/apps/mobile/assets/icons/chevron-left.xml new file mode 100644 index 000000000..ddb20ec23 --- /dev/null +++ b/apps/mobile/assets/icons/chevron-left.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/assets/icons/chevron-right.xml b/apps/mobile/assets/icons/chevron-right.xml new file mode 100644 index 000000000..60f1b2f8b --- /dev/null +++ b/apps/mobile/assets/icons/chevron-right.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/assets/icons/close.xml b/apps/mobile/assets/icons/close.xml new file mode 100644 index 000000000..fdfeebfe8 --- /dev/null +++ b/apps/mobile/assets/icons/close.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/assets/icons/shield.xml b/apps/mobile/assets/icons/shield.xml new file mode 100644 index 000000000..7c9446905 --- /dev/null +++ b/apps/mobile/assets/icons/shield.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/assets/icons/stop.xml b/apps/mobile/assets/icons/stop.xml new file mode 100644 index 000000000..5541768c6 --- /dev/null +++ b/apps/mobile/assets/icons/stop.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/src/components/conversation/option-chip.ios.tsx b/apps/mobile/src/components/conversation/option-chip.ios.tsx index 8faddbc05..bfaf74174 100644 --- a/apps/mobile/src/components/conversation/option-chip.ios.tsx +++ b/apps/mobile/src/components/conversation/option-chip.ios.tsx @@ -49,6 +49,7 @@ export function OptionChip({ modifiers={[ accessibilityLabel(`${label}: ${value}`), padding({ horizontal: 6, vertical: 8 }), + frame({ minWidth: 44, minHeight: 44 }), ]} > {sf ? : null} diff --git a/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx index 195fa3f2a..b06b2a132 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.tsx @@ -6,9 +6,9 @@ import { detailRows, } from '@mobile/components/conversation/prompt-dock/permission-prompt.shared'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { NativeIconButton } from '@mobile/components/form/icon-button'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; -import { XIcon } from 'lucide-react-native'; -import { Pressable, Text as RNText, View } from 'react-native'; +import { Text as RNText, View } from 'react-native'; import { useTranslations } from 'use-intl'; /** @@ -38,14 +38,12 @@ export function PermissionPrompt({ > {t('allowTitle', { title: toolCall.title ?? '' })} - onRespond({ outcome: 'cancelled' })} - > - - + />
{detailRows(toolCall).map((row) => ( void; }): React.ReactNode { const t = useTranslations('mobile.chat'); + const tPermission = useTranslations('workbench.prompt'); const [page, setPage] = useState(0); const palette = useNativePalette(); const pageIndex = Math.min(page, approvals.length - 1); @@ -78,25 +79,21 @@ function PermissionPager({ {approvals.length > 1 ? ( - setPage(pageIndex - 1)} - className="size-8 items-center justify-center" - > - - + /> {t('questionProgress', { current: pageIndex + 1, total: approvals.length })} - = approvals.length - 1} onPress={() => setPage(pageIndex + 1)} - className="size-8 items-center justify-center" - > - - + /> ) : null} ) : null} - - - + diff --git a/apps/mobile/src/components/conversation/send-button.tsx b/apps/mobile/src/components/conversation/send-button.tsx index be7ebebb7..5d4d703ad 100644 --- a/apps/mobile/src/components/conversation/send-button.tsx +++ b/apps/mobile/src/components/conversation/send-button.tsx @@ -1,6 +1,4 @@ -import { useNativePalette } from '@mobile/components/theme/native-palette'; -import { ArrowUpIcon, SquareIcon } from 'lucide-react-native'; -import { Pressable } from 'react-native'; +import { NativeIconButton } from '@mobile/components/form/icon-button'; /** The round send action shared by the conversation composer and the new-thread draft: one * circular button that morphs between send and stop, because a turn in flight is the only thing @@ -20,28 +18,13 @@ export function SendButton({ onSend: () => void; onStop: () => void; }): React.ReactNode { - const palette = useNativePalette(); - const ActionIcon = isRunning ? SquareIcon : ArrowUpIcon; - return ( - ({ - backgroundColor: enabled ? palette.tint : palette.outline, - opacity: pressed ? 0.6 : 1, - })} - > - - + /> ); } diff --git a/apps/mobile/src/components/conversation/session-tools.tsx b/apps/mobile/src/components/conversation/session-tools.tsx index f7eb2d807..b15ee4463 100644 --- a/apps/mobile/src/components/conversation/session-tools.tsx +++ b/apps/mobile/src/components/conversation/session-tools.tsx @@ -12,7 +12,6 @@ import type { } from '@mobile/components/form/selector-sheet.types'; import { modelAxisGroups } from '@mobile/components/form/selector-sheet.types'; import { SheetPicker } from '@mobile/components/form/sheet-picker.android'; -import { ShieldIcon } from 'lucide-react-native'; import { useState } from 'react'; import { View } from 'react-native'; import { useTranslations } from 'use-intl'; @@ -36,7 +35,6 @@ export function SessionApprovalChip({ return ( <> ; + } + return ( - - {Icon ? : null} - {iconOnly ? null : ( - + + {value} - )} - + + ); } diff --git a/apps/mobile/src/components/form/icon-button.ios.tsx b/apps/mobile/src/components/form/icon-button.ios.tsx new file mode 100644 index 000000000..4dd9f710f --- /dev/null +++ b/apps/mobile/src/components/form/icon-button.ios.tsx @@ -0,0 +1,44 @@ +import { Button, Host } from '@expo/ui/swift-ui'; +import { + accessibilityLabel, + buttonStyle, + disabled, + frame, + labelStyle, +} from '@expo/ui/swift-ui/modifiers'; +import type { SFSymbol } from 'sf-symbols-typescript'; +import type { NativeIconButtonProps } from './icon-button.types'; + +const ICONS = { + send: 'arrow.up', + stop: 'stop.fill', + close: 'xmark', + previous: 'chevron.left', + next: 'chevron.right', + shield: 'shield', +} as const satisfies Record; + +export function NativeIconButton({ + icon, + label, + onPress, + disabled: isDisabled, + filled, +}: NativeIconButtonProps): React.ReactNode { + return ( + + + + ); +} + +export function ReasoningRow({ text, streaming }: ReasoningRowProps): React.ReactNode { + const [expanded, setExpanded] = useState(false); + const t = useTranslations('mobile.conversation'); + return ( + + + + + {t('reasoning')} + {streaming ? : null} + + + {text} + + + ); +} diff --git a/apps/mobile/src/components/conversation/activity-row.tsx b/apps/mobile/src/components/conversation/activity-row.tsx new file mode 100644 index 000000000..ea154e914 --- /dev/null +++ b/apps/mobile/src/components/conversation/activity-row.tsx @@ -0,0 +1,93 @@ +import { CircularProgressIndicator, Column, Icon, Row, Text } from '@expo/ui/jetpack-compose'; +import { + clickable, + defaultMinSize, + fillMaxWidth, + size, + weight, +} from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import { useState } from 'react'; +import { useTranslations } from 'use-intl'; +import chevronRight from '../../../assets/icons/chevron-right.xml'; +import expandMore from '../../../assets/icons/expand-more.xml'; +import type { ReasoningRowProps, ToolRowProps } from './activity-row.types'; + +export function ToolRow({ title, status, onPress }: ToolRowProps): React.ReactNode { + const colors = useAppMaterialColors(); + const t = useTranslations('mobile.chat'); + return ( + + + + {title} + + {status === 'failed' ? ( + + {t('failed')} + + ) : null} + {status === 'in_progress' ? ( + + ) : onPress ? ( + + ) : null} + + + ); +} + +export function ReasoningRow({ text, streaming }: ReasoningRowProps): React.ReactNode { + const [expanded, setExpanded] = useState(false); + const colors = useAppMaterialColors(); + const t = useTranslations('mobile.conversation'); + return ( + + + setExpanded((current) => !current)), + ]} + > + + {t('reasoning')} + + {streaming ? : null} + + + {expanded ? ( + + {text} + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/conversation/activity-row.types.ts b/apps/mobile/src/components/conversation/activity-row.types.ts new file mode 100644 index 000000000..0c04ec25f --- /dev/null +++ b/apps/mobile/src/components/conversation/activity-row.types.ts @@ -0,0 +1,12 @@ +import type { ToolCall } from '@linkcode/schema'; + +export interface ToolRowProps { + title: string; + status: ToolCall['status']; + onPress?: () => void; +} + +export interface ReasoningRowProps { + text: string; + streaming: boolean; +} diff --git a/apps/mobile/src/components/conversation/timeline-item.tsx b/apps/mobile/src/components/conversation/timeline-item.tsx index 4320a9cf0..a5c767474 100644 --- a/apps/mobile/src/components/conversation/timeline-item.tsx +++ b/apps/mobile/src/components/conversation/timeline-item.tsx @@ -2,11 +2,9 @@ import type { ConversationItem } from '@linkcode/client-core'; import type { ContentBlock, ToolCall } from '@linkcode/schema'; import { NativeMarkdown } from '@linkcode/ui/native'; import { useNativePalette } from '@mobile/components/theme/native-palette'; -import { Spinner } from 'heroui-native'; -import { ChevronDownIcon, ChevronRightIcon, CircleAlertIcon } from 'lucide-react-native'; -import { useState } from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { Text, View } from 'react-native'; import { useTranslations } from 'use-intl'; +import { ReasoningRow, ToolRow } from './activity-row'; function blocksToText(blocks: ContentBlock[]): string { return blocks @@ -15,13 +13,6 @@ function blocksToText(blocks: ContentBlock[]): string { .trim(); } -const PLAN_STATUS_MARK = { - pending: '○', - in_progress: '◐', - completed: '●', - cancelled: '×', -} as const; - /** Compact token counts ("193437" → "193.4k") — mirrors the web CompactionMarker's format. */ function formatTokens(count: number): string { if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; @@ -84,34 +75,7 @@ export function TimelineItem({ /> ); case 'plan': - return ( - - - {t('plan')} - - {item.plan.entries.map((entry, index) => ( - - {PLAN_STATUS_MARK[entry.status]} {entry.content} - - ))} - - ); + return null; case 'approval': // Pending asks are answered from the prompt dock; the timeline records only resolved ones. if (!item.resolution) return null; @@ -169,69 +133,3 @@ export function TimelineItem({ return null; } } - -function ReasoningRow({ text, streaming }: { text: string; streaming: boolean }): React.ReactNode { - const t = useTranslations('mobile.conversation'); - const palette = useNativePalette(); - const [open, setOpen] = useState(false); - const Chevron = open ? ChevronDownIcon : ChevronRightIcon; - - return ( - - setOpen((current) => !current)} - > - - {t('reasoning')} - - {streaming ? : } - - {open ? ( - - {text} - - ) : null} - - ); -} - -/** A quiet, collapsed summary line — the chevron sits against the label rather than at the - * row's trailing edge, so the row reads as one phrase. A failed call is common and often - * expected here, so it stays in the muted voice and is marked by an icon rather than by - * colouring the whole line; the icon is also what keeps the status off colour alone. */ -function ToolRow({ - title, - status, - onPress, -}: { - title: string; - status: 'pending' | 'in_progress' | 'completed' | 'failed'; - onPress?: () => void; -}): React.ReactNode { - const palette = useNativePalette(); - - return ( - - - {title} - - {status === 'failed' ? : null} - {status === 'in_progress' ? ( - - ) : ( - - )} - - ); -} diff --git a/packages/presentation/ui/src/native/index.ts b/packages/presentation/ui/src/native/index.ts index 59233841b..91bb523cb 100644 --- a/packages/presentation/ui/src/native/index.ts +++ b/packages/presentation/ui/src/native/index.ts @@ -5,7 +5,11 @@ export type { ModelOption } from '../agent-models'; export { groupModelsByProvider, modelChoiceKey, resolveModel } from '../agent-models'; export { stripAnsi } from '../ansi'; export type { CurrentPlan, PromptConversationItem } from '../chat/conversation-prompts'; -export { selectCurrentPlan, selectPendingPromptItems } from '../chat/conversation-prompts'; +export { + conversationFlowItems, + selectCurrentPlan, + selectPendingPromptItems, +} from '../chat/conversation-prompts'; export { diffLines, patchLines } from '../diff-utils'; export { repositoryLabel } from '../repository-label'; export * from '../thread-groups'; From cb6a02c43b02aa3b985a3ebccc0e0db3f5f932c7 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 7 Sep 2026 23:39:54 +0800 Subject: [PATCH 31/37] refactor(mobile): unify native question response flow --- .../__tests__/use-question-response.test.ts | 85 +++++++++++++++++ .../prompt-dock/question-page.ios.tsx | 91 +++++++++++-------- .../prompt-dock/question-page.tsx | 58 ++++++++---- .../prompt-dock/question-page.types.ts | 2 + .../prompt-dock/question-prompt.tsx | 48 ++-------- .../prompt-dock/use-question-response.ts | 74 +++++++++++++++ 6 files changed, 264 insertions(+), 94 deletions(-) create mode 100644 apps/mobile/src/components/conversation/prompt-dock/__tests__/use-question-response.test.ts create mode 100644 apps/mobile/src/components/conversation/prompt-dock/use-question-response.ts diff --git a/apps/mobile/src/components/conversation/prompt-dock/__tests__/use-question-response.test.ts b/apps/mobile/src/components/conversation/prompt-dock/__tests__/use-question-response.test.ts new file mode 100644 index 000000000..374f40db2 --- /dev/null +++ b/apps/mobile/src/components/conversation/prompt-dock/__tests__/use-question-response.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import type { Question } from '@linkcode/schema'; +import { act, renderHook } from '@testing-library/react'; +import { expect, it, vi } from 'vitest'; +import { useQuestionResponse } from '../use-question-response'; + +const questions: Question[] = [ + { + questionId: 'scope', + prompt: 'Scope?', + multiSelect: false, + options: [ + { optionId: 'small', label: 'Small' }, + { optionId: 'large', label: 'Large' }, + ], + }, + { + questionId: 'checks', + prompt: 'Checks?', + multiSelect: true, + options: [ + { optionId: 'unit', label: 'Unit' }, + { optionId: 'native', label: 'Native' }, + ], + }, +]; + +it('preserves earlier answers when navigating back and submits the entire batch', () => { + const respond = vi.fn(); + const { result } = renderHook(() => useQuestionResponse(questions, false, respond)); + act(() => result.current.selectOption('small')); + expect(result.current.current).toBe(2); + act(() => result.current.selectOption('unit')); + act(() => result.current.selectOption('native')); + act(() => result.current.previous(result.current.draft)); + expect(result.current.draft.selected).toEqual(['small']); + act(() => result.current.selectOption('large')); + expect(result.current.draft.selected).toEqual(['unit', 'native']); + act(() => result.current.selectOption('unit')); + act(() => result.current.advance(result.current.draft)); + expect(respond).toHaveBeenCalledExactlyOnceWith({ + outcome: 'answered', + answers: [ + { questionId: 'scope', selectedOptionIds: ['large'] }, + { questionId: 'checks', selectedOptionIds: ['native'] }, + ], + }); +}); + +it('preserves native text across back navigation and gives custom text precedence over choices', () => { + const respond = vi.fn(); + const { result } = renderHook(() => useQuestionResponse(questions, false, respond)); + act(() => result.current.selectOption('small')); + act(() => result.current.previous({ selected: ['unit'], customText: ' Smoke tests ' })); + act(() => result.current.selectOption('small')); + expect(result.current.draft.customText).toBe(' Smoke tests '); + act(() => result.current.advance(result.current.draft)); + expect(respond.mock.calls[0][0].answers[1]).toEqual({ + questionId: 'checks', + selectedOptionIds: [], + customText: 'Smoke tests', + }); +}); + +it('locks edits and navigation while responding and allows retry afterward', () => { + const respond = vi.fn(); + const { result, rerender } = renderHook( + ({ responding }) => useQuestionResponse(questions, responding, respond), + { initialProps: { responding: false } }, + ); + act(() => result.current.selectOption('small')); + rerender({ responding: true }); + act(() => { + result.current.selectOption('unit'); + result.current.setDraft({ selected: [], customText: 'late edit' }); + result.current.previous(result.current.draft); + result.current.advance(result.current.draft); + }); + expect(result.current.current).toBe(2); + expect(result.current.draft).toEqual({ selected: [], customText: '' }); + expect(respond).not.toHaveBeenCalled(); + rerender({ responding: false }); + act(() => result.current.advance(result.current.draft)); + expect(respond).toHaveBeenCalledOnce(); +}); diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx index 43b95fc76..7fc9bd4cf 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx @@ -10,12 +10,14 @@ import { VStack, } from '@expo/ui/swift-ui'; import { + accessibilityLabel, buttonStyle, contentShape, disabled, font, foregroundStyle, - onTapGesture, + frame, + labelStyle, shapes, } from '@expo/ui/swift-ui/modifiers'; import { useNativePalette } from '@mobile/components/theme/native-palette'; @@ -38,26 +40,17 @@ export function QuestionPage({ responding, onDraftChange, onAdvance, + onPrevious, + onSelectOption, onCancel, }: QuestionPageProps): React.ReactNode { const t = useTranslations('mobile.chat'); + const tQuestion = useTranslations('workbench.question'); const palette = useNativePalette(); // Each page owns a distinct native state. Remounting after a structured selection guarantees // that an asynchronous native write from the previous question/answer mode cannot leak here. const customText = useNativeState(draft.customText); - const toggleOption = (optionId: string): void => { - if (responding) return; - const selected = question.multiSelect - ? draft.selected.includes(optionId) - ? draft.selected.filter((id) => id !== optionId) - : [...draft.selected, optionId] - : [optionId]; - const next = { selected, customText: '' }; - onDraftChange(next); - if (!question.multiSelect) onAdvance(next); - }; - const advance = (): void => { const text = customText.get().trim(); onAdvance(text ? { selected: [], customText: text } : { ...draft, customText: '' }); @@ -71,6 +64,19 @@ export function QuestionPage({ + {current > 1 ? ( + ); })} { if (text.trim() && draft.selected.length > 0) { diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx index 7a8dd3802..10c1ab25b 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx @@ -8,7 +8,14 @@ import { Text, useNativeState, } from '@expo/ui/jetpack-compose'; -import { clickable, fillMaxWidth, weight } from '@expo/ui/jetpack-compose/modifiers'; +import { + defaultMinSize, + fillMaxWidth, + selectable, + selectableGroup, + toggleable, + weight, +} from '@expo/ui/jetpack-compose/modifiers'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { NativeIconButton } from '@mobile/components/form/icon-button'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; @@ -28,26 +35,17 @@ export function QuestionPage({ responding, onDraftChange, onAdvance, + onPrevious, + onSelectOption, onCancel, }: QuestionPageProps): React.ReactNode { const t = useTranslations('mobile.chat'); + const tQuestion = useTranslations('workbench.question'); const colors = useAppMaterialColors(); // Each page owns a distinct native state; the orchestrator remounts pages by key so a stale // native write from the previous question cannot leak here. const customText = useNativeState(draft.customText); - const toggleOption = (optionId: string): void => { - if (responding) return; - const selected = question.multiSelect - ? draft.selected.includes(optionId) - ? draft.selected.filter((id) => id !== optionId) - : [...draft.selected, optionId] - : [optionId]; - const next = { selected, customText: '' }; - onDraftChange(next); - if (!question.multiSelect) onAdvance(next); - }; - const advance = (): void => { const text = customText.get().trim(); onAdvance(text ? { selected: [], customText: text } : { ...draft, customText: '' }); @@ -59,6 +57,14 @@ export function QuestionPage({ style={{ backgroundColor: colors.surfaceContainerLow, borderColor: colors.outlineVariant }} > + {current > 1 ? ( + onPrevious({ ...draft, customText: customText.get() })} + /> + ) : null} {question.header ? ( {question.header} @@ -81,7 +87,10 @@ export function QuestionPage({ - + {question.options.map((option) => { const selected = draft.selected.includes(option.optionId); return ( @@ -89,10 +98,26 @@ export function QuestionPage({ key={option.optionId} verticalAlignment="center" horizontalArrangement={{ spacedBy: 8 }} - modifiers={[clickable(() => toggleOption(option.optionId)), fillMaxWidth()]} + modifiers={[ + fillMaxWidth(), + defaultMinSize({ minHeight: 48 }), + ...(responding + ? [] + : [ + question.multiSelect + ? toggleable(selected, () => onSelectOption(option.optionId), { + role: 'checkbox', + }) + : selectable( + selected, + () => onSelectOption(option.optionId), + 'radioButton', + ), + ]), + ]} > {question.multiSelect ? ( - + ) : ( )} @@ -110,6 +135,7 @@ export function QuestionPage({ { if (text.trim() && draft.selected.length > 0) { diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-page.types.ts b/apps/mobile/src/components/conversation/prompt-dock/question-page.types.ts index 1eb33d0d4..0b04b501b 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-page.types.ts +++ b/apps/mobile/src/components/conversation/prompt-dock/question-page.types.ts @@ -14,5 +14,7 @@ export interface QuestionPageProps { responding: boolean; onDraftChange: (draft: QuestionDraft) => void; onAdvance: (draft: QuestionDraft) => void; + onPrevious: (draft: QuestionDraft) => void; + onSelectOption: (optionId: string) => void; onCancel: () => void; } diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx index 1960f3d8d..c330a3829 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx @@ -1,7 +1,6 @@ -import type { Question, QuestionAnswer, QuestionOutcome } from '@linkcode/schema'; -import { useState } from 'react'; +import type { Question, QuestionOutcome } from '@linkcode/schema'; import { QuestionPage } from './question-page'; -import type { QuestionDraft } from './question-page.types'; +import { useQuestionResponse } from './use-question-response'; /** * One agent question batch pages within its card (desktop `question-prompt.tsx`): tappable @@ -18,51 +17,22 @@ export function QuestionPrompt({ responding: boolean; onRespond: (outcome: QuestionOutcome) => void; }): React.ReactNode { - const [index, setIndex] = useState(0); - const [drafts, setDrafts] = useState>({}); - - const question = questions[Math.min(index, questions.length - 1)]; - const draft = drafts[question.questionId] ?? { selected: [], customText: '' }; - const isLast = index >= questions.length - 1; - - const setDraft = (next: QuestionDraft): void => { - setDrafts((current) => ({ ...current, [question.questionId]: next })); - }; - - const buildAnswers = (finalDraft: QuestionDraft): QuestionAnswer[] => - questions.map((entry) => { - const value = - entry.questionId === question.questionId - ? finalDraft - : (drafts[entry.questionId] ?? { selected: [], customText: '' }); - const text = value.customText.trim(); - return { - questionId: entry.questionId, - selectedOptionIds: value.selected, - ...(text.length > 0 && { customText: text }), - }; - }); - - const advanceOrSubmit = (finalDraft: QuestionDraft): void => { - if (isLast) { - onRespond({ outcome: 'answered', answers: buildAnswers(finalDraft) }); - } else { - setDraft(finalDraft); - setIndex((current) => current + 1); - } - }; + const { question, draft, current, total, isLast, setDraft, advance, previous, selectOption } = + useQuestionResponse(questions, responding, onRespond); return ( onRespond({ outcome: 'cancelled' })} /> ); diff --git a/apps/mobile/src/components/conversation/prompt-dock/use-question-response.ts b/apps/mobile/src/components/conversation/prompt-dock/use-question-response.ts new file mode 100644 index 000000000..e79b554fb --- /dev/null +++ b/apps/mobile/src/components/conversation/prompt-dock/use-question-response.ts @@ -0,0 +1,74 @@ +import type { Question, QuestionOutcome } from '@linkcode/schema'; +import { useState } from 'react'; +import type { QuestionDraft } from './question-page.types'; + +export function useQuestionResponse( + questions: Question[], + responding: boolean, + onRespond: (outcome: QuestionOutcome) => void, +) { + const [index, setIndex] = useState(0); + const [drafts, setDrafts] = useState>({}); + const question = questions[index]; + const draft = drafts[question.questionId] ?? { selected: [], customText: '' }; + const isLast = index === questions.length - 1; + + const setDraft = (next: QuestionDraft): void => { + if (responding) return; + setDrafts((current) => ({ ...current, [question.questionId]: next })); + }; + + const advance = (next: QuestionDraft): void => { + if (responding) return; + setDraft(next); + if (!isLast) { + setIndex((current) => current + 1); + return; + } + onRespond({ + outcome: 'answered', + answers: questions.map((entry) => { + const value = + entry.questionId === question.questionId + ? next + : (drafts[entry.questionId] ?? { selected: [], customText: '' }); + const customText = value.customText.trim(); + return { + questionId: entry.questionId, + selectedOptionIds: customText ? [] : value.selected, + ...(customText && { customText }), + }; + }), + }); + }; + + const selectOption = (optionId: string): void => { + if (responding) return; + const selected = question.multiSelect + ? draft.selected.includes(optionId) + ? draft.selected.filter((id) => id !== optionId) + : [...draft.selected, optionId] + : [optionId]; + const next = { selected, customText: '' }; + if (question.multiSelect) setDraft(next); + else advance(next); + }; + + const previous = (next: QuestionDraft): void => { + if (responding || index === 0) return; + setDraft(next); + setIndex((current) => current - 1); + }; + + return { + question, + draft, + current: index + 1, + total: questions.length, + isLast, + setDraft, + advance, + previous, + selectOption, + }; +} From 5a84ac248403828e204e0dab28c8ad75fd1b4319 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 7 Sep 2026 23:46:34 +0800 Subject: [PATCH 32/37] fix(mobile): provide CocoaPods in the development environment --- apps/mobile/AGENTS.md | 5 +++++ devenv.nix | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index dc66866c5..b19875e81 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -180,6 +180,9 @@ but the new-thread picker and selector sheets still name the agent in text. ## Build & run +- Expo SDK 57 requires Xcode 26.4 or newer. Older Xcode can fail while compiling + `expo-modules-jsi`'s Swift/C++ bridging annotations before app code is built. + - **Dev builds, not Expo Go.** Cloud sign-in needs the real `linkcode://` scheme: production HQ trusts only `https://linkcode.ai,linkcode://` (`TRUSTED_ORIGINS`), and the `@better-auth/expo` server plugin auto-trusts `exp://` only under `NODE_ENV=development`, so "Sign in" from Expo Go @@ -201,6 +204,8 @@ but the new-thread picker and selector sheets still name the agent in text. `scripts.mobile` in `devenv.nix` unsets both groups (and sets `SENTRY_DISABLE_AUTO_UPLOAD`, since the Sentry Xcode phase otherwise fails the build without org/project credentials). Outside devenv the shell is already clean and `pnpm -F @linkcode/mobile ios` works as-is. + CocoaPods comes from devenv with its own Ruby runtime; a user-installed `pod` using env Ruby + breaks when this runner puts `/usr/bin` first and resolves Apple's older system Ruby. Symptoms are misleading: the first thing to fail is usually the `[RNDeps] Replace React Native Dependencies` script phase, which is collateral — that script runs fine on its own. diff --git a/devenv.nix b/devenv.nix index 89349230f..fa6a04402 100644 --- a/devenv.nix +++ b/devenv.nix @@ -13,7 +13,7 @@ # Mobile UI e2e drives the iOS simulator, which only exists on macOS; Linux CI would pull the # JVM closure for nothing. Pinned here rather than installed per-machine so the driver version # is part of the toolchain like every other tool. - ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ pkgs.maestro ]; + ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ pkgs.maestro pkgs.cocoapods ]; languages.rust = { enable = true; From 6ef09f3652b84809515d7ff9f9d30d721d753ca5 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 7 Sep 2026 23:52:12 +0800 Subject: [PATCH 33/37] refactor(mobile): align native chat records with desktop --- apps/mobile/AGENTS.md | 5 +- .../conversation/composer-backdrop.ios.tsx | 26 ++++++++ .../conversation/composer-backdrop.ts | 3 + .../src/components/conversation/composer.tsx | 32 +++------ .../components/conversation/timeline-item.tsx | 66 +++++++++++++------ .../conversation/transcript-record.ios.tsx | 42 ++++++++++++ .../conversation/transcript-record.tsx | 34 ++++++++++ .../conversation/transcript-record.types.ts | 5 ++ .../ui/src/chat/question-answer.ts | 8 +++ .../ui/src/chat/question-call-item.tsx | 11 +--- packages/presentation/ui/src/native/index.ts | 1 + 11 files changed, 178 insertions(+), 55 deletions(-) create mode 100644 apps/mobile/src/components/conversation/composer-backdrop.ios.tsx create mode 100644 apps/mobile/src/components/conversation/composer-backdrop.ts create mode 100644 apps/mobile/src/components/conversation/transcript-record.ios.tsx create mode 100644 apps/mobile/src/components/conversation/transcript-record.tsx create mode 100644 apps/mobile/src/components/conversation/transcript-record.types.ts create mode 100644 packages/presentation/ui/src/chat/question-answer.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index b19875e81..56f2d2330 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -97,8 +97,9 @@ only by driving the simulator: RN→SwiftUI is the supported direction; going back needs `RNHostView`, whose bidirectional nesting is the very thing 57.0.5 had to fix. So anything whose indispensable part is an RN view cannot cross over: **sign-in** (`AppleAuthenticationButton` is an RN view and `@expo/ui` has no Sign in with -Apple), the **conversation surface** — timeline, composer, and the screen holding them (excluded by -the redesign decision; the composer also rides `react-native-keyboard-controller`), the **terminal +Apple), the **conversation layout** — virtualized Markdown timeline and controlled multiline input +with `react-native-keyboard-controller` (its controls, disclosures, prompts, and transcript records +use SwiftUI / Compose), the **terminal canvas** (`expo-libghostty`), the **startup splash** (`BrandMark` is a bundled RN image), and the **navigation header** (react-navigation). One smaller loss is accepted rather than worked around: `Image` takes SF Symbols, asset-catalog names, and local file URIs but **never a remote diff --git a/apps/mobile/src/components/conversation/composer-backdrop.ios.tsx b/apps/mobile/src/components/conversation/composer-backdrop.ios.tsx new file mode 100644 index 000000000..ae3ba750c --- /dev/null +++ b/apps/mobile/src/components/conversation/composer-backdrop.ios.tsx @@ -0,0 +1,26 @@ +import { Host, Spacer, VStack } from '@expo/ui/swift-ui'; +import { frame, glassEffect } from '@expo/ui/swift-ui/modifiers'; +import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; +import { View } from 'react-native'; + +const GLASS = [ + frame({ maxWidth: Number.POSITIVE_INFINITY, maxHeight: Number.POSITIVE_INFINITY }), + glassEffect({ + glass: { variant: 'regular' }, + shape: 'roundedRectangle', + cornerRadius: 24, + }), +]; + +export function ComposerBackdrop(): React.ReactNode { + if (!USES_IOS_26_NAVIGATION) return null; + return ( + + + + + + + + ); +} diff --git a/apps/mobile/src/components/conversation/composer-backdrop.ts b/apps/mobile/src/components/conversation/composer-backdrop.ts new file mode 100644 index 000000000..8c53975f0 --- /dev/null +++ b/apps/mobile/src/components/conversation/composer-backdrop.ts @@ -0,0 +1,3 @@ +export function ComposerBackdrop(): React.ReactNode { + return null; +} diff --git a/apps/mobile/src/components/conversation/composer.tsx b/apps/mobile/src/components/conversation/composer.tsx index 01d821ac6..0087de1c2 100644 --- a/apps/mobile/src/components/conversation/composer.tsx +++ b/apps/mobile/src/components/conversation/composer.tsx @@ -1,26 +1,13 @@ -import { Host, Spacer, VStack } from '@expo/ui/swift-ui'; -import { frame, glassEffect } from '@expo/ui/swift-ui/modifiers'; import { SendButton } from '@mobile/components/conversation/send-button'; import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import { useNativePalette } from '@mobile/components/theme/native-palette'; import { Text, TextInput, View } from 'react-native'; import { useTranslations } from 'use-intl'; +import { ComposerBackdrop } from './composer-backdrop'; /** Cap the growing input so a long draft scrolls internally instead of eating the screen. */ const MAX_INPUT_HEIGHT = 140; -/** Matches the card's `rounded-3xl`, so the glass shape and the RN clip agree. */ -const CARD_RADIUS = 24; - -const GLASS = [ - frame({ maxWidth: Number.POSITIVE_INFINITY, maxHeight: Number.POSITIVE_INFINITY }), - glassEffect({ - glass: { variant: 'regular' }, - shape: 'roundedRectangle', - cornerRadius: CARD_RADIUS, - }), -]; - /** The message composer card, shaped like the web composer: the editor on top and a footer row * below it — optional tool slots left and trailing, then the circular send/stop action. Chat * screens pass no tools; the new-thread draft fills the slots with its start-option chips. @@ -69,7 +56,12 @@ export function Composer({ style={USES_IOS_26_NAVIGATION ? undefined : { backgroundColor: palette.background }} > {error ? ( - + {error} ) : null} @@ -80,15 +72,7 @@ export function Composer({ className="rounded-3xl px-2 pt-1 pb-1.5" style={USES_IOS_26_NAVIGATION ? undefined : { backgroundColor: palette.surface }} > - {USES_IOS_26_NAVIGATION ? ( - - - - - - - - ) : null} + void; }): React.ReactNode { const t = useTranslations('mobile.conversation'); + const tq = useTranslations('workbench.question'); const palette = useNativePalette(); switch (item.kind) { @@ -40,7 +42,7 @@ export function TimelineItem({ className="max-w-[85%] rounded-2xl px-4 py-2.5" style={{ backgroundColor: palette.surface }} > - + {blocksToText(item.blocks)}
@@ -76,33 +78,57 @@ export function TimelineItem({ ); case 'plan': return null; - case 'approval': + case 'approval': { // Pending asks are answered from the prompt dock; the timeline records only resolved ones. if (!item.resolution) return null; + const outcome = item.resolution.outcome; + const optionNames = new Map(item.options.map((option) => [option.optionId, option.name])); return ( - - {t('approval')} - {item.toolCall.title ?? ''} - + ); - case 'question': + } + case 'question': { if (!item.resolution) return null; + const outcome = item.resolution.outcome; + const answers = new Map( + (outcome.outcome === 'answered' ? outcome.answers : []).map((answer) => [ + answer.questionId, + answer, + ]), + ); return ( - - {t('question')} - {item.questions.map((question) => ( - - {question.prompt} - - ))} - + { + const answer = answers.get(question.questionId); + return { + id: question.questionId, + label: question.prompt, + value: answer ? (answerText(question, answer) ?? tq('skipped')) : undefined, + }; + })} + /> ); + } case 'error': return ( - - {t('error')} - {item.message} - + ); case 'compaction': if (item.status === 'in_progress') { diff --git a/apps/mobile/src/components/conversation/transcript-record.ios.tsx b/apps/mobile/src/components/conversation/transcript-record.ios.tsx new file mode 100644 index 000000000..dbb64aaef --- /dev/null +++ b/apps/mobile/src/components/conversation/transcript-record.ios.tsx @@ -0,0 +1,42 @@ +import { Host, Text, VStack } from '@expo/ui/swift-ui'; +import { font, foregroundStyle, frame, textSelection } from '@expo/ui/swift-ui/modifiers'; +import type { TranscriptRecordProps } from './transcript-record.types'; + +export function TranscriptRecord({ + title, + entries, + error, +}: TranscriptRecordProps): React.ReactNode { + return ( + + + + {title} + + {entries.map((entry) => ( + + + {entry.label} + + {entry.value ? ( + + {entry.value} + + ) : null} + + ))} + + + ); +} diff --git a/apps/mobile/src/components/conversation/transcript-record.tsx b/apps/mobile/src/components/conversation/transcript-record.tsx new file mode 100644 index 000000000..a428d6415 --- /dev/null +++ b/apps/mobile/src/components/conversation/transcript-record.tsx @@ -0,0 +1,34 @@ +import { Card, Column, Text } from '@expo/ui/jetpack-compose'; +import { fillMaxWidth, paddingAll } from '@expo/ui/jetpack-compose/modifiers'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import type { TranscriptRecordProps } from './transcript-record.types'; + +export function TranscriptRecord({ + title, + entries, + error, +}: TranscriptRecordProps): React.ReactNode { + const colors = useAppMaterialColors(); + return ( + + + + {title} + {entries.map((entry) => ( + + {entry.label} + {entry.value ? {entry.value} : null} + + ))} + + + + ); +} diff --git a/apps/mobile/src/components/conversation/transcript-record.types.ts b/apps/mobile/src/components/conversation/transcript-record.types.ts new file mode 100644 index 000000000..d26ee7be2 --- /dev/null +++ b/apps/mobile/src/components/conversation/transcript-record.types.ts @@ -0,0 +1,5 @@ +export interface TranscriptRecordProps { + title: string; + error?: boolean; + entries: Array<{ id: string; label: string; value?: string }>; +} diff --git a/packages/presentation/ui/src/chat/question-answer.ts b/packages/presentation/ui/src/chat/question-answer.ts new file mode 100644 index 000000000..89af12806 --- /dev/null +++ b/packages/presentation/ui/src/chat/question-answer.ts @@ -0,0 +1,8 @@ +import type { Question, QuestionAnswer } from '@linkcode/schema'; + +export function answerText(question: Question, answer: QuestionAnswer): string | undefined { + const labelByOption = new Map(question.options.map((option) => [option.optionId, option.label])); + const parts = answer.selectedOptionIds.map((optionId) => labelByOption.get(optionId) ?? optionId); + if (answer.customText) parts.push(answer.customText); + return parts.length > 0 ? parts.join(', ') : undefined; +} diff --git a/packages/presentation/ui/src/chat/question-call-item.tsx b/packages/presentation/ui/src/chat/question-call-item.tsx index 01b4f28af..db94c2aa4 100644 --- a/packages/presentation/ui/src/chat/question-call-item.tsx +++ b/packages/presentation/ui/src/chat/question-call-item.tsx @@ -1,7 +1,8 @@ -import type { Question, QuestionAnswer, ToolCall } from '@linkcode/schema'; +import type { ToolCall } from '@linkcode/schema'; import { MessageCircleQuestionMarkIcon } from 'lucide-react'; import { useTranslations } from 'use-intl'; import type { QuestionConversationItem } from './conversation-prompts'; +import { answerText } from './question-answer'; import { Tool, ToolContent, ToolHeader } from './tool'; /** Timeline record of an agent question ask. The dock's QuestionPrompt is the actionable @@ -66,11 +67,3 @@ export function QuestionCallItem({ ); } - -/** Chosen option labels plus any custom text; undefined when the question was skipped. */ -function answerText(question: Question, answer: QuestionAnswer): string | undefined { - const labelByOption = new Map(question.options.map((option) => [option.optionId, option.label])); - const parts = answer.selectedOptionIds.map((optionId) => labelByOption.get(optionId) ?? optionId); - if (answer.customText) parts.push(answer.customText); - return parts.length > 0 ? parts.join(', ') : undefined; -} diff --git a/packages/presentation/ui/src/native/index.ts b/packages/presentation/ui/src/native/index.ts index 91bb523cb..af27e2628 100644 --- a/packages/presentation/ui/src/native/index.ts +++ b/packages/presentation/ui/src/native/index.ts @@ -10,6 +10,7 @@ export { selectCurrentPlan, selectPendingPromptItems, } from '../chat/conversation-prompts'; +export { answerText } from '../chat/question-answer'; export { diffLines, patchLines } from '../diff-utils'; export { repositoryLabel } from '../repository-label'; export * from '../thread-groups'; From 5a4ff56577c1ba07f6f5a6281b118968998ee856 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 7 Sep 2026 23:56:32 +0800 Subject: [PATCH 34/37] fix(mobile): preserve plan contrast in dark mode --- .../conversation/prompt-dock/plan-tracker.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx index 76d6b1a12..08aef1daf 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.tsx @@ -1,5 +1,11 @@ import { CircularProgressIndicator, Column, Row, Text } from '@expo/ui/jetpack-compose'; -import { clickable, fillMaxWidth, size, weight } from '@expo/ui/jetpack-compose/modifiers'; +import { + clickable, + defaultMinSize, + fillMaxWidth, + size, + weight, +} from '@expo/ui/jetpack-compose/modifiers'; import type { CurrentPlan } from '@linkcode/ui/native'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; import { ThemedHost } from '@mobile/components/form/themed-host.android'; @@ -37,14 +43,18 @@ export function PlanTracker({ plan }: { plan: CurrentPlan }): React.ReactNode { setExpanded((current_) => !current_)), fillMaxWidth()]} + modifiers={[ + clickable(() => setExpanded((current_) => !current_)), + fillMaxWidth(), + defaultMinSize({ minHeight: 48 }), + ]} > - + {t('stepLabel', { current: plan.currentIndex + 1, total: plan.total })} {entry.content} From fc1c7609644a023e0ba66f621bd7640ccfdc063e Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 10:22:01 +0800 Subject: [PATCH 35/37] refactor(mobile): move Android questions into a native sheet --- apps/mobile/AGENTS.md | 4 + .../prompt-dock/question-page.tsx | 216 +++++++++--------- .../prompt-dock/question-prompt.ios.tsx | 39 ++++ .../prompt-dock/question-prompt.tsx | 99 ++++++-- packages/presentation/i18n/src/locales/en.ts | 2 + .../presentation/i18n/src/locales/zh-cn.ts | 2 + 6 files changed, 236 insertions(+), 126 deletions(-) create mode 100644 apps/mobile/src/components/conversation/prompt-dock/question-prompt.ios.tsx diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 56f2d2330..860a84961 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -61,6 +61,10 @@ Keep a row's normal tap for navigation; rows without a primary action may also o tap. Destructive actions belong in these native action surfaces, not permanent trailing buttons. Children of `RowActions` must not add their own click handler, which would consume the gesture. +Android question batches open from a compact transcript dock into a Compose modal sheet. Closing +the sheet preserves drafts; cancelling the request requires confirmation. Keep permission actions +inline with one-time approval emphasized, and allow the action row to wrap for longer labels. + ## `@expo/ui` (SwiftUI) — its layout rules are not RN's Settings, terminal appearance, and connect render a real `Form` inside a `Host` (`style={{flex:1}}` diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx index 10c1ab25b..7cc0bd3e6 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/question-page.tsx @@ -6,6 +6,7 @@ import { RadioButton, Row, Text, + TextButton, useNativeState, } from '@expo/ui/jetpack-compose'; import { @@ -17,15 +18,9 @@ import { weight, } from '@expo/ui/jetpack-compose/modifiers'; import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; -import { NativeIconButton } from '@mobile/components/form/icon-button'; -import { ThemedHost } from '@mobile/components/form/themed-host.android'; -import { Text as RNText, View } from 'react-native'; import { useTranslations } from 'use-intl'; import type { QuestionPageProps } from './question-page.types'; -/** One question page on Android: MD3 radio rows (checkboxes under `multiSelect`), an outlined - * free-text field, and a filled advance button. Header stays RN so the lucide close glyph and - * theme tokens match the card chrome. */ export function QuestionPage({ question, draft, @@ -38,7 +33,8 @@ export function QuestionPage({ onPrevious, onSelectOption, onCancel, -}: QuestionPageProps): React.ReactNode { + onClose, +}: QuestionPageProps & { onClose: () => void }): React.ReactNode { const t = useTranslations('mobile.chat'); const tQuestion = useTranslations('workbench.question'); const colors = useAppMaterialColors(); @@ -52,107 +48,117 @@ export function QuestionPage({ }; return ( - - + + {current > 1 ? ( - onPrevious({ ...draft, customText: customText.get() })} - /> - ) : null} - {question.header ? ( - - {question.header} - - ) : null} - - {question.prompt} - - {total > 1 ? ( - - {t('questionProgress', { current, total })} - - ) : null} - - - - - - {question.options.map((option) => { - const selected = draft.selected.includes(option.optionId); - return ( - onSelectOption(option.optionId), { - role: 'checkbox', - }) - : selectable( - selected, - () => onSelectOption(option.optionId), - 'radioButton', - ), - ]), - ]} - > - {question.multiSelect ? ( - - ) : ( - - )} - - {option.label} - {option.description ? ( - - {option.description} - - ) : null} - - - ); - })} - - { - if (text.trim() && draft.selected.length > 0) { - onDraftChange({ selected: [], customText: text }); - } - }} - modifiers={[fillMaxWidth()]} + onClick={() => onPrevious({ ...draft, customText: customText.get() })} > - - {t('customAnswerPlaceholder')} - - - + {tQuestion('previous')} + + ) : null} + + {t('questionProgress', { current, total })} + + { + onDraftChange({ ...draft, customText: customText.get() }); + onClose(); + }} + > + {t('close')} + + + + {question.prompt} + + + {question.multiSelect ? tQuestion('instructionMultiple') : tQuestion('instructionSingle')} + + + + {question.options.map((option) => { + const selected = draft.selected.includes(option.optionId); + return ( + { + customText.set(''); + onSelectOption(option.optionId); + }, + { + role: 'checkbox', + }, + ) + : selectable( + selected, + () => { + customText.set(''); + onSelectOption(option.optionId); + }, + 'radioButton', + ), + ]), + ]} + > + {question.multiSelect ? ( + + ) : ( + + )} + + + {option.label} + + {option.description ? ( + + {option.description} + + ) : null} + + + ); + })} - -
+ { + onDraftChange({ selected: text.trim() ? [] : draft.selected, customText: text }); + }} + modifiers={[fillMaxWidth()]} + > + + {t('customAnswerPlaceholder')} + + + + + {tQuestion('dismiss')} + + + ); } diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-prompt.ios.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-prompt.ios.tsx new file mode 100644 index 000000000..952f1a1ef --- /dev/null +++ b/apps/mobile/src/components/conversation/prompt-dock/question-prompt.ios.tsx @@ -0,0 +1,39 @@ +import type { Question, QuestionOutcome } from '@linkcode/schema'; +import { QuestionPage } from './question-page.ios'; +import { useQuestionResponse } from './use-question-response'; + +/** + * One agent question batch pages within its card (desktop `question-prompt.tsx`): tappable + * option rows draw their own selection state, `multiSelect` toggles, single-select + * auto-advances, an optional free-text answer, and the whole batch resolves as one + * `QuestionOutcome`. The page rendering is platform-split; this orchestrator is shared. + */ +export function QuestionPrompt({ + questions, + responding, + onRespond, +}: { + questions: Question[]; + responding: boolean; + onRespond: (outcome: QuestionOutcome) => void; +}): React.ReactNode { + const { question, draft, current, total, isLast, setDraft, advance, previous, selectOption } = + useQuestionResponse(questions, responding, onRespond); + + return ( + onRespond({ outcome: 'cancelled' })} + /> + ); +} diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx index c330a3829..7ad2ddb42 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/question-prompt.tsx @@ -1,13 +1,19 @@ +import { Column, FilledTonalButton, ModalBottomSheet, Row, Text } from '@expo/ui/jetpack-compose'; +import { + fillMaxWidth, + paddingAll, + verticalScroll, + weight, +} from '@expo/ui/jetpack-compose/modifiers'; import type { Question, QuestionOutcome } from '@linkcode/schema'; +import { useAppMaterialColors } from '@mobile/components/form/compose-theme.android'; +import { ThemedHost } from '@mobile/components/form/themed-host.android'; +import { useState } from 'react'; +import { Alert } from 'react-native'; +import { useTranslations } from 'use-intl'; import { QuestionPage } from './question-page'; import { useQuestionResponse } from './use-question-response'; -/** - * One agent question batch pages within its card (desktop `question-prompt.tsx`): tappable - * option rows draw their own selection state, `multiSelect` toggles, single-select - * auto-advances, an optional free-text answer, and the whole batch resolves as one - * `QuestionOutcome`. The page rendering is platform-split; this orchestrator is shared. - */ export function QuestionPrompt({ questions, responding, @@ -17,23 +23,74 @@ export function QuestionPrompt({ responding: boolean; onRespond: (outcome: QuestionOutcome) => void; }): React.ReactNode { + const [open, setOpen] = useState(false); + const colors = useAppMaterialColors(); + const t = useTranslations('mobile.chat'); + const tq = useTranslations('workbench.question'); const { question, draft, current, total, isLast, setDraft, advance, previous, selectOption } = useQuestionResponse(questions, responding, onRespond); - return ( - onRespond({ outcome: 'cancelled' })} - /> + <> + + + + + {tq('callTitle')} + + + {question.prompt} + + + setOpen(true)}> + {t('answer')} + + + + {open ? ( + + setOpen(false)} + skipPartiallyExpanded + contentColor={colors.onSurface} + > + + setOpen(false)} + onCancel={() => + Alert.alert(tq('dismissConfirmTitle'), tq('dismissConfirmDescription'), [ + { text: tq('dismissConfirmCancel'), style: 'cancel' }, + { + text: tq('dismissConfirmAction'), + style: 'destructive', + onPress: () => onRespond({ outcome: 'cancelled' }), + }, + ]) + } + /> + + + + ) : null} + ); } diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 0e36a93bf..58d591fff 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1407,6 +1407,8 @@ export const en = { }, chat: { allowTitle: 'Allow "{title}"?', + answer: 'Answer', + close: 'Close', skip: 'Skip', next: 'Next', submitAnswers: 'Submit answers', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 8c952adb5..60ec1ca2d 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1362,6 +1362,8 @@ export const zhCN = { compactedTokens: '{pre} → {post} tokens', }, chat: { + answer: '回答', + close: '关闭', allowTitle: '允许「{title}」?', skip: '跳过', next: '下一步', From 39ae808a91b7fdce1b07c8b18d0d23325479632e Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 10:30:36 +0800 Subject: [PATCH 36/37] fix(mobile): repair native chat layout and accessibility --- apps/mobile/src/app/session/[sessionId].tsx | 8 +-- .../prompt-dock/permission-prompt.ios.tsx | 2 +- .../prompt-dock/permission-prompt.tsx | 64 +++++++++---------- .../prompt-dock/plan-tracker.ios.tsx | 9 ++- .../prompt-dock/question-page.ios.tsx | 11 +++- .../components/form/navigation-row.ios.tsx | 20 +++++- .../host/thread-list/thread-row.ios.tsx | 12 +++- devenv.nix | 2 +- 8 files changed, 82 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/app/session/[sessionId].tsx b/apps/mobile/src/app/session/[sessionId].tsx index fed9e07d5..e40ae46e4 100644 --- a/apps/mobile/src/app/session/[sessionId].tsx +++ b/apps/mobile/src/app/session/[sessionId].tsx @@ -33,7 +33,6 @@ import { useSessionActions } from '@mobile/runtime/use-session-actions'; import { useSessionAutoResume } from '@mobile/runtime/use-session-auto-resume'; import * as Clipboard from 'expo-clipboard'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { useHeaderHeight } from 'expo-router/react-navigation'; import { noop } from 'foxact/noop'; import { useEffect, useRef, useState } from 'react'; import { FlatList, View } from 'react-native'; @@ -60,7 +59,6 @@ function SessionScreen(): React.ReactNode { const tChat = useTranslations('mobile.chat'); const tSettings = useTranslations('mobile.settings'); const insets = useSafeAreaInsets(); - const headerHeight = useHeaderHeight(); const palette = useNativePalette(); const router = useRouter(); const { sessionId: rawSessionId, autoResume } = useLocalSearchParams<{ @@ -148,6 +146,9 @@ function SessionScreen(): React.ReactNode { , ...(process.env.EXPO_OS === 'ios' @@ -210,9 +211,6 @@ function SessionScreen(): React.ReactNode { onPressTool={(toolCall) => setOpenToolCallId(toolCall.toolCallId)} /> )} - ListFooterComponent={ - process.env.EXPO_OS === 'ios' ? : null - } // Inverted list: the header renders at the visual bottom — the clearance that keeps // resting content out from under the floating composer. ListHeaderComponent={ diff --git a/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.ios.tsx b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.ios.tsx index 859d453fe..f9207e679 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.ios.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/permission-prompt.ios.tsx @@ -42,7 +42,7 @@ export function PermissionPrompt({ className="rounded-xl border px-3 py-2.5" style={{ backgroundColor: palette.background, borderColor: palette.outline }} > - + onRespond({ outcome: 'cancelled' })} /> - {detailRows(toolCall).map((row) => ( - - {row.value} - - ))} + + {detailRows(toolCall).map((row) => ( + + {row.value} + + ))} + - - {options.map((option) => ( - onRespond({ outcome: 'selected', optionId: option.optionId })} - modifiers={[fillMaxWidth()]} - > - {option.name} - - ))} - + + {options.map((option) => { + const Action = option.kind === 'allow_once' ? FilledTonalButton : TextButton; + return ( + onRespond({ outcome: 'selected', optionId: option.optionId })} + contentPadding={{ start: 12, end: 12 }} + > + {option.name} + + ); + })} + ); diff --git a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.ios.tsx b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.ios.tsx index 67f1af1bd..c1865df55 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.ios.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/plan-tracker.ios.tsx @@ -5,6 +5,7 @@ import { frame, gaugeStyle, lineLimit, + scaleEffect, strikethrough, } from '@expo/ui/swift-ui/modifiers'; import type { CurrentPlan } from '@linkcode/ui/native'; @@ -40,14 +41,18 @@ export function PlanTracker({ plan }: { plan: CurrentPlan }): React.ReactNode { className="rounded-xl border px-3 py-1.5" style={{ backgroundColor: palette.surface, borderColor: palette.outline }} > - + {t('stepLabel', { diff --git a/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx b/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx index 7fc9bd4cf..4dfda3411 100644 --- a/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx +++ b/apps/mobile/src/components/conversation/prompt-dock/question-page.ios.tsx @@ -14,6 +14,7 @@ import { buttonStyle, contentShape, disabled, + fixedSize, font, foregroundStyle, frame, @@ -61,7 +62,7 @@ export function QuestionPage({ className="rounded-xl border px-3 py-2.5" style={{ backgroundColor: palette.background, borderColor: palette.outline }} > - + {current > 1 ? ( @@ -125,7 +126,13 @@ export function QuestionPage({ {option.label} {option.description ? ( - + {option.description} ) : null} diff --git a/apps/mobile/src/components/form/navigation-row.ios.tsx b/apps/mobile/src/components/form/navigation-row.ios.tsx index b34229204..4e3ea5cbb 100644 --- a/apps/mobile/src/components/form/navigation-row.ios.tsx +++ b/apps/mobile/src/components/form/navigation-row.ios.tsx @@ -1,5 +1,12 @@ import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; -import { badge, contentShape, onTapGesture, shapes } from '@expo/ui/swift-ui/modifiers'; +import { + accessibilityAddTraits, + accessibilityElement, + badge, + contentShape, + onTapGesture, + shapes, +} from '@expo/ui/swift-ui/modifiers'; import type { NavigationRowProps } from '@mobile/components/form/navigation-row.types'; import { FOOTNOTE, SECONDARY, TERTIARY } from '@mobile/components/form/styles.ios'; @@ -17,7 +24,16 @@ export function NavigationRow({ onPress, }: NavigationRowProps): React.ReactNode { return ( - + {title} {subtitle ? {subtitle} : null} diff --git a/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx b/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx index 748497d42..28d831b5c 100644 --- a/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx +++ b/apps/mobile/src/components/host/thread-list/thread-row.ios.tsx @@ -1,5 +1,7 @@ import { HStack, Image, RNHostView, Spacer, Text, VStack } from '@expo/ui/swift-ui'; import { + accessibilityAddTraits, + accessibilityElement, contentShape, foregroundStyle, lineLimit, @@ -37,7 +39,15 @@ export function ThreadRow({ session, now, onPress }: ThreadRowProps): React.Reac const palette = useNativePalette(); return ( - + diff --git a/devenv.nix b/devenv.nix index fa6a04402..e08d7fad8 100644 --- a/devenv.nix +++ b/devenv.nix @@ -86,7 +86,7 @@ scripts.mobile.exec = '' export PATH="/usr/bin:$PATH" SENTRY_DISABLE_AUTO_UPLOAD=true unset DEVELOPER_DIR SDKROOT CC CXX LD NIX_CFLAGS_COMPILE NIX_LDFLAGS MACOSX_DEPLOYMENT_TARGET - pnpm run --filter @linkcode/mobile ios + pnpm run --filter @linkcode/mobile ios "$@" ''; scripts.app.exec = "pnpm run --filter @linkcode/daemon build:rust && pnpm --filter @linkcode/daemon --filter @linkcode/desktop --parallel dev"; } From 33bd138aef3b0b45bda32c0dc61b99a3f23db4c9 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 10:48:57 +0800 Subject: [PATCH 37/37] fix(mobile): synchronize native appearance and composer layout --- apps/mobile/src/app/session/[sessionId].tsx | 23 ++----------------- .../components/form/compose-theme.android.ts | 11 +-------- .../components/form/themed-host.android.tsx | 2 +- .../src/components/shell/app-providers.tsx | 9 +++++++- .../src/components/shell/theme-controller.tsx | 2 ++ .../src/components/theme/use-color-scheme.ts | 9 ++++++++ 6 files changed, 23 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/src/components/theme/use-color-scheme.ts diff --git a/apps/mobile/src/app/session/[sessionId].tsx b/apps/mobile/src/app/session/[sessionId].tsx index e40ae46e4..b9374e551 100644 --- a/apps/mobile/src/app/session/[sessionId].tsx +++ b/apps/mobile/src/app/session/[sessionId].tsx @@ -24,7 +24,6 @@ import { TimelineItem } from '@mobile/components/conversation/timeline-item'; import { ToolDetailSheet } from '@mobile/components/conversation/tool-detail-sheet/tool-detail-sheet'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { HeaderMenuButton } from '@mobile/components/shell/header-menu-button'; -import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useNativePalette } from '@mobile/components/theme/native-palette'; import { useAccountModels } from '@mobile/runtime/use-account-models'; @@ -84,9 +83,6 @@ function SessionScreen(): React.ReactNode { const actions = useSessionActions(sessionId, conversation.status); const { stop } = useSessionAutoResume(sessionId, session?.status, autoResumeSuppressed); const [openToolCallId, setOpenToolCallId] = useState(null); - // Measured height of the floating composer block, fed back to the list as its bottom inset so - // resting content clears the card while scrolling still flows under the glass. - const [dockHeight, setDockHeight] = useState(0); const title = session ? (session.title ?? `${AGENT_LABELS[session.kind]} in ${repositoryLabel(session.cwd)}`) @@ -140,7 +136,7 @@ function SessionScreen(): React.ReactNode { className="flex-1" style={{ backgroundColor: palette.background, - paddingBottom: USES_IOS_26_NAVIGATION ? 0 : insets.bottom, + paddingBottom: insets.bottom, }} > setOpenToolCallId(toolCall.toolCallId)} /> )} - // Inverted list: the header renders at the visual bottom — the clearance that keeps - // resting content out from under the floating composer. - ListHeaderComponent={ - USES_IOS_26_NAVIGATION && dockHeight > 0 ? ( - - ) : null - } contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12, gap: 12 }} className="flex-1" /> )} - {/* Sticky rather than an avoiding view: the inverted list already pins to the bottom, so - the composer only has to ride the keyboard instead of resizing the whole screen. On - iOS 26 the block floats over the list so content scrolls under the glass. */} - setDockHeight(event.nativeEvent.layout.height)} - > + state.themePreference); - const system = useColorScheme(); - if (preference === 'system') return system === 'dark' ? 'dark' : 'light'; - return preference; -} +import { useResolvedColorScheme } from '@mobile/components/theme/use-color-scheme'; /** `useMaterialColors` pinned to the APP theme: the bare hook follows the system scheme, which * diverges the moment the in-app appearance preference overrides it. Android components must use diff --git a/apps/mobile/src/components/form/themed-host.android.tsx b/apps/mobile/src/components/form/themed-host.android.tsx index def111e8a..0a7d00f01 100644 --- a/apps/mobile/src/components/form/themed-host.android.tsx +++ b/apps/mobile/src/components/form/themed-host.android.tsx @@ -1,5 +1,5 @@ import { Host } from '@expo/ui/jetpack-compose'; -import { useResolvedColorScheme } from '@mobile/components/form/compose-theme.android'; +import { useResolvedColorScheme } from '@mobile/components/theme/use-color-scheme'; /** `Host` pinned to the APP theme. A bare Host follows the system scheme, which diverges from * the RN surfaces the moment the in-app appearance preference overrides it — dark text on a diff --git a/apps/mobile/src/components/shell/app-providers.tsx b/apps/mobile/src/components/shell/app-providers.tsx index 0e0c96cf2..8f1e408e7 100644 --- a/apps/mobile/src/components/shell/app-providers.tsx +++ b/apps/mobile/src/components/shell/app-providers.tsx @@ -1,3 +1,5 @@ +import { useResolvedColorScheme } from '@mobile/components/theme/use-color-scheme'; +import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router'; import { HeroUINativeProvider } from 'heroui-native'; import { KeyboardProvider } from 'react-native-keyboard-controller'; import { IntlProvider } from 'use-intl'; @@ -5,7 +7,12 @@ import { IntlProvider } from 'use-intl'; /** Children-optional wrappers so the root layout can compose providers whose own * props declare `children` as required (mirrors workbench's AppI18nProvider pattern). */ export function AppThemeProvider({ children }: React.PropsWithChildren): React.ReactNode { - return {children}; + const scheme = useResolvedColorScheme(); + return ( + + {children} + + ); } /** Feeds keyboard frames to `KeyboardStickyView` so the composer tracks the keyboard. */ diff --git a/apps/mobile/src/components/shell/theme-controller.tsx b/apps/mobile/src/components/shell/theme-controller.tsx index e829177bd..337091fec 100644 --- a/apps/mobile/src/components/shell/theme-controller.tsx +++ b/apps/mobile/src/components/shell/theme-controller.tsx @@ -1,6 +1,7 @@ import { useSettingsStore } from '@mobile/stores/settings-store'; import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; +import { Appearance } from 'react-native'; import { Uniwind, useUniwind } from 'uniwind'; /** @@ -13,6 +14,7 @@ export function ThemeController(): React.ReactNode { useEffect(() => { Uniwind.setTheme(preference); + Appearance.setColorScheme(preference === 'system' ? 'unspecified' : preference); }, [preference]); return ; diff --git a/apps/mobile/src/components/theme/use-color-scheme.ts b/apps/mobile/src/components/theme/use-color-scheme.ts new file mode 100644 index 000000000..eee45843e --- /dev/null +++ b/apps/mobile/src/components/theme/use-color-scheme.ts @@ -0,0 +1,9 @@ +import { useSettingsStore } from '@mobile/stores/settings-store'; +import { useColorScheme } from 'react-native'; + +export function useResolvedColorScheme(): 'light' | 'dark' { + const preference = useSettingsStore((state) => state.themePreference); + const system = useColorScheme(); + if (preference === 'system') return system === 'dark' ? 'dark' : 'light'; + return preference; +}