| author | |
| committer | |
| log | 85389b9883a2c7b31022573563b59981d697c3b4 |
| tree | 33fd8531eaed5b9d83417ec8789c370ddcffa40e |
| parent | db1507e986591ae8e82f8fa1e173a9013309c64e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>42 files changed, 3898 insertions(+), 2082 deletions(-)
.agents/skills/test-t3-mobile/SKILL.md+16-16| ... | @@ -125,31 +125,29 @@ Do not start, stop, erase, or reconfigure an emulator owned by another task. Tra | ... | @@ -125,31 +125,29 @@ Do not start, stop, erase, or reconfigure an emulator owned by another task. Tra |
| 125 | 125 | ||
| 126 | ## Pair each client once | 126 | ## Pair each client once |
| 127 | 127 | ||
| 128 | Issue a fresh credential against the running backend's exact base directory: | 128 | Use the bundled helper from the repository root. It issues a fresh credential against the running backend's exact base directory, opens the existing Add Environment route with the credential in an encoded query parameter, and asks that route to connect once: |
| 129 | 129 | ||
| 130 | ```bash | 130 | ```bash |
| 131 | T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \ | 131 | .agents/skills/test-t3-mobile/scripts/pair-client.sh \ |
| 132 | --base-dir <base-dir> \ | 132 | ios <simulator-udid> <server-port> <base-dir> |
| 133 | --base-url <mobile-origin> \ | 133 | |
| 134 | --ttl 15m \ | 134 | .agents/skills/test-t3-mobile/scripts/pair-client.sh \ |
| 135 | --label agent-mobile-<short-device-id> | 135 | android <emulator-serial> <server-port> <base-dir> |
| 136 | ``` | 136 | ``` |
| 137 | 137 | ||
| 138 | In PowerShell, set `$env:T3CODE_PORT = "<server-port>"` first and run the `node ... auth pairing create` command without the leading assignment. | 138 | Run only the command for the selected platform. The helper uses `http://127.0.0.1:<server-port>` for iOS and `http://10.0.2.2:<server-port>` for Android. Pass a fifth argument only when testing a non-development URL scheme. |
| 139 | 139 | ||
| 140 | If the visible Add Environment action is not exposed as a semantic target, open the app's registered route instead of guessing coordinates: | 140 | The helper opens this registered route: |
| 141 | 141 | ||
| 142 | ```bash | 142 | ```text |
| 143 | xcrun simctl openurl <simulator-udid> 't3code-dev://connections/new' | 143 | t3code-dev://connections/new?pairingUrl=<encoded-pairing-url>&autoConnect=1 |
| 144 | adb -s <emulator-serial> shell am start -W \ | ||
| 145 | -a android.intent.action.VIEW \ | ||
| 146 | -d 't3code-dev://connections/new' \ | ||
| 147 | com.t3tools.t3code.dev | ||
| 148 | ``` | 144 | ``` |
| 149 | 145 | ||
| 150 | Run only the command for the selected platform. | 146 | The Add Environment route owns the behavior: `pairingUrl` prefills its normal host and token inputs, while `autoConnect=1` submits once in development builds and returns to Home after success. Without `autoConnect`, the same route only prefills the form for manual inspection. |
| 147 | |||
| 148 | Do not enter pairing hosts or tokens through simulator keyboard automation. Xcode's semantic typer sends HID-style key events through the simulator's active keyboard state, which can corrupt uppercase tokens and punctuation even when the host Mac uses a U.S. input source. The one-shot route is the deterministic pairing path. Use the visible form only as a fallback, and paste credentials rather than typing them character by character. | ||
| 151 | 149 | ||
| 152 | In T3 Code Dev, open Add Environment and enter the complete `<mobile-origin>` and newly printed `Token`. Verify the expected seeded projects appear before exercising the affected flow. | 150 | Verify the expected seeded projects appear before exercising the affected flow. |
| 153 | 151 | ||
| 154 | Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. | 152 | Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. |
| 155 | 153 | ||
| ... | @@ -183,6 +181,8 @@ Keep local verification focused. Do not turn this workflow into a full repositor | ... | @@ -183,6 +181,8 @@ Keep local verification focused. Do not turn this workflow into a full repositor |
| 183 | - **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. | 181 | - **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. |
| 184 | - **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. | 182 | - **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. |
| 185 | - **A second client cannot pair:** pairing tokens are single-use; issue another token. | 183 | - **A second client cannot pair:** pairing tokens are single-use; issue another token. |
| 184 | - **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`. | ||
| 185 | - **Pairing text changes case or punctuation:** do not retry semantic typing. Use `scripts/pair-client.sh`; the simulator keyboard layout and HID input path are not reliable for credentials. | ||
| 186 | - **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. | 186 | - **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. |
| 187 | - **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. | 187 | - **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. |
| 188 | - **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator. | 188 | - **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator. |
.agents/skills/test-t3-mobile/scripts/pair-client.sh created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | #!/usr/bin/env bash | ||
| 2 | |||
| 3 | set -euo pipefail | ||
| 4 | |||
| 5 | usage() { | ||
| 6 | echo "Usage: $0 <ios|android> <device-id> <server-port> <base-dir> [url-scheme]" >&2 | ||
| 7 | exit 2 | ||
| 8 | } | ||
| 9 | |||
| 10 | [[ $# -ge 4 && $# -le 5 ]] || usage | ||
| 11 | |||
| 12 | platform="$1" | ||
| 13 | device_id="$2" | ||
| 14 | server_port="$3" | ||
| 15 | base_dir="$4" | ||
| 16 | url_scheme="${5:-t3code-dev}" | ||
| 17 | |||
| 18 | case "$platform" in | ||
| 19 | ios) | ||
| 20 | mobile_origin="http://127.0.0.1:${server_port}" | ||
| 21 | ;; | ||
| 22 | android) | ||
| 23 | mobile_origin="http://10.0.2.2:${server_port}" | ||
| 24 | ;; | ||
| 25 | *) | ||
| 26 | usage | ||
| 27 | ;; | ||
| 28 | esac | ||
| 29 | |||
| 30 | repo_root="$(git rev-parse --show-toplevel)" | ||
| 31 | cd "$repo_root" | ||
| 32 | |||
| 33 | if ! pairing_output="$({ | ||
| 34 | T3CODE_PORT="$server_port" node apps/server/src/bin.ts auth pairing create \ | ||
| 35 | --base-dir "$base_dir" \ | ||
| 36 | --base-url "$mobile_origin" \ | ||
| 37 | --ttl 15m \ | ||
| 38 | --label "agent-mobile-${device_id:0:8}" | ||
| 39 | } 2>&1)"; then | ||
| 40 | echo "Could not mint a mobile pairing credential." >&2 | ||
| 41 | exit 1 | ||
| 42 | fi | ||
| 43 | |||
| 44 | pairing_url="$(printf '%s\n' "$pairing_output" | sed -n 's/^Pair URL: //p' | tail -n 1)" | ||
| 45 | if [[ -z "$pairing_url" ]]; then | ||
| 46 | echo "Could not parse the mobile pairing URL." >&2 | ||
| 47 | exit 1 | ||
| 48 | fi | ||
| 49 | |||
| 50 | deep_link="$(PAIRING_URL="$pairing_url" URL_SCHEME="$url_scheme" node - <<'NODE' | ||
| 51 | const query = new URLSearchParams({ | ||
| 52 | pairingUrl: process.env.PAIRING_URL, | ||
| 53 | autoConnect: "1", | ||
| 54 | }); | ||
| 55 | process.stdout.write(`${process.env.URL_SCHEME}://connections/new?${query}`); | ||
| 56 | NODE | ||
| 57 | )" | ||
| 58 | |||
| 59 | case "$platform" in | ||
| 60 | ios) | ||
| 61 | xcrun simctl openurl "$device_id" "$deep_link" | ||
| 62 | ;; | ||
| 63 | android) | ||
| 64 | # adb shell re-joins its arguments and evaluates them through the device | ||
| 65 | # shell, so the deep link's `?`/`&` must be quoted once more for that shell. | ||
| 66 | adb -s "$device_id" shell \ | ||
| 67 | "am start -W -a android.intent.action.VIEW -d '$deep_link' com.t3tools.t3code.dev" \ | ||
| 68 | >/dev/null | ||
| 69 | ;; | ||
| 70 | esac | ||
| 71 | |||
| 72 | echo "Opened the existing Add Environment route with a fresh pairing credential." | ||
apps/mobile/src/Stack.tsx+103-35| ... | @@ -11,7 +11,7 @@ import { | ... | @@ -11,7 +11,7 @@ import { |
| 11 | type NativeStackNavigationOptions, | 11 | type NativeStackNavigationOptions, |
| 12 | } from "@react-navigation/native-stack"; | 12 | } from "@react-navigation/native-stack"; |
| 13 | import { useEffect, useRef } from "react"; | 13 | import { useEffect, useRef } from "react"; |
| 14 | import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; | 14 | import { Platform, Pressable, ScrollView, StyleSheet } from "react-native"; |
| 15 | import { useResolveClassNames } from "uniwind"; | 15 | import { useResolveClassNames } from "uniwind"; |
| 16 | 16 | ||
| 17 | import { AppText as Text } from "./components/AppText"; | 17 | import { AppText as Text } from "./components/AppText"; |
| ... | @@ -39,6 +39,15 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; | ... | @@ -39,6 +39,15 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; |
| 39 | import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; | 39 | import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; |
| 40 | import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; | 40 | import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; |
| 41 | import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; | 41 | import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; |
| 42 | import { | ||
| 43 | NewTaskBranchPickerRouteScreen, | ||
| 44 | NewTaskEnvironmentPickerRouteScreen, | ||
| 45 | } from "./features/threads/NewTaskContextPickerScreens"; | ||
| 46 | import { | ||
| 47 | ExistingThreadSettingsRouteProvider, | ||
| 48 | ExistingThreadSettingsRouteScreen, | ||
| 49 | NewTaskThreadSettingsRouteScreen, | ||
| 50 | } from "./features/threads/ThreadSettingsSheet"; | ||
| 42 | import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; | 51 | import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; |
| 43 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; | 52 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; |
| 44 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; | 53 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; |
| ... | @@ -62,17 +71,15 @@ import { | ... | @@ -62,17 +71,15 @@ import { |
| 62 | } from "./features/sharing/incoming-share-presentation"; | 71 | } from "./features/sharing/incoming-share-presentation"; |
| 63 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; | 72 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; |
| 64 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; | 73 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; |
| 74 | import { | ||
| 75 | FORM_SHEET_PRESENTATION_OPTIONS, | ||
| 76 | NATIVE_SHEET_SURFACE_COLOR, | ||
| 77 | NATIVE_SHEET_SURFACE_CONTENT_STYLE, | ||
| 78 | } from "./native/sheet-surface"; | ||
| 65 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; | 79 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; |
| 66 | 80 | ||
| 67 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | 81 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 68 | 82 | ||
| 69 | // Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header | ||
| 70 | // background stay STATIC config while still adapting to appearance changes. | ||
| 71 | const SHEET_BACKGROUND_COLOR = | ||
| 72 | Platform.OS === "ios" | ||
| 73 | ? DynamicColorIOS({ light: "rgba(242, 242, 247, 0.98)", dark: "rgba(14, 14, 14, 0.98)" }) | ||
| 74 | : undefined; | ||
| 75 | |||
| 76 | type AppScreenOptions = NativeStackNavigationOptions & { | 83 | type AppScreenOptions = NativeStackNavigationOptions & { |
| 77 | readonly unstable_navigationItemStyle?: "editor"; | 84 | readonly unstable_navigationItemStyle?: "editor"; |
| 78 | }; | 85 | }; |
| ... | @@ -91,8 +98,8 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { | ... | @@ -91,8 +98,8 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { |
| 91 | headerShown: true, | 98 | headerShown: true, |
| 92 | headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED | 99 | headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED |
| 93 | ? { backgroundColor: "transparent" } | 100 | ? { backgroundColor: "transparent" } |
| 94 | : SHEET_BACKGROUND_COLOR !== undefined | 101 | : NATIVE_SHEET_SURFACE_COLOR !== undefined |
| 95 | ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } | 102 | ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } |
| 96 | : undefined, | 103 | : undefined, |
| 97 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, | 104 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, |
| 98 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, | 105 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, |
| ... | @@ -109,10 +116,10 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = { | ... | @@ -109,10 +116,10 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = { |
| 109 | headerShadowVisible: false, | 116 | headerShadowVisible: false, |
| 110 | headerShown: true, | 117 | headerShown: true, |
| 111 | headerStyle: | 118 | headerStyle: |
| 112 | SHEET_BACKGROUND_COLOR !== undefined | 119 | NATIVE_SHEET_SURFACE_COLOR !== undefined |
| 113 | ? // native-stack types this as `string`, but the native side accepts any | 120 | ? // native-stack types this as `string`, but the native side accepts any |
| 114 | // ColorValue including DynamicColorIOS. | 121 | // ColorValue including DynamicColorIOS. |
| 115 | { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } | 122 | { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } |
| 116 | : undefined, | 123 | : undefined, |
| 117 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, | 124 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, |
| 118 | headerTransparent: false, | 125 | headerTransparent: false, |
| ... | @@ -125,6 +132,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { | ... | @@ -125,6 +132,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { |
| 125 | unstable_navigationItemStyle: undefined, | 132 | unstable_navigationItemStyle: undefined, |
| 126 | }; | 133 | }; |
| 127 | 134 | ||
| 135 | // A native glass header for a sheet screen whose primary child is a scroll | ||
| 136 | // view. The centered sheet title stays stable while UIKit supplies scroll-edge | ||
| 137 | // fading from that child. | ||
| 138 | const SHEET_GLASS_HEADER_OPTIONS: AppScreenOptions = { | ||
| 139 | ...GLASS_HEADER_OPTIONS, | ||
| 140 | unstable_navigationItemStyle: undefined, | ||
| 141 | }; | ||
| 142 | |||
| 128 | const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { | 143 | const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { |
| 129 | ...SHEET_SOLID_HEADER_OPTIONS, | 144 | ...SHEET_SOLID_HEADER_OPTIONS, |
| 130 | headerBackVisible: false, | 145 | headerBackVisible: false, |
| ... | @@ -238,9 +253,16 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; | ... | @@ -238,9 +253,16 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; |
| 238 | const NewTaskSheetStack = createNativeStackNavigator({ | 253 | const NewTaskSheetStack = createNativeStackNavigator({ |
| 239 | initialRouteName: "NewTask", | 254 | initialRouteName: "NewTask", |
| 240 | screenOptions: { | 255 | screenOptions: { |
| 241 | ...GLASS_HEADER_OPTIONS, | 256 | ...SHEET_GLASS_HEADER_OPTIONS, |
| 242 | // Sheets read better with the iOS-default centered title (no editor style). | 257 | // The form-sheet host owns the one opaque adaptive surface. Child screens |
| 243 | unstable_navigationItemStyle: undefined, | 258 | // and the navigation bar stay transparent over it, avoiding visible color |
| 259 | // slabs as view controllers move horizontally. | ||
| 260 | contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, | ||
| 261 | // UIKit's default push adds a dimming shadow and independently transitions | ||
| 262 | // the navigation bar. Both read as mismatched sheet backgrounds here. | ||
| 263 | // simple_push retains native push/pop gestures without either artifact. | ||
| 264 | animation: Platform.OS === "ios" ? "simple_push" : undefined, | ||
| 265 | animationDuration: Platform.OS === "ios" ? 350 : undefined, | ||
| 244 | }, | 266 | }, |
| 245 | screens: { | 267 | screens: { |
| 246 | NewTask: createNativeStackScreen({ | 268 | NewTask: createNativeStackScreen({ |
| ... | @@ -253,9 +275,39 @@ const NewTaskSheetStack = createNativeStackNavigator({ | ... | @@ -253,9 +275,39 @@ const NewTaskSheetStack = createNativeStackNavigator({ |
| 253 | NewTaskDraft: createNativeStackScreen({ | 275 | NewTaskDraft: createNativeStackScreen({ |
| 254 | screen: NewTaskDraftRouteScreen, | 276 | screen: NewTaskDraftRouteScreen, |
| 255 | linking: "draft", | 277 | linking: "draft", |
| 256 | // The draft composer has no scroll view for glass to sample; a solid | 278 | options: { |
| 257 | // header also lays the content out below the bar (no manual inset). | 279 | headerBackVisible: false, |
| 258 | options: SHEET_SOLID_HEADER_OPTIONS, | 280 | title: "", |
| 281 | }, | ||
| 282 | }), | ||
| 283 | NewTaskEnvironment: createNativeStackScreen({ | ||
| 284 | screen: NewTaskEnvironmentPickerRouteScreen, | ||
| 285 | linking: "draft/environment", | ||
| 286 | options: { | ||
| 287 | title: "Environment", | ||
| 288 | }, | ||
| 289 | }), | ||
| 290 | NewTaskBranch: createNativeStackScreen({ | ||
| 291 | screen: NewTaskBranchPickerRouteScreen, | ||
| 292 | linking: "draft/branch", | ||
| 293 | options: { | ||
| 294 | title: "Branch", | ||
| 295 | }, | ||
| 296 | }), | ||
| 297 | ThreadSettings: createNativeStackScreen({ | ||
| 298 | screen: NewTaskThreadSettingsRouteScreen, | ||
| 299 | linking: "draft/settings", | ||
| 300 | options: { | ||
| 301 | gestureEnabled: true, | ||
| 302 | headerShown: false, | ||
| 303 | ...(Platform.OS === "android" | ||
| 304 | ? { presentation: "card" as const } | ||
| 305 | : { | ||
| 306 | ...FORM_SHEET_PRESENTATION_OPTIONS, | ||
| 307 | sheetAllowedDetents: [1], | ||
| 308 | sheetGrabberVisible: true, | ||
| 309 | }), | ||
| 310 | }, | ||
| 259 | }), | 311 | }), |
| 260 | AddProject: createNativeStackScreen({ | 312 | AddProject: createNativeStackScreen({ |
| 261 | screen: AddProjectSourceRoute, | 313 | screen: AddProjectSourceRoute, |
| ... | @@ -294,6 +346,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ | ... | @@ -294,6 +346,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ |
| 294 | "SettingsLegal", | 346 | "SettingsLegal", |
| 295 | "SettingsSheet", | 347 | "SettingsSheet", |
| 296 | "ThreadReviewComment", | 348 | "ThreadReviewComment", |
| 349 | "ThreadSettingsSheet", | ||
| 297 | ]); | 350 | ]); |
| 298 | 351 | ||
| 299 | /** | 352 | /** |
| ... | @@ -356,9 +409,11 @@ function RootStackLayout(props: { | ... | @@ -356,9 +409,11 @@ function RootStackLayout(props: { |
| 356 | <HardwareKeyboardCommandProvider pathname={pathname}> | 409 | <HardwareKeyboardCommandProvider pathname={pathname}> |
| 357 | <ThreadOutboxDrainWorker /> | 410 | <ThreadOutboxDrainWorker /> |
| 358 | <ShowcaseCaptureCoordinator pathname={pathname} /> | 411 | <ShowcaseCaptureCoordinator pathname={pathname} /> |
| 359 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> | 412 | <ExistingThreadSettingsRouteProvider> |
| 360 | {props.children} | 413 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> |
| 361 | </AdaptiveWorkspaceLayout> | 414 | {props.children} |
| 415 | </AdaptiveWorkspaceLayout> | ||
| 416 | </ExistingThreadSettingsRouteProvider> | ||
| 362 | </HardwareKeyboardCommandProvider> | 417 | </HardwareKeyboardCommandProvider> |
| 363 | ); | 418 | ); |
| 364 | } | 419 | } |
| ... | @@ -440,7 +495,9 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -440,7 +495,9 @@ export const RootStack = createNativeStackNavigator({ |
| 440 | options: { | 495 | options: { |
| 441 | // Android cannot host the keyboard-driven comment composer inside a | 496 | // Android cannot host the keyboard-driven comment composer inside a |
| 442 | // formSheet; use a full-screen modal there instead. | 497 | // formSheet; use a full-screen modal there instead. |
| 443 | presentation: Platform.OS === "android" ? "fullScreenModal" : "formSheet", | 498 | ...(Platform.OS === "android" |
| 499 | ? { presentation: "fullScreenModal" as const } | ||
| 500 | : FORM_SHEET_PRESENTATION_OPTIONS), | ||
| 444 | sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], | 501 | sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], |
| 445 | sheetGrabberVisible: Platform.OS !== "android", | 502 | sheetGrabberVisible: Platform.OS !== "android", |
| 446 | }, | 503 | }, |
| ... | @@ -450,10 +507,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -450,10 +507,7 @@ export const RootStack = createNativeStackNavigator({ |
| 450 | linking: `${THREAD_LINKING_PREFIX}/files`, | 507 | linking: `${THREAD_LINKING_PREFIX}/files`, |
| 451 | options: { | 508 | options: { |
| 452 | ...GLASS_HEADER_OPTIONS, | 509 | ...GLASS_HEADER_OPTIONS, |
| 453 | contentStyle: | 510 | contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE, |
| 454 | SHEET_BACKGROUND_COLOR !== undefined | ||
| 455 | ? { backgroundColor: SHEET_BACKGROUND_COLOR } | ||
| 456 | : undefined, | ||
| 457 | title: "Files", | 511 | title: "Files", |
| 458 | }, | 512 | }, |
| 459 | }), | 513 | }), |
| ... | @@ -462,11 +516,25 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -462,11 +516,25 @@ export const RootStack = createNativeStackNavigator({ |
| 462 | linking: `${THREAD_LINKING_PREFIX}/files/:path*`, | 516 | linking: `${THREAD_LINKING_PREFIX}/files/:path*`, |
| 463 | options: SOLID_HEADER_OPTIONS, | 517 | options: SOLID_HEADER_OPTIONS, |
| 464 | }), | 518 | }), |
| 519 | ThreadSettingsSheet: createNativeStackScreen({ | ||
| 520 | screen: ExistingThreadSettingsRouteScreen, | ||
| 521 | options: { | ||
| 522 | gestureEnabled: true, | ||
| 523 | headerShown: false, | ||
| 524 | ...(Platform.OS === "android" | ||
| 525 | ? { presentation: "card" as const } | ||
| 526 | : { | ||
| 527 | ...FORM_SHEET_PRESENTATION_OPTIONS, | ||
| 528 | sheetAllowedDetents: [1], | ||
| 529 | sheetGrabberVisible: true, | ||
| 530 | }), | ||
| 531 | }, | ||
| 532 | }), | ||
| 465 | GitOverview: createNativeStackScreen({ | 533 | GitOverview: createNativeStackScreen({ |
| 466 | screen: GitOverviewSheet, | 534 | screen: GitOverviewSheet, |
| 467 | linking: `${THREAD_LINKING_PREFIX}/git`, | 535 | linking: `${THREAD_LINKING_PREFIX}/git`, |
| 468 | options: { | 536 | options: { |
| 469 | presentation: "formSheet", | 537 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 470 | sheetAllowedDetents: [0.55, 0.92], | 538 | sheetAllowedDetents: [0.55, 0.92], |
| 471 | sheetGrabberVisible: true, | 539 | sheetGrabberVisible: true, |
| 472 | }, | 540 | }, |
| ... | @@ -475,7 +543,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -475,7 +543,7 @@ export const RootStack = createNativeStackNavigator({ |
| 475 | screen: GitCommitSheet, | 543 | screen: GitCommitSheet, |
| 476 | linking: `${THREAD_LINKING_PREFIX}/git/commit`, | 544 | linking: `${THREAD_LINKING_PREFIX}/git/commit`, |
| 477 | options: { | 545 | options: { |
| 478 | presentation: "formSheet", | 546 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 479 | sheetAllowedDetents: [0.55, 0.92], | 547 | sheetAllowedDetents: [0.55, 0.92], |
| 480 | sheetGrabberVisible: true, | 548 | sheetGrabberVisible: true, |
| 481 | }, | 549 | }, |
| ... | @@ -484,7 +552,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -484,7 +552,7 @@ export const RootStack = createNativeStackNavigator({ |
| 484 | screen: GitBranchesSheet, | 552 | screen: GitBranchesSheet, |
| 485 | linking: `${THREAD_LINKING_PREFIX}/git/branches`, | 553 | linking: `${THREAD_LINKING_PREFIX}/git/branches`, |
| 486 | options: { | 554 | options: { |
| 487 | presentation: "formSheet", | 555 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 488 | sheetAllowedDetents: [0.55, 0.92], | 556 | sheetAllowedDetents: [0.55, 0.92], |
| 489 | sheetGrabberVisible: true, | 557 | sheetGrabberVisible: true, |
| 490 | }, | 558 | }, |
| ... | @@ -493,7 +561,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -493,7 +561,7 @@ export const RootStack = createNativeStackNavigator({ |
| 493 | screen: GitConfirmSheet, | 561 | screen: GitConfirmSheet, |
| 494 | linking: `${THREAD_LINKING_PREFIX}/git-confirm`, | 562 | linking: `${THREAD_LINKING_PREFIX}/git-confirm`, |
| 495 | options: { | 563 | options: { |
| 496 | presentation: "formSheet", | 564 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 497 | sheetAllowedDetents: [0.45, 0.7], | 565 | sheetAllowedDetents: [0.45, 0.7], |
| 498 | sheetGrabberVisible: true, | 566 | sheetGrabberVisible: true, |
| 499 | }, | 567 | }, |
| ... | @@ -509,7 +577,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -509,7 +577,7 @@ export const RootStack = createNativeStackNavigator({ |
| 509 | ...(Platform.OS === "android" | 577 | ...(Platform.OS === "android" |
| 510 | ? { presentation: "card" as const } | 578 | ? { presentation: "card" as const } |
| 511 | : { | 579 | : { |
| 512 | presentation: "formSheet" as const, | 580 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 513 | sheetAllowedDetents: [0.7, 0.92], | 581 | sheetAllowedDetents: [0.7, 0.92], |
| 514 | sheetGrabberVisible: true, | 582 | sheetGrabberVisible: true, |
| 515 | }), | 583 | }), |
| ... | @@ -532,7 +600,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -532,7 +600,7 @@ export const RootStack = createNativeStackNavigator({ |
| 532 | ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), | 600 | ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), |
| 533 | title: "Set up T3 Connect", | 601 | title: "Set up T3 Connect", |
| 534 | gestureEnabled: true, | 602 | gestureEnabled: true, |
| 535 | presentation: "formSheet", | 603 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 536 | sheetAllowedDetents: [0.6, 0.95], | 604 | sheetAllowedDetents: [0.6, 0.95], |
| 537 | sheetGrabberVisible: true, | 605 | sheetGrabberVisible: true, |
| 538 | }, | 606 | }, |
| ... | @@ -547,7 +615,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -547,7 +615,7 @@ export const RootStack = createNativeStackNavigator({ |
| 547 | ...(Platform.OS === "android" | 615 | ...(Platform.OS === "android" |
| 548 | ? { presentation: "card" as const, headerShown: false } | 616 | ? { presentation: "card" as const, headerShown: false } |
| 549 | : { | 617 | : { |
| 550 | presentation: "formSheet" as const, | 618 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 551 | sheetAllowedDetents: [0.55, 0.7], | 619 | sheetAllowedDetents: [0.55, 0.7], |
| 552 | sheetGrabberVisible: true, | 620 | sheetGrabberVisible: true, |
| 553 | }), | 621 | }), |
| ... | @@ -557,7 +625,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -557,7 +625,7 @@ export const RootStack = createNativeStackNavigator({ |
| 557 | screen: ConnectionsNewRouteScreen, | 625 | screen: ConnectionsNewRouteScreen, |
| 558 | linking: "connections/new", | 626 | linking: "connections/new", |
| 559 | options: { | 627 | options: { |
| 560 | presentation: "formSheet", | 628 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 561 | sheetAllowedDetents: [0.55, 0.7], | 629 | sheetAllowedDetents: [0.55, 0.7], |
| 562 | sheetGrabberVisible: true, | 630 | sheetGrabberVisible: true, |
| 563 | }, | 631 | }, |
| ... | @@ -577,7 +645,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -577,7 +645,7 @@ export const RootStack = createNativeStackNavigator({ |
| 577 | ...(Platform.OS === "android" | 645 | ...(Platform.OS === "android" |
| 578 | ? { presentation: "card" as const } | 646 | ? { presentation: "card" as const } |
| 579 | : { | 647 | : { |
| 580 | presentation: "formSheet" as const, | 648 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 581 | sheetAllowedDetents: [0.92], | 649 | sheetAllowedDetents: [0.92], |
| 582 | sheetGrabberVisible: true, | 650 | sheetGrabberVisible: true, |
| 583 | }), | 651 | }), |
apps/mobile/src/components/ComposerToolbar.tsx created+311| ... | @@ -0,0 +1,311 @@ | ||
| 1 | import type { ComponentProps, ReactNode } from "react"; | ||
| 2 | import { useCallback, useMemo, useState } from "react"; | ||
| 3 | import { | ||
| 4 | Pressable, | ||
| 5 | ScrollView, | ||
| 6 | View, | ||
| 7 | useColorScheme, | ||
| 8 | type LayoutChangeEvent, | ||
| 9 | type NativeScrollEvent, | ||
| 10 | type NativeSyntheticEvent, | ||
| 11 | type StyleProp, | ||
| 12 | type ViewStyle, | ||
| 13 | } from "react-native"; | ||
| 14 | |||
| 15 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 16 | import { cn } from "../lib/cn"; | ||
| 17 | import { AppText as Text } from "./AppText"; | ||
| 18 | import { SymbolView } from "./AppSymbol"; | ||
| 19 | |||
| 20 | const COMPOSER_TOOLBAR_GAP = 8; | ||
| 21 | const COMPOSER_TOOLBAR_FADE_WIDTH = 18; | ||
| 22 | const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; | ||
| 23 | |||
| 24 | /** | ||
| 25 | * Quiet inline composer control used inside cards and their context rows. | ||
| 26 | * Unlike ComposerToolbarButton, this does not draw another pill inside the | ||
| 27 | * composer surface, so model and workspace controls read as part of the card. | ||
| 28 | */ | ||
| 29 | export function ComposerInlineControl(props: { | ||
| 30 | readonly accessibilityHint?: string; | ||
| 31 | readonly accessibilityLabel?: string; | ||
| 32 | readonly disabled?: boolean; | ||
| 33 | readonly emphasized?: boolean; | ||
| 34 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; | ||
| 35 | readonly iconNode?: ReactNode; | ||
| 36 | readonly label: string; | ||
| 37 | readonly maxWidth?: number; | ||
| 38 | readonly onPress?: () => void; | ||
| 39 | readonly selected?: boolean; | ||
| 40 | readonly static?: boolean; | ||
| 41 | readonly chevronDirection?: "down" | "right"; | ||
| 42 | readonly showChevron?: boolean; | ||
| 43 | }) { | ||
| 44 | const iconColor = useThemeColor( | ||
| 45 | props.emphasized || props.selected ? "--color-icon" : "--color-icon-muted", | ||
| 46 | ); | ||
| 47 | |||
| 48 | return ( | ||
| 49 | <Pressable | ||
| 50 | accessibilityLabel={props.accessibilityLabel ?? props.label} | ||
| 51 | accessibilityHint={props.accessibilityHint} | ||
| 52 | accessibilityRole={props.static ? undefined : "button"} | ||
| 53 | accessibilityState={ | ||
| 54 | props.static ? undefined : { disabled: props.disabled, selected: props.selected } | ||
| 55 | } | ||
| 56 | className="h-11 flex-row items-center gap-2 rounded-xl px-2 active:bg-subtle" | ||
| 57 | disabled={props.disabled || props.static} | ||
| 58 | onPress={props.onPress} | ||
| 59 | style={{ maxWidth: props.maxWidth ?? 190, opacity: props.disabled ? 0.45 : 1 }} | ||
| 60 | > | ||
| 61 | {props.iconNode ? ( | ||
| 62 | <View className="size-4 shrink-0 items-center justify-center">{props.iconNode}</View> | ||
| 63 | ) : props.icon ? ( | ||
| 64 | <SymbolView name={props.icon} size={16} tintColor={iconColor} type="monochrome" /> | ||
| 65 | ) : null} | ||
| 66 | <Text | ||
| 67 | className={cn( | ||
| 68 | "shrink text-sm font-t3-medium", | ||
| 69 | props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted", | ||
| 70 | )} | ||
| 71 | numberOfLines={1} | ||
| 72 | > | ||
| 73 | {props.label} | ||
| 74 | </Text> | ||
| 75 | {props.showChevron === false ? null : ( | ||
| 76 | <SymbolView | ||
| 77 | name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"} | ||
| 78 | size={10} | ||
| 79 | tintColor={iconColor} | ||
| 80 | type="monochrome" | ||
| 81 | /> | ||
| 82 | )} | ||
| 83 | </Pressable> | ||
| 84 | ); | ||
| 85 | } | ||
| 86 | |||
| 87 | export function ComposerToolbarRow(props: { | ||
| 88 | readonly children: ReactNode; | ||
| 89 | readonly paddingBottom?: number; | ||
| 90 | readonly paddingHorizontal?: number; | ||
| 91 | readonly paddingTop?: number; | ||
| 92 | readonly style?: StyleProp<ViewStyle>; | ||
| 93 | }) { | ||
| 94 | return ( | ||
| 95 | <View | ||
| 96 | className="flex-row items-center gap-1.5" | ||
| 97 | style={[ | ||
| 98 | { | ||
| 99 | paddingBottom: props.paddingBottom ?? 8, | ||
| 100 | paddingHorizontal: props.paddingHorizontal ?? 6, | ||
| 101 | paddingTop: props.paddingTop ?? 8, | ||
| 102 | }, | ||
| 103 | props.style, | ||
| 104 | ]} | ||
| 105 | > | ||
| 106 | {props.children} | ||
| 107 | </View> | ||
| 108 | ); | ||
| 109 | } | ||
| 110 | |||
| 111 | export function ComposerToolbarScroller(props: { | ||
| 112 | readonly children: ReactNode; | ||
| 113 | readonly fadeOpaque: string; | ||
| 114 | readonly fadeTransparent: string; | ||
| 115 | readonly contentPaddingRight?: number; | ||
| 116 | }) { | ||
| 117 | const [metrics, setMetrics] = useState({ | ||
| 118 | contentWidth: 0, | ||
| 119 | offsetX: 0, | ||
| 120 | viewportWidth: 0, | ||
| 121 | }); | ||
| 122 | |||
| 123 | const scrollEdges = useMemo(() => { | ||
| 124 | const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); | ||
| 125 | return { | ||
| 126 | showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, | ||
| 127 | showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, | ||
| 128 | }; | ||
| 129 | }, [metrics]); | ||
| 130 | |||
| 131 | const handleLayout = useCallback((event: LayoutChangeEvent) => { | ||
| 132 | const viewportWidth = event.nativeEvent.layout.width; | ||
| 133 | setMetrics((current) => | ||
| 134 | current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth }, | ||
| 135 | ); | ||
| 136 | }, []); | ||
| 137 | |||
| 138 | const handleContentSizeChange = useCallback((contentWidth: number) => { | ||
| 139 | setMetrics((current) => | ||
| 140 | current.contentWidth === contentWidth ? current : { ...current, contentWidth }, | ||
| 141 | ); | ||
| 142 | }, []); | ||
| 143 | |||
| 144 | const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 145 | const offsetX = event.nativeEvent.contentOffset.x; | ||
| 146 | setMetrics((current) => | ||
| 147 | Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX }, | ||
| 148 | ); | ||
| 149 | }, []); | ||
| 150 | |||
| 151 | return ( | ||
| 152 | <View className="relative min-w-0 flex-1"> | ||
| 153 | <ScrollView | ||
| 154 | horizontal | ||
| 155 | keyboardShouldPersistTaps="always" | ||
| 156 | onContentSizeChange={handleContentSizeChange} | ||
| 157 | onLayout={handleLayout} | ||
| 158 | onScroll={handleScroll} | ||
| 159 | scrollEventThrottle={16} | ||
| 160 | showsHorizontalScrollIndicator={false} | ||
| 161 | contentContainerStyle={{ | ||
| 162 | alignItems: "center", | ||
| 163 | gap: COMPOSER_TOOLBAR_GAP, | ||
| 164 | paddingLeft: 0, | ||
| 165 | paddingRight: props.contentPaddingRight ?? 1, | ||
| 166 | }} | ||
| 167 | > | ||
| 168 | {props.children} | ||
| 169 | </ScrollView> | ||
| 170 | {scrollEdges.showLeftFade ? ( | ||
| 171 | <View | ||
| 172 | pointerEvents="none" | ||
| 173 | style={{ | ||
| 174 | bottom: 0, | ||
| 175 | experimental_backgroundImage: `linear-gradient(to right, ${props.fadeOpaque} 0%, ${props.fadeTransparent} 100%)`, | ||
| 176 | left: 0, | ||
| 177 | position: "absolute", | ||
| 178 | top: 0, | ||
| 179 | width: COMPOSER_TOOLBAR_FADE_WIDTH, | ||
| 180 | }} | ||
| 181 | /> | ||
| 182 | ) : null} | ||
| 183 | {scrollEdges.showRightFade ? ( | ||
| 184 | <View | ||
| 185 | pointerEvents="none" | ||
| 186 | style={{ | ||
| 187 | bottom: 0, | ||
| 188 | experimental_backgroundImage: `linear-gradient(to right, ${props.fadeTransparent} 0%, ${props.fadeOpaque} 100%)`, | ||
| 189 | position: "absolute", | ||
| 190 | right: 0, | ||
| 191 | top: 0, | ||
| 192 | width: COMPOSER_TOOLBAR_FADE_WIDTH, | ||
| 193 | }} | ||
| 194 | /> | ||
| 195 | ) : null} | ||
| 196 | </View> | ||
| 197 | ); | ||
| 198 | } | ||
| 199 | |||
| 200 | export function ComposerToolbarButton(props: { | ||
| 201 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; | ||
| 202 | readonly iconNode?: ReactNode; | ||
| 203 | readonly label?: string; | ||
| 204 | readonly accessibilityLabel?: string; | ||
| 205 | readonly active?: boolean; | ||
| 206 | readonly disabled?: boolean; | ||
| 207 | readonly maxWidth?: number; | ||
| 208 | readonly minWidth?: number; | ||
| 209 | readonly onPress?: () => void; | ||
| 210 | readonly showChevron?: boolean; | ||
| 211 | readonly textTransform?: "none" | "uppercase"; | ||
| 212 | readonly variant?: "default" | "primary" | "danger"; | ||
| 213 | readonly className?: string; | ||
| 214 | readonly style?: StyleProp<ViewStyle>; | ||
| 215 | }) { | ||
| 216 | const isDarkMode = useColorScheme() === "dark"; | ||
| 217 | const iconColor = useThemeColor("--color-icon"); | ||
| 218 | const iconSubtle = useThemeColor("--color-icon-subtle"); | ||
| 219 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 220 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 221 | const variant = props.variant ?? "default"; | ||
| 222 | const isCircle = !props.label && props.showChevron === false; | ||
| 223 | const defaultBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)"; | ||
| 224 | const activeBorderColor = isDarkMode ? "rgba(255,255,255,0.13)" : "rgba(0,0,0,0.1)"; | ||
| 225 | const filledBorderColor = | ||
| 226 | variant === "danger" | ||
| 227 | ? "rgba(255,255,255,0.14)" | ||
| 228 | : props.disabled | ||
| 229 | ? defaultBorderColor | ||
| 230 | : "rgba(255,255,255,0.18)"; | ||
| 231 | const iconTintColor = | ||
| 232 | variant === "primary" | ||
| 233 | ? props.disabled | ||
| 234 | ? iconSubtle | ||
| 235 | : primaryFg | ||
| 236 | : variant === "danger" | ||
| 237 | ? dangerFg | ||
| 238 | : iconColor; | ||
| 239 | |||
| 240 | return ( | ||
| 241 | <Pressable | ||
| 242 | accessibilityLabel={props.accessibilityLabel ?? props.label} | ||
| 243 | accessibilityRole="button" | ||
| 244 | disabled={props.disabled} | ||
| 245 | onPress={props.onPress} | ||
| 246 | className={cn( | ||
| 247 | // Default width cap lives in the class chain (not the inline style) | ||
| 248 | // so callers can lift it with max-w-full — flex-filling pills in the | ||
| 249 | // thread composer stretch to the row's edge. The numeric maxWidth | ||
| 250 | // prop still wins via the inline style below. | ||
| 251 | "h-11 max-w-[172px] flex-row items-center justify-center rounded-full active:opacity-70", | ||
| 252 | isCircle ? "w-11" : "gap-2 px-3.5", | ||
| 253 | variant === "primary" | ||
| 254 | ? props.disabled | ||
| 255 | ? "bg-subtle-strong" | ||
| 256 | : "bg-primary" | ||
| 257 | : variant === "danger" | ||
| 258 | ? "bg-danger" | ||
| 259 | : props.active | ||
| 260 | ? "bg-subtle-strong" | ||
| 261 | : "bg-subtle", | ||
| 262 | props.className, | ||
| 263 | )} | ||
| 264 | style={({ pressed }) => [ | ||
| 265 | { | ||
| 266 | borderColor: | ||
| 267 | variant === "default" | ||
| 268 | ? props.active | ||
| 269 | ? activeBorderColor | ||
| 270 | : defaultBorderColor | ||
| 271 | : filledBorderColor, | ||
| 272 | borderWidth: 1, | ||
| 273 | maxWidth: props.maxWidth, | ||
| 274 | minWidth: props.minWidth, | ||
| 275 | opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1, | ||
| 276 | shadowColor: "#000", | ||
| 277 | shadowOffset: { width: 0, height: isDarkMode ? 3 : 2 }, | ||
| 278 | shadowOpacity: props.disabled ? 0 : isDarkMode ? 0.24 : 0.08, | ||
| 279 | shadowRadius: isDarkMode ? 10 : 8, | ||
| 280 | }, | ||
| 281 | props.style, | ||
| 282 | ]} | ||
| 283 | > | ||
| 284 | {props.iconNode ? ( | ||
| 285 | <View className="h-4 w-4 items-center justify-center">{props.iconNode}</View> | ||
| 286 | ) : props.icon ? ( | ||
| 287 | <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" /> | ||
| 288 | ) : null} | ||
| 289 | {props.label ? ( | ||
| 290 | <Text | ||
| 291 | className={cn( | ||
| 292 | "shrink text-center text-sm font-t3-bold", | ||
| 293 | variant === "primary" | ||
| 294 | ? props.disabled | ||
| 295 | ? "text-foreground-muted" | ||
| 296 | : "text-primary-foreground" | ||
| 297 | : "text-foreground", | ||
| 298 | )} | ||
| 299 | ellipsizeMode="tail" | ||
| 300 | numberOfLines={1} | ||
| 301 | style={{ textTransform: props.textTransform ?? "none" }} | ||
| 302 | > | ||
| 303 | {props.label} | ||
| 304 | </Text> | ||
| 305 | ) : null} | ||
| 306 | {props.showChevron === false ? null : ( | ||
| 307 | <SymbolView name="chevron.down" size={11} tintColor={iconTintColor} type="monochrome" /> | ||
| 308 | )} | ||
| 309 | </Pressable> | ||
| 310 | ); | ||
| 311 | } | ||
apps/mobile/src/components/ComposerToolbarTrigger.tsx deleted-251| ... | @@ -1,251 +0,0 @@ | ||
| 1 | import type { ComponentProps, ReactNode } from "react"; | ||
| 2 | import { useCallback, useMemo, useState } from "react"; | ||
| 3 | import { | ||
| 4 | Pressable, | ||
| 5 | ScrollView, | ||
| 6 | View, | ||
| 7 | useColorScheme, | ||
| 8 | type LayoutChangeEvent, | ||
| 9 | type NativeScrollEvent, | ||
| 10 | type NativeSyntheticEvent, | ||
| 11 | type StyleProp, | ||
| 12 | type ViewStyle, | ||
| 13 | } from "react-native"; | ||
| 14 | |||
| 15 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 16 | import { cn } from "../lib/cn"; | ||
| 17 | import { AppText as Text } from "./AppText"; | ||
| 18 | import { SymbolView } from "./AppSymbol"; | ||
| 19 | |||
| 20 | export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44; | ||
| 21 | export const COMPOSER_TOOLBAR_GAP = 8; | ||
| 22 | export const COMPOSER_TOOLBAR_FADE_WIDTH = 18; | ||
| 23 | const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; | ||
| 24 | |||
| 25 | export function ComposerToolbarRow(props: { | ||
| 26 | readonly children: ReactNode; | ||
| 27 | readonly paddingBottom?: number; | ||
| 28 | readonly paddingHorizontal?: number; | ||
| 29 | readonly paddingTop?: number; | ||
| 30 | readonly style?: StyleProp<ViewStyle>; | ||
| 31 | }) { | ||
| 32 | return ( | ||
| 33 | <View | ||
| 34 | className="flex-row items-center gap-1.5" | ||
| 35 | style={[ | ||
| 36 | { | ||
| 37 | paddingBottom: props.paddingBottom ?? 8, | ||
| 38 | paddingHorizontal: props.paddingHorizontal ?? 6, | ||
| 39 | paddingTop: props.paddingTop ?? 8, | ||
| 40 | }, | ||
| 41 | props.style, | ||
| 42 | ]} | ||
| 43 | > | ||
| 44 | {props.children} | ||
| 45 | </View> | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | export function ComposerToolbarScroller(props: { | ||
| 50 | readonly children: ReactNode; | ||
| 51 | readonly fadeOpaque: string; | ||
| 52 | readonly fadeTransparent: string; | ||
| 53 | readonly contentPaddingRight?: number; | ||
| 54 | }) { | ||
| 55 | const [metrics, setMetrics] = useState({ | ||
| 56 | contentWidth: 0, | ||
| 57 | offsetX: 0, | ||
| 58 | viewportWidth: 0, | ||
| 59 | }); | ||
| 60 | |||
| 61 | const scrollEdges = useMemo(() => { | ||
| 62 | const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); | ||
| 63 | return { | ||
| 64 | showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, | ||
| 65 | showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, | ||
| 66 | }; | ||
| 67 | }, [metrics]); | ||
| 68 | |||
| 69 | const handleLayout = useCallback((event: LayoutChangeEvent) => { | ||
| 70 | const viewportWidth = event.nativeEvent.layout.width; | ||
| 71 | setMetrics((current) => | ||
| 72 | current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth }, | ||
| 73 | ); | ||
| 74 | }, []); | ||
| 75 | |||
| 76 | const handleContentSizeChange = useCallback((contentWidth: number) => { | ||
| 77 | setMetrics((current) => | ||
| 78 | current.contentWidth === contentWidth ? current : { ...current, contentWidth }, | ||
| 79 | ); | ||
| 80 | }, []); | ||
| 81 | |||
| 82 | const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 83 | const offsetX = event.nativeEvent.contentOffset.x; | ||
| 84 | setMetrics((current) => | ||
| 85 | Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX }, | ||
| 86 | ); | ||
| 87 | }, []); | ||
| 88 | |||
| 89 | return ( | ||
| 90 | <View className="relative min-w-0 flex-1"> | ||
| 91 | <ScrollView | ||
| 92 | horizontal | ||
| 93 | keyboardShouldPersistTaps="always" | ||
| 94 | onContentSizeChange={handleContentSizeChange} | ||
| 95 | onLayout={handleLayout} | ||
| 96 | onScroll={handleScroll} | ||
| 97 | scrollEventThrottle={16} | ||
| 98 | showsHorizontalScrollIndicator={false} | ||
| 99 | contentContainerStyle={{ | ||
| 100 | alignItems: "center", | ||
| 101 | gap: COMPOSER_TOOLBAR_GAP, | ||
| 102 | paddingLeft: 0, | ||
| 103 | paddingRight: props.contentPaddingRight ?? 1, | ||
| 104 | }} | ||
| 105 | > | ||
| 106 | {props.children} | ||
| 107 | </ScrollView> | ||
| 108 | {scrollEdges.showLeftFade ? ( | ||
| 109 | <View | ||
| 110 | pointerEvents="none" | ||
| 111 | style={{ | ||
| 112 | bottom: 0, | ||
| 113 | experimental_backgroundImage: `linear-gradient(to right, ${props.fadeOpaque} 0%, ${props.fadeTransparent} 100%)`, | ||
| 114 | left: 0, | ||
| 115 | position: "absolute", | ||
| 116 | top: 0, | ||
| 117 | width: COMPOSER_TOOLBAR_FADE_WIDTH, | ||
| 118 | }} | ||
| 119 | /> | ||
| 120 | ) : null} | ||
| 121 | {scrollEdges.showRightFade ? ( | ||
| 122 | <View | ||
| 123 | pointerEvents="none" | ||
| 124 | style={{ | ||
| 125 | bottom: 0, | ||
| 126 | experimental_backgroundImage: `linear-gradient(to right, ${props.fadeTransparent} 0%, ${props.fadeOpaque} 100%)`, | ||
| 127 | position: "absolute", | ||
| 128 | right: 0, | ||
| 129 | top: 0, | ||
| 130 | width: COMPOSER_TOOLBAR_FADE_WIDTH, | ||
| 131 | }} | ||
| 132 | /> | ||
| 133 | ) : null} | ||
| 134 | </View> | ||
| 135 | ); | ||
| 136 | } | ||
| 137 | |||
| 138 | export function ComposerToolbarButton(props: { | ||
| 139 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; | ||
| 140 | readonly iconNode?: ReactNode; | ||
| 141 | readonly label?: string; | ||
| 142 | readonly accessibilityLabel?: string; | ||
| 143 | readonly active?: boolean; | ||
| 144 | readonly disabled?: boolean; | ||
| 145 | readonly maxWidth?: number; | ||
| 146 | readonly minWidth?: number; | ||
| 147 | readonly onPress?: () => void; | ||
| 148 | readonly showChevron?: boolean; | ||
| 149 | readonly textTransform?: "none" | "uppercase"; | ||
| 150 | readonly variant?: "default" | "primary" | "danger"; | ||
| 151 | readonly className?: string; | ||
| 152 | readonly style?: StyleProp<ViewStyle>; | ||
| 153 | }) { | ||
| 154 | const isDarkMode = useColorScheme() === "dark"; | ||
| 155 | const iconColor = useThemeColor("--color-icon"); | ||
| 156 | const iconSubtle = useThemeColor("--color-icon-subtle"); | ||
| 157 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 158 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 159 | const variant = props.variant ?? "default"; | ||
| 160 | const isCircle = !props.label && props.showChevron === false; | ||
| 161 | const defaultBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)"; | ||
| 162 | const activeBorderColor = isDarkMode ? "rgba(255,255,255,0.13)" : "rgba(0,0,0,0.1)"; | ||
| 163 | const filledBorderColor = | ||
| 164 | variant === "danger" | ||
| 165 | ? "rgba(255,255,255,0.14)" | ||
| 166 | : props.disabled | ||
| 167 | ? defaultBorderColor | ||
| 168 | : "rgba(255,255,255,0.18)"; | ||
| 169 | const iconTintColor = | ||
| 170 | variant === "primary" | ||
| 171 | ? props.disabled | ||
| 172 | ? iconSubtle | ||
| 173 | : primaryFg | ||
| 174 | : variant === "danger" | ||
| 175 | ? dangerFg | ||
| 176 | : iconColor; | ||
| 177 | |||
| 178 | return ( | ||
| 179 | <Pressable | ||
| 180 | accessibilityLabel={props.accessibilityLabel ?? props.label} | ||
| 181 | accessibilityRole="button" | ||
| 182 | disabled={props.disabled} | ||
| 183 | onPress={props.onPress} | ||
| 184 | className={cn( | ||
| 185 | // Default width cap lives in the class chain (not the inline style) | ||
| 186 | // so callers can lift it with max-w-full — flex-filling pills in the | ||
| 187 | // thread composer stretch to the row's edge. The numeric maxWidth | ||
| 188 | // prop still wins via the inline style below. | ||
| 189 | "h-11 max-w-[172px] flex-row items-center justify-center rounded-full active:opacity-70", | ||
| 190 | isCircle ? "w-11" : "gap-2 px-3.5", | ||
| 191 | variant === "primary" | ||
| 192 | ? props.disabled | ||
| 193 | ? "bg-subtle-strong" | ||
| 194 | : "bg-primary" | ||
| 195 | : variant === "danger" | ||
| 196 | ? "bg-danger" | ||
| 197 | : props.active | ||
| 198 | ? "bg-subtle-strong" | ||
| 199 | : "bg-subtle", | ||
| 200 | props.className, | ||
| 201 | )} | ||
| 202 | style={({ pressed }) => [ | ||
| 203 | { | ||
| 204 | borderColor: | ||
| 205 | variant === "default" | ||
| 206 | ? props.active | ||
| 207 | ? activeBorderColor | ||
| 208 | : defaultBorderColor | ||
| 209 | : filledBorderColor, | ||
| 210 | borderWidth: 1, | ||
| 211 | maxWidth: props.maxWidth, | ||
| 212 | minWidth: props.minWidth, | ||
| 213 | opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1, | ||
| 214 | shadowColor: "#000", | ||
| 215 | shadowOffset: { width: 0, height: isDarkMode ? 3 : 2 }, | ||
| 216 | shadowOpacity: props.disabled ? 0 : isDarkMode ? 0.24 : 0.08, | ||
| 217 | shadowRadius: isDarkMode ? 10 : 8, | ||
| 218 | }, | ||
| 219 | props.style, | ||
| 220 | ]} | ||
| 221 | > | ||
| 222 | {props.iconNode ? ( | ||
| 223 | <View className="h-4 w-4 items-center justify-center">{props.iconNode}</View> | ||
| 224 | ) : props.icon ? ( | ||
| 225 | <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" /> | ||
| 226 | ) : null} | ||
| 227 | {props.label ? ( | ||
| 228 | <Text | ||
| 229 | className={cn( | ||
| 230 | "shrink text-center text-sm font-t3-bold", | ||
| 231 | variant === "primary" | ||
| 232 | ? props.disabled | ||
| 233 | ? "text-foreground-muted" | ||
| 234 | : "text-primary-foreground" | ||
| 235 | : "text-foreground", | ||
| 236 | )} | ||
| 237 | ellipsizeMode="tail" | ||
| 238 | numberOfLines={1} | ||
| 239 | style={{ textTransform: props.textTransform ?? "none" }} | ||
| 240 | > | ||
| 241 | {props.label} | ||
| 242 | </Text> | ||
| 243 | ) : null} | ||
| 244 | {props.showChevron === false ? null : ( | ||
| 245 | <SymbolView name="chevron.down" size={11} tintColor={iconTintColor} type="monochrome" /> | ||
| 246 | )} | ||
| 247 | </Pressable> | ||
| 248 | ); | ||
| 249 | } | ||
| 250 | |||
| 251 | export const ComposerToolbarTrigger = ComposerToolbarButton; | ||
apps/mobile/src/components/GlassSurface.tsx+6-2| ... | @@ -5,16 +5,19 @@ import { | ... | @@ -5,16 +5,19 @@ import { |
| 5 | useColorScheme, | 5 | useColorScheme, |
| 6 | View, | 6 | View, |
| 7 | type ColorValue, | 7 | type ColorValue, |
| 8 | type StyleProp, | ||
| 8 | type ViewProps, | 9 | type ViewProps, |
| 9 | type ViewStyle, | 10 | type ViewStyle, |
| 10 | } from "react-native"; | 11 | } from "react-native"; |
| 11 | import { useThemeColor } from "../lib/useThemeColor"; | 12 | import { useThemeColor } from "../lib/useThemeColor"; |
| 12 | 13 | ||
| 13 | export interface GlassSurfaceProps extends Omit<ViewProps, "className"> { | 14 | interface GlassSurfaceProps extends Omit<ViewProps, "className"> { |
| 14 | readonly children: ReactNode; | 15 | readonly children: ReactNode; |
| 15 | readonly glassEffectStyle?: "clear" | "regular" | "none"; | 16 | readonly glassEffectStyle?: "clear" | "regular" | "none"; |
| 16 | readonly tintColor?: ColorValue; | 17 | readonly tintColor?: ColorValue; |
| 17 | readonly chrome?: "default" | "none"; | 18 | readonly chrome?: "default" | "none"; |
| 19 | /** Styling used only when native Liquid Glass is unavailable. */ | ||
| 20 | readonly fallbackStyle?: StyleProp<ViewStyle>; | ||
| 18 | } | 21 | } |
| 19 | 22 | ||
| 20 | export function GlassSurface({ | 23 | export function GlassSurface({ |
| ... | @@ -22,6 +25,7 @@ export function GlassSurface({ | ... | @@ -22,6 +25,7 @@ export function GlassSurface({ |
| 22 | glassEffectStyle = "regular", | 25 | glassEffectStyle = "regular", |
| 23 | chrome = "default", | 26 | chrome = "default", |
| 24 | tintColor, | 27 | tintColor, |
| 28 | fallbackStyle, | ||
| 25 | style, | 29 | style, |
| 26 | ...props | 30 | ...props |
| 27 | }: GlassSurfaceProps) { | 31 | }: GlassSurfaceProps) { |
| ... | @@ -67,7 +71,7 @@ export function GlassSurface({ | ... | @@ -67,7 +71,7 @@ export function GlassSurface({ |
| 67 | } | 71 | } |
| 68 | 72 | ||
| 69 | return ( | 73 | return ( |
| 70 | <View {...props} style={[surfaceStyle, style]}> | 74 | <View {...props} style={[surfaceStyle, fallbackStyle, style]}> |
| 71 | {children} | 75 | {children} |
| 72 | </View> | 76 | </View> |
| 73 | ); | 77 | ); |
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+51-16| ... | @@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera"; | ... | @@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera"; |
| 2 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 2 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 3 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 3 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | import { useCallback, useEffect, useState } from "react"; | 5 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | import { Alert, Platform, ScrollView, View } from "react-native"; | 6 | import { Alert, Platform, ScrollView, View } from "react-native"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | import { useThemeColor } from "../../lib/useThemeColor"; | 8 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | @@ -11,12 +11,13 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ... | @@ -11,12 +11,13 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 11 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 11 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 12 | import { ErrorBanner } from "../../components/ErrorBanner"; | 12 | import { ErrorBanner } from "../../components/ErrorBanner"; |
| 13 | import { ConnectionSheetButton } from "./ConnectionSheetButton"; | 13 | import { ConnectionSheetButton } from "./ConnectionSheetButton"; |
| 14 | import { extractPairingUrlFromQrPayload } from "./pairing"; | 14 | import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; |
| 15 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | 15 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 16 | import { buildPairingUrl, parsePairingUrl } from "./pairing"; | ||
| 17 | 16 | ||
| 18 | type ConnectionsNewRouteParams = { | 17 | type ConnectionsNewRouteParams = { |
| 19 | readonly mode?: string; | 18 | readonly mode?: string; |
| 19 | readonly pairingUrl?: string; | ||
| 20 | readonly autoConnect?: string; | ||
| 20 | }; | 21 | }; |
| 21 | 22 | ||
| 22 | export function ConnectionsNewRouteScreen({ | 23 | export function ConnectionsNewRouteScreen({ |
| ... | @@ -30,6 +31,13 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -30,6 +31,13 @@ export function ConnectionsNewRouteScreen({ |
| 30 | } = useRemoteConnections(); | 31 | } = useRemoteConnections(); |
| 31 | const navigation = useNavigation(); | 32 | const navigation = useNavigation(); |
| 32 | const params = route.params ?? {}; | 33 | const params = route.params ?? {}; |
| 34 | // Deep-link prefill exists for development automation only. A production | ||
| 35 | // link must not arrive with attacker-chosen host and token already filled. | ||
| 36 | const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : ""; | ||
| 37 | const shouldAutoConnect = | ||
| 38 | __DEV__ && | ||
| 39 | routePairingUrl.length > 0 && | ||
| 40 | (params.autoConnect === "1" || params.autoConnect === "true"); | ||
| 33 | const insets = useSafeAreaInsets(); | 41 | const insets = useSafeAreaInsets(); |
| 34 | const [hostInput, setHostInput] = useState(""); | 42 | const [hostInput, setHostInput] = useState(""); |
| 35 | const [codeInput, setCodeInput] = useState(""); | 43 | const [codeInput, setCodeInput] = useState(""); |
| ... | @@ -37,6 +45,7 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -37,6 +45,7 @@ export function ConnectionsNewRouteScreen({ |
| 37 | const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); | 45 | const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); |
| 38 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); | 46 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); |
| 39 | const [scannerLocked, setScannerLocked] = useState(false); | 47 | const [scannerLocked, setScannerLocked] = useState(false); |
| 48 | const attemptedAutoConnectRef = useRef<string | null>(null); | ||
| 40 | 49 | ||
| 41 | const headerIconColor = useThemeColor("--color-icon"); | 50 | const headerIconColor = useThemeColor("--color-icon"); |
| 42 | 51 | ||
| ... | @@ -48,6 +57,16 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -48,6 +57,16 @@ export function ConnectionsNewRouteScreen({ |
| 48 | setCodeInput(code); | 57 | setCodeInput(code); |
| 49 | }, [connectionPairingUrl]); | 58 | }, [connectionPairingUrl]); |
| 50 | 59 | ||
| 60 | useEffect(() => { | ||
| 61 | if (routePairingUrl.length === 0) { | ||
| 62 | return; | ||
| 63 | } | ||
| 64 | |||
| 65 | const { host, code } = parsePairingUrl(routePairingUrl); | ||
| 66 | setHostInput(host); | ||
| 67 | setCodeInput(code); | ||
| 68 | }, [routePairingUrl]); | ||
| 69 | |||
| 51 | useEffect(() => { | 70 | useEffect(() => { |
| 52 | if (pairingConnectionError) { | 71 | if (pairingConnectionError) { |
| 53 | setIsSubmitting(false); | 72 | setIsSubmitting(false); |
| ... | @@ -116,22 +135,38 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -116,22 +135,38 @@ export function ConnectionsNewRouteScreen({ |
| 116 | [onChangeConnectionPairingUrl, scannerLocked], | 135 | [onChangeConnectionPairingUrl, scannerLocked], |
| 117 | ); | 136 | ); |
| 118 | 137 | ||
| 138 | const connectAndClose = useCallback( | ||
| 139 | async (pairingUrl: string, replaceWithHome: boolean) => { | ||
| 140 | setIsSubmitting(true); | ||
| 141 | onChangeConnectionPairingUrl(pairingUrl); | ||
| 142 | try { | ||
| 143 | const result = await onConnectPress(pairingUrl); | ||
| 144 | if (AsyncResult.isSuccess(result)) { | ||
| 145 | if (replaceWithHome || !navigation.canGoBack()) { | ||
| 146 | navigation.dispatch(StackActions.replace("Home")); | ||
| 147 | } else { | ||
| 148 | navigation.goBack(); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | } finally { | ||
| 152 | setIsSubmitting(false); | ||
| 153 | } | ||
| 154 | }, | ||
| 155 | [navigation, onChangeConnectionPairingUrl, onConnectPress], | ||
| 156 | ); | ||
| 157 | |||
| 119 | const handleSubmit = useCallback(async () => { | 158 | const handleSubmit = useCallback(async () => { |
| 120 | setIsSubmitting(true); | 159 | await connectAndClose(buildPairingUrl(hostInput, codeInput), false); |
| 160 | }, [codeInput, connectAndClose, hostInput]); | ||
| 121 | 161 | ||
| 122 | const pairingUrl = buildPairingUrl(hostInput, codeInput); | 162 | useEffect(() => { |
| 123 | onChangeConnectionPairingUrl(pairingUrl); | 163 | if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) { |
| 124 | const result = await onConnectPress(pairingUrl); | 164 | return; |
| 125 | if (AsyncResult.isSuccess(result)) { | ||
| 126 | if (navigation.canGoBack()) { | ||
| 127 | navigation.goBack(); | ||
| 128 | } else { | ||
| 129 | navigation.dispatch(StackActions.replace("Home")); | ||
| 130 | } | ||
| 131 | } else { | ||
| 132 | setIsSubmitting(false); | ||
| 133 | } | 165 | } |
| 134 | }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]); | 166 | |
| 167 | attemptedAutoConnectRef.current = routePairingUrl; | ||
| 168 | void connectAndClose(routePairingUrl, true); | ||
| 169 | }, [connectAndClose, routePairingUrl, shouldAutoConnect]); | ||
| 135 | 170 | ||
| 136 | return ( | 171 | return ( |
| 137 | <View collapsable={false} className="flex-1 bg-sheet"> | 172 | <View collapsable={false} className="flex-1 bg-sheet"> |
apps/mobile/src/features/home/HomeHeader.tsx+1| ... | @@ -356,6 +356,7 @@ function IosHomeHeader(props: HomeHeaderProps) { | ... | @@ -356,6 +356,7 @@ function IosHomeHeader(props: HomeHeaderProps) { |
| 356 | onSearchTextChange: props.onSearchQueryChange, | 356 | onSearchTextChange: props.onSearchQueryChange, |
| 357 | placeholder: "Search", | 357 | placeholder: "Search", |
| 358 | searchTextChangeId: "home-search-text", | 358 | searchTextChangeId: "home-search-text", |
| 359 | showsSearchDismissButton: true, | ||
| 359 | }), | 360 | }), |
| 360 | ], | 361 | ], |
| 361 | } | 362 | } |
apps/mobile/src/features/layout/native-mail-search-toolbar.ts+3| ... | @@ -11,6 +11,9 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | ... | @@ -11,6 +11,9 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 11 | */ | 11 | */ |
| 12 | export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; | 12 | export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; |
| 13 | 13 | ||
| 14 | /** Clearance for scroll content that must come to rest above the floating toolbar. */ | ||
| 15 | export const NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET = 56; | ||
| 16 | |||
| 14 | type NativeMailSearchToolbarInput = Omit< | 17 | type NativeMailSearchToolbarInput = Omit< |
| 15 | HeaderBarButtonMailSearchToolbarItem, | 18 | HeaderBarButtonMailSearchToolbarItem, |
| 16 | "type" | "useFallbackSearchField" | 19 | "type" | "useFallbackSearchField" |
apps/mobile/src/features/projects/AddProjectScreen.tsx+41-29| ... | @@ -27,7 +27,7 @@ import { | ... | @@ -27,7 +27,7 @@ import { |
| 27 | inferProjectTitleFromPath, | 27 | inferProjectTitleFromPath, |
| 28 | } from "@t3tools/client-runtime/state/projects"; | 28 | } from "@t3tools/client-runtime/state/projects"; |
| 29 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; | 29 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 30 | import { StackActions, useNavigation } from "@react-navigation/native"; | 30 | import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; |
| 31 | import { SymbolView } from "../../components/AppSymbol"; | 31 | import { SymbolView } from "../../components/AppSymbol"; |
| 32 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 32 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 33 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | 33 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; |
| ... | @@ -402,13 +402,12 @@ function SourceControlRow(props: { | ... | @@ -402,13 +402,12 @@ function SourceControlRow(props: { |
| 402 | icon={icon} | 402 | icon={icon} |
| 403 | isFirst={props.isFirst} | 403 | isFirst={props.isFirst} |
| 404 | onPress={() => | 404 | onPress={() => |
| 405 | navigation.navigate("NewTaskSheet", { | 405 | navigation.dispatch( |
| 406 | screen: "AddProjectRepository", | 406 | StackActions.push("AddProjectRepository", { |
| 407 | params: { | ||
| 408 | environmentId: props.selectedEnvironmentId, | 407 | environmentId: props.selectedEnvironmentId, |
| 409 | source: props.source, | 408 | source: props.source, |
| 410 | }, | 409 | }), |
| 411 | }) | 410 | ) |
| 412 | } | 411 | } |
| 413 | /> | 412 | /> |
| 414 | ); | 413 | ); |
| ... | @@ -498,12 +497,11 @@ export function AddProjectSourceScreen() { | ... | @@ -498,12 +497,11 @@ export function AddProjectSourceScreen() { |
| 498 | } | 497 | } |
| 499 | isFirst | 498 | isFirst |
| 500 | onPress={() => | 499 | onPress={() => |
| 501 | navigation.navigate("NewTaskSheet", { | 500 | navigation.dispatch( |
| 502 | screen: "AddProjectLocal", | 501 | StackActions.push("AddProjectLocal", { |
| 503 | params: { | ||
| 504 | environmentId: selectedEnvironment.environmentId, | 502 | environmentId: selectedEnvironment.environmentId, |
| 505 | }, | 503 | }), |
| 506 | }) | 504 | ) |
| 507 | } | 505 | } |
| 508 | /> | 506 | /> |
| 509 | {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( | 507 | {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( |
| ... | @@ -547,10 +545,18 @@ function useCreateProject(environment: EnvironmentOption | null) { | ... | @@ -547,10 +545,18 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 547 | if (existing) { | 545 | if (existing) { |
| 548 | Alert.alert("Project already exists", existing.title); | 546 | Alert.alert("Project already exists", existing.title); |
| 549 | navigation.dispatch( | 547 | navigation.dispatch( |
| 550 | StackActions.replace("NewTaskDraft", { | 548 | CommonActions.reset({ |
| 551 | environmentId: existing.environmentId, | 549 | index: 0, |
| 552 | projectId: existing.id, | 550 | routes: [ |
| 553 | title: existing.title, | 551 | { |
| 552 | name: "NewTaskDraft", | ||
| 553 | params: { | ||
| 554 | environmentId: existing.environmentId, | ||
| 555 | projectId: existing.id, | ||
| 556 | title: existing.title, | ||
| 557 | }, | ||
| 558 | }, | ||
| 559 | ], | ||
| 554 | }), | 560 | }), |
| 555 | ); | 561 | ); |
| 556 | return; | 562 | return; |
| ... | @@ -571,10 +577,18 @@ function useCreateProject(environment: EnvironmentOption | null) { | ... | @@ -571,10 +577,18 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 571 | return result; | 577 | return result; |
| 572 | } | 578 | } |
| 573 | navigation.dispatch( | 579 | navigation.dispatch( |
| 574 | StackActions.replace("NewTaskDraft", { | 580 | CommonActions.reset({ |
| 575 | environmentId: environment.environmentId, | 581 | index: 0, |
| 576 | projectId, | 582 | routes: [ |
| 577 | title: inferProjectTitleFromPath(workspaceRoot), | 583 | { |
| 584 | name: "NewTaskDraft", | ||
| 585 | params: { | ||
| 586 | environmentId: environment.environmentId, | ||
| 587 | projectId, | ||
| 588 | title: inferProjectTitleFromPath(workspaceRoot), | ||
| 589 | }, | ||
| 590 | }, | ||
| 591 | ], | ||
| 578 | }), | 592 | }), |
| 579 | ); | 593 | ); |
| 580 | return result; | 594 | return result; |
| ... | @@ -612,15 +626,14 @@ export function AddProjectRepositoryScreen(props: { | ... | @@ -612,15 +626,14 @@ export function AddProjectRepositoryScreen(props: { |
| 612 | const provider = addProjectRemoteSourceProvider(source); | 626 | const provider = addProjectRemoteSourceProvider(source); |
| 613 | if (!provider) { | 627 | if (!provider) { |
| 614 | const remoteUrl = repositoryInput.trim(); | 628 | const remoteUrl = repositoryInput.trim(); |
| 615 | navigation.navigate("NewTaskSheet", { | 629 | navigation.dispatch( |
| 616 | screen: "AddProjectDestination", | 630 | StackActions.push("AddProjectDestination", { |
| 617 | params: { | ||
| 618 | environmentId: environment.environmentId, | 631 | environmentId: environment.environmentId, |
| 619 | source, | 632 | source, |
| 620 | remoteUrl, | 633 | remoteUrl, |
| 621 | repositoryTitle: remoteUrl, | 634 | repositoryTitle: remoteUrl, |
| 622 | }, | 635 | }), |
| 623 | }); | 636 | ); |
| 624 | setIsSubmitting(false); | 637 | setIsSubmitting(false); |
| 625 | return; | 638 | return; |
| 626 | } | 639 | } |
| ... | @@ -636,15 +649,14 @@ export function AddProjectRepositoryScreen(props: { | ... | @@ -636,15 +649,14 @@ export function AddProjectRepositoryScreen(props: { |
| 636 | setError(errorMessage(Cause.squash(result.cause))); | 649 | setError(errorMessage(Cause.squash(result.cause))); |
| 637 | } else { | 650 | } else { |
| 638 | const repository = result.value; | 651 | const repository = result.value; |
| 639 | navigation.navigate("NewTaskSheet", { | 652 | navigation.dispatch( |
| 640 | screen: "AddProjectDestination", | 653 | StackActions.push("AddProjectDestination", { |
| 641 | params: { | ||
| 642 | environmentId: environment.environmentId, | 654 | environmentId: environment.environmentId, |
| 643 | source, | 655 | source, |
| 644 | remoteUrl: repository.sshUrl, | 656 | remoteUrl: repository.sshUrl, |
| 645 | repositoryTitle: repository.nameWithOwner, | 657 | repositoryTitle: repository.nameWithOwner, |
| 646 | }, | 658 | }), |
| 647 | }); | 659 | ); |
| 648 | } | 660 | } |
| 649 | setIsSubmitting(false); | 661 | setIsSubmitting(false); |
| 650 | }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); | 662 | }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+11-2| ... | @@ -549,7 +549,10 @@ function GeneralSettingsSection() { | ... | @@ -549,7 +549,10 @@ function GeneralSettingsSection() { |
| 549 | */ | 549 | */ |
| 550 | function LegacySettingsSection() { | 550 | function LegacySettingsSection() { |
| 551 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | 551 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 552 | const preferences = useAtomValue(mobilePreferencesAtom); | ||
| 552 | const threadListV2Enabled = useThreadListV2Enabled(); | 553 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 554 | const planModeEnabled = | ||
| 555 | AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; | ||
| 553 | 556 | ||
| 554 | return ( | 557 | return ( |
| 555 | <View className="gap-3"> | 558 | <View className="gap-3"> |
| ... | @@ -560,10 +563,16 @@ function LegacySettingsSection() { | ... | @@ -560,10 +563,16 @@ function LegacySettingsSection() { |
| 560 | value={!threadListV2Enabled} | 563 | value={!threadListV2Enabled} |
| 561 | onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} | 564 | onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} |
| 562 | /> | 565 | /> |
| 566 | <SettingsSwitchRow | ||
| 567 | icon="hammer" | ||
| 568 | label="Plan Mode" | ||
| 569 | value={planModeEnabled} | ||
| 570 | onValueChange={(value) => savePreferences({ planModeEnabled: value })} | ||
| 571 | /> | ||
| 563 | </SettingsSection> | 572 | </SettingsSection> |
| 564 | <Text className="px-2 text-sm text-foreground-muted"> | 573 | <Text className="px-2 text-sm text-foreground-muted"> |
| 565 | Brings back the original grouped thread list. The default list is flat, in creation order: | 574 | Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan |
| 566 | active work renders as cards; settled threads collapse to compact rows. | 575 | control; otherwise every task runs in Build mode. |
| 567 | </Text> | 576 | </Text> |
| 568 | </View> | 577 | </View> |
| 569 | ); | 578 | ); |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+1-1| ... | @@ -18,7 +18,7 @@ import { | ... | @@ -18,7 +18,7 @@ import { |
| 18 | ComposerToolbarButton, | 18 | ComposerToolbarButton, |
| 19 | ComposerToolbarRow, | 19 | ComposerToolbarRow, |
| 20 | ComposerToolbarScroller, | 20 | ComposerToolbarScroller, |
| 21 | } from "../../components/ComposerToolbarTrigger"; | 21 | } from "../../components/ComposerToolbar"; |
| 22 | import { ControlPillMenu } from "../../components/ControlPill"; | 22 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 23 | import { EmptyState } from "../../components/EmptyState"; | 23 | import { EmptyState } from "../../components/EmptyState"; |
| 24 | import { GlassSurface } from "../../components/GlassSurface"; | 24 | import { GlassSurface } from "../../components/GlassSurface"; |
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx created+473| ... | @@ -0,0 +1,473 @@ | ||
| 1 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; | ||
| 2 | import { LegendList } from "@legendapp/list/react-native"; | ||
| 3 | import { | ||
| 4 | isAtomCommandInterrupted, | ||
| 5 | squashAtomCommandFailure, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import * as Haptics from "expo-haptics"; | ||
| 8 | import { useNavigation } from "@react-navigation/native"; | ||
| 9 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | ||
| 10 | import { | ||
| 11 | ActivityIndicator, | ||
| 12 | Alert, | ||
| 13 | Platform, | ||
| 14 | Pressable, | ||
| 15 | ScrollView, | ||
| 16 | Switch, | ||
| 17 | TextInput, | ||
| 18 | View, | ||
| 19 | } from "react-native"; | ||
| 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 21 | |||
| 22 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 23 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 24 | import { AppText as Text } from "../../components/AppText"; | ||
| 25 | import { cn } from "../../lib/cn"; | ||
| 26 | import { useFontFamily } from "../../lib/useFontFamily"; | ||
| 27 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 28 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 29 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 30 | import { vcsEnvironment } from "../../state/vcs"; | ||
| 31 | import { | ||
| 32 | createNativeMailSearchToolbarItem, | ||
| 33 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, | ||
| 34 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | ||
| 35 | } from "../layout/native-mail-search-toolbar"; | ||
| 36 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | ||
| 37 | import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; | ||
| 38 | |||
| 39 | function SelectionRow(props: { | ||
| 40 | readonly icon?: "arrow.triangle.branch" | "desktopcomputer"; | ||
| 41 | readonly onPress: () => void; | ||
| 42 | readonly disabled?: boolean; | ||
| 43 | readonly selected: boolean; | ||
| 44 | readonly isLast?: boolean; | ||
| 45 | readonly subtitle?: string; | ||
| 46 | readonly title: string; | ||
| 47 | }) { | ||
| 48 | const iconColor = useThemeColor("--color-icon-muted"); | ||
| 49 | const checkmarkColor = useThemeColor("--color-icon"); | ||
| 50 | |||
| 51 | return ( | ||
| 52 | <Pressable | ||
| 53 | accessibilityLabel={[props.title, props.subtitle].filter(Boolean).join(", ")} | ||
| 54 | accessibilityRole="radio" | ||
| 55 | accessibilityState={{ checked: props.selected }} | ||
| 56 | className={cn( | ||
| 57 | "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle", | ||
| 58 | !props.isLast && "border-b border-border-subtle", | ||
| 59 | )} | ||
| 60 | disabled={props.disabled} | ||
| 61 | onPress={props.onPress} | ||
| 62 | style={{ opacity: props.disabled ? 0.45 : 1 }} | ||
| 63 | > | ||
| 64 | {props.icon ? ( | ||
| 65 | <SymbolView name={props.icon} size={17} tintColor={iconColor} type="monochrome" /> | ||
| 66 | ) : null} | ||
| 67 | <View className="min-w-0 flex-1 gap-0.5"> | ||
| 68 | <Text className="text-base font-t3-medium text-foreground" numberOfLines={1}> | ||
| 69 | {props.title} | ||
| 70 | </Text> | ||
| 71 | {props.subtitle ? ( | ||
| 72 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | ||
| 73 | {props.subtitle} | ||
| 74 | </Text> | ||
| 75 | ) : null} | ||
| 76 | </View> | ||
| 77 | {props.selected ? ( | ||
| 78 | <SymbolView | ||
| 79 | name="checkmark" | ||
| 80 | size={16} | ||
| 81 | tintColor={checkmarkColor} | ||
| 82 | type="monochrome" | ||
| 83 | weight="semibold" | ||
| 84 | /> | ||
| 85 | ) : null} | ||
| 86 | </Pressable> | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | function ToggleRow(props: { | ||
| 91 | readonly title: string; | ||
| 92 | readonly value: boolean; | ||
| 93 | readonly onValueChange: (value: boolean) => void; | ||
| 94 | }) { | ||
| 95 | return ( | ||
| 96 | <View className="min-h-14 flex-row items-center gap-3 bg-card px-4 py-3"> | ||
| 97 | <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}> | ||
| 98 | {props.title} | ||
| 99 | </Text> | ||
| 100 | <Switch | ||
| 101 | accessibilityLabel={props.title} | ||
| 102 | onValueChange={props.onValueChange} | ||
| 103 | value={props.value} | ||
| 104 | /> | ||
| 105 | </View> | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | |||
| 109 | function BranchSelectionRow(props: { | ||
| 110 | readonly badge: string | null; | ||
| 111 | readonly branch: VcsRef; | ||
| 112 | readonly disabled: boolean; | ||
| 113 | readonly isFirst: boolean; | ||
| 114 | readonly isLast: boolean; | ||
| 115 | readonly onSelect: (branch: VcsRef) => void; | ||
| 116 | readonly selected: boolean; | ||
| 117 | }) { | ||
| 118 | const onPress = useCallback(() => props.onSelect(props.branch), [props.branch, props.onSelect]); | ||
| 119 | |||
| 120 | return ( | ||
| 121 | <View | ||
| 122 | className={cn( | ||
| 123 | props.isFirst && "overflow-hidden rounded-t-2xl", | ||
| 124 | props.isLast && "overflow-hidden rounded-b-2xl", | ||
| 125 | )} | ||
| 126 | > | ||
| 127 | <SelectionRow | ||
| 128 | icon="arrow.triangle.branch" | ||
| 129 | disabled={props.disabled} | ||
| 130 | isLast={props.isLast} | ||
| 131 | onPress={onPress} | ||
| 132 | selected={props.selected} | ||
| 133 | subtitle={props.badge ? props.badge.toUpperCase() : undefined} | ||
| 134 | title={props.branch.name} | ||
| 135 | /> | ||
| 136 | </View> | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | |||
| 140 | function PickerSurface(props: { readonly children: ReactNode }) { | ||
| 141 | return <View className="overflow-hidden rounded-2xl bg-card">{props.children}</View>; | ||
| 142 | } | ||
| 143 | |||
| 144 | export function NewTaskEnvironmentPickerRouteScreen() { | ||
| 145 | const flow = useNewTaskFlow(); | ||
| 146 | const navigation = useNavigation(); | ||
| 147 | const insets = useSafeAreaInsets(); | ||
| 148 | |||
| 149 | return ( | ||
| 150 | <View className="flex-1 bg-sheet" collapsable={false}> | ||
| 151 | <NativeStackScreenOptions | ||
| 152 | options={{ | ||
| 153 | headerShown: Platform.OS !== "android", | ||
| 154 | title: "Environment", | ||
| 155 | }} | ||
| 156 | /> | ||
| 157 | {Platform.OS === "android" ? ( | ||
| 158 | <AndroidScreenHeader title="Environment" onBack={() => navigation.goBack()} /> | ||
| 159 | ) : null} | ||
| 160 | <ScrollView | ||
| 161 | contentInsetAdjustmentBehavior="automatic" | ||
| 162 | contentContainerStyle={{ | ||
| 163 | paddingBottom: Math.max(insets.bottom, 16) + 16, | ||
| 164 | paddingHorizontal: 16, | ||
| 165 | paddingTop: 16, | ||
| 166 | }} | ||
| 167 | showsVerticalScrollIndicator={false} | ||
| 168 | > | ||
| 169 | <PickerSurface> | ||
| 170 | {flow.environments.map((environment, index) => ( | ||
| 171 | <SelectionRow | ||
| 172 | key={String(environment.environmentId)} | ||
| 173 | icon="desktopcomputer" | ||
| 174 | isLast={index === flow.environments.length - 1} | ||
| 175 | onPress={() => { | ||
| 176 | void Haptics.selectionAsync(); | ||
| 177 | flow.selectEnvironment(environment.environmentId); | ||
| 178 | navigation.goBack(); | ||
| 179 | }} | ||
| 180 | selected={flow.selectedEnvironmentId === environment.environmentId} | ||
| 181 | title={environment.environmentLabel} | ||
| 182 | /> | ||
| 183 | ))} | ||
| 184 | </PickerSurface> | ||
| 185 | </ScrollView> | ||
| 186 | </View> | ||
| 187 | ); | ||
| 188 | } | ||
| 189 | |||
| 190 | export function NewTaskBranchPickerRouteScreen() { | ||
| 191 | const flow = useNewTaskFlow(); | ||
| 192 | const navigation = useNavigation(); | ||
| 193 | const insets = useSafeAreaInsets(); | ||
| 194 | const placeholderColor = useThemeColor("--color-placeholder"); | ||
| 195 | const foregroundColor = useThemeColor("--color-foreground"); | ||
| 196 | const fontFamily = useFontFamily("regular"); | ||
| 197 | const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); | ||
| 198 | const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null); | ||
| 199 | const selectingBranchNameRef = useRef<string | null>(null); | ||
| 200 | const allowSelectionNavigationRef = useRef(false); | ||
| 201 | const mountedRef = useRef(true); | ||
| 202 | const screenTitle = flow.workspaceMode === "worktree" ? "Base branch" : "Branch"; | ||
| 203 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | ||
| 204 | const selectedBranchName = | ||
| 205 | flow.selectedBranchName ?? | ||
| 206 | flow.availableBranches.find((branch) => branch.current)?.name ?? | ||
| 207 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? | ||
| 208 | null; | ||
| 209 | const branchListContentStyle = useMemo( | ||
| 210 | () => ({ | ||
| 211 | paddingBottom: usesNativeMailSearchToolbar | ||
| 212 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16 | ||
| 213 | : Platform.OS === "ios" | ||
| 214 | ? 16 | ||
| 215 | : Math.max(insets.bottom, 16) + 16, | ||
| 216 | paddingHorizontal: 16, | ||
| 217 | paddingTop: 12, | ||
| 218 | }), | ||
| 219 | [insets.bottom, usesNativeMailSearchToolbar], | ||
| 220 | ); | ||
| 221 | |||
| 222 | useEffect(() => { | ||
| 223 | mountedRef.current = true; | ||
| 224 | return () => { | ||
| 225 | mountedRef.current = false; | ||
| 226 | flow.setBranchQuery(""); | ||
| 227 | }; | ||
| 228 | }, [flow.setBranchQuery]); | ||
| 229 | |||
| 230 | useEffect( | ||
| 231 | () => | ||
| 232 | navigation.addListener("beforeRemove", (event) => { | ||
| 233 | if (selectingBranchNameRef.current !== null && !allowSelectionNavigationRef.current) { | ||
| 234 | event.preventDefault(); | ||
| 235 | } | ||
| 236 | }), | ||
| 237 | [navigation], | ||
| 238 | ); | ||
| 239 | |||
| 240 | const selectBranch = useCallback( | ||
| 241 | async (branch: VcsRef) => { | ||
| 242 | if (selectingBranchNameRef.current !== null) { | ||
| 243 | return; | ||
| 244 | } | ||
| 245 | selectingBranchNameRef.current = branch.name; | ||
| 246 | void Haptics.selectionAsync(); | ||
| 247 | |||
| 248 | try { | ||
| 249 | let selectedBranch = branch; | ||
| 250 | const needsCheckout = shouldCheckoutNewTaskBranch({ | ||
| 251 | branchIsCurrent: branch.current, | ||
| 252 | branchWorktreePath: branch.worktreePath, | ||
| 253 | workspaceMode: flow.workspaceMode, | ||
| 254 | }); | ||
| 255 | if (needsCheckout && flow.selectedProject) { | ||
| 256 | setSwitchingBranchName(branch.name); | ||
| 257 | const result = await switchRef({ | ||
| 258 | environmentId: flow.selectedProject.environmentId, | ||
| 259 | input: { | ||
| 260 | cwd: flow.selectedProject.workspaceRoot, | ||
| 261 | refName: branch.name, | ||
| 262 | }, | ||
| 263 | }); | ||
| 264 | if (result._tag === "Failure") { | ||
| 265 | if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) { | ||
| 266 | const error = squashAtomCommandFailure(result); | ||
| 267 | Alert.alert( | ||
| 268 | "Could not switch branch", | ||
| 269 | error instanceof Error ? error.message : "The branch could not be checked out.", | ||
| 270 | ); | ||
| 271 | } | ||
| 272 | return; | ||
| 273 | } | ||
| 274 | selectedBranch = { | ||
| 275 | ...branch, | ||
| 276 | current: true, | ||
| 277 | isRemote: false, | ||
| 278 | name: result.value.refName ?? branch.name, | ||
| 279 | }; | ||
| 280 | } | ||
| 281 | |||
| 282 | // The checkout has already changed the repository. Persist the matching | ||
| 283 | // draft selection even if the native sheet was dismissed while the | ||
| 284 | // command was in flight; only visible-screen work is focus-gated below. | ||
| 285 | flow.selectBranch(selectedBranch); | ||
| 286 | if (!mountedRef.current || !navigation.isFocused()) { | ||
| 287 | return; | ||
| 288 | } | ||
| 289 | flow.setBranchQuery(""); | ||
| 290 | allowSelectionNavigationRef.current = true; | ||
| 291 | navigation.goBack(); | ||
| 292 | } finally { | ||
| 293 | selectingBranchNameRef.current = null; | ||
| 294 | allowSelectionNavigationRef.current = false; | ||
| 295 | if (mountedRef.current) { | ||
| 296 | setSwitchingBranchName(null); | ||
| 297 | } | ||
| 298 | } | ||
| 299 | }, | ||
| 300 | [ | ||
| 301 | flow.selectBranch, | ||
| 302 | flow.selectedProject, | ||
| 303 | flow.setBranchQuery, | ||
| 304 | flow.workspaceMode, | ||
| 305 | navigation, | ||
| 306 | switchRef, | ||
| 307 | ], | ||
| 308 | ); | ||
| 309 | |||
| 310 | const renderBranch = useCallback( | ||
| 311 | ({ item, index }: { readonly item: VcsRef; readonly index: number }) => ( | ||
| 312 | <BranchSelectionRow | ||
| 313 | badge={branchBadgeLabel({ branch: item, project: flow.selectedProject })} | ||
| 314 | branch={item} | ||
| 315 | disabled={switchingBranchName !== null} | ||
| 316 | isFirst={index === 0} | ||
| 317 | isLast={index === flow.filteredBranches.length - 1} | ||
| 318 | onSelect={selectBranch} | ||
| 319 | selected={selectedBranchName === item.name} | ||
| 320 | /> | ||
| 321 | ), | ||
| 322 | [ | ||
| 323 | flow.filteredBranches.length, | ||
| 324 | flow.selectedProject, | ||
| 325 | selectBranch, | ||
| 326 | selectedBranchName, | ||
| 327 | switchingBranchName, | ||
| 328 | ], | ||
| 329 | ); | ||
| 330 | |||
| 331 | const branchListHeader = | ||
| 332 | flow.workspaceMode === "worktree" ? ( | ||
| 333 | <View className="mb-3 overflow-hidden rounded-2xl"> | ||
| 334 | <ToggleRow | ||
| 335 | onValueChange={flow.setStartFromOrigin} | ||
| 336 | title="Start from origin" | ||
| 337 | value={flow.startFromOrigin} | ||
| 338 | /> | ||
| 339 | </View> | ||
| 340 | ) : null; | ||
| 341 | |||
| 342 | const branchContent = | ||
| 343 | flow.filteredBranches.length === 0 ? ( | ||
| 344 | <ScrollView | ||
| 345 | className="flex-1 bg-sheet" | ||
| 346 | contentInsetAdjustmentBehavior="automatic" | ||
| 347 | contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 12 }} | ||
| 348 | scrollEnabled={false} | ||
| 349 | showsVerticalScrollIndicator={false} | ||
| 350 | > | ||
| 351 | {branchListHeader} | ||
| 352 | <View | ||
| 353 | className="flex-1 items-center justify-center gap-3 px-4" | ||
| 354 | style={{ | ||
| 355 | marginBottom: usesNativeMailSearchToolbar | ||
| 356 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET | ||
| 357 | : 0, | ||
| 358 | }} | ||
| 359 | > | ||
| 360 | {flow.branchesLoading ? <ActivityIndicator /> : null} | ||
| 361 | <Text className="text-center text-sm text-foreground-muted"> | ||
| 362 | {flow.branchesLoading | ||
| 363 | ? "Loading branches…" | ||
| 364 | : flow.branchesError | ||
| 365 | ? flow.branchesError | ||
| 366 | : flow.branchQuery | ||
| 367 | ? "No matching branches" | ||
| 368 | : "No branches available"} | ||
| 369 | </Text> | ||
| 370 | {!flow.branchesLoading && flow.branchesError ? ( | ||
| 371 | <Pressable | ||
| 372 | accessibilityRole="button" | ||
| 373 | className="rounded-full bg-card px-4 py-2 active:opacity-70" | ||
| 374 | onPress={flow.loadBranches} | ||
| 375 | > | ||
| 376 | <Text className="text-sm font-t3-medium text-foreground">Try again</Text> | ||
| 377 | </Pressable> | ||
| 378 | ) : null} | ||
| 379 | </View> | ||
| 380 | </ScrollView> | ||
| 381 | ) : ( | ||
| 382 | <LegendList | ||
| 383 | alwaysBounceVertical={false} | ||
| 384 | automaticallyAdjustsScrollIndicatorInsets | ||
| 385 | automaticallyAdjustKeyboardInsets={Platform.OS === "ios"} | ||
| 386 | className="flex-1 bg-sheet" | ||
| 387 | contentInsetAdjustmentBehavior="automatic" | ||
| 388 | contentContainerStyle={branchListContentStyle} | ||
| 389 | data={flow.filteredBranches} | ||
| 390 | keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"} | ||
| 391 | keyboardShouldPersistTaps="handled" | ||
| 392 | keyExtractor={(branch) => | ||
| 393 | `${branch.remoteName ?? "local"}:${branch.name}:${branch.worktreePath ?? ""}` | ||
| 394 | } | ||
| 395 | ListHeaderComponent={branchListHeader} | ||
| 396 | ListFooterComponent={ | ||
| 397 | flow.branchesFetchingNextPage ? ( | ||
| 398 | <View className="items-center py-4"> | ||
| 399 | <ActivityIndicator /> | ||
| 400 | </View> | ||
| 401 | ) : null | ||
| 402 | } | ||
| 403 | onEndReached={flow.hasMoreBranches ? flow.loadMoreBranches : undefined} | ||
| 404 | onEndReachedThreshold={0.35} | ||
| 405 | renderItem={renderBranch} | ||
| 406 | showsVerticalScrollIndicator={false} | ||
| 407 | /> | ||
| 408 | ); | ||
| 409 | |||
| 410 | if (Platform.OS === "android") { | ||
| 411 | return ( | ||
| 412 | <View className="flex-1 bg-sheet" collapsable={false}> | ||
| 413 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 414 | <AndroidScreenHeader title={screenTitle} onBack={() => navigation.goBack()} /> | ||
| 415 | <View className="px-4 pb-2 pt-3"> | ||
| 416 | <TextInput | ||
| 417 | autoCapitalize="none" | ||
| 418 | autoCorrect={false} | ||
| 419 | className="h-11 rounded-xl bg-card px-4 text-base text-foreground" | ||
| 420 | onChangeText={flow.setBranchQuery} | ||
| 421 | placeholder="Find a branch" | ||
| 422 | placeholderTextColor={placeholderColor} | ||
| 423 | style={{ color: foregroundColor, fontFamily }} | ||
| 424 | value={flow.branchQuery} | ||
| 425 | /> | ||
| 426 | </View> | ||
| 427 | {branchContent} | ||
| 428 | </View> | ||
| 429 | ); | ||
| 430 | } | ||
| 431 | |||
| 432 | return ( | ||
| 433 | <> | ||
| 434 | <NativeStackScreenOptions | ||
| 435 | options={{ | ||
| 436 | headerShown: true, | ||
| 437 | title: screenTitle, | ||
| 438 | unstable_headerToolbarItems: usesNativeMailSearchToolbar | ||
| 439 | ? () => [ | ||
| 440 | createNativeMailSearchToolbarItem({ | ||
| 441 | onSearchTextChange: flow.setBranchQuery, | ||
| 442 | placeholder: "Find a branch", | ||
| 443 | searchTextChangeId: "new-task-branch-search-text", | ||
| 444 | showsSearchDismissButton: true, | ||
| 445 | }), | ||
| 446 | ] | ||
| 447 | : undefined, | ||
| 448 | headerSearchBarOptions: usesNativeMailSearchToolbar | ||
| 449 | ? undefined | ||
| 450 | : { | ||
| 451 | allowToolbarIntegration: true, | ||
| 452 | autoCapitalize: "none", | ||
| 453 | hideNavigationBar: false, | ||
| 454 | obscureBackground: false, | ||
| 455 | placeholder: "Find a branch", | ||
| 456 | onChangeText: (event) => { | ||
| 457 | flow.setBranchQuery(event.nativeEvent.text); | ||
| 458 | }, | ||
| 459 | onCancelButtonPress: () => { | ||
| 460 | flow.setBranchQuery(""); | ||
| 461 | }, | ||
| 462 | }, | ||
| 463 | }} | ||
| 464 | /> | ||
| 465 | {usesNativeMailSearchToolbar ? null : ( | ||
| 466 | <NativeHeaderToolbar placement="bottom"> | ||
| 467 | <NativeHeaderToolbar.SearchBarSlot /> | ||
| 468 | </NativeHeaderToolbar> | ||
| 469 | )} | ||
| 470 | {branchContent} | ||
| 471 | </> | ||
| 472 | ); | ||
| 473 | } | ||
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+339-384| ... | @@ -1,9 +1,14 @@ | ... | @@ -1,9 +1,14 @@ |
| 1 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; | ||
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 4 | import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; | ||
| 5 | import { | 2 | import { |
| 6 | KeyboardAvoidingView, | 3 | StackActions, |
| 4 | useFocusEffect, | ||
| 5 | useNavigation, | ||
| 6 | usePreventRemove, | ||
| 7 | } from "@react-navigation/native"; | ||
| 8 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 9 | import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native"; | ||
| 10 | import { | ||
| 11 | KeyboardController, | ||
| 7 | KeyboardStickyView, | 12 | KeyboardStickyView, |
| 8 | useKeyboardState, | 13 | useKeyboardState, |
| 9 | } from "react-native-keyboard-controller"; | 14 | } from "react-native-keyboard-controller"; |
| ... | @@ -11,7 +16,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -11,7 +16,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 11 | import { useThemeColor } from "../../lib/useThemeColor"; | 16 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 12 | import { useFontFamily } from "../../lib/useFontFamily"; | 17 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 13 | 18 | ||
| 14 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 15 | import { | 19 | import { |
| 16 | isAtomCommandInterrupted, | 20 | isAtomCommandInterrupted, |
| 17 | squashAtomCommandFailure, | 21 | squashAtomCommandFailure, |
| ... | @@ -19,22 +23,24 @@ import { | ... | @@ -19,22 +23,24 @@ import { |
| 19 | 23 | ||
| 20 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; | 24 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 21 | import { | 25 | import { |
| 26 | ComposerInlineControl, | ||
| 22 | ComposerToolbarButton, | 27 | ComposerToolbarButton, |
| 23 | ComposerToolbarRow, | 28 | ComposerToolbarRow, |
| 24 | ComposerToolbarScroller, | 29 | ComposerToolbarScroller, |
| 25 | ComposerToolbarTrigger, | 30 | } from "../../components/ComposerToolbar"; |
| 26 | } from "../../components/ComposerToolbarTrigger"; | ||
| 27 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 31 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 28 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 32 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 29 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | ||
| 30 | import { ProviderIcon } from "../../components/ProviderIcon"; | 33 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 34 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 35 | import { AppText as Text } from "../../components/AppText"; | ||
| 31 | import { ComposerSurface } from "./ThreadComposer"; | 36 | import { ComposerSurface } from "./ThreadComposer"; |
| 32 | import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; | 37 | import { |
| 33 | import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; | 38 | useThreadSettingsSheetPresentation, |
| 39 | type NavigationWithFinishTransitioning, | ||
| 40 | } from "./use-thread-settings-sheet-presentation"; | ||
| 34 | 41 | ||
| 35 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | 42 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 36 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | 43 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 37 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | ||
| 38 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 44 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 39 | import { | 45 | import { |
| 40 | clearComposerDraftContent, | 46 | clearComposerDraftContent, |
| ... | @@ -49,21 +55,33 @@ import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; | ... | @@ -49,21 +55,33 @@ import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; |
| 49 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | 55 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 50 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; | 56 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; |
| 51 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | 57 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 52 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 58 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 53 | import { useCreateProjectThread } from "./use-project-actions"; | 59 | import { useCreateProjectThread } from "./use-project-actions"; |
| 54 | import { resolveDraftProjectSelection } from "./new-task-project-selection"; | 60 | import { resolveDraftProjectSelection } from "./new-task-project-selection"; |
| 61 | import { | ||
| 62 | resolveNewTaskBranchLabel, | ||
| 63 | resolveNewTaskWorkspaceLabel, | ||
| 64 | } from "./new-task-context-presentation"; | ||
| 55 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; | 65 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; |
| 56 | 66 | ||
| 57 | function formatWorkspaceLabel(input: { | 67 | function NewTaskWorkspaceIcon(props: { |
| 58 | readonly workspaceMode: string; | 68 | readonly workspaceMode: "local" | "worktree"; |
| 59 | readonly currentBranchName: string | null; | 69 | readonly worktreePath: string | null; |
| 60 | readonly selectedBranchName: string | null; | 70 | }) { |
| 61 | }): string { | 71 | const iconColor = useThemeColor("--color-icon-muted"); |
| 62 | const branchName = input.selectedBranchName ?? input.currentBranchName; | 72 | |
| 63 | if (input.workspaceMode === "worktree") { | 73 | if (props.workspaceMode === "local" && props.worktreePath === null) { |
| 64 | return branchName ? `New worktree · ${branchName}` : "New worktree"; | 74 | return <SymbolView name="folder" size={16} tintColor={iconColor} type="monochrome" />; |
| 65 | } | 75 | } |
| 66 | return branchName ? `Current · ${branchName}` : "Current checkout"; | 76 | |
| 77 | return ( | ||
| 78 | <View className="size-4"> | ||
| 79 | <SymbolView name="folder" size={16} tintColor={iconColor} type="monochrome" /> | ||
| 80 | <View className="absolute -right-1 -bottom-1"> | ||
| 81 | <SymbolView name="arrow.triangle.branch" size={9} tintColor={iconColor} type="monochrome" /> | ||
| 82 | </View> | ||
| 83 | </View> | ||
| 84 | ); | ||
| 67 | } | 85 | } |
| 68 | 86 | ||
| 69 | export function NewTaskDraftScreen(props: { | 87 | export function NewTaskDraftScreen(props: { |
| ... | @@ -90,7 +108,8 @@ export function NewTaskDraftScreen(props: { | ... | @@ -90,7 +108,8 @@ export function NewTaskDraftScreen(props: { |
| 90 | const insets = useSafeAreaInsets(); | 108 | const insets = useSafeAreaInsets(); |
| 91 | const colorScheme = useColorScheme(); | 109 | const colorScheme = useColorScheme(); |
| 92 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | 110 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| 93 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); | 111 | const controlsBottomPadding = Math.max(insets.bottom, 10); |
| 112 | const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); | ||
| 94 | const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; | 113 | const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; |
| 95 | const { connectedEnvironments } = useRemoteConnectionStatus(); | 114 | const { connectedEnvironments } = useRemoteConnectionStatus(); |
| 96 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( | 115 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( |
| ... | @@ -108,6 +127,49 @@ export function NewTaskDraftScreen(props: { | ... | @@ -108,6 +127,49 @@ export function NewTaskDraftScreen(props: { |
| 108 | editorRef: promptInputRef, | 127 | editorRef: promptInputRef, |
| 109 | isEditorFocused: isComposerFocused, | 128 | isEditorFocused: isComposerFocused, |
| 110 | }); | 129 | }); |
| 130 | useEffect(() => { | ||
| 131 | if (Platform.OS !== "ios") { | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | |||
| 135 | navigation.getParent()?.setOptions({ gestureEnabled: !isKeyboardVisible }); | ||
| 136 | }, [isKeyboardVisible, navigation]); | ||
| 137 | useEffect(() => { | ||
| 138 | return () => { | ||
| 139 | if (Platform.OS === "ios") { | ||
| 140 | navigation.getParent()?.setOptions({ gestureEnabled: true }); | ||
| 141 | } | ||
| 142 | }; | ||
| 143 | }, [navigation]); | ||
| 144 | const settingsRoutePresentedRef = useRef(false); | ||
| 145 | useEffect(() => { | ||
| 146 | if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { | ||
| 147 | return; | ||
| 148 | } | ||
| 149 | |||
| 150 | settingsRoutePresentedRef.current = true; | ||
| 151 | navigation.dispatch(StackActions.push("ThreadSettings")); | ||
| 152 | }, [navigation, settingsSheetPresentation.isVisible]); | ||
| 153 | useFocusEffect( | ||
| 154 | useCallback(() => { | ||
| 155 | if (!settingsRoutePresentedRef.current) { | ||
| 156 | return; | ||
| 157 | } | ||
| 158 | |||
| 159 | settingsRoutePresentedRef.current = false; | ||
| 160 | settingsSheetPresentation.onDismissed(); | ||
| 161 | }, [settingsSheetPresentation.onDismissed]), | ||
| 162 | ); | ||
| 163 | useEffect( | ||
| 164 | () => | ||
| 165 | // UIKit's completion callback for the sheet dismissal, surfaced by the | ||
| 166 | // native-stack patch. This is when the queued keyboard restore runs. | ||
| 167 | (navigation as unknown as NavigationWithFinishTransitioning).addListener( | ||
| 168 | "finishTransitioning", | ||
| 169 | settingsSheetPresentation.onStackTransitionsFinished, | ||
| 170 | ), | ||
| 171 | [navigation, settingsSheetPresentation.onStackTransitionsFinished], | ||
| 172 | ); | ||
| 111 | const [importingShareKey, setImportingShareKey] = useState<string | null>(null); | 173 | const [importingShareKey, setImportingShareKey] = useState<string | null>(null); |
| 112 | const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); | 174 | const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); |
| 113 | const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null); | 175 | const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null); |
| ... | @@ -227,9 +289,9 @@ export function NewTaskDraftScreen(props: { | ... | @@ -227,9 +289,9 @@ export function NewTaskDraftScreen(props: { |
| 227 | }, [props.pendingTaskId, cancelEditingPendingTask]); | 289 | }, [props.pendingTaskId, cancelEditingPendingTask]); |
| 228 | 290 | ||
| 229 | const foregroundColor = useThemeColor("--color-foreground"); | 291 | const foregroundColor = useThemeColor("--color-foreground"); |
| 292 | const projectUnderlineColor = useThemeColor("--color-foreground-muted"); | ||
| 230 | const regularFontFamily = useFontFamily("regular"); | 293 | const regularFontFamily = useFontFamily("regular"); |
| 231 | const bodyText = useScaledTextRole("body"); | 294 | const bodyText = useScaledTextRole("body"); |
| 232 | const headlineText = useScaledTextRole("headline"); | ||
| 233 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; | 295 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; |
| 234 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; | 296 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; |
| 235 | 297 | ||
| ... | @@ -315,7 +377,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -315,7 +377,7 @@ export function NewTaskDraftScreen(props: { |
| 315 | return; | 377 | return; |
| 316 | } | 378 | } |
| 317 | loadedBranchesProjectKeyRef.current = projectKey; | 379 | loadedBranchesProjectKeyRef.current = projectKey; |
| 318 | void flow.loadBranches(); | 380 | flow.loadBranches(); |
| 319 | }, [flow.loadBranches, selectedProject]); | 381 | }, [flow.loadBranches, selectedProject]); |
| 320 | 382 | ||
| 321 | useEffect(() => { | 383 | useEffect(() => { |
| ... | @@ -517,121 +579,6 @@ export function NewTaskDraftScreen(props: { | ... | @@ -517,121 +579,6 @@ export function NewTaskDraftScreen(props: { |
| 517 | shareImportAttempt, | 579 | shareImportAttempt, |
| 518 | ]); | 580 | ]); |
| 519 | 581 | ||
| 520 | useEffect(() => { | ||
| 521 | // Android starts with the collapsed composer pill (like an open thread) | ||
| 522 | // and only expands/focuses when tapped. | ||
| 523 | if (!selectedProject || Platform.OS === "android") { | ||
| 524 | return; | ||
| 525 | } | ||
| 526 | |||
| 527 | let focusFrame: ReturnType<typeof requestAnimationFrame> | null = null; | ||
| 528 | const interaction = InteractionManager.runAfterInteractions(() => { | ||
| 529 | focusFrame = requestAnimationFrame(() => { | ||
| 530 | // The delayed focus can land after the settings sheet opened, which | ||
| 531 | // would pop the keyboard underneath its modal. | ||
| 532 | if (!settingsSheetPresentation.isActiveRef.current) { | ||
| 533 | promptInputRef.current?.focus(); | ||
| 534 | } else { | ||
| 535 | settingsSheetPresentation.restoreFocusAfterSave(); | ||
| 536 | } | ||
| 537 | }); | ||
| 538 | }); | ||
| 539 | |||
| 540 | return () => { | ||
| 541 | interaction.cancel(); | ||
| 542 | if (focusFrame !== null) { | ||
| 543 | cancelAnimationFrame(focusFrame); | ||
| 544 | } | ||
| 545 | }; | ||
| 546 | }, [ | ||
| 547 | selectedProject, | ||
| 548 | settingsSheetPresentation.isActiveRef, | ||
| 549 | settingsSheetPresentation.restoreFocusAfterSave, | ||
| 550 | ]); | ||
| 551 | |||
| 552 | const environmentMenuActions = useMemo( | ||
| 553 | () => | ||
| 554 | flow.environments.map((environment) => ({ | ||
| 555 | id: `environment:${environment.environmentId}`, | ||
| 556 | title: environment.environmentLabel, | ||
| 557 | attributes: isIncomingShareTransferPending ? { disabled: true } : undefined, | ||
| 558 | state: | ||
| 559 | flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined, | ||
| 560 | })), | ||
| 561 | [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], | ||
| 562 | ); | ||
| 563 | |||
| 564 | const providerOptionDescriptors = useMemo( | ||
| 565 | () => | ||
| 566 | resolveProviderOptionDescriptors({ | ||
| 567 | capabilities: flow.selectedModelOption?.capabilities, | ||
| 568 | selections: flow.selectedModel?.options, | ||
| 569 | }), | ||
| 570 | [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], | ||
| 571 | ); | ||
| 572 | |||
| 573 | const workspaceMenuActions = useMemo(() => { | ||
| 574 | const branchActions = | ||
| 575 | flow.availableBranches.length === 0 | ||
| 576 | ? [ | ||
| 577 | { | ||
| 578 | id: "workspace:branch:none", | ||
| 579 | title: flow.branchesLoading ? "Loading branches…" : "No branches available", | ||
| 580 | attributes: { disabled: true }, | ||
| 581 | }, | ||
| 582 | ] | ||
| 583 | : flow.availableBranches.slice(0, 12).map((branch) => { | ||
| 584 | const badge = branchBadgeLabel({ | ||
| 585 | branch, | ||
| 586 | project: flow.selectedProject, | ||
| 587 | }); | ||
| 588 | |||
| 589 | return { | ||
| 590 | id: `workspace:branch:${branch.name}`, | ||
| 591 | title: branch.name, | ||
| 592 | subtitle: badge ? badge.toUpperCase() : undefined, | ||
| 593 | state: flow.selectedBranchName === branch.name ? ("on" as const) : undefined, | ||
| 594 | }; | ||
| 595 | }); | ||
| 596 | |||
| 597 | return [ | ||
| 598 | { | ||
| 599 | id: "workspace:mode", | ||
| 600 | title: "Mode", | ||
| 601 | subtitle: flow.workspaceMode === "local" ? "Current checkout" : "New worktree", | ||
| 602 | subactions: (["local", "worktree"] as const).map((value) => ({ | ||
| 603 | id: `workspace:mode:${value}`, | ||
| 604 | title: value === "local" ? "Current checkout" : "New worktree", | ||
| 605 | state: flow.workspaceMode === value ? ("on" as const) : undefined, | ||
| 606 | })), | ||
| 607 | }, | ||
| 608 | { | ||
| 609 | id: "workspace:branch", | ||
| 610 | title: "Branch", | ||
| 611 | subtitle: flow.selectedBranchName ?? "Choose branch", | ||
| 612 | subactions: branchActions, | ||
| 613 | }, | ||
| 614 | ...(flow.workspaceMode === "worktree" | ||
| 615 | ? [ | ||
| 616 | { | ||
| 617 | id: "workspace:start-from-origin", | ||
| 618 | title: "Start from origin", | ||
| 619 | subtitle: "Base the worktree on the latest origin branch", | ||
| 620 | image: "arrow.triangle.pull", | ||
| 621 | state: flow.startFromOrigin ? ("on" as const) : undefined, | ||
| 622 | }, | ||
| 623 | ] | ||
| 624 | : []), | ||
| 625 | ]; | ||
| 626 | }, [ | ||
| 627 | flow.availableBranches, | ||
| 628 | flow.branchesLoading, | ||
| 629 | flow.selectedBranchName, | ||
| 630 | flow.selectedProject, | ||
| 631 | flow.startFromOrigin, | ||
| 632 | flow.workspaceMode, | ||
| 633 | ]); | ||
| 634 | |||
| 635 | const selectedEnvironmentLabel = | 582 | const selectedEnvironmentLabel = |
| 636 | flow.environments.find( | 583 | flow.environments.find( |
| 637 | (environment) => environment.environmentId === flow.selectedEnvironmentId, | 584 | (environment) => environment.environmentId === flow.selectedEnvironmentId, |
| ... | @@ -640,50 +587,17 @@ export function NewTaskDraftScreen(props: { | ... | @@ -640,50 +587,17 @@ export function NewTaskDraftScreen(props: { |
| 640 | flow.availableBranches.find((branch) => branch.current)?.name ?? | 587 | flow.availableBranches.find((branch) => branch.current)?.name ?? |
| 641 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? | 588 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? |
| 642 | null; | 589 | null; |
| 643 | const settingsSummaryLabel = threadSettingsSummaryLabel({ | 590 | const selectedBranchName = flow.selectedBranchName ?? currentBranchName; |
| 644 | modelLabel: flow.selectedModelOption?.label ?? "Model", | 591 | const selectedBranchLabel = resolveNewTaskBranchLabel({ |
| 645 | optionDescriptors: providerOptionDescriptors, | 592 | branchName: selectedBranchName, |
| 646 | runtimeMode: flow.runtimeMode, | 593 | startFromOrigin: flow.startFromOrigin, |
| 647 | interactionMode: flow.interactionMode, | 594 | workspaceMode: flow.workspaceMode, |
| 648 | }); | 595 | }); |
| 649 | const workspaceLabel = useMemo( | 596 | const workspaceLabel = resolveNewTaskWorkspaceLabel({ |
| 650 | () => | 597 | workspaceMode: flow.workspaceMode, |
| 651 | formatWorkspaceLabel({ | 598 | worktreePath: flow.selectedWorktreePath, |
| 652 | currentBranchName, | 599 | }); |
| 653 | selectedBranchName: flow.selectedBranchName, | 600 | const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; |
| 654 | workspaceMode: flow.workspaceMode, | ||
| 655 | }), | ||
| 656 | [currentBranchName, flow.selectedBranchName, flow.workspaceMode], | ||
| 657 | ); | ||
| 658 | function handleEnvironmentMenuAction(event: string) { | ||
| 659 | if (isIncomingShareTransferPending || !event.startsWith("environment:")) { | ||
| 660 | return; | ||
| 661 | } | ||
| 662 | flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); | ||
| 663 | } | ||
| 664 | |||
| 665 | function handleWorkspaceMenuAction(event: string) { | ||
| 666 | if (isIncomingShareTransferPending) { | ||
| 667 | return; | ||
| 668 | } | ||
| 669 | if (event.startsWith("workspace:mode:")) { | ||
| 670 | flow.setWorkspaceMode( | ||
| 671 | event.slice("workspace:mode:".length) as Parameters<typeof flow.setWorkspaceMode>[0], | ||
| 672 | ); | ||
| 673 | return; | ||
| 674 | } | ||
| 675 | if (event === "workspace:start-from-origin") { | ||
| 676 | flow.setStartFromOrigin(!flow.startFromOrigin); | ||
| 677 | return; | ||
| 678 | } | ||
| 679 | if (event.startsWith("workspace:branch:")) { | ||
| 680 | const branchName = event.slice("workspace:branch:".length); | ||
| 681 | const branch = flow.availableBranches.find((candidate) => candidate.name === branchName); | ||
| 682 | if (branch) { | ||
| 683 | flow.selectBranch(branch); | ||
| 684 | } | ||
| 685 | } | ||
| 686 | } | ||
| 687 | 601 | ||
| 688 | async function handlePickImages(): Promise<void> { | 602 | async function handlePickImages(): Promise<void> { |
| 689 | if (isIncomingShareTransferPending) { | 603 | if (isIncomingShareTransferPending) { |
| ... | @@ -733,7 +647,9 @@ export function NewTaskDraftScreen(props: { | ... | @@ -733,7 +647,9 @@ export function NewTaskDraftScreen(props: { |
| 733 | draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; | 647 | draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; |
| 734 | const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; | 648 | const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; |
| 735 | const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; | 649 | const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; |
| 736 | const interactionMode = draft.interactionMode ?? flow.interactionMode; | 650 | const interactionMode = flow.planModeEnabled |
| 651 | ? (draft.interactionMode ?? flow.interactionMode) | ||
| 652 | : "default"; | ||
| 737 | const initialMessageText = draft.text.trim(); | 653 | const initialMessageText = draft.text.trim(); |
| 738 | 654 | ||
| 739 | if ( | 655 | if ( |
| ... | @@ -851,7 +767,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -851,7 +767,7 @@ export function NewTaskDraftScreen(props: { |
| 851 | 767 | ||
| 852 | if (!selectedProject) { | 768 | if (!selectedProject) { |
| 853 | return ( | 769 | return ( |
| 854 | <View className="flex-1 bg-sheet"> | 770 | <View className="flex-1 bg-sheet" collapsable={false}> |
| 855 | {Platform.OS === "android" ? ( | 771 | {Platform.OS === "android" ? ( |
| 856 | <> | 772 | <> |
| 857 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 773 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| ... | @@ -866,11 +782,6 @@ export function NewTaskDraftScreen(props: { | ... | @@ -866,11 +782,6 @@ export function NewTaskDraftScreen(props: { |
| 866 | 782 | ||
| 867 | const isAndroid = Platform.OS === "android"; | 783 | const isAndroid = Platform.OS === "android"; |
| 868 | const isDarkMode = colorScheme === "dark"; | 784 | const isDarkMode = colorScheme === "dark"; |
| 869 | // Android expansion follows native editor focus so relayout cannot race | ||
| 870 | // the touch gesture that opens the keyboard. | ||
| 871 | // The settings sheet dismisses the keyboard, so its flag keeps the Android | ||
| 872 | // draft composer expanded through the blur (mirrors ThreadComposer). | ||
| 873 | const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive; | ||
| 874 | const canStart = | 785 | const canStart = |
| 875 | Boolean(flow.selectedProject) && | 786 | Boolean(flow.selectedProject) && |
| 876 | Boolean(flow.selectedModel) && | 787 | Boolean(flow.selectedModel) && |
| ... | @@ -882,235 +793,279 @@ export function NewTaskDraftScreen(props: { | ... | @@ -882,235 +793,279 @@ export function NewTaskDraftScreen(props: { |
| 882 | const promptEditor = ( | 793 | const promptEditor = ( |
| 883 | <ComposerEditor | 794 | <ComposerEditor |
| 884 | ref={promptInputRef} | 795 | ref={promptInputRef} |
| 885 | // Native autoFocus fires becomeFirstResponder in didMoveToWindow, which | 796 | // The context-first screen intentionally opens with the keyboard closed. |
| 886 | // forces the iOS keyboard bring-up during the formSheet present | 797 | // Focusing is a user action, so presenting the form sheet has one motion. |
| 887 | // animation and stalls it. The runAfterInteractions effect above focuses | ||
| 888 | // the editor once the transition settles instead. | ||
| 889 | autoFocus={false} | 798 | autoFocus={false} |
| 890 | editable={!isIncomingShareTransferPending} | 799 | editable={!isIncomingShareTransferPending} |
| 891 | multiline | 800 | multiline |
| 892 | scrollEnabled={isExpanded} | 801 | scrollEnabled |
| 893 | value={flow.prompt} | 802 | value={flow.prompt} |
| 894 | skills={flow.selectedProviderSkills} | 803 | skills={flow.selectedProviderSkills} |
| 895 | onChangeText={flow.setPrompt} | 804 | onChangeText={flow.setPrompt} |
| 896 | onFocus={() => setIsComposerFocused(true)} | 805 | onFocus={() => setIsComposerFocused(true)} |
| 897 | onBlur={() => setIsComposerFocused(false)} | 806 | onBlur={() => setIsComposerFocused(false)} |
| 898 | onPasteImages={(uris) => void handleNativePasteImages(uris)} | 807 | onPasteImages={(uris) => void handleNativePasteImages(uris)} |
| 899 | placeholder={`Describe a coding task in ${selectedProject.title}`} | 808 | placeholder="Ask anything…" |
| 900 | // Same collapsed centering as ThreadComposer: native vertical gravity | 809 | singleLineCentered={false} |
| 901 | // in a pill-height box. | 810 | contentInsetVertical={0} |
| 902 | singleLineCentered={!isExpanded} | 811 | style={{ |
| 903 | contentInsetVertical={isAndroid ? 0 : undefined} | 812 | minHeight: 72, |
| 904 | style={ | 813 | maxHeight: 160, |
| 905 | isAndroid | 814 | paddingHorizontal: 4, |
| 906 | ? isExpanded | 815 | paddingVertical: 4, |
| 907 | ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 } | 816 | }} |
| 908 | : { height: 36 } | 817 | textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }} |
| 909 | : { flex: 1, minHeight: 0 } | ||
| 910 | } | ||
| 911 | textStyle={ | ||
| 912 | isAndroid | ||
| 913 | ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily } | ||
| 914 | : headlineText | ||
| 915 | } | ||
| 916 | /> | 818 | /> |
| 917 | ); | 819 | ); |
| 918 | 820 | ||
| 919 | const toolbarPills = ( | 821 | const closeNewTask = () => { |
| 920 | <> | 822 | void KeyboardController.dismiss({ animated: true }); |
| 921 | <ComposerToolbarButton | 823 | const parentNavigation = navigation.getParent(); |
| 922 | icon="plus" | 824 | if (parentNavigation) { |
| 923 | onPress={() => void handlePickImages()} | 825 | parentNavigation.goBack(); |
| 924 | showChevron={false} | 826 | return; |
| 925 | disabled={isIncomingShareTransferPending} | 827 | } |
| 926 | /> | 828 | navigation.goBack(); |
| 927 | <ComposerToolbarTrigger | 829 | }; |
| 928 | accessibilityLabel="Thread settings" | 830 | const chooseProject = () => { |
| 831 | if (isIncomingShareTransferPending) { | ||
| 832 | return; | ||
| 833 | } | ||
| 834 | promptInputRef.current?.blur(); | ||
| 835 | void KeyboardController.dismiss({ animated: true }); | ||
| 836 | navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId })); | ||
| 837 | }; | ||
| 838 | const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => { | ||
| 839 | if (isIncomingShareTransferPending) { | ||
| 840 | return; | ||
| 841 | } | ||
| 842 | promptInputRef.current?.blur(); | ||
| 843 | void KeyboardController.dismiss({ animated: true }); | ||
| 844 | navigation.dispatch(StackActions.push(routeName)); | ||
| 845 | }; | ||
| 846 | |||
| 847 | const hero = ( | ||
| 848 | <View className="items-center gap-6 px-6" testID="new-task-hero"> | ||
| 849 | <View className="w-full items-center gap-1.5"> | ||
| 850 | <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground"> | ||
| 851 | What should we build | ||
| 852 | </Text> | ||
| 853 | <View className="max-w-full flex-row items-center justify-center"> | ||
| 854 | <Text className="text-2xl font-t3-medium tracking-tight text-foreground">in </Text> | ||
| 855 | <Pressable | ||
| 856 | accessibilityHint="Opens the project picker" | ||
| 857 | accessibilityLabel={`Change project from ${selectedProject.title}`} | ||
| 858 | accessibilityRole="button" | ||
| 859 | disabled={isIncomingShareTransferPending} | ||
| 860 | onPress={chooseProject} | ||
| 861 | className="min-w-0 max-w-[250px] active:opacity-65" | ||
| 862 | style={{ | ||
| 863 | borderBottomColor: projectUnderlineColor, | ||
| 864 | borderBottomWidth: 1, | ||
| 865 | }} | ||
| 866 | > | ||
| 867 | <Text | ||
| 868 | className="text-2xl font-t3-medium tracking-tight text-foreground" | ||
| 869 | numberOfLines={1} | ||
| 870 | > | ||
| 871 | {selectedProject.title} | ||
| 872 | </Text> | ||
| 873 | </Pressable> | ||
| 874 | <Text className="text-2xl font-t3-medium tracking-tight text-foreground">?</Text> | ||
| 875 | </View> | ||
| 876 | </View> | ||
| 877 | |||
| 878 | <ComposerInlineControl | ||
| 879 | accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`} | ||
| 880 | chevronDirection="right" | ||
| 929 | disabled={isIncomingShareTransferPending} | 881 | disabled={isIncomingShareTransferPending} |
| 930 | iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />} | 882 | icon="desktopcomputer" |
| 931 | label={settingsSummaryLabel} | 883 | label={`on ${selectedEnvironmentLabel}`} |
| 932 | maxWidth={320} | 884 | maxWidth={260} |
| 933 | onPress={settingsSheetPresentation.open} | 885 | onPress={ |
| 886 | flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined | ||
| 887 | } | ||
| 888 | showChevron={flow.environments.length > 1} | ||
| 889 | static={flow.environments.length <= 1} | ||
| 934 | /> | 890 | /> |
| 935 | <ControlPillMenu | 891 | </View> |
| 936 | actions={environmentMenuActions} | 892 | ); |
| 937 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} | 893 | const heroViewport = ( |
| 938 | > | 894 | <View className="flex-1" collapsable={false}> |
| 939 | <ComposerToolbarTrigger | 895 | <ScrollView |
| 940 | accessibilityLabel="Environment" | 896 | alwaysBounceVertical={isKeyboardVisible} |
| 941 | disabled={isIncomingShareTransferPending} | 897 | className="flex-1" |
| 942 | icon="desktopcomputer" | 898 | contentInsetAdjustmentBehavior="never" |
| 943 | label={selectedEnvironmentLabel} | 899 | contentContainerClassName="grow items-center pb-[236px] pt-12 ios:pt-[72px]" |
| 944 | /> | 900 | keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"} |
| 945 | </ControlPillMenu> | 901 | keyboardShouldPersistTaps="handled" |
| 946 | <ControlPillMenu | 902 | showsVerticalScrollIndicator={false} |
| 947 | actions={workspaceMenuActions} | 903 | style={{ flex: 1 }} |
| 948 | onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)} | 904 | testID="new-task-hero-scroll" |
| 949 | > | 905 | > |
| 950 | <ComposerToolbarTrigger | 906 | {hero} |
| 951 | accessibilityLabel="Workspace" | 907 | </ScrollView> |
| 952 | disabled={isIncomingShareTransferPending} | 908 | </View> |
| 953 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 954 | label={workspaceLabel} | ||
| 955 | /> | ||
| 956 | </ControlPillMenu> | ||
| 957 | </> | ||
| 958 | ); | 909 | ); |
| 959 | 910 | ||
| 960 | const settingsSheet = ( | 911 | const workspaceControls = ( |
| 961 | <ThreadSettingsSheet | 912 | <View className="flex-row items-center gap-1 px-2"> |
| 962 | visible={settingsSheetPresentation.isVisible} | 913 | <ComposerInlineControl |
| 963 | onClose={settingsSheetPresentation.close} | 914 | accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} |
| 964 | onDismissed={settingsSheetPresentation.onDismissed} | 915 | accessibilityLabel={workspaceLabel} |
| 965 | providerGroups={flow.providerGroups} | 916 | disabled={isIncomingShareTransferPending} |
| 966 | selectedModel={flow.selectedModel} | 917 | iconNode={ |
| 967 | onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)} | 918 | <NewTaskWorkspaceIcon |
| 968 | optionDescriptors={providerOptionDescriptors} | 919 | workspaceMode={flow.workspaceMode} |
| 969 | onUpdateOptionSelections={flow.setSelectedModelOptions} | 920 | worktreePath={flow.selectedWorktreePath} |
| 970 | runtimeMode={flow.runtimeMode} | 921 | /> |
| 971 | onUpdateRuntimeMode={flow.setRuntimeMode} | 922 | } |
| 972 | /> | 923 | label={workspaceLabel} |
| 924 | maxWidth={flow.workspaceMode === "local" ? 220 : 148} | ||
| 925 | onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} | ||
| 926 | showChevron={false} | ||
| 927 | /> | ||
| 928 | |||
| 929 | <ComposerInlineControl | ||
| 930 | accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`} | ||
| 931 | chevronDirection="right" | ||
| 932 | disabled={isIncomingShareTransferPending} | ||
| 933 | icon="arrow.triangle.branch" | ||
| 934 | label={showBranchLoading ? "Loading branches…" : selectedBranchLabel} | ||
| 935 | maxWidth={190} | ||
| 936 | onPress={() => openContextPicker("NewTaskBranch")} | ||
| 937 | /> | ||
| 938 | </View> | ||
| 973 | ); | 939 | ); |
| 974 | 940 | ||
| 975 | const startButton = ( | 941 | const composerDock = ( |
| 976 | <ComposerToolbarButton | 942 | <View className="bg-sheet px-4 pt-1" style={{ paddingBottom: controlsBottomPadding }}> |
| 977 | accessibilityLabel={ | 943 | <View className="pb-1">{workspaceControls}</View> |
| 978 | flow.submitting ? "Starting task" : environmentConnected ? "Start task" : "Queue task" | 944 | |
| 979 | } | 945 | <ComposerSurface |
| 980 | icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} | 946 | animateLayout={false} |
| 981 | onPress={() => void handleStart()} | 947 | isDarkMode={isDarkMode} |
| 982 | variant="primary" | 948 | style={{ |
| 983 | showChevron={false} | 949 | borderRadius: 26, |
| 984 | disabled={!canStart} | 950 | minHeight: 140, |
| 985 | /> | 951 | overflow: "hidden", |
| 952 | paddingBottom: 6, | ||
| 953 | paddingHorizontal: 14, | ||
| 954 | paddingTop: 14, | ||
| 955 | }} | ||
| 956 | > | ||
| 957 | {flow.attachments.length > 0 ? ( | ||
| 958 | <View className="pb-2.5"> | ||
| 959 | <ComposerAttachmentStrip | ||
| 960 | attachments={flow.attachments} | ||
| 961 | imageBorderRadius={16} | ||
| 962 | imageSize={72} | ||
| 963 | onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment} | ||
| 964 | /> | ||
| 965 | </View> | ||
| 966 | ) : null} | ||
| 967 | |||
| 968 | {promptEditor} | ||
| 969 | |||
| 970 | <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}> | ||
| 971 | <ComposerToolbarScroller | ||
| 972 | fadeOpaque={sheetFadeOpaque} | ||
| 973 | fadeTransparent={sheetFadeTransparent} | ||
| 974 | contentPaddingRight={8} | ||
| 975 | > | ||
| 976 | <ComposerToolbarButton | ||
| 977 | accessibilityLabel="Add attachment" | ||
| 978 | disabled={isIncomingShareTransferPending} | ||
| 979 | icon="plus" | ||
| 980 | onPress={() => void handlePickImages()} | ||
| 981 | showChevron={false} | ||
| 982 | /> | ||
| 983 | <ComposerInlineControl | ||
| 984 | accessibilityLabel="Model and reasoning settings" | ||
| 985 | disabled={isIncomingShareTransferPending} | ||
| 986 | emphasized | ||
| 987 | iconNode={ | ||
| 988 | <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} /> | ||
| 989 | } | ||
| 990 | label={flow.selectedModelOption?.label ?? "Choose model"} | ||
| 991 | maxWidth={152} | ||
| 992 | onPress={settingsSheetPresentation.open} | ||
| 993 | /> | ||
| 994 | {flow.planModeEnabled ? ( | ||
| 995 | <ComposerInlineControl | ||
| 996 | accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`} | ||
| 997 | accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`} | ||
| 998 | disabled={isIncomingShareTransferPending} | ||
| 999 | emphasized | ||
| 1000 | icon={ | ||
| 1001 | flow.interactionMode === "plan" | ||
| 1002 | ? { ios: "list.bullet.clipboard", android: "auto_awesome" } | ||
| 1003 | : { ios: "hammer", android: "construction" } | ||
| 1004 | } | ||
| 1005 | label={flow.interactionMode === "plan" ? "Plan" : "Build"} | ||
| 1006 | onPress={() => | ||
| 1007 | flow.setInteractionMode(flow.interactionMode === "plan" ? "default" : "plan") | ||
| 1008 | } | ||
| 1009 | showChevron={false} | ||
| 1010 | /> | ||
| 1011 | ) : null} | ||
| 1012 | </ComposerToolbarScroller> | ||
| 1013 | <ComposerToolbarButton | ||
| 1014 | accessibilityLabel={ | ||
| 1015 | flow.submitting ? "Starting task" : environmentConnected ? "Start task" : "Queue task" | ||
| 1016 | } | ||
| 1017 | disabled={!canStart} | ||
| 1018 | icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} | ||
| 1019 | onPress={() => void handleStart()} | ||
| 1020 | showChevron={false} | ||
| 1021 | variant="primary" | ||
| 1022 | /> | ||
| 1023 | </ComposerToolbarRow> | ||
| 1024 | </ComposerSurface> | ||
| 1025 | </View> | ||
| 986 | ); | 1026 | ); |
| 987 | 1027 | ||
| 988 | if (isAndroid) { | 1028 | if (isAndroid) { |
| 989 | // The draft is a thread that doesn't exist yet, so it mirrors the thread | ||
| 990 | // page: in-screen header, empty feed canvas above, and the same floating | ||
| 991 | // composer chrome as ThreadComposer (collapsed pill → expanded card). | ||
| 992 | // | ||
| 993 | // Composer positioning mirrors ThreadDetailScreen's floating overlay | ||
| 994 | // (KeyboardStickyView, absolute bottom overlay) rather than | ||
| 995 | // KeyboardAvoidingView's automaticOffset+padding: automaticOffset | ||
| 996 | // resolves the composer's on-screen frame via a native | ||
| 997 | // viewPositionInWindow measurement, which this app's Android | ||
| 998 | // edge-to-edge setup (KeyboardProvider's native content-view margin | ||
| 999 | // handling neutralizes windowSoftInputMode="adjustResize" while active) | ||
| 1000 | // makes unreliable — the composer stayed under the keyboard instead of | ||
| 1001 | // translating above it. KeyboardStickyView sticks directly to the | ||
| 1002 | // animated keyboard height instead, sidestepping that measurement. | ||
| 1003 | return ( | 1029 | return ( |
| 1004 | <View className="flex-1 bg-screen"> | 1030 | <View className="flex-1 bg-sheet" collapsable={false}> |
| 1005 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 1031 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 1006 | <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} /> | 1032 | <AndroidScreenHeader title="New task" onBack={closeNewTask} /> |
| 1007 | 1033 | {heroViewport} | |
| 1008 | <View className="flex-1" /> | ||
| 1009 | 1034 | ||
| 1010 | <KeyboardStickyView | 1035 | <KeyboardStickyView |
| 1011 | enabled={isKeyboardVisible} | ||
| 1012 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | 1036 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 1013 | offset={{ closed: 0, opened: 0 }} | 1037 | offset={{ closed: 0, opened: keyboardOpenedOffset }} |
| 1014 | > | 1038 | > |
| 1015 | <View | 1039 | {composerDock} |
| 1016 | className="px-4 pt-2" | ||
| 1017 | style={{ | ||
| 1018 | paddingBottom: controlsBottomPadding, | ||
| 1019 | experimental_backgroundImage: isDarkMode | ||
| 1020 | ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)" | ||
| 1021 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", | ||
| 1022 | }} | ||
| 1023 | > | ||
| 1024 | <ComposerSurface | ||
| 1025 | isDarkMode={isDarkMode} | ||
| 1026 | style={ | ||
| 1027 | isExpanded | ||
| 1028 | ? { | ||
| 1029 | borderRadius: 20, | ||
| 1030 | overflow: "hidden", | ||
| 1031 | paddingHorizontal: 14, | ||
| 1032 | paddingVertical: 12, | ||
| 1033 | } | ||
| 1034 | : { | ||
| 1035 | borderRadius: 999, | ||
| 1036 | overflow: "hidden", | ||
| 1037 | flexDirection: "row", | ||
| 1038 | alignItems: "center", | ||
| 1039 | paddingLeft: 18, | ||
| 1040 | paddingRight: 5, | ||
| 1041 | paddingVertical: 5, | ||
| 1042 | } | ||
| 1043 | } | ||
| 1044 | > | ||
| 1045 | {isExpanded && flow.attachments.length > 0 ? ( | ||
| 1046 | <View className="pb-2.5"> | ||
| 1047 | <ComposerAttachmentStrip | ||
| 1048 | attachments={flow.attachments} | ||
| 1049 | onRemove={ | ||
| 1050 | isIncomingShareTransferPending ? () => undefined : flow.removeAttachment | ||
| 1051 | } | ||
| 1052 | /> | ||
| 1053 | </View> | ||
| 1054 | ) : null} | ||
| 1055 | <View className={isExpanded ? undefined : "min-w-0 flex-1"}>{promptEditor}</View> | ||
| 1056 | {!isExpanded ? ( | ||
| 1057 | <ControlPill | ||
| 1058 | icon="arrow.up" | ||
| 1059 | variant="primary" | ||
| 1060 | disabled={!canStart} | ||
| 1061 | onPress={() => void handleStart()} | ||
| 1062 | /> | ||
| 1063 | ) : null} | ||
| 1064 | </ComposerSurface> | ||
| 1065 | |||
| 1066 | {isExpanded ? ( | ||
| 1067 | <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}> | ||
| 1068 | <ComposerToolbarScroller | ||
| 1069 | fadeOpaque={isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"} | ||
| 1070 | fadeTransparent={isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"} | ||
| 1071 | > | ||
| 1072 | {toolbarPills} | ||
| 1073 | </ComposerToolbarScroller> | ||
| 1074 | {startButton} | ||
| 1075 | </ComposerToolbarRow> | ||
| 1076 | ) : null} | ||
| 1077 | </View> | ||
| 1078 | </KeyboardStickyView> | 1040 | </KeyboardStickyView> |
| 1079 | {settingsSheet} | ||
| 1080 | </View> | 1041 | </View> |
| 1081 | ); | 1042 | ); |
| 1082 | } | 1043 | } |
| 1083 | 1044 | ||
| 1084 | return ( | 1045 | return ( |
| 1085 | <View className="flex-1 bg-sheet"> | 1046 | <View className="flex-1 bg-sheet" collapsable={false}> |
| 1086 | <NativeStackScreenOptions options={{ title: selectedProject.title }} /> | 1047 | <NativeStackScreenOptions |
| 1087 | 1048 | options={{ | |
| 1088 | <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1"> | 1049 | headerBackVisible: false, |
| 1089 | <View className="min-h-0 flex-1 px-5 pt-2">{promptEditor}</View> | 1050 | headerShadowVisible: false, |
| 1051 | title: "", | ||
| 1052 | }} | ||
| 1053 | /> | ||
| 1054 | <NativeHeaderToolbar placement="left"> | ||
| 1055 | <NativeHeaderToolbar.Button | ||
| 1056 | accessibilityLabel="Cancel new task" | ||
| 1057 | label="Cancel" | ||
| 1058 | onPress={closeNewTask} | ||
| 1059 | /> | ||
| 1060 | </NativeHeaderToolbar> | ||
| 1090 | 1061 | ||
| 1091 | <View className="border-t border-border" style={{ paddingBottom: controlsBottomPadding }}> | 1062 | {heroViewport} |
| 1092 | {flow.attachments.length > 0 ? ( | 1063 | <KeyboardStickyView |
| 1093 | <View className="px-4 pt-3"> | 1064 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 1094 | <ComposerAttachmentStrip | 1065 | offset={{ closed: 0, opened: keyboardOpenedOffset }} |
| 1095 | attachments={flow.attachments} | 1066 | > |
| 1096 | onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment} | 1067 | {composerDock} |
| 1097 | imageSize={88} | 1068 | </KeyboardStickyView> |
| 1098 | imageBorderRadius={20} | ||
| 1099 | /> | ||
| 1100 | </View> | ||
| 1101 | ) : null} | ||
| 1102 | <ComposerToolbarRow paddingBottom={controlsBottomPadding} paddingHorizontal={6}> | ||
| 1103 | <ComposerToolbarScroller | ||
| 1104 | fadeOpaque={sheetFadeOpaque} | ||
| 1105 | fadeTransparent={sheetFadeTransparent} | ||
| 1106 | > | ||
| 1107 | {toolbarPills} | ||
| 1108 | </ComposerToolbarScroller> | ||
| 1109 | {startButton} | ||
| 1110 | </ComposerToolbarRow> | ||
| 1111 | </View> | ||
| 1112 | </KeyboardAvoidingView> | ||
| 1113 | {settingsSheet} | ||
| 1114 | </View> | 1069 | </View> |
| 1115 | ); | 1070 | ); |
| 1116 | } | 1071 | } |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+33-78| ... | @@ -1,8 +1,13 @@ | ... | @@ -1,8 +1,13 @@ |
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 2 | import { |
| 3 | StackActions, | ||
| 4 | useIsFocused, | ||
| 5 | useNavigation, | ||
| 6 | type StaticScreenProps, | ||
| 7 | } from "@react-navigation/native"; | ||
| 3 | import { SymbolView } from "../../components/AppSymbol"; | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 9 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 5 | import { useEffect, useRef, useState } from "react"; | 10 | import { useEffect, useRef } from "react"; |
| 6 | import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; | 11 | import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | import { useThemeColor } from "../../lib/useThemeColor"; | 13 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | @@ -14,10 +19,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; | ... | @@ -14,10 +19,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 14 | import { useProjects } from "../../state/entities"; | 19 | import { useProjects } from "../../state/entities"; |
| 15 | import type { WorkspaceState } from "../../state/workspaceModel"; | 20 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 16 | import { useWorkspaceState } from "../../state/workspace"; | 21 | import { useWorkspaceState } from "../../state/workspace"; |
| 17 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 18 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 22 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 19 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; | 23 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; |
| 20 | import { useNewTaskFlow } from "./new-task-flow-provider"; | 24 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 25 | import { getProjectScopeSelectionTarget } from "./new-task-project-selection"; | ||
| 21 | 26 | ||
| 22 | type NewTaskRouteParams = { | 27 | type NewTaskRouteParams = { |
| 23 | readonly incomingShareId?: string | string[]; | 28 | readonly incomingShareId?: string | string[]; |
| ... | @@ -80,7 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { | ... | @@ -80,7 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { |
| 80 | 85 | ||
| 81 | export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) { | 86 | export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) { |
| 82 | const projects = useProjects(); | 87 | const projects = useProjects(); |
| 83 | const { projectScopes } = useNewTaskFlow(); | 88 | const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow(); |
| 84 | const { state: catalogState } = useWorkspaceState(); | 89 | const { state: catalogState } = useWorkspaceState(); |
| 85 | const navigation = useNavigation(); | 90 | const navigation = useNavigation(); |
| 86 | const isFocused = useIsFocused(); | 91 | const isFocused = useIsFocused(); |
| ... | @@ -88,7 +93,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -88,7 +93,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 88 | const insets = useSafeAreaInsets(); | 93 | const insets = useSafeAreaInsets(); |
| 89 | const chevronColor = useThemeColor("--color-chevron"); | 94 | const chevronColor = useThemeColor("--color-chevron"); |
| 90 | const accentColor = useThemeColor("--color-icon-muted"); | 95 | const accentColor = useThemeColor("--color-icon-muted"); |
| 91 | const [expandedGroupKeys, setExpandedGroupKeys] = useState<ReadonlySet<string>>(() => new Set()); | ||
| 92 | const { getShare, releaseShareReservation } = useIncomingShare(); | 96 | const { getShare, releaseShareReservation } = useIncomingShare(); |
| 93 | const routeShareId = Array.isArray(route.params?.incomingShareId) | 97 | const routeShareId = Array.isArray(route.params?.incomingShareId) |
| 94 | ? route.params.incomingShareId[0] | 98 | ? route.params.incomingShareId[0] |
| ... | @@ -126,27 +130,22 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -126,27 +130,22 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 126 | return; | 130 | return; |
| 127 | } | 131 | } |
| 128 | } | 132 | } |
| 129 | navigation.navigate("NewTaskSheet", { | 133 | const state = navigation.getState(); |
| 130 | screen: "NewTaskDraft", | 134 | const previousRoute = state?.routes[state.index - 1]; |
| 131 | params: { | 135 | if (previousRoute?.name === "NewTaskDraft") { |
| 136 | setProject(project); | ||
| 137 | navigation.goBack(); | ||
| 138 | return; | ||
| 139 | } | ||
| 140 | |||
| 141 | navigation.dispatch( | ||
| 142 | StackActions.push("NewTaskDraft", { | ||
| 132 | environmentId: project.environmentId, | 143 | environmentId: project.environmentId, |
| 133 | projectId: project.id, | 144 | projectId: project.id, |
| 134 | title: project.title, | 145 | title: project.title, |
| 135 | incomingShareId: incomingShare?.id, | 146 | incomingShareId: incomingShare?.id, |
| 136 | }, | 147 | }), |
| 137 | }); | 148 | ); |
| 138 | } | ||
| 139 | |||
| 140 | function toggleGroup(groupKey: string): void { | ||
| 141 | setExpandedGroupKeys((current) => { | ||
| 142 | const next = new Set(current); | ||
| 143 | if (next.has(groupKey)) { | ||
| 144 | next.delete(groupKey); | ||
| 145 | } else { | ||
| 146 | next.add(groupKey); | ||
| 147 | } | ||
| 148 | return next; | ||
| 149 | }); | ||
| 150 | } | 149 | } |
| 151 | 150 | ||
| 152 | useEffect(() => { | 151 | useEffect(() => { |
| ... | @@ -169,15 +168,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -169,15 +168,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 169 | return; | 168 | return; |
| 170 | } | 169 | } |
| 171 | resumedDestinationKeyRef.current = destinationKey; | 170 | resumedDestinationKeyRef.current = destinationKey; |
| 172 | navigation.navigate("NewTaskSheet", { | 171 | navigation.dispatch( |
| 173 | screen: "NewTaskDraft", | 172 | StackActions.push("NewTaskDraft", { |
| 174 | params: { | ||
| 175 | environmentId: reservedDestinationProject.environmentId, | 173 | environmentId: reservedDestinationProject.environmentId, |
| 176 | projectId: reservedDestinationProject.id, | 174 | projectId: reservedDestinationProject.id, |
| 177 | title: reservedDestinationProject.title, | 175 | title: reservedDestinationProject.title, |
| 178 | incomingShareId: incomingShare.id, | 176 | incomingShareId: incomingShare.id, |
| 179 | }, | 177 | }), |
| 180 | }); | 178 | ); |
| 181 | }, [incomingShare, isFocused, navigation, reservedDestinationProject]); | 179 | }, [incomingShare, isFocused, navigation, reservedDestinationProject]); |
| 182 | 180 | ||
| 183 | return ( | 181 | return ( |
| ... | @@ -196,7 +194,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -196,7 +194,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 196 | { | 194 | { |
| 197 | accessibilityLabel: "Add project", | 195 | accessibilityLabel: "Add project", |
| 198 | icon: "plus", | 196 | icon: "plus", |
| 199 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), | 197 | onPress: () => navigation.dispatch(StackActions.push("AddProject")), |
| 200 | }, | 198 | }, |
| 201 | ] | 199 | ] |
| 202 | : [] | 200 | : [] |
| ... | @@ -223,7 +221,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -223,7 +221,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 223 | {catalogState.hasReadyEnvironment ? ( | 221 | {catalogState.hasReadyEnvironment ? ( |
| 224 | <NativeHeaderToolbar.Button | 222 | <NativeHeaderToolbar.Button |
| 225 | icon="plus" | 223 | icon="plus" |
| 226 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | 224 | onPress={() => navigation.dispatch(StackActions.push("AddProject"))} |
| 227 | separateBackground | 225 | separateBackground |
| 228 | /> | 226 | /> |
| 229 | ) : null} | 227 | ) : null} |
| ... | @@ -263,7 +261,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -263,7 +261,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 263 | ) : ( | 261 | ) : ( |
| 264 | <Pressable | 262 | <Pressable |
| 265 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | 263 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" |
| 266 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | 264 | onPress={() => navigation.dispatch(StackActions.push("AddProject"))} |
| 267 | > | 265 | > |
| 268 | <Text className="text-sm font-t3-bold text-primary-foreground"> | 266 | <Text className="text-sm font-t3-bold text-primary-foreground"> |
| 269 | Add new project | 267 | Add new project |
| ... | @@ -275,22 +273,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -275,22 +273,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 275 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | 273 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> |
| 276 | {projectScopes.map((scope, scopeIndex) => { | 274 | {projectScopes.map((scope, scopeIndex) => { |
| 277 | const hasMultipleProjects = scope.projects.length > 1; | 275 | const hasMultipleProjects = scope.projects.length > 1; |
| 278 | const expanded = expandedGroupKeys.has(scope.key); | 276 | const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId); |
| 279 | const singleProject = hasMultipleProjects ? null : scope.projects[0]; | ||
| 280 | return ( | 277 | return ( |
| 281 | <View | 278 | <View |
| 282 | key={scope.key} | 279 | key={scope.key} |
| 283 | className={cn(scopeIndex > 0 && "border-t border-border-subtle")} | 280 | className={cn(scopeIndex > 0 && "border-t border-border-subtle")} |
| 284 | > | 281 | > |
| 285 | <Pressable | 282 | <Pressable |
| 286 | disabled={singleProject !== null && reservedDestinationProject !== null} | 283 | disabled={reservedDestinationProject !== null} |
| 287 | onPress={() => { | 284 | onPress={() => void selectProject(selectionTarget)} |
| 288 | if (singleProject) { | ||
| 289 | void selectProject(singleProject); | ||
| 290 | } else { | ||
| 291 | toggleGroup(scope.key); | ||
| 292 | } | ||
| 293 | }} | ||
| 294 | className="flex-row items-center gap-3 bg-card px-4 py-3.5" | 285 | className="flex-row items-center gap-3 bg-card px-4 py-3.5" |
| 295 | > | 286 | > |
| 296 | <View className="h-7 w-7 items-center justify-center"> | 287 | <View className="h-7 w-7 items-center justify-center"> |
| ... | @@ -311,52 +302,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -311,52 +302,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 311 | > | 302 | > |
| 312 | {hasMultipleProjects | 303 | {hasMultipleProjects |
| 313 | ? `${scope.projects.length} workspaces` | 304 | ? `${scope.projects.length} workspaces` |
| 314 | : singleProject?.workspaceRoot} | 305 | : selectionTarget.workspaceRoot} |
| 315 | </Text> | 306 | </Text> |
| 316 | </View> | 307 | </View> |
| 317 | <SymbolView | 308 | <SymbolView |
| 318 | name={hasMultipleProjects && expanded ? "chevron.down" : "chevron.right"} | 309 | name="chevron.right" |
| 319 | size={14} | 310 | size={14} |
| 320 | tintColor={chevronColor} | 311 | tintColor={chevronColor} |
| 321 | type="monochrome" | 312 | type="monochrome" |
| 322 | /> | 313 | /> |
| 323 | </Pressable> | 314 | </Pressable> |
| 324 | {hasMultipleProjects && expanded | ||
| 325 | ? scope.projects.map((project) => ( | ||
| 326 | <Pressable | ||
| 327 | key={scopedProjectKey(project.environmentId, project.id)} | ||
| 328 | disabled={reservedDestinationProject !== null} | ||
| 329 | onPress={() => void selectProject(project)} | ||
| 330 | className="flex-row items-center gap-3 border-t border-border-subtle bg-card py-3 pr-4 pl-10" | ||
| 331 | > | ||
| 332 | <ProjectFavicon | ||
| 333 | environmentId={project.environmentId} | ||
| 334 | faviconPath={project.faviconPath} | ||
| 335 | size={18} | ||
| 336 | projectTitle={project.title} | ||
| 337 | workspaceRoot={project.workspaceRoot} | ||
| 338 | /> | ||
| 339 | <View className="min-w-0 flex-1"> | ||
| 340 | <Text className="text-sm font-t3-bold text-foreground"> | ||
| 341 | {project.title} | ||
| 342 | </Text> | ||
| 343 | <Text | ||
| 344 | className="text-xs text-foreground-muted" | ||
| 345 | ellipsizeMode="middle" | ||
| 346 | numberOfLines={1} | ||
| 347 | > | ||
| 348 | {project.workspaceRoot} | ||
| 349 | </Text> | ||
| 350 | </View> | ||
| 351 | <SymbolView | ||
| 352 | name="chevron.right" | ||
| 353 | size={14} | ||
| 354 | tintColor={chevronColor} | ||
| 355 | type="monochrome" | ||
| 356 | /> | ||
| 357 | </Pressable> | ||
| 358 | )) | ||
| 359 | : null} | ||
| 360 | </View> | 315 | </View> |
| 361 | ); | 316 | ); |
| 362 | })} | 317 | })} |
apps/mobile/src/features/threads/ThreadComposer.tsx+121-151| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | ||
| 2 | import type { | 1 | import type { |
| 3 | EnvironmentId, | 2 | EnvironmentId, |
| 4 | MessageId, | 3 | MessageId, |
| ... | @@ -14,7 +13,7 @@ import { | ... | @@ -14,7 +13,7 @@ import { |
| 14 | serializeComposerFileLink, | 13 | serializeComposerFileLink, |
| 15 | type ComposerTrigger, | 14 | type ComposerTrigger, |
| 16 | } from "@t3tools/shared/composerTrigger"; | 15 | } from "@t3tools/shared/composerTrigger"; |
| 17 | import * as Haptics from "expo-haptics"; | 16 | import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 18 | import type { ReactNode } from "react"; | 17 | import type { ReactNode } from "react"; |
| 19 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; | 18 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 20 | import { | 19 | import { |
| ... | @@ -41,18 +40,19 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; | ... | @@ -41,18 +40,19 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 41 | 40 | ||
| 42 | import { AppText as Text } from "../../components/AppText"; | 41 | import { AppText as Text } from "../../components/AppText"; |
| 43 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 42 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 43 | import { GlassSurface } from "../../components/GlassSurface"; | ||
| 44 | import { | 44 | import { |
| 45 | ComposerEditor, | 45 | ComposerEditor, |
| 46 | type ComposerEditorHandle, | 46 | type ComposerEditorHandle, |
| 47 | type ComposerEditorSelection, | 47 | type ComposerEditorSelection, |
| 48 | } from "../../components/ComposerEditor"; | 48 | } from "../../components/ComposerEditor"; |
| 49 | import { | 49 | import { |
| 50 | ComposerInlineControl, | ||
| 50 | ComposerToolbarButton, | 51 | ComposerToolbarButton, |
| 51 | ComposerToolbarRow, | 52 | ComposerToolbarRow, |
| 52 | ComposerToolbarScroller, | 53 | ComposerToolbarScroller, |
| 53 | ComposerToolbarTrigger, | 54 | } from "../../components/ComposerToolbar"; |
| 54 | } from "../../components/ComposerToolbarTrigger"; | 55 | import { ControlPill } from "../../components/ControlPill"; |
| 55 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | ||
| 56 | import { ProviderIcon } from "../../components/ProviderIcon"; | 56 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 57 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 57 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 58 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 58 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| ... | @@ -63,15 +63,17 @@ import { | ... | @@ -63,15 +63,17 @@ import { |
| 63 | normalizeSearchQuery, | 63 | normalizeSearchQuery, |
| 64 | scoreQueryMatch, | 64 | scoreQueryMatch, |
| 65 | } from "@t3tools/shared/searchRanking"; | 65 | } from "@t3tools/shared/searchRanking"; |
| 66 | import { | 66 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; |
| 67 | applyProviderOptionSelection, | ||
| 68 | resolveProviderOptionDescriptors, | ||
| 69 | } from "../../lib/providerOptions"; | ||
| 70 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; | 67 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; |
| 71 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | 68 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; |
| 72 | import { buildThreadSettingsMenu } from "./thread-settings-menu"; | 69 | import { |
| 73 | import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; | 70 | type ExistingThreadSettingsRouteSession, |
| 74 | import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; | 71 | useExistingThreadSettingsRoutePresentation, |
| 72 | } from "./ThreadSettingsSheet"; | ||
| 73 | import { | ||
| 74 | useThreadSettingsSheetPresentation, | ||
| 75 | type NavigationWithFinishTransitioning, | ||
| 76 | } from "./use-thread-settings-sheet-presentation"; | ||
| 75 | 77 | ||
| 76 | /** | 78 | /** |
| 77 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). | 79 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). |
| ... | @@ -83,7 +85,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60; | ... | @@ -83,7 +85,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60; |
| 83 | * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). | 85 | * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). |
| 84 | * Used by the parent to compute the larger feed bottom inset when the composer is focused. | 86 | * Used by the parent to compute the larger feed bottom inset when the composer is focused. |
| 85 | */ | 87 | */ |
| 86 | export const COMPOSER_EXPANDED_CHROME = 174; | 88 | export const COMPOSER_EXPANDED_CHROME = 156; |
| 87 | 89 | ||
| 88 | export interface ThreadComposerProps { | 90 | export interface ThreadComposerProps { |
| 89 | readonly draftMessage: string; | 91 | readonly draftMessage: string; |
| ... | @@ -123,8 +125,8 @@ export interface ThreadComposerProps { | ... | @@ -123,8 +125,8 @@ export interface ThreadComposerProps { |
| 123 | } | 125 | } |
| 124 | 126 | ||
| 125 | /** | 127 | /** |
| 126 | * The pill / card container — renders as LiquidGlassView on supported | 128 | * The pill / card container — renders with Expo's native GlassView on supported |
| 127 | * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. | 129 | * iOS 26+ devices and keeps the existing opaque fallback elsewhere. |
| 128 | * Exported so NewTaskDraftScreen can render the same composer chrome. | 130 | * Exported so NewTaskDraftScreen can render the same composer chrome. |
| 129 | */ | 131 | */ |
| 130 | // One timing for every piece of the expanded↔compact morph so the surface, | 132 | // One timing for every piece of the expanded↔compact morph so the surface, |
| ... | @@ -140,6 +142,8 @@ export function ComposerSurface(props: { | ... | @@ -140,6 +142,8 @@ export function ComposerSurface(props: { |
| 140 | readonly children: ReactNode; | 142 | readonly children: ReactNode; |
| 141 | readonly style: ViewStyle; | 143 | readonly style: ViewStyle; |
| 142 | readonly isDarkMode: boolean; | 144 | readonly isDarkMode: boolean; |
| 145 | /** Existing thread composers morph between pill and card layouts. */ | ||
| 146 | readonly animateLayout?: boolean; | ||
| 143 | }) { | 147 | }) { |
| 144 | // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself | 148 | // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself |
| 145 | // (needed to clip content to the pill shape) would clip the shadow on iOS. | 149 | // (needed to clip content to the pill shape) would clip the shadow on iOS. |
| ... | @@ -152,35 +156,26 @@ export function ComposerSurface(props: { | ... | @@ -152,35 +156,26 @@ export function ComposerSurface(props: { |
| 152 | elevation: 10, | 156 | elevation: 10, |
| 153 | }; | 157 | }; |
| 154 | 158 | ||
| 155 | if (isLiquidGlassSupported) { | ||
| 156 | return ( | ||
| 157 | <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}> | ||
| 158 | <LiquidGlassView | ||
| 159 | effect="regular" | ||
| 160 | interactive | ||
| 161 | colorScheme={props.isDarkMode ? "dark" : "light"} | ||
| 162 | style={props.style} | ||
| 163 | > | ||
| 164 | {props.children} | ||
| 165 | </LiquidGlassView> | ||
| 166 | </Animated.View> | ||
| 167 | ); | ||
| 168 | } | ||
| 169 | |||
| 170 | return ( | 159 | return ( |
| 171 | <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}> | 160 | <Animated.View |
| 172 | <View | 161 | layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION} |
| 173 | style={[ | 162 | style={shadowStyle} |
| 174 | props.style, | 163 | > |
| 175 | { | 164 | <GlassSurface |
| 176 | backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | 165 | chrome="none" |
| 177 | borderWidth: 1, | 166 | fallbackStyle={{ |
| 178 | borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | 167 | backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", |
| 179 | }, | 168 | borderWidth: 1, |
| 180 | ]} | 169 | borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", |
| 170 | }} | ||
| 171 | glassEffectStyle="regular" | ||
| 172 | // The composer is a passive material containing interactive controls. | ||
| 173 | // Expo GlassView defaults to non-interactive and both layouts share it. | ||
| 174 | tintColor="transparent" | ||
| 175 | style={props.style} | ||
| 181 | > | 176 | > |
| 182 | {props.children} | 177 | {props.children} |
| 183 | </View> | 178 | </GlassSurface> |
| 184 | </Animated.View> | 179 | </Animated.View> |
| 185 | ); | 180 | ); |
| 186 | } | 181 | } |
| ... | @@ -271,6 +266,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( | ... | @@ -271,6 +266,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 271 | }); | 266 | }); |
| 272 | 267 | ||
| 273 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | 268 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 269 | const navigation = useNavigation(); | ||
| 274 | const isDarkMode = useColorScheme() === "dark"; | 270 | const isDarkMode = useColorScheme() === "dark"; |
| 275 | const foregroundColor = useThemeColor("--color-foreground"); | 271 | const foregroundColor = useThemeColor("--color-foreground"); |
| 276 | const bodyText = useScaledTextRole("body"); | 272 | const bodyText = useScaledTextRole("body"); |
| ... | @@ -281,14 +277,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -281,14 +277,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 281 | editorRef: inputRef, | 277 | editorRef: inputRef, |
| 282 | isEditorFocused: isFocused, | 278 | isEditorFocused: isFocused, |
| 283 | }); | 279 | }); |
| 280 | const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation(); | ||
| 281 | const settingsRoutePresentedRef = useRef(false); | ||
| 284 | const wasExpandedBeforePreviewRef = useRef(false); | 282 | const wasExpandedBeforePreviewRef = useRef(false); |
| 285 | const inFlightThreadIdsRef = useRef(new Set<string>()); | 283 | const inFlightThreadIdsRef = useRef(new Set<string>()); |
| 286 | const { onExpandedChange } = props; | 284 | const { onExpandedChange } = props; |
| 287 | 285 | ||
| 288 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | 286 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); |
| 289 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; | 287 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; |
| 290 | // Opening and closing count as active so the composer stays expanded while | 288 | // Opening and presentation count as active so the composer stays expanded |
| 291 | // focus moves between its native editor and the settings modal. | 289 | // while focus moves between its native editor and the settings picker. |
| 292 | const isExpanded = isFocused || settingsSheetPresentation.isActive; | 290 | const isExpanded = isFocused || settingsSheetPresentation.isActive; |
| 293 | const canSend = hasContent; | 291 | const canSend = hasContent; |
| 294 | 292 | ||
| ... | @@ -334,7 +332,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -334,7 +332,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 334 | : "Send"; | 332 | : "Send"; |
| 335 | const currentModelSelection = props.selectedThread.modelSelection; | 333 | const currentModelSelection = props.selectedThread.modelSelection; |
| 336 | const currentRuntimeMode = props.selectedThread.runtimeMode; | 334 | const currentRuntimeMode = props.selectedThread.runtimeMode; |
| 337 | const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; | ||
| 338 | const connectionStatus = composerConnectionStatus({ | 335 | const connectionStatus = composerConnectionStatus({ |
| 339 | connectionError: props.connectionError, | 336 | connectionError: props.connectionError, |
| 340 | connectionState: props.connectionState, | 337 | connectionState: props.connectionState, |
| ... | @@ -626,67 +623,71 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -626,67 +623,71 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 626 | }), | 623 | }), |
| 627 | [currentModelOption?.capabilities, currentModelSelection.options], | 624 | [currentModelOption?.capabilities, currentModelSelection.options], |
| 628 | ); | 625 | ); |
| 629 | const settingsSummaryLabel = threadSettingsSummaryLabel({ | 626 | const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 630 | modelLabel: currentModelOption?.label ?? currentModelSelection.model, | 627 | const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>( |
| 631 | optionDescriptors: providerOptionDescriptors, | 628 | () => ({ |
| 632 | runtimeMode: currentRuntimeMode, | 629 | ownerId: settingsOwnerId, |
| 633 | interactionMode: currentInteractionMode, | 630 | providerGroups: threadProviderGroups, |
| 634 | }); | 631 | selectedModel: currentModelSelection, |
| 635 | 632 | onSelectModel: (option) => props.onUpdateModelSelection(option.selection), | |
| 636 | // iOS gets a native menu on the trigger pill: the everyday adjustments | 633 | optionDescriptors: providerOptionDescriptors, |
| 637 | // apply without resigning the keyboard, while "All Settings…" (and the | 634 | onUpdateOptionSelections: (options) => |
| 638 | // Android trigger) still route through the sheet, which must dismiss it. | 635 | props.onUpdateModelSelection({ ...currentModelSelection, options }), |
| 639 | const settingsMenu = useMemo( | 636 | runtimeMode: currentRuntimeMode, |
| 640 | () => | 637 | onUpdateRuntimeMode: props.onUpdateRuntimeMode, |
| 641 | Platform.OS === "ios" | 638 | }), |
| 642 | ? buildThreadSettingsMenu({ | ||
| 643 | providerGroups: threadProviderGroups, | ||
| 644 | selectedModel: currentModelSelection, | ||
| 645 | optionDescriptors: providerOptionDescriptors, | ||
| 646 | runtimeMode: currentRuntimeMode, | ||
| 647 | }) | ||
| 648 | : null, | ||
| 649 | [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode], | ||
| 650 | ); | ||
| 651 | |||
| 652 | const onUpdateModelSelection = props.onUpdateModelSelection; | ||
| 653 | const onUpdateRuntimeMode = props.onUpdateRuntimeMode; | ||
| 654 | const handleSettingsMenuAction = useCallback( | ||
| 655 | (eventId: string) => { | ||
| 656 | const event = settingsMenu?.events.get(eventId); | ||
| 657 | if (!event) { | ||
| 658 | return; | ||
| 659 | } | ||
| 660 | switch (event.type) { | ||
| 661 | case "select-model": | ||
| 662 | void Haptics.selectionAsync(); | ||
| 663 | onUpdateModelSelection(event.option.selection); | ||
| 664 | return; | ||
| 665 | case "set-option": { | ||
| 666 | const options = applyProviderOptionSelection(providerOptionDescriptors, { | ||
| 667 | id: event.optionId, | ||
| 668 | value: event.value, | ||
| 669 | }); | ||
| 670 | if (options) { | ||
| 671 | void Haptics.selectionAsync(); | ||
| 672 | onUpdateModelSelection({ ...currentModelSelection, options }); | ||
| 673 | } | ||
| 674 | return; | ||
| 675 | } | ||
| 676 | case "set-runtime": | ||
| 677 | void Haptics.selectionAsync(); | ||
| 678 | onUpdateRuntimeMode(event.mode); | ||
| 679 | return; | ||
| 680 | } | ||
| 681 | }, | ||
| 682 | [ | 639 | [ |
| 683 | currentModelSelection, | 640 | currentModelSelection, |
| 684 | onUpdateModelSelection, | 641 | currentRuntimeMode, |
| 685 | onUpdateRuntimeMode, | 642 | props.onUpdateModelSelection, |
| 643 | props.onUpdateRuntimeMode, | ||
| 686 | providerOptionDescriptors, | 644 | providerOptionDescriptors, |
| 687 | settingsMenu, | 645 | settingsOwnerId, |
| 646 | threadProviderGroups, | ||
| 688 | ], | 647 | ], |
| 689 | ); | 648 | ); |
| 649 | const openSettings = useCallback(() => { | ||
| 650 | settingsRoutePresentation.present(settingsRouteSession); | ||
| 651 | settingsSheetPresentation.open(); | ||
| 652 | }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.open]); | ||
| 653 | |||
| 654 | useEffect(() => { | ||
| 655 | if (settingsSheetPresentation.isActive) { | ||
| 656 | settingsRoutePresentation.present(settingsRouteSession); | ||
| 657 | } | ||
| 658 | }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]); | ||
| 659 | |||
| 660 | useEffect(() => { | ||
| 661 | if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { | ||
| 662 | return; | ||
| 663 | } | ||
| 664 | |||
| 665 | settingsRoutePresentedRef.current = true; | ||
| 666 | navigation.dispatch(StackActions.push("ThreadSettingsSheet")); | ||
| 667 | }, [navigation, settingsSheetPresentation.isVisible]); | ||
| 668 | |||
| 669 | useFocusEffect( | ||
| 670 | useCallback(() => { | ||
| 671 | if (!settingsRoutePresentedRef.current) { | ||
| 672 | return; | ||
| 673 | } | ||
| 674 | |||
| 675 | settingsRoutePresentedRef.current = false; | ||
| 676 | settingsSheetPresentation.onDismissed(); | ||
| 677 | settingsRoutePresentation.clear(settingsOwnerId); | ||
| 678 | }, [settingsOwnerId, settingsRoutePresentation.clear, settingsSheetPresentation.onDismissed]), | ||
| 679 | ); | ||
| 680 | |||
| 681 | useEffect( | ||
| 682 | () => | ||
| 683 | // UIKit's completion callback for the sheet dismissal, surfaced by the | ||
| 684 | // native-stack patch. This is when the queued keyboard restore runs. | ||
| 685 | (navigation as unknown as NavigationWithFinishTransitioning).addListener( | ||
| 686 | "finishTransitioning", | ||
| 687 | settingsSheetPresentation.onStackTransitionsFinished, | ||
| 688 | ), | ||
| 689 | [navigation, settingsSheetPresentation.onStackTransitionsFinished], | ||
| 690 | ); | ||
| 690 | 691 | ||
| 691 | return ( | 692 | return ( |
| 692 | <Animated.View | 693 | <Animated.View |
| ... | @@ -739,10 +740,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -739,10 +740,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 739 | style={ | 740 | style={ |
| 740 | isExpanded | 741 | isExpanded |
| 741 | ? { | 742 | ? { |
| 742 | borderRadius: 20, | 743 | borderRadius: 26, |
| 744 | minHeight: 140, | ||
| 743 | overflow: "hidden" as const, | 745 | overflow: "hidden" as const, |
| 746 | paddingBottom: 6, | ||
| 744 | paddingHorizontal: 14, | 747 | paddingHorizontal: 14, |
| 745 | paddingVertical: 12, | 748 | paddingTop: 14, |
| 746 | } | 749 | } |
| 747 | : { | 750 | : { |
| 748 | borderRadius: 999, | 751 | borderRadius: 999, |
| ... | @@ -792,7 +795,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -792,7 +795,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 792 | style={ | 795 | style={ |
| 793 | isExpanded | 796 | isExpanded |
| 794 | ? { | 797 | ? { |
| 795 | minHeight: 80, | 798 | minHeight: 72, |
| 796 | maxHeight: 160, | 799 | maxHeight: 160, |
| 797 | paddingHorizontal: 4, | 800 | paddingHorizontal: 4, |
| 798 | paddingVertical: 4, | 801 | paddingVertical: 4, |
| ... | @@ -841,15 +844,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -841,15 +844,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 841 | )} | 844 | )} |
| 842 | </Animated.View> | 845 | </Animated.View> |
| 843 | ) : null} | 846 | ) : null} |
| 844 | </ComposerSurface> | 847 | {isExpanded ? ( |
| 845 | 848 | <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}> | |
| 846 | {isExpanded ? ( | ||
| 847 | // Toolbar row — matches draft page layout (expanded only) | ||
| 848 | <Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)}> | ||
| 849 | <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}> | ||
| 850 | <ComposerToolbarScroller | 849 | <ComposerToolbarScroller |
| 851 | fadeOpaque={toolbarFadeOpaque} | 850 | fadeOpaque={toolbarFadeOpaque} |
| 852 | fadeTransparent={toolbarFadeTransparent} | 851 | fadeTransparent={toolbarFadeTransparent} |
| 852 | contentPaddingRight={8} | ||
| 853 | > | 853 | > |
| 854 | <ComposerToolbarButton | 854 | <ComposerToolbarButton |
| 855 | accessibilityLabel="Add attachment" | 855 | accessibilityLabel="Add attachment" |
| ... | @@ -857,31 +857,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -857,31 +857,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 857 | onPress={() => void props.onPickDraftImages()} | 857 | onPress={() => void props.onPickDraftImages()} |
| 858 | showChevron={false} | 858 | showChevron={false} |
| 859 | /> | 859 | /> |
| 860 | {settingsMenu ? ( | 860 | <ComposerInlineControl |
| 861 | <ControlPillMenu | 861 | accessibilityLabel="Model and reasoning settings" |
| 862 | actions={settingsMenu.actions} | 862 | emphasized |
| 863 | onPressAction={({ nativeEvent }) => handleSettingsMenuAction(nativeEvent.event)} | 863 | iconNode={ |
| 864 | > | 864 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> |
| 865 | <ComposerToolbarTrigger | 865 | } |
| 866 | accessibilityLabel="Thread settings" | 866 | label={currentModelOption?.label ?? currentModelSelection.model} |
| 867 | iconNode={ | 867 | maxWidth={152} |
| 868 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | 868 | onPress={openSettings} |
| 869 | } | 869 | /> |
| 870 | label={settingsSummaryLabel} | ||
| 871 | maxWidth={320} | ||
| 872 | /> | ||
| 873 | </ControlPillMenu> | ||
| 874 | ) : ( | ||
| 875 | <ComposerToolbarTrigger | ||
| 876 | accessibilityLabel="Thread settings" | ||
| 877 | iconNode={ | ||
| 878 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | ||
| 879 | } | ||
| 880 | label={settingsSummaryLabel} | ||
| 881 | maxWidth={320} | ||
| 882 | onPress={settingsSheetPresentation.open} | ||
| 883 | /> | ||
| 884 | )} | ||
| 885 | {showStopAction ? ( | 870 | {showStopAction ? ( |
| 886 | <ComposerToolbarButton | 871 | <ComposerToolbarButton |
| 887 | accessibilityLabel="Stop" | 872 | accessibilityLabel="Stop" |
| ... | @@ -901,8 +886,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -901,8 +886,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 901 | showChevron={false} | 886 | showChevron={false} |
| 902 | /> | 887 | /> |
| 903 | </ComposerToolbarRow> | 888 | </ComposerToolbarRow> |
| 904 | </Animated.View> | 889 | ) : null} |
| 905 | ) : null} | 890 | </ComposerSurface> |
| 906 | 891 | ||
| 907 | {/* Queue count */} | 892 | {/* Queue count */} |
| 908 | {props.queueCount > 0 ? ( | 893 | {props.queueCount > 0 ? ( |
| ... | @@ -915,21 +900,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -915,21 +900,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 915 | ) : null} | 900 | ) : null} |
| 916 | </Animated.View> | 901 | </Animated.View> |
| 917 | 902 | ||
| 918 | <ThreadSettingsSheet | ||
| 919 | visible={settingsSheetPresentation.isVisible} | ||
| 920 | onClose={settingsSheetPresentation.close} | ||
| 921 | onDismissed={settingsSheetPresentation.onDismissed} | ||
| 922 | providerGroups={threadProviderGroups} | ||
| 923 | selectedModel={currentModelSelection} | ||
| 924 | onSelectModel={(option) => props.onUpdateModelSelection(option.selection)} | ||
| 925 | optionDescriptors={providerOptionDescriptors} | ||
| 926 | onUpdateOptionSelections={(options) => | ||
| 927 | props.onUpdateModelSelection({ ...currentModelSelection, options }) | ||
| 928 | } | ||
| 929 | runtimeMode={currentRuntimeMode} | ||
| 930 | onUpdateRuntimeMode={props.onUpdateRuntimeMode} | ||
| 931 | /> | ||
| 932 | |||
| 933 | <ImageViewing | 903 | <ImageViewing |
| 934 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | 904 | images={previewImageUri ? [{ uri: previewImageUri }] : []} |
| 935 | imageIndex={0} | 905 | imageIndex={0} |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+1024-437| ... | @@ -1,92 +1,113 @@ | ... | @@ -1,92 +1,113 @@ |
| 1 | import type { | 1 | import type { |
| 2 | ModelSelection, | 2 | ModelSelection, |
| 3 | ProviderInteractionMode, | ||
| 4 | ProviderOptionDescriptor, | 3 | ProviderOptionDescriptor, |
| 5 | ProviderOptionSelection, | 4 | ProviderOptionSelection, |
| 6 | RuntimeMode, | 5 | RuntimeMode, |
| 7 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; | ||
| 8 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; | ||
| 9 | import { HeaderHeightContext } from "@react-navigation/elements"; | ||
| 8 | import { | 10 | import { |
| 9 | getProviderOptionCurrentLabel, | 11 | getProviderOptionCurrentLabel, |
| 10 | getProviderOptionCurrentValue, | 12 | getProviderOptionCurrentValue, |
| 11 | getProviderOptionDescriptors, | 13 | getProviderOptionDescriptors, |
| 12 | } from "@t3tools/shared/model"; | 14 | } from "@t3tools/shared/model"; |
| 15 | import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; | ||
| 16 | import { | ||
| 17 | createNativeStackNavigator, | ||
| 18 | type NativeStackNavigationProp, | ||
| 19 | } from "@react-navigation/native-stack"; | ||
| 13 | import * as Haptics from "expo-haptics"; | 20 | import * as Haptics from "expo-haptics"; |
| 14 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 15 | import { | 21 | import { |
| 16 | Modal, | 22 | createContext, |
| 17 | Platform, | 23 | use, |
| 18 | Pressable, | 24 | useCallback, |
| 19 | ScrollView, | 25 | useEffect, |
| 20 | Switch, | 26 | useMemo, |
| 21 | useWindowDimensions, | 27 | useState, |
| 22 | View, | 28 | type ReactNode, |
| 23 | } from "react-native"; | 29 | } from "react"; |
| 30 | import { Platform, Pressable, ScrollView, Switch, TextInput, View } from "react-native"; | ||
| 31 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | ||
| 24 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 32 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 25 | 33 | ||
| 26 | import { SymbolView } from "../../components/AppSymbol"; | 34 | import { SymbolView } from "../../components/AppSymbol"; |
| 27 | import { AppText as Text } from "../../components/AppText"; | 35 | import { AppText as Text } from "../../components/AppText"; |
| 36 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; | ||
| 28 | import { ProviderIcon } from "../../components/ProviderIcon"; | 37 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 29 | import { cn } from "../../lib/cn"; | 38 | import { cn } from "../../lib/cn"; |
| 30 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | 39 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 31 | import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; | 40 | import { applyProviderOptionSelection } from "../../lib/providerOptions"; |
| 41 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | ||
| 32 | import { useThemeColor } from "../../lib/useThemeColor"; | 42 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 33 | import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu"; | 43 | import { |
| 34 | import { pendingModelAfterPress } from "./thread-settings-sheet-state"; | 44 | NativeHeaderToolbar, |
| 35 | import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; | 45 | NativeStackScreenOptions, |
| 46 | nativeHeaderScrollEdgeEffects, | ||
| 47 | } from "../../native/StackHeader"; | ||
| 48 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | ||
| 49 | import { | ||
| 50 | NATIVE_SHEET_SURFACE_COLOR, | ||
| 51 | NATIVE_SHEET_SURFACE_CONTENT_STYLE, | ||
| 52 | } from "../../native/sheet-surface"; | ||
| 53 | import { useNewTaskFlow } from "./new-task-flow-provider"; | ||
| 54 | import { | ||
| 55 | createNativeMailSearchToolbarItem, | ||
| 56 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, | ||
| 57 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | ||
| 58 | } from "../layout/native-mail-search-toolbar"; | ||
| 59 | import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; | ||
| 60 | import { | ||
| 61 | modelMatchesCatalogQuery, | ||
| 62 | pendingModelAfterPress, | ||
| 63 | providerSectionIsCollapsed, | ||
| 64 | } from "./thread-settings-sheet-state"; | ||
| 36 | 65 | ||
| 37 | /** | 66 | /** |
| 38 | * The everyday harnesses stay expanded; every other provider (OpenRouter | 67 | * Everyday harnesses start expanded; every other provider (OpenRouter catalogs |
| 39 | * catalogs and friends) folds behind its header so a 300-model catalog can't | 68 | * and friends) starts folded so a 300-model catalog cannot bury the list. All |
| 40 | * bury the list. | 69 | * provider headers remain user-collapsible. |
| 41 | */ | 70 | */ |
| 42 | const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]); | 71 | const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]); |
| 43 | |||
| 44 | /** | 72 | /** |
| 45 | * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, | 73 | * Keep measured row changes stable, but let catalog mutations use the list's |
| 46 | * covering model, provider options, runtime mode, and plan mode in one label. | 74 | * native bounds so a filtered catalog that underflows returns to the top. |
| 47 | */ | 75 | */ |
| 48 | export function threadSettingsSummaryLabel(input: { | 76 | const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = { |
| 49 | readonly modelLabel: string; | 77 | data: false, |
| 50 | readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; | 78 | size: true, |
| 51 | readonly runtimeMode: RuntimeMode; | 79 | } as const; |
| 52 | readonly interactionMode: ProviderInteractionMode; | 80 | const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180); |
| 53 | }): string { | 81 | const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140); |
| 54 | const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode); | 82 | const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120); |
| 55 | return [ | 83 | const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180); |
| 56 | input.modelLabel, | 84 | const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140); |
| 57 | ...providerOptionValueLabels(input.optionDescriptors), | 85 | const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100); |
| 58 | ...(runtime ? [runtime.shortLabel] : []), | 86 | const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects( |
| 59 | ...(input.interactionMode === "plan" ? ["Plan"] : []), | 87 | Platform.OS, |
| 60 | ].join(" · "); | 88 | Platform.Version, |
| 61 | } | 89 | ); |
| 62 | |||
| 63 | function ModelRow(props: { | 90 | function ModelRow(props: { |
| 64 | readonly option: ModelOption; | 91 | readonly option: ModelOption; |
| 65 | readonly selected: boolean; | 92 | readonly selected: boolean; |
| 66 | readonly onPress: () => void; | 93 | readonly onPress: () => void; |
| 94 | readonly isFirst: boolean; | ||
| 95 | readonly isLast: boolean; | ||
| 67 | }) { | 96 | }) { |
| 68 | const primaryFg = useThemeColor("--color-primary-foreground"); | 97 | const checkmarkColor = useThemeColor("--color-icon"); |
| 69 | return ( | 98 | return ( |
| 70 | <Pressable | 99 | <Pressable |
| 71 | accessibilityRole="button" | 100 | accessibilityLabel={props.option.label} |
| 72 | accessibilityState={{ selected: props.selected }} | 101 | accessibilityRole="radio" |
| 102 | accessibilityState={{ checked: props.selected }} | ||
| 73 | onPress={props.onPress} | 103 | onPress={props.onPress} |
| 74 | // Selected rows get the same primary treatment as the submenu rows. | ||
| 75 | // Subtle backgrounds (bg-subtle-strong) get overridden by the OS | ||
| 76 | // selection chrome on iOS 26, so use the explicit high-contrast style | ||
| 77 | // everywhere instead. | ||
| 78 | className={cn( | 104 | className={cn( |
| 79 | "mx-2.5 flex-row items-center gap-2 rounded-xl px-3 py-3.5 active:opacity-70", | 105 | "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", |
| 80 | props.selected ? "bg-primary" : "bg-transparent", | 106 | props.isFirst && "rounded-t-2xl", |
| 107 | props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle", | ||
| 81 | )} | 108 | )} |
| 82 | > | 109 | > |
| 83 | <Text | 110 | <Text className="min-w-0 shrink text-base font-t3-medium text-foreground" numberOfLines={1}> |
| 84 | className={cn( | ||
| 85 | "shrink text-sm font-t3-medium", | ||
| 86 | props.selected ? "text-primary-foreground" : "text-foreground", | ||
| 87 | )} | ||
| 88 | numberOfLines={1} | ||
| 89 | > | ||
| 90 | {props.option.label} | 111 | {props.option.label} |
| 91 | </Text> | 112 | </Text> |
| 92 | {props.option.isDefault ? ( | 113 | {props.option.isDefault ? ( |
| ... | @@ -101,17 +122,19 @@ function ModelRow(props: { | ... | @@ -101,17 +122,19 @@ function ModelRow(props: { |
| 101 | ) : null} | 122 | ) : null} |
| 102 | <View className="flex-1" /> | 123 | <View className="flex-1" /> |
| 103 | {props.selected ? ( | 124 | {props.selected ? ( |
| 104 | <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" /> | 125 | <SymbolView |
| 126 | name="checkmark" | ||
| 127 | size={16} | ||
| 128 | tintColor={checkmarkColor} | ||
| 129 | type="monochrome" | ||
| 130 | weight="semibold" | ||
| 131 | /> | ||
| 105 | ) : null} | 132 | ) : null} |
| 106 | </Pressable> | 133 | </Pressable> |
| 107 | ); | 134 | ); |
| 108 | } | 135 | } |
| 109 | 136 | ||
| 110 | /** | 137 | /** Provider catalog header with its harness logo and disclosure state. */ |
| 111 | * Provider section header with the harness logo. Secondary providers render | ||
| 112 | * as a tappable fold (count + chevron while collapsed); primary providers | ||
| 113 | * and the group holding the current selection are static headers. | ||
| 114 | */ | ||
| 115 | function ProviderHeader(props: { | 138 | function ProviderHeader(props: { |
| 116 | readonly driver: string | undefined; | 139 | readonly driver: string | undefined; |
| 117 | readonly label: string; | 140 | readonly label: string; |
| ... | @@ -121,24 +144,10 @@ function ProviderHeader(props: { | ... | @@ -121,24 +144,10 @@ function ProviderHeader(props: { |
| 121 | readonly onToggle: () => void; | 144 | readonly onToggle: () => void; |
| 122 | }) { | 145 | }) { |
| 123 | const iconSubtle = useThemeColor("--color-icon-subtle"); | 146 | const iconSubtle = useThemeColor("--color-icon-subtle"); |
| 124 | return ( | 147 | const content = ( |
| 125 | <Pressable | 148 | <> |
| 126 | accessibilityRole={props.collapsible ? "button" : "header"} | ||
| 127 | accessibilityState={props.collapsible ? { expanded: !props.collapsed } : undefined} | ||
| 128 | accessibilityLabel={ | ||
| 129 | props.collapsible ? `${props.label}, ${props.modelCount} models` : props.label | ||
| 130 | } | ||
| 131 | disabled={!props.collapsible} | ||
| 132 | onPress={props.onToggle} | ||
| 133 | className={cn( | ||
| 134 | "mx-2.5 flex-row items-center gap-2 rounded-xl px-3", | ||
| 135 | props.collapsible ? "py-3.5 active:opacity-70" : "pb-2 pt-4", | ||
| 136 | )} | ||
| 137 | > | ||
| 138 | <ProviderIcon provider={props.driver} size={15} /> | 149 | <ProviderIcon provider={props.driver} size={15} /> |
| 139 | <Text className="text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted"> | 150 | <Text className="text-sm font-t3-medium text-foreground-muted">{props.label}</Text> |
| 140 | {props.label} | ||
| 141 | </Text> | ||
| 142 | {props.collapsible ? ( | 151 | {props.collapsible ? ( |
| 143 | <> | 152 | <> |
| 144 | <View className="flex-1" /> | 153 | <View className="flex-1" /> |
| ... | @@ -149,13 +158,33 @@ function ProviderHeader(props: { | ... | @@ -149,13 +158,33 @@ function ProviderHeader(props: { |
| 149 | ) : null} | 158 | ) : null} |
| 150 | <SymbolView | 159 | <SymbolView |
| 151 | name={props.collapsed ? "chevron.down" : "chevron.up"} | 160 | name={props.collapsed ? "chevron.down" : "chevron.up"} |
| 152 | size={11} | 161 | size={12} |
| 153 | tintColor={iconSubtle} | 162 | tintColor={iconSubtle} |
| 154 | type="monochrome" | 163 | type="monochrome" |
| 155 | /> | 164 | /> |
| 156 | </> | 165 | </> |
| 157 | ) : null} | 166 | ) : null} |
| 158 | </Pressable> | 167 | </> |
| 168 | ); | ||
| 169 | |||
| 170 | if (props.collapsible) { | ||
| 171 | return ( | ||
| 172 | <Pressable | ||
| 173 | accessibilityLabel={`${props.label}, ${props.modelCount} models`} | ||
| 174 | accessibilityRole="button" | ||
| 175 | accessibilityState={{ expanded: !props.collapsed }} | ||
| 176 | className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60" | ||
| 177 | onPress={props.onToggle} | ||
| 178 | > | ||
| 179 | {content} | ||
| 180 | </Pressable> | ||
| 181 | ); | ||
| 182 | } | ||
| 183 | |||
| 184 | return ( | ||
| 185 | <View accessibilityRole="header" className="mx-4 min-h-9 flex-row items-center gap-2 px-1 pt-1"> | ||
| 186 | {content} | ||
| 187 | </View> | ||
| 159 | ); | 188 | ); |
| 160 | } | 189 | } |
| 161 | 190 | ||
| ... | @@ -163,18 +192,17 @@ function ProviderHeader(props: { | ... | @@ -163,18 +192,17 @@ function ProviderHeader(props: { |
| 163 | function DisclosureRow(props: { | 192 | function DisclosureRow(props: { |
| 164 | readonly label: string; | 193 | readonly label: string; |
| 165 | readonly value: string | undefined; | 194 | readonly value: string | undefined; |
| 166 | readonly disabled?: boolean; | ||
| 167 | readonly onPress: () => void; | 195 | readonly onPress: () => void; |
| 196 | readonly isLast?: boolean; | ||
| 168 | }) { | 197 | }) { |
| 169 | const iconSubtle = useThemeColor("--color-icon-subtle"); | 198 | const iconSubtle = useThemeColor("--color-icon-subtle"); |
| 170 | return ( | 199 | return ( |
| 171 | <Pressable | 200 | <Pressable |
| 172 | accessibilityRole="button" | 201 | accessibilityRole="button" |
| 173 | disabled={props.disabled} | ||
| 174 | onPress={props.onPress} | 202 | onPress={props.onPress} |
| 175 | className={cn( | 203 | className={cn( |
| 176 | "flex-row items-center gap-2 px-5 py-3 active:opacity-70", | 204 | "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", |
| 177 | props.disabled && "opacity-40", | 205 | !props.isLast && "border-b border-border-subtle", |
| 178 | )} | 206 | )} |
| 179 | > | 207 | > |
| 180 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> | 208 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> |
| ... | @@ -192,31 +220,37 @@ function DisclosureRow(props: { | ... | @@ -192,31 +220,37 @@ function DisclosureRow(props: { |
| 192 | /** Single option inside a submenu panel. */ | 220 | /** Single option inside a submenu panel. */ |
| 193 | function ChoiceRow(props: { | 221 | function ChoiceRow(props: { |
| 194 | readonly label: string; | 222 | readonly label: string; |
| 223 | readonly description?: string; | ||
| 195 | readonly selected: boolean; | 224 | readonly selected: boolean; |
| 196 | readonly onPress: () => void; | 225 | readonly onPress: () => void; |
| 226 | readonly isLast: boolean; | ||
| 197 | }) { | 227 | }) { |
| 198 | const primaryFg = useThemeColor("--color-primary-foreground"); | 228 | const checkmarkColor = useThemeColor("--color-icon"); |
| 199 | return ( | 229 | return ( |
| 200 | <Pressable | 230 | <Pressable |
| 201 | accessibilityRole="button" | 231 | accessibilityLabel={props.description ? `${props.label}. ${props.description}` : props.label} |
| 202 | accessibilityState={{ selected: props.selected }} | 232 | accessibilityRole="radio" |
| 233 | accessibilityState={{ checked: props.selected }} | ||
| 203 | onPress={props.onPress} | 234 | onPress={props.onPress} |
| 204 | className={cn( | 235 | className={cn( |
| 205 | "mx-2.5 flex-row items-center rounded-xl px-3 py-3.5 active:opacity-70", | 236 | "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle", |
| 206 | props.selected ? "bg-primary" : "bg-transparent", | 237 | !props.isLast && "border-b border-border-subtle", |
| 207 | )} | 238 | )} |
| 208 | > | 239 | > |
| 209 | <Text | 240 | <View className="min-w-0 flex-1 gap-0.5"> |
| 210 | className={cn( | 241 | <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> |
| 211 | "shrink text-sm font-t3-medium", | 242 | {props.description ? ( |
| 212 | props.selected ? "text-primary-foreground" : "text-foreground", | 243 | <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text> |
| 213 | )} | 244 | ) : null} |
| 214 | > | 245 | </View> |
| 215 | {props.label} | ||
| 216 | </Text> | ||
| 217 | <View className="flex-1" /> | ||
| 218 | {props.selected ? ( | 246 | {props.selected ? ( |
| 219 | <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" /> | 247 | <SymbolView |
| 248 | name="checkmark" | ||
| 249 | size={16} | ||
| 250 | tintColor={checkmarkColor} | ||
| 251 | type="monochrome" | ||
| 252 | weight="semibold" | ||
| 253 | /> | ||
| 220 | ) : null} | 254 | ) : null} |
| 221 | </Pressable> | 255 | </Pressable> |
| 222 | ); | 256 | ); |
| ... | @@ -225,60 +259,31 @@ function ChoiceRow(props: { | ... | @@ -225,60 +259,31 @@ function ChoiceRow(props: { |
| 225 | function SwitchRow(props: { | 259 | function SwitchRow(props: { |
| 226 | readonly label: string; | 260 | readonly label: string; |
| 227 | readonly value: boolean; | 261 | readonly value: boolean; |
| 228 | readonly disabled?: boolean; | ||
| 229 | readonly onValueChange: (value: boolean) => void; | 262 | readonly onValueChange: (value: boolean) => void; |
| 263 | readonly isLast?: boolean; | ||
| 230 | }) { | 264 | }) { |
| 231 | const activeTrack = String(useThemeColor("--color-switch-active")); | ||
| 232 | const track = String(useThemeColor("--color-secondary-border")); | ||
| 233 | return ( | 265 | return ( |
| 234 | <View | 266 | <View |
| 235 | className={cn( | 267 | className={cn( |
| 236 | "flex-row items-center justify-between px-5 py-2.5", | 268 | "min-h-11 flex-row items-center justify-between bg-card px-4 py-1", |
| 237 | props.disabled && "opacity-40", | 269 | !props.isLast && "border-b border-border-subtle", |
| 238 | )} | 270 | )} |
| 239 | > | 271 | > |
| 240 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> | 272 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> |
| 241 | <Switch | 273 | <Switch |
| 242 | disabled={props.disabled} | 274 | accessibilityLabel={props.label} |
| 243 | ios_backgroundColor={track} | ||
| 244 | onValueChange={props.onValueChange} | 275 | onValueChange={props.onValueChange} |
| 245 | trackColor={{ false: track, true: activeTrack }} | ||
| 246 | value={props.value} | 276 | value={props.value} |
| 247 | /> | 277 | /> |
| 248 | </View> | 278 | </View> |
| 249 | ); | 279 | ); |
| 250 | } | 280 | } |
| 251 | 281 | ||
| 252 | type SubmenuPage = | 282 | type ThreadSettingsSubmenuPage = |
| 253 | | { readonly kind: "descriptor"; readonly id: string } | 283 | | { readonly kind: "descriptor"; readonly id: string } |
| 254 | | { readonly kind: "runtime" }; | 284 | | { readonly kind: "runtime" }; |
| 255 | 285 | ||
| 256 | /** | 286 | type ThreadSettingsSessionProps = { |
| 257 | * Unified thread settings: the sheet is the provider-grouped model list | ||
| 258 | * (primary harnesses expanded, other providers folded, legacy behind the | ||
| 259 | * top-right pill) with a Save button, plus compact disclosure rows whose | ||
| 260 | * single-choice submenus stack in a small panel over the sheet so it never | ||
| 261 | * changes size. Model changes stage until Save — while staged, the settings | ||
| 262 | * rows edit the staged model's options and Save applies everything together. | ||
| 263 | * | ||
| 264 | * Callers control which harnesses are offered via providerGroups: an | ||
| 265 | * existing thread must pass only its own provider's group, since a session | ||
| 266 | * can't switch harness mid-thread. | ||
| 267 | * | ||
| 268 | * Rendered through an RN Modal (not the root OverlayPortal) so it also | ||
| 269 | * presents above natively-presented form sheets like the new-task draft. | ||
| 270 | * Callers must dismiss the keyboard when opening — the iOS keyboard window | ||
| 271 | * would otherwise cover the lower half of the sheet. | ||
| 272 | */ | ||
| 273 | export function ThreadSettingsSheet(props: { | ||
| 274 | readonly visible: boolean; | ||
| 275 | /** | ||
| 276 | * "save" = the Save/Done button (the user is finished configuring); | ||
| 277 | * "dismiss" = backdrop, grabber, or system back. Hosts only restore the | ||
| 278 | * keyboard for "save" so a stray tap outside a control never pops it. | ||
| 279 | */ | ||
| 280 | readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void; | ||
| 281 | readonly onDismissed: () => void; | ||
| 282 | readonly providerGroups: ReadonlyArray<ProviderGroup>; | 287 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 283 | readonly selectedModel: ModelSelection | null; | 288 | readonly selectedModel: ModelSelection | null; |
| 284 | readonly onSelectModel: (option: ModelOption) => void; | 289 | readonly onSelectModel: (option: ModelOption) => void; |
| ... | @@ -286,367 +291,949 @@ export function ThreadSettingsSheet(props: { | ... | @@ -286,367 +291,949 @@ export function ThreadSettingsSheet(props: { |
| 286 | readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void; | 291 | readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void; |
| 287 | readonly runtimeMode: RuntimeMode; | 292 | readonly runtimeMode: RuntimeMode; |
| 288 | readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; | 293 | readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; |
| 289 | }) { | 294 | }; |
| 290 | const insets = useSafeAreaInsets(); | 295 | |
| 291 | const { height: windowHeight } = useWindowDimensions(); | 296 | export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & { |
| 297 | readonly ownerId: string; | ||
| 298 | }; | ||
| 299 | |||
| 300 | type ExistingThreadSettingsRouteContextValue = { | ||
| 301 | readonly session: ExistingThreadSettingsRouteSession | null; | ||
| 302 | readonly present: (session: ExistingThreadSettingsRouteSession) => void; | ||
| 303 | readonly clear: (ownerId: string) => void; | ||
| 304 | }; | ||
| 305 | |||
| 306 | const ExistingThreadSettingsRouteContext = | ||
| 307 | createContext<ExistingThreadSettingsRouteContextValue | null>(null); | ||
| 308 | |||
| 309 | /** Bridges the active thread's settings state into the root native sheet route. */ | ||
| 310 | export function ExistingThreadSettingsRouteProvider(props: { readonly children: ReactNode }) { | ||
| 311 | const [session, setSession] = useState<ExistingThreadSettingsRouteSession | null>(null); | ||
| 312 | const present = useCallback((nextSession: ExistingThreadSettingsRouteSession) => { | ||
| 313 | setSession(nextSession); | ||
| 314 | }, []); | ||
| 315 | const clear = useCallback((ownerId: string) => { | ||
| 316 | setSession((current) => (current?.ownerId === ownerId ? null : current)); | ||
| 317 | }, []); | ||
| 318 | const value = useMemo(() => ({ session, present, clear }), [clear, present, session]); | ||
| 319 | |||
| 320 | return ( | ||
| 321 | <ExistingThreadSettingsRouteContext.Provider value={value}> | ||
| 322 | {props.children} | ||
| 323 | </ExistingThreadSettingsRouteContext.Provider> | ||
| 324 | ); | ||
| 325 | } | ||
| 326 | |||
| 327 | export function useExistingThreadSettingsRoutePresentation() { | ||
| 328 | const value = use(ExistingThreadSettingsRouteContext); | ||
| 329 | if (!value) { | ||
| 330 | throw new Error( | ||
| 331 | "useExistingThreadSettingsRoutePresentation must be used inside ExistingThreadSettingsRouteProvider.", | ||
| 332 | ); | ||
| 333 | } | ||
| 334 | return value; | ||
| 335 | } | ||
| 336 | |||
| 337 | type ThreadSettingsSessionValue = { | ||
| 338 | readonly providerGroups: ReadonlyArray<ProviderGroup>; | ||
| 339 | readonly runtimeMode: RuntimeMode; | ||
| 340 | readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; | ||
| 341 | readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>; | ||
| 342 | readonly providerExpansionOverrides: ReadonlySet<string>; | ||
| 343 | readonly hasLegacyModels: boolean; | ||
| 344 | readonly pendingModel: ModelOption | null; | ||
| 345 | readonly providerFilter: string | null; | ||
| 346 | readonly searchQuery: string; | ||
| 347 | readonly showLegacy: boolean; | ||
| 348 | readonly applyOptionChange: (id: string, value: string | boolean) => void; | ||
| 349 | readonly commitPendingModel: () => void; | ||
| 350 | readonly isApplied: (option: ModelOption) => boolean; | ||
| 351 | readonly isDisplayed: (option: ModelOption) => boolean; | ||
| 352 | readonly pressModel: (option: ModelOption) => void; | ||
| 353 | readonly setProviderFilter: (providerKey: string | null) => void; | ||
| 354 | readonly setSearchQuery: (query: string) => void; | ||
| 355 | readonly setShowLegacy: (showLegacy: boolean) => void; | ||
| 356 | readonly toggleProvider: (providerKey: string) => void; | ||
| 357 | }; | ||
| 358 | |||
| 359 | const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue | null>(null); | ||
| 360 | |||
| 361 | /** Owns the staged model and option state for one picker presentation. */ | ||
| 362 | function ThreadSettingsSessionProvider( | ||
| 363 | props: ThreadSettingsSessionProps & { readonly children: ReactNode }, | ||
| 364 | ) { | ||
| 292 | const [showLegacyToggle, setShowLegacyToggle] = useState(false); | 365 | const [showLegacyToggle, setShowLegacyToggle] = useState(false); |
| 293 | const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set()); | 366 | const [providerFilter, setProviderFilter] = useState<string | null>(null); |
| 367 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 368 | const [providerExpansionOverrides, setProviderExpansionOverrides] = useState<ReadonlySet<string>>( | ||
| 369 | () => new Set(), | ||
| 370 | ); | ||
| 294 | const [pendingModel, setPendingModel] = useState<ModelOption | null>(null); | 371 | const [pendingModel, setPendingModel] = useState<ModelOption | null>(null); |
| 295 | const [submenu, setSubmenu] = useState<SubmenuPage | null>(null); | ||
| 296 | const wasPresentedRef = useRef(false); | ||
| 297 | const notifyDismissed = useCallback(() => { | ||
| 298 | if (!wasPresentedRef.current) { | ||
| 299 | return; | ||
| 300 | } | ||
| 301 | wasPresentedRef.current = false; | ||
| 302 | props.onDismissed(); | ||
| 303 | }, [props.onDismissed]); | ||
| 304 | 372 | ||
| 305 | // Every open starts fresh: no staged model, no submenu, legacy hidden, | 373 | const isApplied = useCallback( |
| 306 | // secondary providers folded. The sheet stays mounted between opens, so | 374 | (option: ModelOption) => |
| 307 | // state would otherwise stick around. | 375 | option.selection.instanceId === props.selectedModel?.instanceId && |
| 308 | useEffect(() => { | 376 | option.selection.model === props.selectedModel.model, |
| 309 | if (props.visible) { | 377 | [props.selectedModel], |
| 310 | wasPresentedRef.current = true; | 378 | ); |
| 311 | setShowLegacyToggle(false); | ||
| 312 | setExpandedProviders(new Set()); | ||
| 313 | setPendingModel(null); | ||
| 314 | setSubmenu(null); | ||
| 315 | } else if (Platform.OS === "android" && wasPresentedRef.current) { | ||
| 316 | // React Native only emits Modal.onDismiss on iOS. Android uses no exit | ||
| 317 | // animation below, so the post-commit effect is its dismissal boundary. | ||
| 318 | notifyDismissed(); | ||
| 319 | } | ||
| 320 | }, [notifyDismissed, props.visible]); | ||
| 321 | |||
| 322 | const isApplied = (option: ModelOption) => | ||
| 323 | option.selection.instanceId === props.selectedModel?.instanceId && | ||
| 324 | option.selection.model === props.selectedModel.model; | ||
| 325 | // The list highlights the staged pick; Save turns it into the applied one. | 379 | // The list highlights the staged pick; Save turns it into the applied one. |
| 326 | const isDisplayed = (option: ModelOption) => | 380 | const isDisplayed = useCallback( |
| 327 | pendingModel ? option.key === pendingModel.key : isApplied(option); | 381 | (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)), |
| 382 | [isApplied, pendingModel], | ||
| 383 | ); | ||
| 328 | 384 | ||
| 329 | // While a model is staged, the settings rows describe and edit the staged | 385 | // While a model is staged, the settings rows describe and edit the staged |
| 330 | // model's options (kept on its pending selection); Save applies model and | 386 | // model's options (kept on its pending selection); Save applies model and |
| 331 | // options together. Otherwise they edit the applied selection directly. | 387 | // options together. Otherwise they edit the applied selection directly. |
| 332 | const displayedDescriptors = pendingModel | 388 | const displayedDescriptors = useMemo( |
| 333 | ? pendingModel.capabilities | 389 | () => |
| 334 | ? getProviderOptionDescriptors({ | 390 | pendingModel |
| 335 | caps: pendingModel.capabilities, | 391 | ? pendingModel.capabilities |
| 336 | selections: pendingModel.selection.options, | 392 | ? getProviderOptionDescriptors({ |
| 337 | }) | 393 | caps: pendingModel.capabilities, |
| 338 | : [] | 394 | selections: pendingModel.selection.options, |
| 339 | : props.optionDescriptors; | 395 | }) |
| 340 | 396 | : [] | |
| 341 | const hasLegacyModels = props.providerGroups.some((group) => | 397 | : props.optionDescriptors, |
| 342 | group.models.some((model) => model.isLegacy), | 398 | [pendingModel, props.optionDescriptors], |
| 343 | ); | 399 | ); |
| 344 | // Legacy stays hidden unless the pill is toggled this open; a highlighted | ||
| 345 | // legacy model is exempted from the filter instead of forcing the whole | ||
| 346 | // legacy list visible. | ||
| 347 | const showLegacy = showLegacyToggle; | ||
| 348 | |||
| 349 | // Stable settings rows: the union of descriptors across the primary | ||
| 350 | // harnesses' current models (plus whatever the displayed model advertises) | ||
| 351 | // always renders, with unsupported rows disabled instead of vanishing when | ||
| 352 | // the selection changes. Keyed by label, not id — Claude and Codex use | ||
| 353 | // different ids for the same "Reasoning" concept. | ||
| 354 | const descriptorTemplate = (() => { | ||
| 355 | const seen = new Map<string, { type: "select" | "boolean" }>(); | ||
| 356 | for (const group of props.providerGroups) { | ||
| 357 | const driver = group.models[0]?.providerDriver; | ||
| 358 | if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) { | ||
| 359 | continue; | ||
| 360 | } | ||
| 361 | for (const model of group.models) { | ||
| 362 | if (model.isLegacy) { | ||
| 363 | continue; | ||
| 364 | } | ||
| 365 | for (const descriptor of model.capabilities?.optionDescriptors ?? []) { | ||
| 366 | if (!seen.has(descriptor.label)) { | ||
| 367 | seen.set(descriptor.label, { type: descriptor.type }); | ||
| 368 | } | ||
| 369 | } | ||
| 370 | } | ||
| 371 | } | ||
| 372 | for (const descriptor of displayedDescriptors) { | ||
| 373 | if (!seen.has(descriptor.label)) { | ||
| 374 | seen.set(descriptor.label, { type: descriptor.type }); | ||
| 375 | } | ||
| 376 | } | ||
| 377 | return [...seen.entries()].map(([label, entry]) => ({ label, ...entry })); | ||
| 378 | })(); | ||
| 379 | 400 | ||
| 380 | const handleSave = () => { | 401 | const hasLegacyModels = useMemo( |
| 402 | () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)), | ||
| 403 | [props.providerGroups], | ||
| 404 | ); | ||
| 405 | const commitPendingModel = useCallback(() => { | ||
| 381 | if (pendingModel) { | 406 | if (pendingModel) { |
| 382 | void Haptics.selectionAsync(); | 407 | void Haptics.selectionAsync(); |
| 383 | props.onSelectModel(pendingModel); | 408 | props.onSelectModel(pendingModel); |
| 384 | } | 409 | } |
| 385 | props.onClose("save"); | 410 | }, [pendingModel, props.onSelectModel]); |
| 386 | }; | ||
| 387 | 411 | ||
| 388 | const handleOptionChange = (id: string, value: string | boolean) => { | 412 | const applyOptionChange = useCallback( |
| 389 | const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); | 413 | (id: string, value: string | boolean) => { |
| 390 | if (!next) { | 414 | const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); |
| 391 | return; | 415 | if (!next) { |
| 392 | } | 416 | return; |
| 393 | if (pendingModel) { | 417 | } |
| 394 | setPendingModel({ | 418 | if (pendingModel) { |
| 395 | ...pendingModel, | 419 | setPendingModel({ |
| 396 | selection: { ...pendingModel.selection, options: next }, | 420 | ...pendingModel, |
| 397 | }); | 421 | selection: { ...pendingModel.selection, options: next }, |
| 398 | } else { | 422 | }); |
| 399 | props.onUpdateOptionSelections(next); | 423 | } else { |
| 400 | } | 424 | props.onUpdateOptionSelections(next); |
| 401 | }; | 425 | } |
| 426 | }, | ||
| 427 | [displayedDescriptors, pendingModel, props.onUpdateOptionSelections], | ||
| 428 | ); | ||
| 402 | 429 | ||
| 403 | const toggleProvider = (providerKey: string) => { | 430 | const toggleProvider = useCallback((providerKey: string) => { |
| 404 | setExpandedProviders((current) => { | 431 | setProviderExpansionOverrides((current) => { |
| 405 | const next = new Set(current); | 432 | const next = new Set(current); |
| 406 | if (!next.delete(providerKey)) { | 433 | if (!next.delete(providerKey)) { |
| 407 | next.add(providerKey); | 434 | next.add(providerKey); |
| 408 | } | 435 | } |
| 409 | return next; | 436 | return next; |
| 410 | }); | 437 | }); |
| 411 | }; | 438 | }, []); |
| 439 | |||
| 440 | const pressModel = useCallback( | ||
| 441 | (option: ModelOption) => { | ||
| 442 | void Haptics.selectionAsync(); | ||
| 443 | setPendingModel((current) => | ||
| 444 | pendingModelAfterPress({ | ||
| 445 | current, | ||
| 446 | pressed: option, | ||
| 447 | pressedIsApplied: isApplied(option), | ||
| 448 | }), | ||
| 449 | ); | ||
| 450 | }, | ||
| 451 | [isApplied], | ||
| 452 | ); | ||
| 453 | |||
| 454 | const value = useMemo<ThreadSettingsSessionValue>( | ||
| 455 | () => ({ | ||
| 456 | providerGroups: props.providerGroups, | ||
| 457 | runtimeMode: props.runtimeMode, | ||
| 458 | onUpdateRuntimeMode: props.onUpdateRuntimeMode, | ||
| 459 | displayedDescriptors, | ||
| 460 | providerExpansionOverrides, | ||
| 461 | hasLegacyModels, | ||
| 462 | pendingModel, | ||
| 463 | providerFilter, | ||
| 464 | searchQuery, | ||
| 465 | showLegacy: showLegacyToggle, | ||
| 466 | applyOptionChange, | ||
| 467 | commitPendingModel, | ||
| 468 | isApplied, | ||
| 469 | isDisplayed, | ||
| 470 | pressModel, | ||
| 471 | setProviderFilter, | ||
| 472 | setSearchQuery, | ||
| 473 | setShowLegacy: setShowLegacyToggle, | ||
| 474 | toggleProvider, | ||
| 475 | }), | ||
| 476 | [ | ||
| 477 | applyOptionChange, | ||
| 478 | commitPendingModel, | ||
| 479 | displayedDescriptors, | ||
| 480 | providerExpansionOverrides, | ||
| 481 | hasLegacyModels, | ||
| 482 | isApplied, | ||
| 483 | isDisplayed, | ||
| 484 | pendingModel, | ||
| 485 | pressModel, | ||
| 486 | providerFilter, | ||
| 487 | props.onUpdateRuntimeMode, | ||
| 488 | props.providerGroups, | ||
| 489 | props.runtimeMode, | ||
| 490 | searchQuery, | ||
| 491 | showLegacyToggle, | ||
| 492 | toggleProvider, | ||
| 493 | ], | ||
| 494 | ); | ||
| 495 | |||
| 496 | return ( | ||
| 497 | <ThreadSettingsSessionContext.Provider value={value}> | ||
| 498 | {props.children} | ||
| 499 | </ThreadSettingsSessionContext.Provider> | ||
| 500 | ); | ||
| 501 | } | ||
| 502 | |||
| 503 | function useThreadSettingsSession() { | ||
| 504 | const value = use(ThreadSettingsSessionContext); | ||
| 505 | if (!value) { | ||
| 506 | throw new Error("useThreadSettingsSession must be used inside ThreadSettingsSessionProvider."); | ||
| 507 | } | ||
| 508 | return value; | ||
| 509 | } | ||
| 510 | |||
| 511 | type ThreadSettingsProviderCatalog = { | ||
| 512 | readonly key: string; | ||
| 513 | readonly driver: string | undefined; | ||
| 514 | readonly label: string; | ||
| 515 | readonly collapsible: boolean; | ||
| 516 | readonly collapsed: boolean; | ||
| 517 | readonly modelCount: number; | ||
| 518 | readonly models: ReadonlyArray<ModelOption>; | ||
| 519 | }; | ||
| 520 | |||
| 521 | type ThreadSettingsCatalogItem = | ||
| 522 | | { | ||
| 523 | readonly kind: "provider"; | ||
| 524 | readonly key: string; | ||
| 525 | readonly provider: ThreadSettingsProviderCatalog; | ||
| 526 | } | ||
| 527 | | { | ||
| 528 | readonly kind: "model"; | ||
| 529 | readonly key: string; | ||
| 530 | readonly option: ModelOption; | ||
| 531 | readonly isFirst: boolean; | ||
| 532 | readonly isLast: boolean; | ||
| 533 | } | ||
| 534 | | { | ||
| 535 | readonly kind: "empty"; | ||
| 536 | readonly key: "empty"; | ||
| 537 | } | ||
| 538 | | { | ||
| 539 | readonly kind: "options"; | ||
| 540 | readonly key: "options"; | ||
| 541 | }; | ||
| 542 | |||
| 543 | function ThreadSettingsModelListRow(props: { | ||
| 544 | readonly option: ModelOption; | ||
| 545 | readonly isFirst: boolean; | ||
| 546 | readonly isLast: boolean; | ||
| 547 | }) { | ||
| 548 | const session = useThreadSettingsSession(); | ||
| 549 | const onPress = useCallback( | ||
| 550 | () => session.pressModel(props.option), | ||
| 551 | [props.option, session.pressModel], | ||
| 552 | ); | ||
| 553 | |||
| 554 | return ( | ||
| 555 | <ModelRow | ||
| 556 | isFirst={props.isFirst} | ||
| 557 | isLast={props.isLast} | ||
| 558 | onPress={onPress} | ||
| 559 | option={props.option} | ||
| 560 | selected={session.isDisplayed(props.option)} | ||
| 561 | /> | ||
| 562 | ); | ||
| 563 | } | ||
| 564 | |||
| 565 | function ThreadSettingsProviderListHeader(props: { | ||
| 566 | readonly provider: ThreadSettingsProviderCatalog; | ||
| 567 | }) { | ||
| 568 | const session = useThreadSettingsSession(); | ||
| 569 | const onToggle = useCallback( | ||
| 570 | () => session.toggleProvider(props.provider.key), | ||
| 571 | [props.provider.key, session.toggleProvider], | ||
| 572 | ); | ||
| 573 | |||
| 574 | return ( | ||
| 575 | <ProviderHeader | ||
| 576 | collapsible={props.provider.collapsible} | ||
| 577 | collapsed={props.provider.collapsed} | ||
| 578 | driver={props.provider.driver} | ||
| 579 | label={props.provider.label} | ||
| 580 | modelCount={props.provider.modelCount} | ||
| 581 | onToggle={onToggle} | ||
| 582 | /> | ||
| 583 | ); | ||
| 584 | } | ||
| 585 | |||
| 586 | function useThreadSettingsCatalogItems( | ||
| 587 | session: ThreadSettingsSessionValue, | ||
| 588 | ): ReadonlyArray<ThreadSettingsCatalogItem> { | ||
| 589 | return useMemo( | ||
| 590 | () => | ||
| 591 | session.providerGroups.flatMap((group) => { | ||
| 592 | if (session.providerFilter !== null && group.providerKey !== session.providerFilter) { | ||
| 593 | return []; | ||
| 594 | } | ||
| 595 | const driver = group.models[0]?.providerDriver; | ||
| 596 | const catalogModels = session.showLegacy | ||
| 597 | ? group.models | ||
| 598 | : group.models.filter((model) => !model.isLegacy || session.isDisplayed(model)); | ||
| 599 | const visibleModels = catalogModels.filter((model) => | ||
| 600 | modelMatchesCatalogQuery({ | ||
| 601 | model, | ||
| 602 | providerLabel: group.providerLabel, | ||
| 603 | query: session.searchQuery, | ||
| 604 | }), | ||
| 605 | ); | ||
| 606 | if (visibleModels.length === 0) { | ||
| 607 | return []; | ||
| 608 | } | ||
| 609 | const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); | ||
| 610 | // Staging a model must not change disclosure state. The applied model | ||
| 611 | // stays stable for the lifetime of this picker (Save closes it), so it | ||
| 612 | // is safe to use as the initial selected-provider default. | ||
| 613 | const containsAppliedSelection = group.models.some(session.isApplied); | ||
| 614 | const isNarrowed = session.providerFilter !== null || session.searchQuery.trim().length > 0; | ||
| 615 | const collapsible = !isNarrowed; | ||
| 616 | const collapsed = providerSectionIsCollapsed({ | ||
| 617 | defaultExpanded: isPrimary || containsAppliedSelection, | ||
| 618 | hasExpansionOverride: session.providerExpansionOverrides.has(group.providerKey), | ||
| 619 | isNarrowed, | ||
| 620 | }); | ||
| 621 | const provider: ThreadSettingsProviderCatalog = { | ||
| 622 | key: group.providerKey, | ||
| 623 | driver, | ||
| 624 | label: group.providerLabel, | ||
| 625 | collapsible, | ||
| 626 | collapsed, | ||
| 627 | modelCount: visibleModels.length, | ||
| 628 | models: collapsed ? [] : visibleModels, | ||
| 629 | }; | ||
| 630 | return [ | ||
| 631 | { | ||
| 632 | kind: "provider" as const, | ||
| 633 | key: `provider:${group.providerKey}`, | ||
| 634 | provider, | ||
| 635 | }, | ||
| 636 | ...provider.models.map((option, index) => ({ | ||
| 637 | kind: "model" as const, | ||
| 638 | key: `model:${option.key}`, | ||
| 639 | option, | ||
| 640 | isFirst: index === 0, | ||
| 641 | isLast: index === provider.models.length - 1, | ||
| 642 | })), | ||
| 643 | ]; | ||
| 644 | }), | ||
| 645 | [ | ||
| 646 | session.isApplied, | ||
| 647 | session.isDisplayed, | ||
| 648 | session.providerExpansionOverrides, | ||
| 649 | session.providerFilter, | ||
| 650 | session.providerGroups, | ||
| 651 | session.searchQuery, | ||
| 652 | session.showLegacy, | ||
| 653 | ], | ||
| 654 | ); | ||
| 655 | } | ||
| 656 | |||
| 657 | function ThreadSettingsOptionsItem(props: { | ||
| 658 | readonly animationsReady: boolean; | ||
| 659 | readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; | ||
| 660 | }) { | ||
| 661 | const insets = useSafeAreaInsets(); | ||
| 662 | const session = useThreadSettingsSession(); | ||
| 663 | const bottomToolbarInset = | ||
| 664 | Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED | ||
| 665 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET | ||
| 666 | : 0; | ||
| 667 | |||
| 668 | return ( | ||
| 669 | <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}> | ||
| 670 | <Text className="px-5 pb-2 pt-2 text-sm font-t3-medium text-foreground-muted">Options</Text> | ||
| 671 | <Animated.View | ||
| 672 | className="mx-4 overflow-hidden rounded-2xl bg-card" | ||
| 673 | layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION} | ||
| 674 | > | ||
| 675 | {session.displayedDescriptors.map((descriptor) => { | ||
| 676 | if (descriptor.type === "select") { | ||
| 677 | return ( | ||
| 678 | <Animated.View | ||
| 679 | key={descriptor.id} | ||
| 680 | entering={ | ||
| 681 | props.animationsReady ? THREAD_SETTINGS_OPTION_ENTER_TRANSITION : undefined | ||
| 682 | } | ||
| 683 | exiting={props.animationsReady ? THREAD_SETTINGS_OPTION_EXIT_TRANSITION : undefined} | ||
| 684 | layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION} | ||
| 685 | > | ||
| 686 | <DisclosureRow | ||
| 687 | label={descriptor.label} | ||
| 688 | value={getProviderOptionCurrentLabel(descriptor)} | ||
| 689 | onPress={() => props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })} | ||
| 690 | /> | ||
| 691 | </Animated.View> | ||
| 692 | ); | ||
| 693 | } | ||
| 694 | return ( | ||
| 695 | <Animated.View | ||
| 696 | key={descriptor.id} | ||
| 697 | entering={props.animationsReady ? THREAD_SETTINGS_OPTION_ENTER_TRANSITION : undefined} | ||
| 698 | exiting={props.animationsReady ? THREAD_SETTINGS_OPTION_EXIT_TRANSITION : undefined} | ||
| 699 | layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION} | ||
| 700 | > | ||
| 701 | <SwitchRow | ||
| 702 | label={descriptor.label} | ||
| 703 | value={descriptor.currentValue ?? false} | ||
| 704 | onValueChange={(value) => session.applyOptionChange(descriptor.id, value)} | ||
| 705 | /> | ||
| 706 | </Animated.View> | ||
| 707 | ); | ||
| 708 | })} | ||
| 709 | <Animated.View layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION}> | ||
| 710 | <DisclosureRow | ||
| 711 | isLast | ||
| 712 | label="Runtime" | ||
| 713 | value={ | ||
| 714 | RUNTIME_MODE_CHOICES.find((choice) => choice.mode === session.runtimeMode)?.label | ||
| 715 | } | ||
| 716 | onPress={() => props.onOpenSubmenu({ kind: "runtime" })} | ||
| 717 | /> | ||
| 718 | </Animated.View> | ||
| 719 | </Animated.View> | ||
| 720 | |||
| 721 | {Platform.OS !== "ios" && session.hasLegacyModels ? ( | ||
| 722 | <> | ||
| 723 | <Text className="px-5 pb-2 pt-7 text-sm font-t3-medium text-foreground-muted"> | ||
| 724 | Catalog | ||
| 725 | </Text> | ||
| 726 | <View className="mx-4 overflow-hidden rounded-2xl bg-card"> | ||
| 727 | <SwitchRow | ||
| 728 | isLast | ||
| 729 | label="Legacy models" | ||
| 730 | onValueChange={session.setShowLegacy} | ||
| 731 | value={session.showLegacy} | ||
| 732 | /> | ||
| 733 | </View> | ||
| 734 | </> | ||
| 735 | ) : null} | ||
| 736 | </View> | ||
| 737 | ); | ||
| 738 | } | ||
| 739 | |||
| 740 | /** One native scroll owner for the model catalog and its related settings. */ | ||
| 741 | function ThreadSettingsMainContent(props: { | ||
| 742 | readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; | ||
| 743 | }) { | ||
| 744 | const session = useThreadSettingsSession(); | ||
| 745 | const catalogItems = useThreadSettingsCatalogItems(session); | ||
| 746 | const [animationsReady, setAnimationsReady] = useState(false); | ||
| 747 | const nativeHeaderHeight = use(HeaderHeightContext) ?? 0; | ||
| 748 | const hasActiveCatalogFilter = | ||
| 749 | session.providerFilter !== null || session.searchQuery.trim().length > 0; | ||
| 750 | const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED; | ||
| 751 | const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>( | ||
| 752 | () => [ | ||
| 753 | ...(catalogItems.length === 0 && hasActiveCatalogFilter | ||
| 754 | ? ([{ kind: "empty", key: "empty" }] as const) | ||
| 755 | : catalogItems), | ||
| 756 | { kind: "options", key: "options" }, | ||
| 757 | ], | ||
| 758 | [catalogItems, hasActiveCatalogFilter], | ||
| 759 | ); | ||
| 760 | const renderCatalogItem = useCallback( | ||
| 761 | (itemProps: LegendListRenderItemProps<ThreadSettingsCatalogItem>) => { | ||
| 762 | const item = itemProps.item; | ||
| 763 | let content: ReactNode; | ||
| 764 | |||
| 765 | if (item.kind === "provider") { | ||
| 766 | content = <ThreadSettingsProviderListHeader provider={item.provider} />; | ||
| 767 | } else if (item.kind === "model") { | ||
| 768 | content = ( | ||
| 769 | <ThreadSettingsModelListRow | ||
| 770 | isFirst={item.isFirst} | ||
| 771 | isLast={item.isLast} | ||
| 772 | option={item.option} | ||
| 773 | /> | ||
| 774 | ); | ||
| 775 | } else if (item.kind === "empty") { | ||
| 776 | content = ( | ||
| 777 | <View className="items-center px-8 py-14"> | ||
| 778 | <Text className="text-center text-sm text-foreground-muted">No matching models</Text> | ||
| 779 | </View> | ||
| 780 | ); | ||
| 781 | } else { | ||
| 782 | content = ( | ||
| 783 | <ThreadSettingsOptionsItem | ||
| 784 | animationsReady={animationsReady} | ||
| 785 | onOpenSubmenu={props.onOpenSubmenu} | ||
| 786 | /> | ||
| 787 | ); | ||
| 788 | } | ||
| 789 | |||
| 790 | return ( | ||
| 791 | <Animated.View | ||
| 792 | key={item.key} | ||
| 793 | entering={animationsReady ? THREAD_SETTINGS_CATALOG_ENTER_TRANSITION : undefined} | ||
| 794 | exiting={animationsReady ? THREAD_SETTINGS_CATALOG_EXIT_TRANSITION : undefined} | ||
| 795 | > | ||
| 796 | {content} | ||
| 797 | </Animated.View> | ||
| 798 | ); | ||
| 799 | }, | ||
| 800 | [animationsReady, props.onOpenSubmenu], | ||
| 801 | ); | ||
| 802 | |||
| 803 | return ( | ||
| 804 | <AnimatedLegendList | ||
| 805 | automaticallyAdjustsScrollIndicatorInsets | ||
| 806 | className="flex-1 bg-sheet" | ||
| 807 | contentContainerStyle={{ paddingTop: 4 }} | ||
| 808 | contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"} | ||
| 809 | data={listItems} | ||
| 810 | estimatedItemSize={48} | ||
| 811 | extraData={animationsReady} | ||
| 812 | getItemType={(item) => item.kind} | ||
| 813 | itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} | ||
| 814 | keyExtractor={(item) => item.key} | ||
| 815 | keyboardDismissMode="on-drag" | ||
| 816 | keyboardShouldPersistTaps="handled" | ||
| 817 | maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION} | ||
| 818 | ListHeaderComponent={ | ||
| 819 | <> | ||
| 820 | {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null} | ||
| 821 | {Platform.OS === "android" ? ( | ||
| 822 | <View className="px-4 pb-2 pt-3"> | ||
| 823 | <TextInput | ||
| 824 | accessibilityLabel="Find a model" | ||
| 825 | autoCapitalize="none" | ||
| 826 | autoCorrect={false} | ||
| 827 | className="h-11 rounded-xl bg-card px-4 text-base text-foreground" | ||
| 828 | onChangeText={session.setSearchQuery} | ||
| 829 | placeholder="Find a model" | ||
| 830 | placeholderTextColorClassName="accent-placeholder" | ||
| 831 | value={session.searchQuery} | ||
| 832 | /> | ||
| 833 | </View> | ||
| 834 | ) : null} | ||
| 835 | </> | ||
| 836 | } | ||
| 837 | recycleItems | ||
| 838 | onLoad={() => setAnimationsReady(true)} | ||
| 839 | renderItem={renderCatalogItem} | ||
| 840 | showsVerticalScrollIndicator={false} | ||
| 841 | /> | ||
| 842 | ); | ||
| 843 | } | ||
| 844 | |||
| 845 | /** Compact choice page pushed by the picker navigator. */ | ||
| 846 | function ThreadSettingsChoiceContent(props: { | ||
| 847 | readonly submenu: ThreadSettingsSubmenuPage; | ||
| 848 | readonly onSelected: () => void; | ||
| 849 | }) { | ||
| 850 | const insets = useSafeAreaInsets(); | ||
| 851 | const session = useThreadSettingsSession(); | ||
| 852 | const descriptorId = props.submenu.kind === "descriptor" ? props.submenu.id : null; | ||
| 412 | 853 | ||
| 413 | const activeDescriptor = | 854 | const activeDescriptor = |
| 414 | submenu?.kind === "descriptor" | 855 | descriptorId !== null |
| 415 | ? displayedDescriptors.find( | 856 | ? session.displayedDescriptors.find( |
| 416 | (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, | 857 | (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId, |
| 417 | ) | 858 | ) |
| 418 | : undefined; | 859 | : undefined; |
| 419 | 860 | ||
| 420 | const submenuContent = | 861 | const submenuContent = |
| 421 | submenu?.kind === "runtime" | 862 | props.submenu.kind === "runtime" |
| 422 | ? { | 863 | ? { |
| 423 | title: "Runtime", | ||
| 424 | rows: RUNTIME_MODE_CHOICES.map((choice) => ({ | 864 | rows: RUNTIME_MODE_CHOICES.map((choice) => ({ |
| 425 | id: choice.mode, | 865 | id: choice.mode, |
| 426 | label: choice.label, | 866 | label: choice.label, |
| 427 | selected: choice.mode === props.runtimeMode, | 867 | description: choice.description, |
| 868 | selected: choice.mode === session.runtimeMode, | ||
| 428 | onPress: () => { | 869 | onPress: () => { |
| 429 | void Haptics.selectionAsync(); | 870 | void Haptics.selectionAsync(); |
| 430 | props.onUpdateRuntimeMode(choice.mode); | 871 | session.onUpdateRuntimeMode(choice.mode); |
| 431 | setSubmenu(null); | 872 | props.onSelected(); |
| 432 | }, | 873 | }, |
| 433 | })), | 874 | })), |
| 434 | } | 875 | } |
| 435 | : activeDescriptor?.type === "select" | 876 | : activeDescriptor?.type === "select" |
| 436 | ? { | 877 | ? { |
| 437 | title: activeDescriptor.label, | ||
| 438 | rows: selectableChoices(activeDescriptor).map((choice) => ({ | 878 | rows: selectableChoices(activeDescriptor).map((choice) => ({ |
| 439 | id: choice.id, | 879 | id: choice.id, |
| 440 | label: choice.label, | 880 | label: choice.label, |
| 881 | description: undefined, | ||
| 441 | selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), | 882 | selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), |
| 442 | onPress: () => { | 883 | onPress: () => { |
| 443 | void Haptics.selectionAsync(); | 884 | void Haptics.selectionAsync(); |
| 444 | handleOptionChange(activeDescriptor.id, choice.id); | 885 | session.applyOptionChange(activeDescriptor.id, choice.id); |
| 445 | setSubmenu(null); | 886 | props.onSelected(); |
| 446 | }, | 887 | }, |
| 447 | })), | 888 | })), |
| 448 | } | 889 | } |
| 449 | : null; | 890 | : null; |
| 450 | 891 | ||
| 892 | if (!submenuContent) { | ||
| 893 | return <View className="flex-1 bg-sheet" />; | ||
| 894 | } | ||
| 895 | |||
| 451 | return ( | 896 | return ( |
| 452 | <Modal | 897 | <ScrollView |
| 453 | transparent | 898 | className="flex-1 bg-sheet" |
| 454 | statusBarTranslucent | 899 | contentContainerStyle={{ |
| 455 | navigationBarTranslucent | 900 | paddingBottom: insets.bottom + 12, |
| 456 | animationType={Platform.OS === "ios" ? "fade" : "none"} | 901 | paddingHorizontal: 16, |
| 457 | visible={props.visible} | 902 | paddingTop: 16, |
| 458 | onDismiss={notifyDismissed} | 903 | }} |
| 459 | onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")} | 904 | contentInsetAdjustmentBehavior="automatic" |
| 905 | showsVerticalScrollIndicator={false} | ||
| 460 | > | 906 | > |
| 461 | <View className="flex-1 justify-end"> | 907 | <View className="overflow-hidden rounded-2xl bg-card"> |
| 462 | <Pressable | 908 | {submenuContent.rows.map((row, index) => ( |
| 463 | accessibilityLabel="Close thread settings" | 909 | <ChoiceRow |
| 464 | className="absolute inset-0 bg-backdrop" | 910 | key={row.id} |
| 465 | onPress={() => props.onClose("dismiss")} | 911 | description={row.description} |
| 912 | isLast={index === submenuContent.rows.length - 1} | ||
| 913 | label={row.label} | ||
| 914 | selected={row.selected} | ||
| 915 | onPress={row.onPress} | ||
| 916 | /> | ||
| 917 | ))} | ||
| 918 | </View> | ||
| 919 | </ScrollView> | ||
| 920 | ); | ||
| 921 | } | ||
| 922 | |||
| 923 | type ThreadSettingsPickerStackParams = { | ||
| 924 | ThreadSettingsModels: undefined; | ||
| 925 | ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; | ||
| 926 | }; | ||
| 927 | |||
| 928 | type ThreadSettingsPickerPresentation = { | ||
| 929 | readonly onClose: () => void; | ||
| 930 | }; | ||
| 931 | |||
| 932 | const ThreadSettingsPickerStack = createNativeStackNavigator<ThreadSettingsPickerStackParams>(); | ||
| 933 | const ThreadSettingsPickerPresentationContext = | ||
| 934 | createContext<ThreadSettingsPickerPresentation | null>(null); | ||
| 935 | |||
| 936 | function useThreadSettingsPickerPresentation() { | ||
| 937 | const value = use(ThreadSettingsPickerPresentationContext); | ||
| 938 | if (!value) { | ||
| 939 | throw new Error( | ||
| 940 | "useThreadSettingsPickerPresentation must be used inside ThreadSettingsPickerNavigator.", | ||
| 941 | ); | ||
| 942 | } | ||
| 943 | return value; | ||
| 944 | } | ||
| 945 | |||
| 946 | function ThreadSettingsModelsScreen() { | ||
| 947 | const session = useThreadSettingsSession(); | ||
| 948 | const presentation = useThreadSettingsPickerPresentation(); | ||
| 949 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); | ||
| 950 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | ||
| 951 | const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; | ||
| 952 | const commitAndClose = useCallback(() => { | ||
| 953 | session.commitPendingModel(); | ||
| 954 | presentation.onClose(); | ||
| 955 | }, [presentation, session]); | ||
| 956 | const filterMenu = useMemo( | ||
| 957 | () => ({ | ||
| 958 | title: "Model filters", | ||
| 959 | items: [ | ||
| 960 | { | ||
| 961 | type: "submenu" as const, | ||
| 962 | title: "Provider", | ||
| 963 | items: [ | ||
| 964 | { | ||
| 965 | type: "action" as const, | ||
| 966 | title: "All providers", | ||
| 967 | state: session.providerFilter === null ? ("on" as const) : ("off" as const), | ||
| 968 | onPress: () => session.setProviderFilter(null), | ||
| 969 | }, | ||
| 970 | ...session.providerGroups.map((group) => ({ | ||
| 971 | type: "action" as const, | ||
| 972 | title: group.providerLabel, | ||
| 973 | state: | ||
| 974 | session.providerFilter === group.providerKey ? ("on" as const) : ("off" as const), | ||
| 975 | onPress: () => session.setProviderFilter(group.providerKey), | ||
| 976 | })), | ||
| 977 | ], | ||
| 978 | }, | ||
| 979 | ...(session.hasLegacyModels | ||
| 980 | ? [ | ||
| 981 | { | ||
| 982 | type: "action" as const, | ||
| 983 | title: "Show legacy models", | ||
| 984 | state: session.showLegacy ? ("on" as const) : ("off" as const), | ||
| 985 | onPress: () => session.setShowLegacy(!session.showLegacy), | ||
| 986 | }, | ||
| 987 | ] | ||
| 988 | : []), | ||
| 989 | ], | ||
| 990 | }), | ||
| 991 | [session], | ||
| 992 | ); | ||
| 993 | |||
| 994 | return ( | ||
| 995 | <> | ||
| 996 | {Platform.OS === "android" ? ( | ||
| 997 | <AndroidSheetHeader | ||
| 998 | actions={[ | ||
| 999 | { | ||
| 1000 | accessibilityLabel: session.pendingModel ? "Save thread settings" : "Done", | ||
| 1001 | icon: "checkmark", | ||
| 1002 | onPress: commitAndClose, | ||
| 1003 | }, | ||
| 1004 | ]} | ||
| 1005 | onBack={presentation.onClose} | ||
| 1006 | title="Thread settings" | ||
| 466 | /> | 1007 | /> |
| 467 | <View | 1008 | ) : null} |
| 468 | className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet" | 1009 | <NativeStackScreenOptions |
| 469 | style={{ maxHeight: windowHeight * 0.85 }} | 1010 | optionsVersion={[ |
| 470 | > | 1011 | session.providerFilter, |
| 471 | {/* The grabber doubles as the accessible close control: the dim | 1012 | session.providerGroups.map((group) => group.providerKey), |
| 472 | backdrop above a tall sheet is a sliver, and VoiceOver can't | 1013 | session.showLegacy, |
| 473 | reach it at all. */} | 1014 | ]} |
| 474 | <Pressable | 1015 | options={{ |
| 475 | accessibilityLabel="Close thread settings" | 1016 | unstable_headerToolbarItems: usesNativeMailSearchToolbar |
| 476 | accessibilityRole="button" | 1017 | ? () => [ |
| 477 | onPress={() => props.onClose("dismiss")} | 1018 | createNativeMailSearchToolbarItem({ |
| 478 | className="items-center pb-1 pt-2.5" | 1019 | filterButtonId: "thread-settings-model-filter", |
| 479 | > | 1020 | filterMenu, |
| 480 | <View className="h-1 w-9 rounded-full bg-subtle-strong" /> | 1021 | filterSystemImageName: hasCustomCatalogFilter |
| 481 | </Pressable> | 1022 | ? "line.3.horizontal.decrease.circle.fill" |
| 482 | {hasLegacyModels ? ( | 1023 | : "line.3.horizontal.decrease", |
| 483 | <View className="flex-row justify-end px-4 pb-1.5"> | 1024 | onSearchTextChange: session.setSearchQuery, |
| 484 | <Pressable | 1025 | placeholder: "Find a model", |
| 485 | accessibilityRole="button" | 1026 | searchTextChangeId: "thread-settings-model-search-text", |
| 486 | accessibilityState={{ selected: showLegacy }} | 1027 | showsSearchDismissButton: true, |
| 487 | hitSlop={8} | 1028 | }), |
| 488 | onPress={() => { | 1029 | ] |
| 489 | void Haptics.selectionAsync(); | 1030 | : undefined, |
| 490 | setShowLegacyToggle(!showLegacy); | 1031 | headerShown: Platform.OS !== "android", |
| 491 | }} | 1032 | headerSearchBarOptions: |
| 492 | className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70" | 1033 | Platform.OS === "ios" && !usesNativeMailSearchToolbar |
| 493 | > | 1034 | ? { |
| 494 | <Text className="text-2xs font-t3-medium text-foreground-muted"> | 1035 | autoCapitalize: "none", |
| 495 | {showLegacy ? "Hide legacy models" : "Show legacy models"} | 1036 | hideNavigationBar: false, |
| 496 | </Text> | 1037 | obscureBackground: false, |
| 497 | </Pressable> | 1038 | onCancelButtonPress: () => session.setSearchQuery(""), |
| 498 | </View> | 1039 | onChangeText: (event) => session.setSearchQuery(event.nativeEvent.text), |
| 499 | ) : null} | 1040 | placeholder: "Find a model", |
| 500 | {/* Only the model list scrolls. Provider catalogs can run to | 1041 | } |
| 501 | hundreds of models (OpenRouter), so the rows below stay pinned | 1042 | : undefined, |
| 502 | and reachable instead of living at the end of that scroll. */} | 1043 | }} |
| 503 | <ScrollView | 1044 | /> |
| 504 | style={{ flexShrink: 1 }} | 1045 | <ThreadSettingsMainContent |
| 505 | contentContainerStyle={{ paddingBottom: 8 }} | 1046 | onOpenSubmenu={(submenu) => { |
| 506 | showsVerticalScrollIndicator={false} | 1047 | const title = |
| 1048 | submenu.kind === "runtime" | ||
| 1049 | ? "Runtime" | ||
| 1050 | : (session.displayedDescriptors.find( | ||
| 1051 | (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, | ||
| 1052 | )?.label ?? "Option"); | ||
| 1053 | navigation.navigate("ThreadSettingsChoice", { ...submenu, title }); | ||
| 1054 | }} | ||
| 1055 | /> | ||
| 1056 | <NativeHeaderToolbar placement="left"> | ||
| 1057 | <NativeHeaderToolbar.Button | ||
| 1058 | accessibilityLabel="Cancel thread settings" | ||
| 1059 | label="Cancel" | ||
| 1060 | onPress={presentation.onClose} | ||
| 1061 | /> | ||
| 1062 | </NativeHeaderToolbar> | ||
| 1063 | <NativeHeaderToolbar placement="right"> | ||
| 1064 | <NativeHeaderToolbar.Button | ||
| 1065 | accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"} | ||
| 1066 | label={session.pendingModel ? "Save" : "Done"} | ||
| 1067 | onPress={commitAndClose} | ||
| 1068 | /> | ||
| 1069 | </NativeHeaderToolbar> | ||
| 1070 | {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? ( | ||
| 1071 | <NativeHeaderToolbar placement="bottom"> | ||
| 1072 | <NativeHeaderToolbar.Menu | ||
| 1073 | accessibilityLabel="Filter models" | ||
| 1074 | icon={ | ||
| 1075 | hasCustomCatalogFilter | ||
| 1076 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 1077 | : "line.3.horizontal.decrease.circle" | ||
| 1078 | } | ||
| 1079 | separateBackground | ||
| 1080 | title="Model filters" | ||
| 507 | > | 1081 | > |
| 508 | {props.providerGroups.map((group) => { | 1082 | <NativeHeaderToolbar.Menu title="Provider"> |
| 509 | const driver = group.models[0]?.providerDriver; | 1083 | <NativeHeaderToolbar.Label>Provider</NativeHeaderToolbar.Label> |
| 510 | const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); | 1084 | <NativeHeaderToolbar.MenuAction |
| 511 | const visibleModels = showLegacy | 1085 | isOn={session.providerFilter === null} |
| 512 | ? group.models | 1086 | onPress={() => session.setProviderFilter(null)} |
| 513 | : group.models.filter((model) => !model.isLegacy || isDisplayed(model)); | ||
| 514 | if (visibleModels.length === 0) { | ||
| 515 | return null; | ||
| 516 | } | ||
| 517 | const containsSelection = group.models.some(isDisplayed); | ||
| 518 | const collapsible = !isPrimary && !containsSelection; | ||
| 519 | const collapsed = collapsible && !expandedProviders.has(group.providerKey); | ||
| 520 | return ( | ||
| 521 | <View key={group.providerKey}> | ||
| 522 | <ProviderHeader | ||
| 523 | driver={driver} | ||
| 524 | label={group.providerLabel} | ||
| 525 | collapsible={collapsible} | ||
| 526 | collapsed={collapsed} | ||
| 527 | modelCount={visibleModels.length} | ||
| 528 | onToggle={() => toggleProvider(group.providerKey)} | ||
| 529 | /> | ||
| 530 | {collapsed | ||
| 531 | ? null | ||
| 532 | : visibleModels.map((option) => ( | ||
| 533 | <ModelRow | ||
| 534 | key={option.key} | ||
| 535 | option={option} | ||
| 536 | selected={isDisplayed(option)} | ||
| 537 | onPress={() => { | ||
| 538 | void Haptics.selectionAsync(); | ||
| 539 | // Re-tapping the applied model cancels staging. | ||
| 540 | setPendingModel((current) => | ||
| 541 | pendingModelAfterPress({ | ||
| 542 | current, | ||
| 543 | pressed: option, | ||
| 544 | pressedIsApplied: isApplied(option), | ||
| 545 | }), | ||
| 546 | ); | ||
| 547 | }} | ||
| 548 | /> | ||
| 549 | ))} | ||
| 550 | </View> | ||
| 551 | ); | ||
| 552 | })} | ||
| 553 | </ScrollView> | ||
| 554 | |||
| 555 | <View className="mx-5 h-px bg-border" /> | ||
| 556 | |||
| 557 | <View style={{ paddingBottom: insets.bottom + 12 }}> | ||
| 558 | {descriptorTemplate.map((entry) => { | ||
| 559 | const live = displayedDescriptors.find( | ||
| 560 | (descriptor) => descriptor.label === entry.label, | ||
| 561 | ); | ||
| 562 | if ((live?.type ?? entry.type) === "select") { | ||
| 563 | return ( | ||
| 564 | <DisclosureRow | ||
| 565 | key={entry.label} | ||
| 566 | label={entry.label} | ||
| 567 | value={live ? getProviderOptionCurrentLabel(live) : undefined} | ||
| 568 | disabled={!live} | ||
| 569 | onPress={() => { | ||
| 570 | if (live) { | ||
| 571 | setSubmenu({ kind: "descriptor", id: live.id }); | ||
| 572 | } | ||
| 573 | }} | ||
| 574 | /> | ||
| 575 | ); | ||
| 576 | } | ||
| 577 | return ( | ||
| 578 | <SwitchRow | ||
| 579 | key={entry.label} | ||
| 580 | label={entry.label} | ||
| 581 | value={live?.type === "boolean" ? (live.currentValue ?? false) : false} | ||
| 582 | disabled={!live} | ||
| 583 | onValueChange={(value) => { | ||
| 584 | if (live) { | ||
| 585 | handleOptionChange(live.id, value); | ||
| 586 | } | ||
| 587 | }} | ||
| 588 | /> | ||
| 589 | ); | ||
| 590 | })} | ||
| 591 | <DisclosureRow | ||
| 592 | label="Runtime" | ||
| 593 | value={ | ||
| 594 | RUNTIME_MODE_CHOICES.find((choice) => choice.mode === props.runtimeMode)?.label | ||
| 595 | } | ||
| 596 | onPress={() => setSubmenu({ kind: "runtime" })} | ||
| 597 | /> | ||
| 598 | <Pressable | ||
| 599 | accessibilityRole="button" | ||
| 600 | onPress={handleSave} | ||
| 601 | className="mx-4 mt-2 h-12 items-center justify-center rounded-full bg-primary active:opacity-80" | ||
| 602 | > | ||
| 603 | <Text className="text-sm font-t3-bold text-primary-foreground"> | ||
| 604 | {pendingModel ? "Save" : "Done"} | ||
| 605 | </Text> | ||
| 606 | </Pressable> | ||
| 607 | </View> | ||
| 608 | </View> | ||
| 609 | |||
| 610 | {/* Submenus stack over the sheet instead of replacing its content, | ||
| 611 | so the main sheet keeps its size while drilling in and out. */} | ||
| 612 | {submenuContent ? ( | ||
| 613 | <View className="absolute inset-0 justify-end"> | ||
| 614 | <Pressable | ||
| 615 | accessibilityLabel={`Close ${submenuContent.title}`} | ||
| 616 | className="absolute inset-0 bg-backdrop" | ||
| 617 | onPress={() => setSubmenu(null)} | ||
| 618 | /> | ||
| 619 | <View className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet"> | ||
| 620 | <Pressable | ||
| 621 | accessibilityLabel={`Close ${submenuContent.title}`} | ||
| 622 | accessibilityRole="button" | ||
| 623 | onPress={() => setSubmenu(null)} | ||
| 624 | className="items-center pb-1 pt-2.5" | ||
| 625 | > | 1087 | > |
| 626 | <View className="h-1 w-9 rounded-full bg-subtle-strong" /> | 1088 | All providers |
| 627 | </Pressable> | 1089 | </NativeHeaderToolbar.MenuAction> |
| 628 | <Text className="px-5 pb-1.5 text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted"> | 1090 | {session.providerGroups.map((group) => ( |
| 629 | {submenuContent.title} | 1091 | <NativeHeaderToolbar.MenuAction |
| 630 | </Text> | 1092 | key={group.providerKey} |
| 631 | <ScrollView | 1093 | isOn={session.providerFilter === group.providerKey} |
| 632 | style={{ maxHeight: windowHeight * 0.5 }} | 1094 | onPress={() => session.setProviderFilter(group.providerKey)} |
| 633 | contentContainerStyle={{ paddingBottom: insets.bottom + 12 }} | 1095 | > |
| 634 | showsVerticalScrollIndicator={false} | 1096 | {group.providerLabel} |
| 635 | bounces={false} | 1097 | </NativeHeaderToolbar.MenuAction> |
| 1098 | ))} | ||
| 1099 | </NativeHeaderToolbar.Menu> | ||
| 1100 | {session.hasLegacyModels ? ( | ||
| 1101 | <NativeHeaderToolbar.MenuAction | ||
| 1102 | isOn={session.showLegacy} | ||
| 1103 | onPress={() => session.setShowLegacy(!session.showLegacy)} | ||
| 636 | > | 1104 | > |
| 637 | {submenuContent.rows.map((row) => ( | 1105 | Show legacy models |
| 638 | <ChoiceRow | 1106 | </NativeHeaderToolbar.MenuAction> |
| 639 | key={row.id} | 1107 | ) : null} |
| 640 | label={row.label} | 1108 | </NativeHeaderToolbar.Menu> |
| 641 | selected={row.selected} | 1109 | </NativeHeaderToolbar> |
| 642 | onPress={row.onPress} | 1110 | ) : null} |
| 643 | /> | 1111 | </> |
| 644 | ))} | 1112 | ); |
| 645 | </ScrollView> | 1113 | } |
| 646 | </View> | 1114 | |
| 647 | </View> | 1115 | function ThreadSettingsChoiceScreen() { |
| 648 | ) : null} | 1116 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); |
| 649 | </View> | 1117 | const route = useRoute<RouteProp<ThreadSettingsPickerStackParams, "ThreadSettingsChoice">>(); |
| 650 | </Modal> | 1118 | |
| 1119 | return ( | ||
| 1120 | <> | ||
| 1121 | <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} /> | ||
| 1122 | {Platform.OS === "android" ? ( | ||
| 1123 | <AndroidSheetHeader title={route.params.title} onBack={() => navigation.goBack()} /> | ||
| 1124 | ) : null} | ||
| 1125 | <ThreadSettingsChoiceContent submenu={route.params} onSelected={() => navigation.goBack()} /> | ||
| 1126 | </> | ||
| 1127 | ); | ||
| 1128 | } | ||
| 1129 | |||
| 1130 | function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) { | ||
| 1131 | const sheetBackground = String(useThemeColor("--color-sheet")); | ||
| 1132 | const foreground = String(useThemeColor("--color-foreground")); | ||
| 1133 | const nativeSheetBackground = NATIVE_SHEET_SURFACE_COLOR ?? sheetBackground; | ||
| 1134 | const presentation = useMemo( | ||
| 1135 | () => ({ | ||
| 1136 | onClose: props.onClose, | ||
| 1137 | }), | ||
| 1138 | [props.onClose], | ||
| 1139 | ); | ||
| 1140 | |||
| 1141 | return ( | ||
| 1142 | <ThreadSettingsPickerPresentationContext.Provider value={presentation}> | ||
| 1143 | <ThreadSettingsPickerStack.Navigator | ||
| 1144 | initialRouteName="ThreadSettingsModels" | ||
| 1145 | screenOptions={{ | ||
| 1146 | animation: "slide_from_right", | ||
| 1147 | contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE ?? { | ||
| 1148 | backgroundColor: nativeSheetBackground, | ||
| 1149 | }, | ||
| 1150 | gestureEnabled: true, | ||
| 1151 | headerBackButtonDisplayMode: "minimal", | ||
| 1152 | headerBackTitle: "", | ||
| 1153 | headerShadowVisible: false, | ||
| 1154 | headerStyle: { | ||
| 1155 | backgroundColor: (NATIVE_LIQUID_GLASS_SUPPORTED | ||
| 1156 | ? "transparent" | ||
| 1157 | : nativeSheetBackground) as unknown as string, | ||
| 1158 | }, | ||
| 1159 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, | ||
| 1160 | headerTintColor: foreground, | ||
| 1161 | headerTitleStyle: { fontSize: 17, fontWeight: "700" }, | ||
| 1162 | scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED | ||
| 1163 | ? THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS | ||
| 1164 | : undefined, | ||
| 1165 | }} | ||
| 1166 | > | ||
| 1167 | <ThreadSettingsPickerStack.Screen | ||
| 1168 | name="ThreadSettingsModels" | ||
| 1169 | component={ThreadSettingsModelsScreen} | ||
| 1170 | options={{ headerBackVisible: false, title: "Thread settings" }} | ||
| 1171 | /> | ||
| 1172 | <ThreadSettingsPickerStack.Screen | ||
| 1173 | name="ThreadSettingsChoice" | ||
| 1174 | component={ThreadSettingsChoiceScreen} | ||
| 1175 | options={({ route }) => ({ title: route.params.title })} | ||
| 1176 | /> | ||
| 1177 | </ThreadSettingsPickerStack.Navigator> | ||
| 1178 | </ThreadSettingsPickerPresentationContext.Provider> | ||
| 1179 | ); | ||
| 1180 | } | ||
| 1181 | |||
| 1182 | /** Existing-thread model picker hosted by the root RNS form-sheet route. */ | ||
| 1183 | export function ExistingThreadSettingsRouteScreen() { | ||
| 1184 | const navigation = useNavigation<NativeStackNavigationProp<Record<string, object | undefined>>>(); | ||
| 1185 | const presentation = useExistingThreadSettingsRoutePresentation(); | ||
| 1186 | const session = presentation.session; | ||
| 1187 | |||
| 1188 | useEffect(() => { | ||
| 1189 | if (session) { | ||
| 1190 | return; | ||
| 1191 | } | ||
| 1192 | |||
| 1193 | navigation.goBack(); | ||
| 1194 | }, [navigation, session]); | ||
| 1195 | |||
| 1196 | if (!session) { | ||
| 1197 | return <View className="flex-1 bg-sheet" />; | ||
| 1198 | } | ||
| 1199 | |||
| 1200 | const { ownerId: _ownerId, ...settings } = session; | ||
| 1201 | |||
| 1202 | return ( | ||
| 1203 | <ThreadSettingsSessionProvider {...settings}> | ||
| 1204 | <ThreadSettingsPickerNavigator onClose={() => navigation.goBack()} /> | ||
| 1205 | </ThreadSettingsSessionProvider> | ||
| 1206 | ); | ||
| 1207 | } | ||
| 1208 | |||
| 1209 | /** | ||
| 1210 | * Native stack hosted by the New Task navigator's form-sheet route. Keeping | ||
| 1211 | * the sheet presentation in RNS gives UIKit ownership of nested dismissal, | ||
| 1212 | * while Reasoning and Runtime remain regular pushes inside this navigator. | ||
| 1213 | */ | ||
| 1214 | export function NewTaskThreadSettingsRouteScreen() { | ||
| 1215 | const flow = useNewTaskFlow(); | ||
| 1216 | const navigation = useNavigation<NativeStackNavigationProp<Record<string, object | undefined>>>(); | ||
| 1217 | const optionDescriptors = useMemo( | ||
| 1218 | () => | ||
| 1219 | resolveProviderOptionDescriptors({ | ||
| 1220 | capabilities: flow.selectedModelOption?.capabilities, | ||
| 1221 | selections: flow.selectedModel?.options, | ||
| 1222 | }), | ||
| 1223 | [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], | ||
| 1224 | ); | ||
| 1225 | |||
| 1226 | return ( | ||
| 1227 | <ThreadSettingsSessionProvider | ||
| 1228 | providerGroups={flow.providerGroups} | ||
| 1229 | selectedModel={flow.selectedModel} | ||
| 1230 | onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)} | ||
| 1231 | optionDescriptors={optionDescriptors} | ||
| 1232 | onUpdateOptionSelections={flow.setSelectedModelOptions} | ||
| 1233 | runtimeMode={flow.runtimeMode} | ||
| 1234 | onUpdateRuntimeMode={flow.setRuntimeMode} | ||
| 1235 | > | ||
| 1236 | <ThreadSettingsPickerNavigator onClose={() => navigation.goBack()} /> | ||
| 1237 | </ThreadSettingsSessionProvider> | ||
| 651 | ); | 1238 | ); |
| 652 | } | 1239 | } |
apps/mobile/src/features/threads/legacy-plan-mode.test.ts created+57| ... | @@ -0,0 +1,57 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; | ||
| 4 | |||
| 5 | describe("resolvePendingTaskInteractionMode", () => { | ||
| 6 | it("preserves a queued plan task while the preference is still loading", () => { | ||
| 7 | expect( | ||
| 8 | resolvePendingTaskInteractionMode({ | ||
| 9 | preferenceLoaded: false, | ||
| 10 | planModeEnabled: false, | ||
| 11 | draftInteractionMode: "plan", | ||
| 12 | queuedInteractionMode: "plan", | ||
| 13 | }), | ||
| 14 | ).toBe("plan"); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("forces build mode once the disabled preference has loaded", () => { | ||
| 18 | expect( | ||
| 19 | resolvePendingTaskInteractionMode({ | ||
| 20 | preferenceLoaded: true, | ||
| 21 | planModeEnabled: false, | ||
| 22 | draftInteractionMode: "plan", | ||
| 23 | queuedInteractionMode: "plan", | ||
| 24 | }), | ||
| 25 | ).toBe("default"); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("keeps a fresh draft in build mode while the preference is loading", () => { | ||
| 29 | expect( | ||
| 30 | resolvePendingTaskInteractionMode({ | ||
| 31 | preferenceLoaded: false, | ||
| 32 | planModeEnabled: false, | ||
| 33 | draftInteractionMode: "plan", | ||
| 34 | queuedInteractionMode: undefined, | ||
| 35 | }), | ||
| 36 | ).toBe("default"); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("honors the draft's mode when the plan preference is enabled", () => { | ||
| 40 | expect( | ||
| 41 | resolvePendingTaskInteractionMode({ | ||
| 42 | preferenceLoaded: true, | ||
| 43 | planModeEnabled: true, | ||
| 44 | draftInteractionMode: "plan", | ||
| 45 | queuedInteractionMode: undefined, | ||
| 46 | }), | ||
| 47 | ).toBe("plan"); | ||
| 48 | expect( | ||
| 49 | resolvePendingTaskInteractionMode({ | ||
| 50 | preferenceLoaded: true, | ||
| 51 | planModeEnabled: true, | ||
| 52 | draftInteractionMode: undefined, | ||
| 53 | queuedInteractionMode: "plan", | ||
| 54 | }), | ||
| 55 | ).toBe("default"); | ||
| 56 | }); | ||
| 57 | }); | ||
apps/mobile/src/features/threads/legacy-plan-mode.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 3 | type ProviderInteractionMode, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | export function resolveLegacyPlanModeEnabled(input: { | ||
| 7 | readonly loaded: boolean; | ||
| 8 | readonly preference: boolean | undefined; | ||
| 9 | }): boolean { | ||
| 10 | return input.loaded && input.preference === true; | ||
| 11 | } | ||
| 12 | |||
| 13 | export function resolvePendingTaskInteractionMode(input: { | ||
| 14 | readonly preferenceLoaded: boolean; | ||
| 15 | readonly planModeEnabled: boolean; | ||
| 16 | readonly draftInteractionMode: ProviderInteractionMode | undefined; | ||
| 17 | readonly queuedInteractionMode: ProviderInteractionMode | undefined; | ||
| 18 | }): ProviderInteractionMode { | ||
| 19 | if (input.planModeEnabled) { | ||
| 20 | return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 21 | } | ||
| 22 | if (!input.preferenceLoaded) { | ||
| 23 | // Only an existing queued task may retain its previous mode while the | ||
| 24 | // preference is unknown. A fresh draft still defaults to Build so a stale | ||
| 25 | // persisted Plan selection cannot bypass a disabled preference at launch. | ||
| 26 | return input.queuedInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 27 | } | ||
| 28 | return DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 29 | } | ||
apps/mobile/src/features/threads/new-task-context-presentation.test.ts created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | resolveNewTaskBranchWorktreePath, | ||
| 5 | resolveNewTaskBranchLabel, | ||
| 6 | resolveNewTaskLocalWorkspaceSelection, | ||
| 7 | } from "./new-task-context-presentation"; | ||
| 8 | |||
| 9 | describe("resolveNewTaskLocalWorkspaceSelection", () => { | ||
| 10 | it("waits for refs instead of carrying a worktree base into Current checkout", () => { | ||
| 11 | expect( | ||
| 12 | resolveNewTaskLocalWorkspaceSelection({ | ||
| 13 | branches: [], | ||
| 14 | projectCwd: "/repo", | ||
| 15 | }), | ||
| 16 | ).toEqual({ | ||
| 17 | branch: null, | ||
| 18 | worktreePath: null, | ||
| 19 | awaitsCurrentBranch: true, | ||
| 20 | }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("adopts the checkout's current branch once refs load", () => { | ||
| 24 | expect( | ||
| 25 | resolveNewTaskLocalWorkspaceSelection({ | ||
| 26 | branches: [ | ||
| 27 | { name: "feature/worktree-base", current: false, worktreePath: "/worktree" }, | ||
| 28 | { name: "main", current: true, worktreePath: "/repo" }, | ||
| 29 | ], | ||
| 30 | projectCwd: "/repo", | ||
| 31 | }), | ||
| 32 | ).toEqual({ | ||
| 33 | branch: "main", | ||
| 34 | worktreePath: null, | ||
| 35 | awaitsCurrentBranch: false, | ||
| 36 | }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("carries the worktree path when the current branch lives in another worktree", () => { | ||
| 40 | expect( | ||
| 41 | resolveNewTaskLocalWorkspaceSelection({ | ||
| 42 | branches: [ | ||
| 43 | { name: "feature/split", current: true, worktreePath: "/repo/.t3/worktrees/split" }, | ||
| 44 | { name: "main", current: false, worktreePath: "/repo" }, | ||
| 45 | ], | ||
| 46 | projectCwd: "/repo", | ||
| 47 | }), | ||
| 48 | ).toEqual({ | ||
| 49 | branch: "feature/split", | ||
| 50 | worktreePath: "/repo/.t3/worktrees/split", | ||
| 51 | awaitsCurrentBranch: false, | ||
| 52 | }); | ||
| 53 | }); | ||
| 54 | }); | ||
| 55 | |||
| 56 | describe("resolveNewTaskBranchWorktreePath", () => { | ||
| 57 | it("moves Current checkout to the selected existing worktree", () => { | ||
| 58 | expect( | ||
| 59 | resolveNewTaskBranchWorktreePath({ | ||
| 60 | workspaceMode: "local", | ||
| 61 | projectCwd: "/repo", | ||
| 62 | branchWorktreePath: "/repo/.t3/worktrees/feature", | ||
| 63 | }), | ||
| 64 | ).toBe("/repo/.t3/worktrees/feature"); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("keeps the project checkout represented by a null override", () => { | ||
| 68 | expect( | ||
| 69 | resolveNewTaskBranchWorktreePath({ | ||
| 70 | workspaceMode: "local", | ||
| 71 | projectCwd: "/repo", | ||
| 72 | branchWorktreePath: "/repo", | ||
| 73 | }), | ||
| 74 | ).toBeNull(); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("does not reuse an existing worktree while creating a new one", () => { | ||
| 78 | expect( | ||
| 79 | resolveNewTaskBranchWorktreePath({ | ||
| 80 | workspaceMode: "worktree", | ||
| 81 | projectCwd: "/repo", | ||
| 82 | branchWorktreePath: "/repo/.t3/worktrees/feature", | ||
| 83 | }), | ||
| 84 | ).toBeNull(); | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | describe("resolveNewTaskBranchLabel", () => { | ||
| 89 | it("shows the checked-out branch without a base-ref prefix", () => { | ||
| 90 | expect( | ||
| 91 | resolveNewTaskBranchLabel({ | ||
| 92 | branchName: "feature/mobile", | ||
| 93 | startFromOrigin: true, | ||
| 94 | workspaceMode: "local", | ||
| 95 | }), | ||
| 96 | ).toBe("feature/mobile"); | ||
| 97 | }); | ||
| 98 | |||
| 99 | it("labels a local worktree base with From", () => { | ||
| 100 | expect( | ||
| 101 | resolveNewTaskBranchLabel({ | ||
| 102 | branchName: "main", | ||
| 103 | startFromOrigin: false, | ||
| 104 | workspaceMode: "worktree", | ||
| 105 | }), | ||
| 106 | ).toBe("From main"); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("labels a remote worktree base with From origin", () => { | ||
| 110 | expect( | ||
| 111 | resolveNewTaskBranchLabel({ | ||
| 112 | branchName: "main", | ||
| 113 | startFromOrigin: true, | ||
| 114 | workspaceMode: "worktree", | ||
| 115 | }), | ||
| 116 | ).toBe("From origin/main"); | ||
| 117 | }); | ||
| 118 | |||
| 119 | it("prompts when no branch is available", () => { | ||
| 120 | expect( | ||
| 121 | resolveNewTaskBranchLabel({ | ||
| 122 | branchName: null, | ||
| 123 | startFromOrigin: true, | ||
| 124 | workspaceMode: "worktree", | ||
| 125 | }), | ||
| 126 | ).toBe("Choose branch"); | ||
| 127 | }); | ||
| 128 | }); | ||
apps/mobile/src/features/threads/new-task-context-presentation.ts created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | type WorkspaceMode = "local" | "worktree"; | ||
| 2 | |||
| 3 | export function resolveNewTaskWorkspaceLabel(input: { | ||
| 4 | readonly workspaceMode: WorkspaceMode; | ||
| 5 | readonly worktreePath: string | null; | ||
| 6 | }): "Current checkout" | "Current worktree" | "New worktree" { | ||
| 7 | if (input.workspaceMode === "worktree") { | ||
| 8 | return "New worktree"; | ||
| 9 | } | ||
| 10 | return input.worktreePath ? "Current worktree" : "Current checkout"; | ||
| 11 | } | ||
| 12 | |||
| 13 | export function resolveNewTaskBranchWorktreePath(input: { | ||
| 14 | readonly workspaceMode: WorkspaceMode; | ||
| 15 | readonly projectCwd: string; | ||
| 16 | readonly branchWorktreePath: string | null | undefined; | ||
| 17 | }): string | null { | ||
| 18 | if ( | ||
| 19 | input.workspaceMode === "worktree" || | ||
| 20 | !input.branchWorktreePath || | ||
| 21 | input.branchWorktreePath === input.projectCwd | ||
| 22 | ) { | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | return input.branchWorktreePath; | ||
| 26 | } | ||
| 27 | |||
| 28 | export function resolveNewTaskLocalWorkspaceSelection(input: { | ||
| 29 | readonly branches: ReadonlyArray<{ | ||
| 30 | readonly name: string; | ||
| 31 | readonly current: boolean; | ||
| 32 | readonly worktreePath?: string | null; | ||
| 33 | }>; | ||
| 34 | readonly projectCwd: string; | ||
| 35 | }): { | ||
| 36 | readonly branch: string | null; | ||
| 37 | readonly worktreePath: string | null; | ||
| 38 | readonly awaitsCurrentBranch: boolean; | ||
| 39 | } { | ||
| 40 | const currentBranch = input.branches.find((branch) => branch.current) ?? null; | ||
| 41 | if (!currentBranch) { | ||
| 42 | return { | ||
| 43 | branch: null, | ||
| 44 | worktreePath: null, | ||
| 45 | awaitsCurrentBranch: true, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | |||
| 49 | return { | ||
| 50 | branch: currentBranch.name, | ||
| 51 | worktreePath: resolveNewTaskBranchWorktreePath({ | ||
| 52 | workspaceMode: "local", | ||
| 53 | projectCwd: input.projectCwd, | ||
| 54 | branchWorktreePath: currentBranch.worktreePath, | ||
| 55 | }), | ||
| 56 | awaitsCurrentBranch: false, | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 60 | export function resolveNewTaskBranchLabel(input: { | ||
| 61 | readonly branchName: string | null; | ||
| 62 | readonly startFromOrigin: boolean; | ||
| 63 | readonly workspaceMode: WorkspaceMode; | ||
| 64 | }): string { | ||
| 65 | if (!input.branchName) { | ||
| 66 | return "Choose branch"; | ||
| 67 | } | ||
| 68 | |||
| 69 | if (input.workspaceMode === "local") { | ||
| 70 | return input.branchName; | ||
| 71 | } | ||
| 72 | |||
| 73 | const baseRef = input.startFromOrigin ? `origin/${input.branchName}` : input.branchName; | ||
| 74 | return `From ${baseRef}`; | ||
| 75 | } | ||
| 76 | |||
| 77 | export function shouldCheckoutNewTaskBranch(input: { | ||
| 78 | readonly branchIsCurrent: boolean; | ||
| 79 | readonly branchWorktreePath: string | null | undefined; | ||
| 80 | readonly workspaceMode: WorkspaceMode; | ||
| 81 | }): boolean { | ||
| 82 | return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath; | ||
| 83 | } | ||
apps/mobile/src/features/threads/new-task-flow-provider.tsx+129-31| ... | @@ -42,6 +42,7 @@ import { useEnvironmentQuery } from "../../state/query"; | ... | @@ -42,6 +42,7 @@ import { useEnvironmentQuery } from "../../state/query"; |
| 42 | import { | 42 | import { |
| 43 | appendComposerDraftAttachments, | 43 | appendComposerDraftAttachments, |
| 44 | clearComposerDraft, | 44 | clearComposerDraft, |
| 45 | copyComposerDraftContentIfEmpty, | ||
| 45 | getComposerDraftSnapshot, | 46 | getComposerDraftSnapshot, |
| 46 | isComposerDraftEmpty, | 47 | isComposerDraftEmpty, |
| 47 | removeComposerDraftAttachment, | 48 | removeComposerDraftAttachment, |
| ... | @@ -50,7 +51,7 @@ import { | ... | @@ -50,7 +51,7 @@ import { |
| 50 | updateComposerDraftSettings, | 51 | updateComposerDraftSettings, |
| 51 | useComposerDraft, | 52 | useComposerDraft, |
| 52 | } from "../../state/use-composer-drafts"; | 53 | } from "../../state/use-composer-drafts"; |
| 53 | import { useBranches } from "../../state/queries"; | 54 | import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; |
| 54 | import { | 55 | import { |
| 55 | flattenQueuedThreadMessages, | 56 | flattenQueuedThreadMessages, |
| 56 | threadOutboxManager, | 57 | threadOutboxManager, |
| ... | @@ -74,10 +75,16 @@ import { | ... | @@ -74,10 +75,16 @@ import { |
| 74 | type HomeProjectScope, | 75 | type HomeProjectScope, |
| 75 | } from "../home/homeThreadList"; | 76 | } from "../home/homeThreadList"; |
| 76 | import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; | 77 | import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; |
| 78 | import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; | ||
| 79 | import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; | ||
| 80 | import { | ||
| 81 | resolveNewTaskBranchWorktreePath, | ||
| 82 | resolveNewTaskLocalWorkspaceSelection, | ||
| 83 | } from "./new-task-context-presentation"; | ||
| 77 | 84 | ||
| 78 | type WorkspaceMode = "local" | "worktree"; | 85 | type WorkspaceMode = "local" | "worktree"; |
| 79 | 86 | ||
| 80 | const EMPTY_BRANCH_REFS: ReadonlyArray<VcsRef> = []; | 87 | const BRANCH_SEARCH_DEBOUNCE_MS = 150; |
| 81 | 88 | ||
| 82 | function pendingTaskDraftKey(messageId: string): string { | 89 | function pendingTaskDraftKey(messageId: string): string { |
| 83 | return `pending-task:${messageId}`; | 90 | return `pending-task:${messageId}`; |
| ... | @@ -96,14 +103,6 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { | ... | @@ -96,14 +103,6 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { |
| 96 | return message?.creation !== undefined ? message : null; | 103 | return message?.creation !== undefined ? message : null; |
| 97 | } | 104 | } |
| 98 | 105 | ||
| 99 | function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { | ||
| 100 | if (!branch.worktreePath) { | ||
| 101 | return null; | ||
| 102 | } | ||
| 103 | |||
| 104 | return branch.worktreePath === project.workspaceRoot ? null : branch.worktreePath; | ||
| 105 | } | ||
| 106 | |||
| 107 | export function branchBadgeLabel(input: { | 106 | export function branchBadgeLabel(input: { |
| 108 | readonly branch: VcsRef; | 107 | readonly branch: VcsRef; |
| 109 | readonly project: EnvironmentProject | null; | 108 | readonly project: EnvironmentProject | null; |
| ... | @@ -117,9 +116,6 @@ export function branchBadgeLabel(input: { | ... | @@ -117,9 +116,6 @@ export function branchBadgeLabel(input: { |
| 117 | if (input.branch.isDefault) { | 116 | if (input.branch.isDefault) { |
| 118 | return "default"; | 117 | return "default"; |
| 119 | } | 118 | } |
| 120 | if (input.branch.isRemote) { | ||
| 121 | return "remote"; | ||
| 122 | } | ||
| 123 | return null; | 119 | return null; |
| 124 | } | 120 | } |
| 125 | 121 | ||
| ... | @@ -139,9 +135,13 @@ type NewTaskFlowContextValue = { | ... | @@ -139,9 +135,13 @@ type NewTaskFlowContextValue = { |
| 139 | readonly submitting: boolean; | 135 | readonly submitting: boolean; |
| 140 | readonly branchQuery: string; | 136 | readonly branchQuery: string; |
| 141 | readonly branchesLoading: boolean; | 137 | readonly branchesLoading: boolean; |
| 138 | readonly branchesError: string | null; | ||
| 139 | readonly branchesFetchingNextPage: boolean; | ||
| 140 | readonly hasMoreBranches: boolean; | ||
| 142 | readonly availableBranches: ReadonlyArray<VcsRef>; | 141 | readonly availableBranches: ReadonlyArray<VcsRef>; |
| 143 | readonly runtimeMode: RuntimeMode; | 142 | readonly runtimeMode: RuntimeMode; |
| 144 | readonly interactionMode: ProviderInteractionMode; | 143 | readonly interactionMode: ProviderInteractionMode; |
| 144 | readonly planModeEnabled: boolean; | ||
| 145 | readonly expandedProvider: string | null; | 145 | readonly expandedProvider: string | null; |
| 146 | readonly environments: ReadonlyArray<{ | 146 | readonly environments: ReadonlyArray<{ |
| 147 | readonly environmentId: EnvironmentId; | 147 | readonly environmentId: EnvironmentId; |
| ... | @@ -175,7 +175,8 @@ type NewTaskFlowContextValue = { | ... | @@ -175,7 +175,8 @@ type NewTaskFlowContextValue = { |
| 175 | readonly clearAttachments: () => void; | 175 | readonly clearAttachments: () => void; |
| 176 | readonly setSubmitting: (value: boolean) => void; | 176 | readonly setSubmitting: (value: boolean) => void; |
| 177 | readonly setBranchQuery: (value: string) => void; | 177 | readonly setBranchQuery: (value: string) => void; |
| 178 | readonly loadBranches: () => Promise<void>; | 178 | readonly loadBranches: () => void; |
| 179 | readonly loadMoreBranches: () => void; | ||
| 179 | readonly setRuntimeMode: (value: RuntimeMode) => void; | 180 | readonly setRuntimeMode: (value: RuntimeMode) => void; |
| 180 | readonly setInteractionMode: (value: ProviderInteractionMode) => void; | 181 | readonly setInteractionMode: (value: ProviderInteractionMode) => void; |
| 181 | readonly setSelectedModelOptions: ( | 182 | readonly setSelectedModelOptions: ( |
| ... | @@ -191,6 +192,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -191,6 +192,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 191 | const threads = useThreadShells(); | 192 | const threads = useThreadShells(); |
| 192 | const { savedConnectionsById } = useSavedRemoteConnections(); | 193 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 193 | const groupingSettings = useMobileProjectGroupingSettings(); | 194 | const groupingSettings = useMobileProjectGroupingSettings(); |
| 195 | const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); | ||
| 194 | const projectScopes = useMemo( | 196 | const projectScopes = useMemo( |
| 195 | () => | 197 | () => |
| 196 | sortHomeProjectScopes({ | 198 | sortHomeProjectScopes({ |
| ... | @@ -219,6 +221,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -219,6 +221,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 219 | const [branchQuery, setBranchQuery] = useState(""); | 221 | const [branchQuery, setBranchQuery] = useState(""); |
| 220 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); | 222 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); |
| 221 | const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null); | 223 | const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null); |
| 224 | const pendingLocalBranchSyncDraftKeysRef = useRef(new Set<string>()); | ||
| 222 | // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act | 225 | // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act |
| 223 | // on a task whose editing session already ended this render. | 226 | // on a task whose editing session already ended this render. |
| 224 | const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null); | 227 | const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null); |
| ... | @@ -229,6 +232,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -229,6 +232,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 229 | setSubmitting(false); | 232 | setSubmitting(false); |
| 230 | setBranchQuery(""); | 233 | setBranchQuery(""); |
| 231 | setExpandedProvider(null); | 234 | setExpandedProvider(null); |
| 235 | pendingLocalBranchSyncDraftKeysRef.current.clear(); | ||
| 232 | const editing = editingPendingTaskRef.current; | 236 | const editing = editingPendingTaskRef.current; |
| 233 | editingPendingTaskRef.current = null; | 237 | editingPendingTaskRef.current = null; |
| 234 | setEditingPendingTask(null); | 238 | setEditingPendingTask(null); |
| ... | @@ -395,7 +399,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -395,7 +399,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 395 | selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? | 399 | selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? |
| 396 | true; | 400 | true; |
| 397 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; | 401 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 398 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | 402 | const interactionMode = planModeEnabled |
| 403 | ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) | ||
| 404 | : DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 399 | 405 | ||
| 400 | // Stored selections only count while their provider is usable on the | 406 | // Stored selections only count while their provider is usable on the |
| 401 | // server; otherwise the server's default model wins instead of silently | 407 | // server; otherwise the server's default model wins instead of silently |
| ... | @@ -521,18 +527,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -521,18 +527,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 521 | } | 527 | } |
| 522 | replaceComposerDraftAttachments(selectedProjectDraftKey, []); | 528 | replaceComposerDraftAttachments(selectedProjectDraftKey, []); |
| 523 | }, [selectedProjectDraftKey]); | 529 | }, [selectedProjectDraftKey]); |
| 530 | const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS); | ||
| 524 | const branchTarget = useMemo( | 531 | const branchTarget = useMemo( |
| 525 | () => ({ | 532 | () => ({ |
| 526 | environmentId: selectedProject?.environmentId ?? null, | 533 | environmentId: selectedProject?.environmentId ?? null, |
| 527 | // `|| null` also skips the stand-in project's empty workspaceRoot. | 534 | // `|| null` also skips the stand-in project's empty workspaceRoot. |
| 528 | cwd: selectedProject?.workspaceRoot || null, | 535 | cwd: selectedProject?.workspaceRoot || null, |
| 529 | query: null, | 536 | query: debouncedBranchQuery, |
| 530 | }), | 537 | }), |
| 531 | [selectedProject?.environmentId, selectedProject?.workspaceRoot], | 538 | [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot], |
| 532 | ); | 539 | ); |
| 533 | const branchState = useBranches(branchTarget); | 540 | const branchState = usePaginatedBranches(branchTarget); |
| 534 | const branchesLoading = branchState.isPending; | 541 | const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim(); |
| 535 | const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS; | 542 | const branchesLoading = |
| 543 | branchSearchIsDebouncing || (branchState.isPending && branchState.data === null); | ||
| 544 | const branchesFetchingNextPage = branchState.isFetchingNextPage; | ||
| 545 | const hasMoreBranches = | ||
| 546 | branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined; | ||
| 547 | const allBranchRefs = branchState.refs; | ||
| 536 | const availableBranches = useMemo( | 548 | const availableBranches = useMemo( |
| 537 | () => | 549 | () => |
| 538 | pipe( | 550 | pipe( |
| ... | @@ -554,11 +566,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -554,11 +566,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 554 | ); | 566 | ); |
| 555 | }, [availableBranches, branchQuery]); | 567 | }, [availableBranches, branchQuery]); |
| 556 | 568 | ||
| 557 | const setProject = useCallback((project: EnvironmentProject) => { | 569 | const setProject = useCallback( |
| 558 | const nextProjectKey = scopedProjectKey(project.environmentId, project.id); | 570 | (project: EnvironmentProject) => { |
| 559 | setSelectedEnvironmentId(project.environmentId); | 571 | const nextProjectKey = scopedProjectKey(project.environmentId, project.id); |
| 560 | setSelectedProjectKey(nextProjectKey); | 572 | const nextDraftKey = `new-task:${nextProjectKey}`; |
| 561 | }, []); | 573 | if ( |
| 574 | selectedProjectDraftKey?.startsWith("new-task:") && | ||
| 575 | selectedProjectDraftKey !== nextDraftKey | ||
| 576 | ) { | ||
| 577 | void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); | ||
| 578 | } | ||
| 579 | setSelectedEnvironmentId(project.environmentId); | ||
| 580 | setSelectedProjectKey(nextProjectKey); | ||
| 581 | }, | ||
| 582 | [selectedProjectDraftKey], | ||
| 583 | ); | ||
| 562 | 584 | ||
| 563 | const selectEnvironment = useCallback( | 585 | const selectEnvironment = useCallback( |
| 564 | (environmentId: EnvironmentId) => { | 586 | (environmentId: EnvironmentId) => { |
| ... | @@ -596,28 +618,86 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -596,28 +618,86 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 596 | if (!selectedProjectDraftKey) { | 618 | if (!selectedProjectDraftKey) { |
| 597 | return; | 619 | return; |
| 598 | } | 620 | } |
| 621 | if (!selectedProject) { | ||
| 622 | return; | ||
| 623 | } | ||
| 624 | const localSelection = resolveNewTaskLocalWorkspaceSelection({ | ||
| 625 | branches: availableBranches, | ||
| 626 | projectCwd: selectedProject.workspaceRoot, | ||
| 627 | }); | ||
| 628 | if (mode === "local" && localSelection.awaitsCurrentBranch) { | ||
| 629 | pendingLocalBranchSyncDraftKeysRef.current.add(selectedProjectDraftKey); | ||
| 630 | } else { | ||
| 631 | pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); | ||
| 632 | } | ||
| 599 | updateComposerDraftSettings(selectedProjectDraftKey, { | 633 | updateComposerDraftSettings(selectedProjectDraftKey, { |
| 600 | workspaceSelection: { | 634 | workspaceSelection: { |
| 601 | mode, | 635 | mode, |
| 602 | branch: selectedBranchName, | 636 | branch: mode === "local" ? localSelection.branch : selectedBranchName, |
| 603 | worktreePath: selectedWorktreePath, | 637 | worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, |
| 604 | ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), | 638 | ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), |
| 605 | }, | 639 | }, |
| 606 | }); | 640 | }); |
| 607 | }, | 641 | }, |
| 608 | [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], | 642 | [ |
| 643 | availableBranches, | ||
| 644 | draftStartFromOrigin, | ||
| 645 | selectedBranchName, | ||
| 646 | selectedProject, | ||
| 647 | selectedProjectDraftKey, | ||
| 648 | selectedWorktreePath, | ||
| 649 | ], | ||
| 609 | ); | 650 | ); |
| 610 | 651 | ||
| 652 | useEffect(() => { | ||
| 653 | if ( | ||
| 654 | workspaceMode !== "local" || | ||
| 655 | !selectedProject || | ||
| 656 | !selectedProjectDraftKey || | ||
| 657 | !pendingLocalBranchSyncDraftKeysRef.current.has(selectedProjectDraftKey) | ||
| 658 | ) { | ||
| 659 | return; | ||
| 660 | } | ||
| 661 | const localSelection = resolveNewTaskLocalWorkspaceSelection({ | ||
| 662 | branches: availableBranches, | ||
| 663 | projectCwd: selectedProject.workspaceRoot, | ||
| 664 | }); | ||
| 665 | if (localSelection.awaitsCurrentBranch) { | ||
| 666 | return; | ||
| 667 | } | ||
| 668 | |||
| 669 | pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); | ||
| 670 | updateComposerDraftSettings(selectedProjectDraftKey, { | ||
| 671 | workspaceSelection: { | ||
| 672 | mode: "local", | ||
| 673 | branch: localSelection.branch, | ||
| 674 | worktreePath: localSelection.worktreePath, | ||
| 675 | ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), | ||
| 676 | }, | ||
| 677 | }); | ||
| 678 | }, [ | ||
| 679 | availableBranches, | ||
| 680 | draftStartFromOrigin, | ||
| 681 | selectedProject, | ||
| 682 | selectedProjectDraftKey, | ||
| 683 | workspaceMode, | ||
| 684 | ]); | ||
| 685 | |||
| 611 | const selectBranch = useCallback( | 686 | const selectBranch = useCallback( |
| 612 | (branch: VcsRef) => { | 687 | (branch: VcsRef) => { |
| 613 | if (!selectedProject || !selectedProjectDraftKey) { | 688 | if (!selectedProject || !selectedProjectDraftKey) { |
| 614 | return; | 689 | return; |
| 615 | } | 690 | } |
| 691 | pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); | ||
| 616 | updateComposerDraftSettings(selectedProjectDraftKey, { | 692 | updateComposerDraftSettings(selectedProjectDraftKey, { |
| 617 | workspaceSelection: { | 693 | workspaceSelection: { |
| 618 | mode: workspaceMode, | 694 | mode: workspaceMode, |
| 619 | branch: branch.name, | 695 | branch: branch.name, |
| 620 | worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), | 696 | worktreePath: resolveNewTaskBranchWorktreePath({ |
| 697 | workspaceMode, | ||
| 698 | projectCwd: selectedProject.workspaceRoot, | ||
| 699 | branchWorktreePath: branch.worktreePath, | ||
| 700 | }), | ||
| 621 | ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), | 701 | ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), |
| 622 | }, | 702 | }, |
| 623 | }); | 703 | }); |
| ... | @@ -643,7 +723,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -643,7 +723,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 643 | ); | 723 | ); |
| 644 | 724 | ||
| 645 | const refreshBranches = branchState.refresh; | 725 | const refreshBranches = branchState.refresh; |
| 646 | const loadBranches = useCallback(async () => { | 726 | const loadMoreBranches = branchState.loadNext; |
| 727 | const loadBranches = useCallback(() => { | ||
| 647 | if (!selectedProject) { | 728 | if (!selectedProject) { |
| 648 | return; | 729 | return; |
| 649 | } | 730 | } |
| ... | @@ -767,7 +848,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -767,7 +848,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 767 | attachments: draft.attachments, | 848 | attachments: draft.attachments, |
| 768 | modelSelection: draftModelSelection, | 849 | modelSelection: draftModelSelection, |
| 769 | runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, | 850 | runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 770 | interactionMode: draft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, | 851 | interactionMode: resolvePendingTaskInteractionMode({ |
| 852 | preferenceLoaded: planModePreferenceLoaded, | ||
| 853 | planModeEnabled, | ||
| 854 | draftInteractionMode: draft.interactionMode, | ||
| 855 | queuedInteractionMode: editingPendingTask?.interactionMode, | ||
| 856 | }), | ||
| 771 | creation: { | 857 | creation: { |
| 772 | projectId: selectedProject.id, | 858 | projectId: selectedProject.id, |
| 773 | ...(projectTitle !== undefined ? { projectTitle } : {}), | 859 | ...(projectTitle !== undefined ? { projectTitle } : {}), |
| ... | @@ -792,6 +878,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -792,6 +878,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 792 | selectedModel, | 878 | selectedModel, |
| 793 | selectedProject, | 879 | selectedProject, |
| 794 | selectedProjectDraftKey, | 880 | selectedProjectDraftKey, |
| 881 | planModeEnabled, | ||
| 882 | planModePreferenceLoaded, | ||
| 795 | startFromOrigin, | 883 | startFromOrigin, |
| 796 | workspaceMode, | 884 | workspaceMode, |
| 797 | ], | 885 | ], |
| ... | @@ -904,9 +992,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -904,9 +992,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 904 | submitting, | 992 | submitting, |
| 905 | branchQuery, | 993 | branchQuery, |
| 906 | branchesLoading, | 994 | branchesLoading, |
| 995 | branchesError: branchState.error, | ||
| 996 | branchesFetchingNextPage, | ||
| 997 | hasMoreBranches, | ||
| 907 | availableBranches, | 998 | availableBranches, |
| 908 | runtimeMode, | 999 | runtimeMode, |
| 909 | interactionMode, | 1000 | interactionMode, |
| 1001 | planModeEnabled, | ||
| 910 | expandedProvider, | 1002 | expandedProvider, |
| 911 | environments, | 1003 | environments, |
| 912 | selectedProject, | 1004 | selectedProject, |
| ... | @@ -935,6 +1027,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -935,6 +1027,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 935 | setSubmitting, | 1027 | setSubmitting, |
| 936 | setBranchQuery, | 1028 | setBranchQuery, |
| 937 | loadBranches, | 1029 | loadBranches, |
| 1030 | loadMoreBranches, | ||
| 938 | setRuntimeMode, | 1031 | setRuntimeMode, |
| 939 | setInteractionMode, | 1032 | setInteractionMode, |
| 940 | setSelectedModelOptions, | 1033 | setSelectedModelOptions, |
| ... | @@ -946,6 +1039,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -946,6 +1039,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 946 | beginEditingPendingTask, | 1039 | beginEditingPendingTask, |
| 947 | branchQuery, | 1040 | branchQuery, |
| 948 | branchesLoading, | 1041 | branchesLoading, |
| 1042 | branchState.error, | ||
| 1043 | branchesFetchingNextPage, | ||
| 949 | buildPendingTaskMessage, | 1044 | buildPendingTaskMessage, |
| 950 | cancelEditingPendingTask, | 1045 | cancelEditingPendingTask, |
| 951 | editingPendingTask, | 1046 | editingPendingTask, |
| ... | @@ -954,7 +1049,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -954,7 +1049,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 954 | filteredBranches, | 1049 | filteredBranches, |
| 955 | finishEditingPendingTask, | 1050 | finishEditingPendingTask, |
| 956 | interactionMode, | 1051 | interactionMode, |
| 1052 | planModeEnabled, | ||
| 957 | loadBranches, | 1053 | loadBranches, |
| 1054 | loadMoreBranches, | ||
| 958 | projectScopes, | 1055 | projectScopes, |
| 959 | modelOptions, | 1056 | modelOptions, |
| 960 | prompt, | 1057 | prompt, |
| ... | @@ -963,6 +1060,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -963,6 +1060,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 963 | reset, | 1060 | reset, |
| 964 | runtimeMode, | 1061 | runtimeMode, |
| 965 | selectedBranchName, | 1062 | selectedBranchName, |
| 1063 | hasMoreBranches, | ||
| 966 | selectedEnvironmentId, | 1064 | selectedEnvironmentId, |
| 967 | selectedModel, | 1065 | selectedModel, |
| 968 | selectedModelKey, | 1066 | selectedModelKey, |
apps/mobile/src/features/threads/new-task-project-selection.test.ts+24-6| ... | @@ -5,12 +5,13 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | ... | @@ -5,12 +5,13 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 5 | import type { HomeProjectScope } from "../home/homeThreadList"; | 5 | import type { HomeProjectScope } from "../home/homeThreadList"; |
| 6 | import { | 6 | import { |
| 7 | getOnlySelectableProject, | 7 | getOnlySelectableProject, |
| 8 | getProjectScopeSelectionTarget, | ||
| 8 | resolveDraftProjectSelection, | 9 | resolveDraftProjectSelection, |
| 9 | } from "./new-task-project-selection"; | 10 | } from "./new-task-project-selection"; |
| 10 | 11 | ||
| 11 | function makeProject(id: string): EnvironmentProject { | 12 | function makeProject(id: string, environmentId = "environment"): EnvironmentProject { |
| 12 | return { | 13 | return { |
| 13 | environmentId: EnvironmentId.make("environment"), | 14 | environmentId: EnvironmentId.make(environmentId), |
| 14 | id: ProjectId.make(id), | 15 | id: ProjectId.make(id), |
| 15 | title: id, | 16 | title: id, |
| 16 | workspaceRoot: `/work/${id}`, | 17 | workspaceRoot: `/work/${id}`, |
| ... | @@ -41,9 +42,25 @@ describe("getOnlySelectableProject", () => { | ... | @@ -41,9 +42,25 @@ describe("getOnlySelectableProject", () => { |
| 41 | expect(getOnlySelectableProject([makeScope([project])])).toBe(project); | 42 | expect(getOnlySelectableProject([makeScope([project])])).toBe(project); |
| 42 | }); | 43 | }); |
| 43 | 44 | ||
| 44 | it("does not auto-select a representative when one group has multiple clones", () => { | 45 | it("selects the representative when one logical project has multiple workspaces", () => { |
| 45 | const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; | 46 | const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; |
| 46 | expect(getOnlySelectableProject([makeScope(projects)])).toBeNull(); | 47 | expect(getOnlySelectableProject([makeScope(projects)])).toBe(projects[0]); |
| 48 | }); | ||
| 49 | }); | ||
| 50 | |||
| 51 | describe("getProjectScopeSelectionTarget", () => { | ||
| 52 | it("keeps the current environment when it hosts the selected logical project", () => { | ||
| 53 | const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; | ||
| 54 | expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("server"))).toBe( | ||
| 55 | projects[1], | ||
| 56 | ); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("falls back to the representative when the current environment does not host the project", () => { | ||
| 60 | const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; | ||
| 61 | expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("other"))).toBe( | ||
| 62 | projects[0], | ||
| 63 | ); | ||
| 47 | }); | 64 | }); |
| 48 | }); | 65 | }); |
| 49 | 66 | ||
| ... | @@ -63,10 +80,11 @@ describe("resolveDraftProjectSelection", () => { | ... | @@ -63,10 +80,11 @@ describe("resolveDraftProjectSelection", () => { |
| 63 | }); | 80 | }); |
| 64 | }); | 81 | }); |
| 65 | 82 | ||
| 66 | it("opens the picker for multiple physical projects in one logical group", () => { | 83 | it("selects one logical project even when it has multiple physical workspaces", () => { |
| 67 | const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; | 84 | const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; |
| 68 | expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({ | 85 | expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({ |
| 69 | kind: "pick", | 86 | kind: "select", |
| 87 | project: projects[0], | ||
| 70 | }); | 88 | }); |
| 71 | }); | 89 | }); |
| 72 | 90 |
apps/mobile/src/features/threads/new-task-project-selection.ts+13-2| ... | @@ -1,18 +1,29 @@ | ... | @@ -1,18 +1,29 @@ |
| 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 4 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 4 | import type { HomeProjectScope } from "../home/homeThreadList"; | 5 | import type { HomeProjectScope } from "../home/homeThreadList"; |
| 5 | 6 | ||
| 6 | export type DraftProjectSelectionResolution = | 7 | type DraftProjectSelectionResolution = |
| 7 | | { readonly kind: "preserve" } | 8 | | { readonly kind: "preserve" } |
| 8 | | { readonly kind: "select"; readonly project: EnvironmentProject } | 9 | | { readonly kind: "select"; readonly project: EnvironmentProject } |
| 9 | | { readonly kind: "pick" }; | 10 | | { readonly kind: "pick" }; |
| 10 | 11 | ||
| 12 | export function getProjectScopeSelectionTarget( | ||
| 13 | scope: HomeProjectScope, | ||
| 14 | preferredEnvironmentId: EnvironmentId | null, | ||
| 15 | ): EnvironmentProject { | ||
| 16 | return ( | ||
| 17 | scope.projects.find((project) => project.environmentId === preferredEnvironmentId) ?? | ||
| 18 | scope.representative | ||
| 19 | ); | ||
| 20 | } | ||
| 21 | |||
| 11 | export function getOnlySelectableProject( | 22 | export function getOnlySelectableProject( |
| 12 | projectScopes: ReadonlyArray<HomeProjectScope>, | 23 | projectScopes: ReadonlyArray<HomeProjectScope>, |
| 13 | ): EnvironmentProject | null { | 24 | ): EnvironmentProject | null { |
| 14 | const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; | 25 | const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; |
| 15 | return onlyScope?.projects.length === 1 ? (onlyScope.projects[0] ?? null) : null; | 26 | return onlyScope?.representative ?? null; |
| 16 | } | 27 | } |
| 17 | 28 | ||
| 18 | export function resolveDraftProjectSelection( | 29 | export function resolveDraftProjectSelection( |
apps/mobile/src/features/threads/thread-settings-menu.test.ts deleted-284| ... | @@ -1,284 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | ||
| 6 | import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu"; | ||
| 7 | |||
| 8 | function modelOption( | ||
| 9 | model: string, | ||
| 10 | overrides: Partial<Pick<ModelOption, "isDefault" | "isLegacy" | "providerKey">> = {}, | ||
| 11 | ): ModelOption { | ||
| 12 | const providerKey = overrides.providerKey ?? "codex"; | ||
| 13 | return { | ||
| 14 | key: `${providerKey}:${model}`, | ||
| 15 | label: model, | ||
| 16 | subtitle: providerKey, | ||
| 17 | providerKey, | ||
| 18 | providerLabel: providerKey === "codex" ? "Codex" : "Claude", | ||
| 19 | providerDriver: providerKey === "codex" ? "codex" : "claudeAgent", | ||
| 20 | isDefault: overrides.isDefault ?? false, | ||
| 21 | isLegacy: overrides.isLegacy ?? false, | ||
| 22 | capabilities: null, | ||
| 23 | selection: { | ||
| 24 | instanceId: ProviderInstanceId.make(providerKey), | ||
| 25 | model, | ||
| 26 | options: [], | ||
| 27 | }, | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | |||
| 31 | function group(models: ReadonlyArray<ModelOption>): ProviderGroup { | ||
| 32 | const first = models[0]; | ||
| 33 | if (!first) { | ||
| 34 | throw new Error("group requires at least one model"); | ||
| 35 | } | ||
| 36 | return { | ||
| 37 | providerKey: first.providerKey, | ||
| 38 | providerLabel: first.providerLabel, | ||
| 39 | models, | ||
| 40 | }; | ||
| 41 | } | ||
| 42 | |||
| 43 | const effortDescriptor: ProviderOptionDescriptor = { | ||
| 44 | id: "effort", | ||
| 45 | label: "Reasoning", | ||
| 46 | type: "select", | ||
| 47 | options: [ | ||
| 48 | { id: "low", label: "Low" }, | ||
| 49 | { id: "medium", label: "Medium", isDefault: true }, | ||
| 50 | { id: "high", label: "High" }, | ||
| 51 | { id: "ultrathink", label: "Ultrathink" }, | ||
| 52 | { id: "ultracode", label: "Ultracode" }, | ||
| 53 | ], | ||
| 54 | currentValue: "high", | ||
| 55 | promptInjectedValues: ["ultrathink"], | ||
| 56 | }; | ||
| 57 | |||
| 58 | const fastModeDescriptor: ProviderOptionDescriptor = { | ||
| 59 | id: "fastMode", | ||
| 60 | label: "Fast mode", | ||
| 61 | type: "boolean", | ||
| 62 | currentValue: false, | ||
| 63 | }; | ||
| 64 | |||
| 65 | function baseInput() { | ||
| 66 | const models = [ | ||
| 67 | modelOption("gpt-current", { isDefault: true }), | ||
| 68 | modelOption("gpt-next"), | ||
| 69 | modelOption("gpt-old", { isLegacy: true }), | ||
| 70 | ]; | ||
| 71 | return { | ||
| 72 | providerGroups: [group(models)], | ||
| 73 | selectedModel: models[0]?.selection ?? null, | ||
| 74 | optionDescriptors: [effortDescriptor, fastModeDescriptor], | ||
| 75 | runtimeMode: "auto", | ||
| 76 | } as const; | ||
| 77 | } | ||
| 78 | |||
| 79 | function eventFor(menu: ReturnType<typeof buildThreadSettingsMenu>, id: string | undefined) { | ||
| 80 | return id === undefined ? undefined : menu.events.get(id); | ||
| 81 | } | ||
| 82 | |||
| 83 | describe("buildThreadSettingsMenu", () => { | ||
| 84 | it("orders the top level as model, options, runtime", () => { | ||
| 85 | const menu = buildThreadSettingsMenu(baseInput()); | ||
| 86 | |||
| 87 | expect(menu.actions.map((action) => action.title)).toEqual([ | ||
| 88 | "Model", | ||
| 89 | "Reasoning", | ||
| 90 | "Fast mode", | ||
| 91 | "Runtime", | ||
| 92 | ]); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("summarizes the current choice on each submenu row", () => { | ||
| 96 | const menu = buildThreadSettingsMenu(baseInput()); | ||
| 97 | |||
| 98 | expect(menu.actions.find((action) => action.title === "Model")?.subtitle).toBe("gpt-current"); | ||
| 99 | expect(menu.actions.find((action) => action.title === "Reasoning")?.subtitle).toBe("High"); | ||
| 100 | expect(menu.actions.find((action) => action.title === "Runtime")?.subtitle).toBe("Auto"); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("checkmarks the selected model and resolves selection events", () => { | ||
| 104 | const menu = buildThreadSettingsMenu(baseInput()); | ||
| 105 | |||
| 106 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | ||
| 107 | const current = modelItems.find((action) => action.title === "gpt-current"); | ||
| 108 | expect(current?.state).toBe("on"); | ||
| 109 | expect(current?.subtitle).toBe("Default"); | ||
| 110 | expect(modelItems.find((action) => action.title === "gpt-next")?.state).toBe("off"); | ||
| 111 | |||
| 112 | const event = eventFor(menu, modelItems.find((action) => action.title === "gpt-next")?.id); | ||
| 113 | expect(event?.type).toBe("select-model"); | ||
| 114 | expect(event?.type === "select-model" ? event.option.selection.model : null).toBe("gpt-next"); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("folds unselected legacy models behind a nested submenu", () => { | ||
| 118 | const menu = buildThreadSettingsMenu(baseInput()); | ||
| 119 | |||
| 120 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | ||
| 121 | expect(modelItems.map((action) => action.title)).toEqual([ | ||
| 122 | "gpt-current", | ||
| 123 | "gpt-next", | ||
| 124 | "Legacy Models", | ||
| 125 | ]); | ||
| 126 | expect( | ||
| 127 | modelItems | ||
| 128 | .find((action) => action.title === "Legacy Models") | ||
| 129 | ?.subactions?.map((action) => action.title), | ||
| 130 | ).toEqual(["gpt-old"]); | ||
| 131 | }); | ||
| 132 | |||
| 133 | it("keeps a selected legacy model in the main list", () => { | ||
| 134 | const input = baseInput(); | ||
| 135 | const legacy = input.providerGroups[0]?.models.find((model) => model.isLegacy); | ||
| 136 | const menu = buildThreadSettingsMenu({ | ||
| 137 | ...input, | ||
| 138 | selectedModel: legacy?.selection ?? null, | ||
| 139 | }); | ||
| 140 | |||
| 141 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | ||
| 142 | expect(modelItems.map((action) => action.title)).toEqual([ | ||
| 143 | "gpt-current", | ||
| 144 | "gpt-next", | ||
| 145 | "gpt-old", | ||
| 146 | ]); | ||
| 147 | expect(modelItems.find((action) => action.title === "gpt-old")?.state).toBe("on"); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("hides prompt-injected and workflow-trigger efforts but still summarizes them", () => { | ||
| 151 | const menu = buildThreadSettingsMenu({ | ||
| 152 | ...baseInput(), | ||
| 153 | optionDescriptors: [{ ...effortDescriptor, currentValue: "ultracode" }], | ||
| 154 | }); | ||
| 155 | |||
| 156 | const reasoning = menu.actions.find((action) => action.title === "Reasoning"); | ||
| 157 | expect(reasoning?.subactions?.map((action) => action.title)).toEqual(["Low", "Medium", "High"]); | ||
| 158 | // The hidden value stays visible as the current summary; it just can't be | ||
| 159 | // picked from the phone. | ||
| 160 | expect(reasoning?.subtitle).toBe("Ultracode"); | ||
| 161 | expect(reasoning?.subactions?.every((action) => action.state === "off")).toBe(true); | ||
| 162 | }); | ||
| 163 | |||
| 164 | it("resolves select-option and runtime events with checkmarked current values", () => { | ||
| 165 | const menu = buildThreadSettingsMenu(baseInput()); | ||
| 166 | |||
| 167 | const reasoningItems = | ||
| 168 | menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; | ||
| 169 | expect(reasoningItems.find((action) => action.title === "High")?.state).toBe("on"); | ||
| 170 | expect(eventFor(menu, reasoningItems.find((action) => action.title === "Low")?.id)).toEqual({ | ||
| 171 | type: "set-option", | ||
| 172 | optionId: "effort", | ||
| 173 | value: "low", | ||
| 174 | }); | ||
| 175 | |||
| 176 | const runtimeItems = | ||
| 177 | menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; | ||
| 178 | expect(runtimeItems.find((action) => action.title === "Auto")?.state).toBe("on"); | ||
| 179 | expect( | ||
| 180 | eventFor(menu, runtimeItems.find((action) => action.title === "Full access")?.id), | ||
| 181 | ).toEqual({ type: "set-runtime", mode: "full-access" }); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it("toggles boolean options with the inverted current value", () => { | ||
| 185 | const menu = buildThreadSettingsMenu(baseInput()); | ||
| 186 | |||
| 187 | const fastMode = menu.actions.find((action) => action.title === "Fast mode"); | ||
| 188 | expect(fastMode?.state).toBe("off"); | ||
| 189 | expect(fastMode?.subactions).toBeUndefined(); | ||
| 190 | expect(eventFor(menu, fastMode?.id)).toEqual({ | ||
| 191 | type: "set-option", | ||
| 192 | optionId: "fastMode", | ||
| 193 | value: true, | ||
| 194 | }); | ||
| 195 | |||
| 196 | const enabled = buildThreadSettingsMenu({ | ||
| 197 | ...baseInput(), | ||
| 198 | optionDescriptors: [{ ...fastModeDescriptor, currentValue: true }], | ||
| 199 | }); | ||
| 200 | const enabledRow = enabled.actions.find((action) => action.title === "Fast mode"); | ||
| 201 | expect(enabledRow?.state).toBe("on"); | ||
| 202 | expect(eventFor(enabled, enabledRow?.id)).toEqual({ | ||
| 203 | type: "set-option", | ||
| 204 | optionId: "fastMode", | ||
| 205 | value: false, | ||
| 206 | }); | ||
| 207 | }); | ||
| 208 | |||
| 209 | it("keeps the menu presented only for top-level toggles", () => { | ||
| 210 | const menu = buildThreadSettingsMenu(baseInput()); | ||
| 211 | |||
| 212 | // Root-level boolean toggles refresh in place with clean chrome, so they | ||
| 213 | // keep the menu presented. | ||
| 214 | expect( | ||
| 215 | menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, | ||
| 216 | ).toBe(true); | ||
| 217 | |||
| 218 | // Picks inside nested submenus close the menu: staying presented leaves | ||
| 219 | // the submenu on screen with an expanded-submenu header, and the | ||
| 220 | // bottom-anchored collapse back out drops by the levels' height delta. | ||
| 221 | const expected = undefined; | ||
| 222 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | ||
| 223 | const reasoningItems = | ||
| 224 | menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; | ||
| 225 | const runtimeItems = | ||
| 226 | menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; | ||
| 227 | const nestedPicks = [...modelItems, ...reasoningItems, ...runtimeItems].filter( | ||
| 228 | (action) => action.subactions === undefined, | ||
| 229 | ); | ||
| 230 | expect(nestedPicks.length).toBeGreaterThan(0); | ||
| 231 | expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe( | ||
| 232 | true, | ||
| 233 | ); | ||
| 234 | }); | ||
| 235 | |||
| 236 | it("sections models by provider only when multiple groups are offered", () => { | ||
| 237 | const codexModels = [modelOption("gpt-current", { isDefault: true })]; | ||
| 238 | const claudeModels = [modelOption("fable-5", { providerKey: "claude" })]; | ||
| 239 | const menu = buildThreadSettingsMenu({ | ||
| 240 | providerGroups: [group(codexModels), group(claudeModels)], | ||
| 241 | selectedModel: codexModels[0]?.selection ?? null, | ||
| 242 | optionDescriptors: [], | ||
| 243 | runtimeMode: "auto", | ||
| 244 | }); | ||
| 245 | |||
| 246 | const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; | ||
| 247 | expect( | ||
| 248 | modelItems.map((action) => ({ title: action.title, inline: action.displayInline ?? false })), | ||
| 249 | ).toEqual([ | ||
| 250 | { title: "Codex", inline: true }, | ||
| 251 | { title: "Claude", inline: true }, | ||
| 252 | ]); | ||
| 253 | const claudeSection = modelItems.find((action) => action.title === "Claude"); | ||
| 254 | expect(claudeSection?.subactions?.map((action) => action.title)).toEqual(["fable-5"]); | ||
| 255 | }); | ||
| 256 | |||
| 257 | const eventTypes = (menu: ReturnType<typeof buildThreadSettingsMenu>) => { | ||
| 258 | const types = new Set<ThreadSettingsMenuEvent["type"]>(); | ||
| 259 | for (const event of menu.events.values()) { | ||
| 260 | types.add(event.type); | ||
| 261 | } | ||
| 262 | return types; | ||
| 263 | }; | ||
| 264 | |||
| 265 | it("registers an event for every leaf action id", () => { | ||
| 266 | const menu = buildThreadSettingsMenu(baseInput()); | ||
| 267 | const leafIds: string[] = []; | ||
| 268 | const collect = (items: ReadonlyArray<{ id?: string; subactions?: unknown[] }>) => { | ||
| 269 | for (const item of items) { | ||
| 270 | if (Array.isArray(item.subactions) && item.subactions.length > 0) { | ||
| 271 | collect(item.subactions as ReadonlyArray<{ id?: string; subactions?: unknown[] }>); | ||
| 272 | } else if (item.id !== undefined) { | ||
| 273 | leafIds.push(item.id); | ||
| 274 | } | ||
| 275 | } | ||
| 276 | }; | ||
| 277 | collect(menu.actions); | ||
| 278 | |||
| 279 | for (const id of leafIds) { | ||
| 280 | expect(menu.events.get(id), `missing event for ${id}`).toBeDefined(); | ||
| 281 | } | ||
| 282 | expect(eventTypes(menu)).toEqual(new Set(["select-model", "set-option", "set-runtime"])); | ||
| 283 | }); | ||
| 284 | }); | ||
apps/mobile/src/features/threads/thread-settings-menu.ts deleted-202| ... | @@ -1,202 +0,0 @@ | ||
| 1 | import type { MenuAction } from "@react-native-menu/menu"; | ||
| 2 | import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | getProviderOptionCurrentLabel, | ||
| 5 | getProviderOptionCurrentValue, | ||
| 6 | } from "@t3tools/shared/model"; | ||
| 7 | |||
| 8 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | ||
| 9 | |||
| 10 | /** | ||
| 11 | * Desktop-oriented effort keywords that don't belong in the phone picker. | ||
| 12 | * Prompt-injected values (ultrathink and friends) are filtered from the | ||
| 13 | * descriptor metadata; ultracode is a real option but a workflow trigger, not | ||
| 14 | * a reasoning level. A value set elsewhere still displays, it just isn't | ||
| 15 | * offered. | ||
| 16 | */ | ||
| 17 | export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]); | ||
| 18 | |||
| 19 | export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ | ||
| 20 | readonly mode: RuntimeMode; | ||
| 21 | readonly label: string; | ||
| 22 | readonly shortLabel: string; | ||
| 23 | }> = [ | ||
| 24 | { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, | ||
| 25 | { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, | ||
| 26 | { mode: "auto", label: "Auto", shortLabel: "Auto" }, | ||
| 27 | { mode: "full-access", label: "Full access", shortLabel: "Full" }, | ||
| 28 | ]; | ||
| 29 | |||
| 30 | export function selectableChoices( | ||
| 31 | descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>, | ||
| 32 | ) { | ||
| 33 | const injected = new Set(descriptor.promptInjectedValues ?? []); | ||
| 34 | return descriptor.options.filter( | ||
| 35 | (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | |||
| 39 | export type ThreadSettingsMenuEvent = | ||
| 40 | | { readonly type: "select-model"; readonly option: ModelOption } | ||
| 41 | | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } | ||
| 42 | | { readonly type: "set-runtime"; readonly mode: RuntimeMode }; | ||
| 43 | |||
| 44 | export type ThreadSettingsMenu = { | ||
| 45 | readonly actions: MenuAction[]; | ||
| 46 | /** Menu action id → the change it applies, for the onPressAction dispatch. */ | ||
| 47 | readonly events: ReadonlyMap<string, ThreadSettingsMenuEvent>; | ||
| 48 | }; | ||
| 49 | |||
| 50 | /** | ||
| 51 | * Native menu replacement for the thread settings sheet (model, select and | ||
| 52 | * boolean provider options, runtime mode). The menu presents from the | ||
| 53 | * composer pill without resigning the keyboard, so adjusting settings never | ||
| 54 | * bounces focus. A thread is bound to one harness, so the menu covers the | ||
| 55 | * sheet's full surface for existing threads; the sheet remains the Android | ||
| 56 | * and new-task-draft surface. | ||
| 57 | * | ||
| 58 | * Selections apply immediately — the sheet's stage-then-Save flow only exists | ||
| 59 | * because the sheet batches a model change with its option edits. | ||
| 60 | */ | ||
| 61 | export function buildThreadSettingsMenu(input: { | ||
| 62 | readonly providerGroups: ReadonlyArray<ProviderGroup>; | ||
| 63 | readonly selectedModel: ModelSelection | null; | ||
| 64 | readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; | ||
| 65 | readonly runtimeMode: RuntimeMode; | ||
| 66 | }): ThreadSettingsMenu { | ||
| 67 | const events = new Map<string, ThreadSettingsMenuEvent>(); | ||
| 68 | const actions: MenuAction[] = []; | ||
| 69 | |||
| 70 | const isSelected = (option: ModelOption) => | ||
| 71 | option.selection.instanceId === input.selectedModel?.instanceId && | ||
| 72 | option.selection.model === input.selectedModel.model; | ||
| 73 | |||
| 74 | // Only top-level leaves (boolean toggles) keep the menu presented (iOS | ||
| 75 | // 16+): the root refreshes in place with clean chrome. Picks inside nested | ||
| 76 | // submenus close the menu — keeping the submenu presented renders an | ||
| 77 | // expanded-submenu header with no way to pop back to the root, and the | ||
| 78 | // bottom-anchored collapse back out travels the levels' height difference. | ||
| 79 | const keepPresented = { keepsMenuPresented: true } as const; | ||
| 80 | |||
| 81 | const modelAction = (option: ModelOption, id: string): MenuAction => { | ||
| 82 | events.set(id, { type: "select-model", option }); | ||
| 83 | return { | ||
| 84 | id, | ||
| 85 | title: option.label, | ||
| 86 | ...(option.isDefault ? { subtitle: "Default" } : {}), | ||
| 87 | state: isSelected(option) ? "on" : "off", | ||
| 88 | }; | ||
| 89 | }; | ||
| 90 | |||
| 91 | const modelItems: MenuAction[] = []; | ||
| 92 | const legacyItems: MenuAction[] = []; | ||
| 93 | let selectedModelLabel: string | undefined; | ||
| 94 | input.providerGroups.forEach((group, groupIndex) => { | ||
| 95 | const groupItems: MenuAction[] = []; | ||
| 96 | group.models.forEach((option, modelIndex) => { | ||
| 97 | if (isSelected(option)) { | ||
| 98 | selectedModelLabel = option.label; | ||
| 99 | } | ||
| 100 | const id = `model:${groupIndex}:${modelIndex}`; | ||
| 101 | // A highlighted legacy model stays in the main list (mirroring the | ||
| 102 | // sheet) so the checkmark isn't hidden behind the Legacy fold. | ||
| 103 | if (option.isLegacy && !isSelected(option)) { | ||
| 104 | legacyItems.push(modelAction(option, id)); | ||
| 105 | } else { | ||
| 106 | groupItems.push(modelAction(option, id)); | ||
| 107 | } | ||
| 108 | }); | ||
| 109 | if (groupItems.length === 0) { | ||
| 110 | return; | ||
| 111 | } | ||
| 112 | // A thread is bound to one harness, so provider sections only appear for | ||
| 113 | // multi-group callers (the new-task draft, if it ever adopts the menu). | ||
| 114 | if (input.providerGroups.length > 1) { | ||
| 115 | modelItems.push({ | ||
| 116 | id: `model-group:${groupIndex}`, | ||
| 117 | title: group.providerLabel, | ||
| 118 | displayInline: true, | ||
| 119 | subactions: groupItems, | ||
| 120 | }); | ||
| 121 | } else { | ||
| 122 | modelItems.push(...groupItems); | ||
| 123 | } | ||
| 124 | }); | ||
| 125 | if (legacyItems.length > 0) { | ||
| 126 | modelItems.push({ | ||
| 127 | id: "legacy-models", | ||
| 128 | title: "Legacy Models", | ||
| 129 | subactions: legacyItems, | ||
| 130 | }); | ||
| 131 | } | ||
| 132 | if (modelItems.length > 0) { | ||
| 133 | actions.push({ | ||
| 134 | id: "model", | ||
| 135 | title: "Model", | ||
| 136 | ...(selectedModelLabel === undefined | ||
| 137 | ? input.selectedModel | ||
| 138 | ? { subtitle: input.selectedModel.model } | ||
| 139 | : {} | ||
| 140 | : { subtitle: selectedModelLabel }), | ||
| 141 | subactions: modelItems, | ||
| 142 | }); | ||
| 143 | } | ||
| 144 | |||
| 145 | for (const descriptor of input.optionDescriptors) { | ||
| 146 | if (descriptor.type === "boolean") { | ||
| 147 | const id = `option:${descriptor.id}`; | ||
| 148 | events.set(id, { | ||
| 149 | type: "set-option", | ||
| 150 | optionId: descriptor.id, | ||
| 151 | value: !(descriptor.currentValue ?? false), | ||
| 152 | }); | ||
| 153 | actions.push({ | ||
| 154 | id, | ||
| 155 | title: descriptor.label, | ||
| 156 | state: descriptor.currentValue ? "on" : "off", | ||
| 157 | attributes: keepPresented, | ||
| 158 | }); | ||
| 159 | continue; | ||
| 160 | } | ||
| 161 | const currentValue = getProviderOptionCurrentValue(descriptor); | ||
| 162 | const choices = selectableChoices(descriptor).map((choice): MenuAction => { | ||
| 163 | const id = `option:${descriptor.id}:${choice.id}`; | ||
| 164 | events.set(id, { type: "set-option", optionId: descriptor.id, value: choice.id }); | ||
| 165 | return { | ||
| 166 | id, | ||
| 167 | title: choice.label, | ||
| 168 | state: choice.id === currentValue ? "on" : "off", | ||
| 169 | }; | ||
| 170 | }); | ||
| 171 | if (choices.length === 0) { | ||
| 172 | continue; | ||
| 173 | } | ||
| 174 | const currentLabel = getProviderOptionCurrentLabel(descriptor); | ||
| 175 | actions.push({ | ||
| 176 | id: `option:${descriptor.id}`, | ||
| 177 | title: descriptor.label, | ||
| 178 | ...(currentLabel === undefined ? {} : { subtitle: currentLabel }), | ||
| 179 | subactions: choices, | ||
| 180 | }); | ||
| 181 | } | ||
| 182 | |||
| 183 | const runtimeLabel = RUNTIME_MODE_CHOICES.find( | ||
| 184 | (choice) => choice.mode === input.runtimeMode, | ||
| 185 | )?.label; | ||
| 186 | actions.push({ | ||
| 187 | id: "runtime", | ||
| 188 | title: "Runtime", | ||
| 189 | ...(runtimeLabel === undefined ? {} : { subtitle: runtimeLabel }), | ||
| 190 | subactions: RUNTIME_MODE_CHOICES.map((choice): MenuAction => { | ||
| 191 | const id = `runtime:${choice.mode}`; | ||
| 192 | events.set(id, { type: "set-runtime", mode: choice.mode }); | ||
| 193 | return { | ||
| 194 | id, | ||
| 195 | title: choice.label, | ||
| 196 | state: choice.mode === input.runtimeMode ? "on" : "off", | ||
| 197 | }; | ||
| 198 | }), | ||
| 199 | }); | ||
| 200 | |||
| 201 | return { actions, events }; | ||
| 202 | } | ||
apps/mobile/src/features/threads/thread-settings-options.test.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import type { ProviderOptionDescriptor } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { selectableChoices } from "./thread-settings-options"; | ||
| 5 | |||
| 6 | const effortDescriptor: Extract<ProviderOptionDescriptor, { type: "select" }> = { | ||
| 7 | id: "effort", | ||
| 8 | label: "Reasoning", | ||
| 9 | type: "select", | ||
| 10 | options: [ | ||
| 11 | { id: "low", label: "Low" }, | ||
| 12 | { id: "medium", label: "Medium", isDefault: true }, | ||
| 13 | { id: "high", label: "High" }, | ||
| 14 | { id: "ultrathink", label: "Ultrathink" }, | ||
| 15 | { id: "ultracode", label: "Ultracode" }, | ||
| 16 | ], | ||
| 17 | currentValue: "high", | ||
| 18 | promptInjectedValues: ["ultrathink"], | ||
| 19 | }; | ||
| 20 | |||
| 21 | describe("selectableChoices", () => { | ||
| 22 | it("hides prompt-injected and workflow-trigger choices, keeping declared order", () => { | ||
| 23 | expect(selectableChoices(effortDescriptor).map((choice) => choice.id)).toEqual([ | ||
| 24 | "low", | ||
| 25 | "medium", | ||
| 26 | "high", | ||
| 27 | ]); | ||
| 28 | }); | ||
| 29 | }); | ||
apps/mobile/src/features/threads/thread-settings-options.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * Desktop-oriented effort keywords that don't belong in the phone picker. | ||
| 5 | * Prompt-injected values (ultrathink and friends) are filtered from the | ||
| 6 | * descriptor metadata; ultracode is a real option but a workflow trigger, not | ||
| 7 | * a reasoning level. A value set elsewhere still displays, it just isn't | ||
| 8 | * offered. | ||
| 9 | */ | ||
| 10 | const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]); | ||
| 11 | |||
| 12 | export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ | ||
| 13 | readonly mode: RuntimeMode; | ||
| 14 | readonly label: string; | ||
| 15 | readonly description: string; | ||
| 16 | }> = [ | ||
| 17 | { | ||
| 18 | mode: "approval-required", | ||
| 19 | label: "Supervised", | ||
| 20 | description: "Ask before commands and file changes.", | ||
| 21 | }, | ||
| 22 | { | ||
| 23 | mode: "auto-accept-edits", | ||
| 24 | label: "Auto-accept edits", | ||
| 25 | description: "Auto-approve edits, ask before other actions.", | ||
| 26 | }, | ||
| 27 | { | ||
| 28 | mode: "auto", | ||
| 29 | label: "Auto", | ||
| 30 | description: "Supported providers approve routine actions; others still ask.", | ||
| 31 | }, | ||
| 32 | { | ||
| 33 | mode: "full-access", | ||
| 34 | label: "Full access", | ||
| 35 | description: "Allow commands and edits without prompts.", | ||
| 36 | }, | ||
| 37 | ]; | ||
| 38 | |||
| 39 | export function selectableChoices( | ||
| 40 | descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>, | ||
| 41 | ) { | ||
| 42 | const injected = new Set(descriptor.promptInjectedValues ?? []); | ||
| 43 | return descriptor.options.filter( | ||
| 44 | (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), | ||
| 45 | ); | ||
| 46 | } | ||
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts+21-1| ... | @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; | 3 | import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import type { ModelOption } from "../../lib/modelOptions"; | 5 | import type { ModelOption } from "../../lib/modelOptions"; |
| 6 | import { pendingModelAfterPress } from "./thread-settings-sheet-state"; | 6 | import { modelMatchesCatalogQuery, pendingModelAfterPress } from "./thread-settings-sheet-state"; |
| 7 | 7 | ||
| 8 | function modelOption( | 8 | function modelOption( |
| 9 | model: string, | 9 | model: string, |
| ... | @@ -28,6 +28,26 @@ function modelOption( | ... | @@ -28,6 +28,26 @@ function modelOption( |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | describe("thread settings sheet state", () => { | 30 | describe("thread settings sheet state", () => { |
| 31 | it("matches visible model and provider terms", () => { | ||
| 32 | const model = modelOption("gpt-next"); | ||
| 33 | |||
| 34 | expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "NEXT" })).toBe(true); | ||
| 35 | expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "codex" })).toBe(true); | ||
| 36 | expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "claude" })).toBe( | ||
| 37 | false, | ||
| 38 | ); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("treats whitespace-only catalog searches as empty", () => { | ||
| 42 | expect( | ||
| 43 | modelMatchesCatalogQuery({ | ||
| 44 | model: modelOption("gpt-next"), | ||
| 45 | providerLabel: "Codex", | ||
| 46 | query: " ", | ||
| 47 | }), | ||
| 48 | ).toBe(true); | ||
| 49 | }); | ||
| 50 | |||
| 31 | it("clears staging when the applied model is pressed", () => { | 51 | it("clears staging when the applied model is pressed", () => { |
| 32 | expect( | 52 | expect( |
| 33 | pendingModelAfterPress({ | 53 | pendingModelAfterPress({ |
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+34| ... | @@ -1,5 +1,24 @@ | ... | @@ -1,5 +1,24 @@ |
| 1 | import type { ModelOption } from "../../lib/modelOptions"; | 1 | import type { ModelOption } from "../../lib/modelOptions"; |
| 2 | 2 | ||
| 3 | /** Match the terms a user can actually see or recognize in the model picker. */ | ||
| 4 | export function modelMatchesCatalogQuery(input: { | ||
| 5 | readonly model: ModelOption; | ||
| 6 | readonly providerLabel: string; | ||
| 7 | readonly query: string; | ||
| 8 | }): boolean { | ||
| 9 | const query = input.query.trim().toLocaleLowerCase(); | ||
| 10 | if (query.length === 0) { | ||
| 11 | return true; | ||
| 12 | } | ||
| 13 | |||
| 14 | return [ | ||
| 15 | input.model.label, | ||
| 16 | input.model.subtitle, | ||
| 17 | input.model.selection.model, | ||
| 18 | input.providerLabel, | ||
| 19 | ].some((value) => value.toLocaleLowerCase().includes(query)); | ||
| 20 | } | ||
| 21 | |||
| 3 | /** Preserve staged provider options when the highlighted model is tapped again. */ | 22 | /** Preserve staged provider options when the highlighted model is tapped again. */ |
| 4 | export function pendingModelAfterPress(input: { | 23 | export function pendingModelAfterPress(input: { |
| 5 | readonly current: ModelOption | null; | 24 | readonly current: ModelOption | null; |
| ... | @@ -11,3 +30,18 @@ export function pendingModelAfterPress(input: { | ... | @@ -11,3 +30,18 @@ export function pendingModelAfterPress(input: { |
| 11 | } | 30 | } |
| 12 | return input.current?.key === input.pressed.key ? input.current : input.pressed; | 31 | return input.current?.key === input.pressed.key ? input.current : input.pressed; |
| 13 | } | 32 | } |
| 33 | |||
| 34 | /** | ||
| 35 | * Primary and selected providers start open; all other catalogs start closed. | ||
| 36 | * A user's disclosure tap inverts that default until the picker is dismissed. | ||
| 37 | */ | ||
| 38 | export function providerSectionIsCollapsed(input: { | ||
| 39 | readonly defaultExpanded: boolean; | ||
| 40 | readonly hasExpansionOverride: boolean; | ||
| 41 | readonly isNarrowed: boolean; | ||
| 42 | }): boolean { | ||
| 43 | if (input.isNarrowed) { | ||
| 44 | return false; | ||
| 45 | } | ||
| 46 | return input.defaultExpanded ? input.hasExpansionOverride : !input.hasExpansionOverride; | ||
| 47 | } |
apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import { mobilePreferencesAtom } from "../../state/preferences"; | ||
| 5 | import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * Mobile preferences are device-local, matching the desktop client setting. | ||
| 9 | * Keep the legacy composer mode hidden until the preference has loaded and is | ||
| 10 | * explicitly enabled. | ||
| 11 | */ | ||
| 12 | export function useLegacyPlanModeEnabled(): boolean { | ||
| 13 | return useLegacyPlanModeState().enabled; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function useLegacyPlanModeState(): { readonly enabled: boolean; readonly loaded: boolean } { | ||
| 17 | const preferences = useAtomValue(mobilePreferencesAtom); | ||
| 18 | const loaded = AsyncResult.isSuccess(preferences); | ||
| 19 | return { | ||
| 20 | enabled: resolveLegacyPlanModeEnabled({ | ||
| 21 | loaded, | ||
| 22 | preference: loaded ? preferences.value.planModeEnabled : undefined, | ||
| 23 | }), | ||
| 24 | loaded, | ||
| 25 | }; | ||
| 26 | } | ||
apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts+137-41| ... | @@ -3,14 +3,46 @@ import { KeyboardController } from "react-native-keyboard-controller"; | ... | @@ -3,14 +3,46 @@ import { KeyboardController } from "react-native-keyboard-controller"; |
| 3 | 3 | ||
| 4 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 4 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 5 | 5 | ||
| 6 | export type ThreadSettingsSheetCloseReason = "save" | "dismiss"; | 6 | type PresentationPhase = "closed" | "opening" | "visible"; |
| 7 | 7 | ||
| 8 | type PresentationPhase = "closed" | "opening" | "visible" | "closing"; | 8 | /** |
| 9 | * The navigator-level UIKit completion event added by the repo's | ||
| 10 | * `@react-navigation/native-stack` patch; absent from upstream event maps. | ||
| 11 | */ | ||
| 12 | export type NavigationWithFinishTransitioning = { | ||
| 13 | readonly addListener: (type: "finishTransitioning", callback: () => void) => () => void; | ||
| 14 | }; | ||
| 15 | |||
| 16 | /** | ||
| 17 | * How long after the dismissal's state change the keyboard starts rising, so | ||
| 18 | * its ~250ms show overlaps the tail of the sheet's ~500ms travel the way | ||
| 19 | * UIKit apps choreograph it. This is aesthetics, not correctness: without | ||
| 20 | * keepFocus-style inputView overrides a show started mid-dismissal completes | ||
| 21 | * cleanly, so a slower device merely gets more overlap — no failure mode. | ||
| 22 | * The navigator's `finishTransitioning` event (UIKit's real completion | ||
| 23 | * callback, surfaced by the repo's native-stack patch) additionally bounds | ||
| 24 | * the restore at the true landing moment should this timer ever lag it. | ||
| 25 | */ | ||
| 26 | const SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS = 300; | ||
| 27 | |||
| 28 | /** | ||
| 29 | * A JS-initiated dismissal pops state before its animation runs; a | ||
| 30 | * gesture-driven one animates natively first and pops afterwards, with the | ||
| 31 | * navigator's completion event landing a few dozen milliseconds before the | ||
| 32 | * pop. A completion this fresh at pop time therefore means the sheet is | ||
| 33 | * already gone and the keyboard should return immediately. The two orderings | ||
| 34 | * are separated by the sheet's full ~500ms travel, so this window is a | ||
| 35 | * classification with wide margin, not an animation race. | ||
| 36 | */ | ||
| 37 | const NATIVE_DISMISSAL_ECHO_WINDOW_MS = 150; | ||
| 9 | 38 | ||
| 10 | /** | 39 | /** |
| 11 | * Keeps the custom native composer and the settings modal from owning focus at | 40 | * Keeps the custom native composer and the settings sheet from owning focus at |
| 12 | * the same time. Opening waits for the keyboard dismissal to finish, while | 41 | * the same time. Opening resigns the editor cleanly; a dismissal re-focuses it |
| 13 | * focus restoration waits for the modal's dismissal callback. | 42 | * once the sheet has fully landed. A plain blur/focus pair costs one keyboard |
| 43 | * animation each way — keepFocus-style inputView overrides are avoided because | ||
| 44 | * removing them forces UIKit to reload input views, replaying the keyboard's | ||
| 45 | * show as a visible collapse/re-open. | ||
| 14 | */ | 46 | */ |
| 15 | export function useThreadSettingsSheetPresentation(input: { | 47 | export function useThreadSettingsSheetPresentation(input: { |
| 16 | readonly editorRef: RefObject<ComposerEditorHandle | null>; | 48 | readonly editorRef: RefObject<ComposerEditorHandle | null>; |
| ... | @@ -19,18 +51,36 @@ export function useThreadSettingsSheetPresentation(input: { | ... | @@ -19,18 +51,36 @@ export function useThreadSettingsSheetPresentation(input: { |
| 19 | const [phase, setPhase] = useState<PresentationPhase>("closed"); | 51 | const [phase, setPhase] = useState<PresentationPhase>("closed"); |
| 20 | const isActiveRef = useRef(false); | 52 | const isActiveRef = useRef(false); |
| 21 | const isMountedRef = useRef(true); | 53 | const isMountedRef = useRef(true); |
| 54 | const isEditorFocusedRef = useRef(input.isEditorFocused); | ||
| 22 | const openingIdRef = useRef(0); | 55 | const openingIdRef = useRef(0); |
| 23 | const restoreFocusOnSaveRef = useRef(false); | 56 | const focusRestoreIdRef = useRef(0); |
| 24 | const shouldRestoreAfterDismissRef = useRef(false); | 57 | const restoreFocusAfterDismissRef = useRef(false); |
| 58 | const restorePendingRef = useRef(false); | ||
| 59 | const lastStackTransitionFinishedAtRef = useRef(0); | ||
| 60 | const dismissRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 61 | const clearDismissRestoreTimer = useCallback(() => { | ||
| 62 | if (dismissRestoreTimerRef.current !== null) { | ||
| 63 | clearTimeout(dismissRestoreTimerRef.current); | ||
| 64 | dismissRestoreTimerRef.current = null; | ||
| 65 | } | ||
| 66 | }, []); | ||
| 25 | 67 | ||
| 26 | useEffect( | 68 | useEffect(() => { |
| 27 | () => () => { | 69 | isEditorFocusedRef.current = input.isEditorFocused; |
| 70 | }, [input.isEditorFocused]); | ||
| 71 | |||
| 72 | useEffect(() => { | ||
| 73 | // React Strict Mode and Fast Refresh both run an effect cleanup/setup | ||
| 74 | // cycle without recreating refs. Re-arm the mounted guard on every setup. | ||
| 75 | isMountedRef.current = true; | ||
| 76 | return () => { | ||
| 28 | isMountedRef.current = false; | 77 | isMountedRef.current = false; |
| 29 | isActiveRef.current = false; | 78 | isActiveRef.current = false; |
| 30 | openingIdRef.current += 1; | 79 | openingIdRef.current += 1; |
| 31 | }, | 80 | focusRestoreIdRef.current += 1; |
| 32 | [], | 81 | clearDismissRestoreTimer(); |
| 33 | ); | 82 | }; |
| 83 | }, [clearDismissRestoreTimer]); | ||
| 34 | 84 | ||
| 35 | const open = useCallback(() => { | 85 | const open = useCallback(() => { |
| 36 | if (isActiveRef.current) { | 86 | if (isActiveRef.current) { |
| ... | @@ -38,61 +88,107 @@ export function useThreadSettingsSheetPresentation(input: { | ... | @@ -38,61 +88,107 @@ export function useThreadSettingsSheetPresentation(input: { |
| 38 | } | 88 | } |
| 39 | 89 | ||
| 40 | isActiveRef.current = true; | 90 | isActiveRef.current = true; |
| 41 | restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible(); | 91 | focusRestoreIdRef.current += 1; |
| 42 | shouldRestoreAfterDismissRef.current = false; | 92 | clearDismissRestoreTimer(); |
| 93 | restorePendingRef.current = false; | ||
| 94 | restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible(); | ||
| 43 | setPhase("opening"); | 95 | setPhase("opening"); |
| 44 | 96 | ||
| 45 | const openingId = openingIdRef.current + 1; | 97 | const openingId = openingIdRef.current + 1; |
| 46 | openingIdRef.current = openingId; | 98 | openingIdRef.current = openingId; |
| 47 | 99 | ||
| 48 | // Keyboard.dismiss() only tracks React Native TextInputs. The composer is | 100 | // Start the keyboard transition before the custom native editor resigns |
| 49 | // a custom native text view, so explicitly resign its first responder too. | 101 | // first responder, then present the sheet on the next frame. The sheet and |
| 102 | // keyboard animate together instead of serializing two native transitions. | ||
| 103 | void KeyboardController.dismiss({ animated: true }); | ||
| 50 | input.editorRef.current?.blur(); | 104 | input.editorRef.current?.blur(); |
| 51 | void KeyboardController.dismiss().then(() => { | 105 | |
| 106 | requestAnimationFrame(() => { | ||
| 52 | if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { | 107 | if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { |
| 53 | return; | 108 | return; |
| 54 | } | 109 | } |
| 55 | setPhase("visible"); | 110 | setPhase("visible"); |
| 56 | }); | 111 | }); |
| 57 | }, [input.editorRef, input.isEditorFocused]); | 112 | }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]); |
| 113 | |||
| 114 | const restoreEditorFocus = useCallback(() => { | ||
| 115 | const focusRestoreId = focusRestoreIdRef.current + 1; | ||
| 116 | focusRestoreIdRef.current = focusRestoreId; | ||
| 117 | let attemptsRemaining = 20; | ||
| 118 | |||
| 119 | // Restoration runs after the dismissal transition, so the first attempt | ||
| 120 | // normally succeeds; the retries are insurance against UIKit briefly | ||
| 121 | // refusing first-responder status right at the transition boundary. | ||
| 122 | const restoreFocus = () => { | ||
| 123 | if ( | ||
| 124 | !isMountedRef.current || | ||
| 125 | focusRestoreIdRef.current !== focusRestoreId || | ||
| 126 | isEditorFocusedRef.current || | ||
| 127 | attemptsRemaining <= 0 | ||
| 128 | ) { | ||
| 129 | return; | ||
| 130 | } | ||
| 58 | 131 | ||
| 59 | const close = useCallback((reason: ThreadSettingsSheetCloseReason) => { | 132 | attemptsRemaining -= 1; |
| 60 | if (!isActiveRef.current) { | 133 | input.editorRef.current?.focus(); |
| 134 | setTimeout(restoreFocus, 50); | ||
| 135 | }; | ||
| 136 | requestAnimationFrame(restoreFocus); | ||
| 137 | }, [input.editorRef]); | ||
| 138 | |||
| 139 | /** Runs the queued restore once — whichever completion signal arrives first. */ | ||
| 140 | const runPendingDismissalRestore = useCallback(() => { | ||
| 141 | if (!restorePendingRef.current) { | ||
| 61 | return; | 142 | return; |
| 62 | } | 143 | } |
| 144 | restorePendingRef.current = false; | ||
| 145 | clearDismissRestoreTimer(); | ||
| 146 | // A reopened sheet owns focus again; drop the stale restore request. | ||
| 147 | if (!isMountedRef.current || isActiveRef.current) { | ||
| 148 | return; | ||
| 149 | } | ||
| 150 | restoreEditorFocus(); | ||
| 151 | }, [clearDismissRestoreTimer, restoreEditorFocus]); | ||
| 63 | 152 | ||
| 64 | openingIdRef.current += 1; | 153 | /** |
| 65 | shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current; | 154 | * Marks the sheet closed and queues the keyboard's return for the moment |
| 66 | setPhase("closing"); | 155 | * the dismissal transition actually completes: the sheet slides away over a |
| 67 | }, []); | 156 | * resting composer, then the keyboard lifts it in one continuous motion. |
| 68 | 157 | */ | |
| 69 | const onDismissed = useCallback(() => { | 158 | const onDismissed = useCallback(() => { |
| 70 | const shouldRestoreFocus = shouldRestoreAfterDismissRef.current; | ||
| 71 | shouldRestoreAfterDismissRef.current = false; | ||
| 72 | restoreFocusOnSaveRef.current = false; | ||
| 73 | isActiveRef.current = false; | 159 | isActiveRef.current = false; |
| 74 | setPhase("closed"); | 160 | setPhase("closed"); |
| 75 | 161 | ||
| 76 | if (shouldRestoreFocus) { | 162 | if (!restoreFocusAfterDismissRef.current) { |
| 77 | input.editorRef.current?.focus(); | 163 | return; |
| 78 | } | 164 | } |
| 79 | }, [input.editorRef]); | 165 | restoreFocusAfterDismissRef.current = false; |
| 80 | 166 | restorePendingRef.current = true; | |
| 81 | // The new-task screen can have an autofocus queued before the sheet opens. | 167 | clearDismissRestoreTimer(); |
| 82 | // Preserve that intent for Save without allowing it to focus under the modal. | 168 | if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) { |
| 83 | const restoreFocusAfterSave = useCallback(() => { | 169 | // A stack transition finished just before this pop reached JS: the pop |
| 84 | if (isActiveRef.current) { | 170 | // is the state echo of a gesture-driven dismissal whose animation has |
| 85 | restoreFocusOnSaveRef.current = true; | 171 | // already completed. The sheet is gone — bring the keyboard back now. |
| 172 | runPendingDismissalRestore(); | ||
| 173 | return; | ||
| 86 | } | 174 | } |
| 87 | }, []); | 175 | dismissRestoreTimerRef.current = setTimeout(() => { |
| 176 | dismissRestoreTimerRef.current = null; | ||
| 177 | runPendingDismissalRestore(); | ||
| 178 | }, SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS); | ||
| 179 | }, [clearDismissRestoreTimer, runPendingDismissalRestore]); | ||
| 180 | |||
| 181 | /** Wire to the navigator's `finishTransitioning` event. */ | ||
| 182 | const onStackTransitionsFinished = useCallback(() => { | ||
| 183 | lastStackTransitionFinishedAtRef.current = Date.now(); | ||
| 184 | runPendingDismissalRestore(); | ||
| 185 | }, [runPendingDismissalRestore]); | ||
| 88 | 186 | ||
| 89 | return { | 187 | return { |
| 90 | isActive: phase !== "closed", | 188 | isActive: phase !== "closed", |
| 91 | isActiveRef, | ||
| 92 | isVisible: phase === "visible", | 189 | isVisible: phase === "visible", |
| 93 | open, | 190 | open, |
| 94 | close, | ||
| 95 | onDismissed, | 191 | onDismissed, |
| 96 | restoreFocusAfterSave, | 192 | onStackTransitionsFinished, |
| 97 | } as const; | 193 | } as const; |
| 98 | } | 194 | } |
apps/mobile/src/native/native-glass.ts+2-2| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import { isLiquidGlassSupported } from "@callstack/liquid-glass"; | 1 | import { isGlassEffectAPIAvailable } from "expo-glass-effect"; |
| 2 | import { Platform } from "react-native"; | 2 | import { Platform } from "react-native"; |
| 3 | 3 | ||
| 4 | import { supportsNativeLiquidGlass } from "../lib/native-glass-capability"; | 4 | import { supportsNativeLiquidGlass } from "../lib/native-glass-capability"; |
| 5 | 5 | ||
| 6 | export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass( | 6 | export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass( |
| 7 | Platform.OS, | 7 | Platform.OS, |
| 8 | isLiquidGlassSupported, | 8 | isGlassEffectAPIAvailable(), |
| 9 | ); | 9 | ); |
apps/mobile/src/native/sheet-surface.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { DynamicColorIOS, Platform, type ColorValue, type ViewStyle } from "react-native"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * One opaque surface for content rendered inside a native form sheet. | ||
| 5 | * | ||
| 6 | * UIKit owns the outer sheet material and rounded corners. The presented route | ||
| 7 | * owns this surface so nested navigators never expose a differently colored | ||
| 8 | * native container while their screens move. | ||
| 9 | */ | ||
| 10 | export const NATIVE_SHEET_SURFACE_COLOR: ColorValue | undefined = | ||
| 11 | Platform.OS === "ios" ? DynamicColorIOS({ light: "#f2f2f7", dark: "#0e0e0e" }) : undefined; | ||
| 12 | |||
| 13 | export const NATIVE_SHEET_SURFACE_CONTENT_STYLE: ViewStyle | undefined = | ||
| 14 | NATIVE_SHEET_SURFACE_COLOR === undefined | ||
| 15 | ? undefined | ||
| 16 | : { backgroundColor: NATIVE_SHEET_SURFACE_COLOR }; | ||
| 17 | |||
| 18 | /** | ||
| 19 | * Paint the adaptive background on the presented screen itself. Nested stacks | ||
| 20 | * can stay transparent over this single surface, so a push never exposes an | ||
| 21 | * unpainted form-sheet host behind the moving child view controllers. | ||
| 22 | */ | ||
| 23 | export const FORM_SHEET_PRESENTATION_OPTIONS = { | ||
| 24 | presentation: "formSheet" as const, | ||
| 25 | ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined | ||
| 26 | ? null | ||
| 27 | : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }), | ||
| 28 | }; | ||
apps/mobile/src/persistence/mobile-preferences.ts+6| ... | @@ -35,6 +35,8 @@ export interface Preferences { | ... | @@ -35,6 +35,8 @@ export interface Preferences { |
| 35 | * default flat list — see `resolveThreadListV2Enabled`. | 35 | * default flat list — see `resolveThreadListV2Enabled`. |
| 36 | */ | 36 | */ |
| 37 | readonly legacyThreadListEnabled?: boolean; | 37 | readonly legacyThreadListEnabled?: boolean; |
| 38 | /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ | ||
| 39 | readonly planModeEnabled?: boolean; | ||
| 38 | } | 40 | } |
| 39 | 41 | ||
| 40 | export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()( | 42 | export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()( |
| ... | @@ -88,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -88,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 88 | projectGroupingMode?: SidebarProjectGroupingMode; | 90 | projectGroupingMode?: SidebarProjectGroupingMode; |
| 89 | autoSettleOnMerge?: boolean; | 91 | autoSettleOnMerge?: boolean; |
| 90 | legacyThreadListEnabled?: boolean; | 92 | legacyThreadListEnabled?: boolean; |
| 93 | planModeEnabled?: boolean; | ||
| 91 | } = {}; | 94 | } = {}; |
| 92 | 95 | ||
| 93 | if (typeof parsed.liveActivitiesEnabled === "boolean") { | 96 | if (typeof parsed.liveActivitiesEnabled === "boolean") { |
| ... | @@ -130,6 +133,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -130,6 +133,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 130 | if (typeof parsed.legacyThreadListEnabled === "boolean") { | 133 | if (typeof parsed.legacyThreadListEnabled === "boolean") { |
| 131 | preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; | 134 | preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; |
| 132 | } | 135 | } |
| 136 | if (typeof parsed.planModeEnabled === "boolean") { | ||
| 137 | preferences.planModeEnabled = parsed.planModeEnabled; | ||
| 138 | } | ||
| 133 | return preferences; | 139 | return preferences; |
| 134 | } | 140 | } |
| 135 | 141 |
apps/mobile/src/state/queries.ts+122-4| ... | @@ -1,14 +1,23 @@ | ... | @@ -1,14 +1,23 @@ |
| 1 | import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; | 1 | import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; |
| 2 | import type { | ||
| 3 | EnvironmentId, | ||
| 4 | OrchestrationThread, | ||
| 5 | ThreadId, | ||
| 6 | VcsListRefsResult, | ||
| 7 | VcsRef, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 2 | import { | 9 | import { |
| 3 | createThreadSearchResultsAtomFamily, | 10 | createThreadSearchResultsAtomFamily, |
| 4 | makeThreadSearchKey, | 11 | makeThreadSearchKey, |
| 5 | type EnvironmentThreadSearchMatch, | 12 | type EnvironmentThreadSearchMatch, |
| 6 | } from "@t3tools/client-runtime/state/thread-search"; | 13 | } from "@t3tools/client-runtime/state/thread-search"; |
| 7 | import { useAtomValue } from "@effect/atom-react"; | 14 | import { useAtomValue } from "@effect/atom-react"; |
| 15 | import * as Cause from "effect/Cause"; | ||
| 8 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 9 | import { Atom } from "effect/unstable/reactivity"; | 17 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 10 | import { useEffect, useMemo, useState } from "react"; | 18 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 11 | 19 | ||
| 20 | import { appAtomRegistry } from "./atom-registry"; | ||
| 12 | import { orchestrationEnvironment } from "./orchestration"; | 21 | import { orchestrationEnvironment } from "./orchestration"; |
| 13 | import { projectEnvironment } from "./projects"; | 22 | import { projectEnvironment } from "./projects"; |
| 14 | import { useEnvironmentQuery } from "./query"; | 23 | import { useEnvironmentQuery } from "./query"; |
| ... | @@ -24,6 +33,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; | ... | @@ -24,6 +33,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; |
| 24 | const COMPOSER_PATH_SEARCH_LIMIT = 20; | 33 | const COMPOSER_PATH_SEARCH_LIMIT = 20; |
| 25 | const THREAD_SEARCH_DEBOUNCE_MS = 200; | 34 | const THREAD_SEARCH_DEBOUNCE_MS = 200; |
| 26 | const VCS_REF_LIST_LIMIT = 100; | 35 | const VCS_REF_LIST_LIMIT = 100; |
| 36 | const EMPTY_REFS: ReadonlyArray<VcsRef> = []; | ||
| 37 | const INITIAL_BRANCH_CURSORS = [undefined] as const; | ||
| 27 | const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]); | 38 | const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]); |
| 28 | const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ | 39 | const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ |
| 29 | matches: EMPTY_THREAD_SEARCH_MATCHES, | 40 | matches: EMPTY_THREAD_SEARCH_MATCHES, |
| ... | @@ -52,7 +63,7 @@ export interface ComposerPathSearchTarget { | ... | @@ -52,7 +63,7 @@ export interface ComposerPathSearchTarget { |
| 52 | readonly query: string | null; | 63 | readonly query: string | null; |
| 53 | } | 64 | } |
| 54 | 65 | ||
| 55 | function useDebouncedValue<A>(value: A, delayMs: number): A { | 66 | export function useDebouncedValue<A>(value: A, delayMs: number): A { |
| 56 | const [debounced, setDebounced] = useState(value); | 67 | const [debounced, setDebounced] = useState(value); |
| 57 | 68 | ||
| 58 | useEffect(() => { | 69 | useEffect(() => { |
| ... | @@ -125,6 +136,113 @@ export function useBranches(input: { | ... | @@ -125,6 +136,113 @@ export function useBranches(input: { |
| 125 | ); | 136 | ); |
| 126 | } | 137 | } |
| 127 | 138 | ||
| 139 | export function usePaginatedBranches(target: VcsRefTarget) { | ||
| 140 | const query = target.query?.trim() ?? ""; | ||
| 141 | const targetKey = | ||
| 142 | target.environmentId !== null && target.cwd !== null | ||
| 143 | ? JSON.stringify([target.environmentId, target.cwd, query]) | ||
| 144 | : null; | ||
| 145 | const [pagination, setPagination] = useState<{ | ||
| 146 | readonly targetKey: string | null; | ||
| 147 | readonly cursors: ReadonlyArray<number | undefined>; | ||
| 148 | }>({ | ||
| 149 | targetKey, | ||
| 150 | cursors: INITIAL_BRANCH_CURSORS, | ||
| 151 | }); | ||
| 152 | const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; | ||
| 153 | const pageAtoms = useMemo( | ||
| 154 | () => | ||
| 155 | target.environmentId !== null && target.cwd !== null | ||
| 156 | ? cursors.map((cursor) => | ||
| 157 | vcsEnvironment.listRefs({ | ||
| 158 | environmentId: target.environmentId!, | ||
| 159 | input: { | ||
| 160 | cwd: target.cwd!, | ||
| 161 | ...(query.length > 0 ? { query } : {}), | ||
| 162 | ...(cursor === undefined ? {} : { cursor }), | ||
| 163 | limit: VCS_REF_LIST_LIMIT, | ||
| 164 | }, | ||
| 165 | }), | ||
| 166 | ) | ||
| 167 | : [], | ||
| 168 | [cursors, query, target.cwd, target.environmentId], | ||
| 169 | ); | ||
| 170 | const pagesAtom = useMemo( | ||
| 171 | () => | ||
| 172 | Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( | ||
| 173 | Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), | ||
| 174 | ), | ||
| 175 | [pageAtoms, targetKey], | ||
| 176 | ); | ||
| 177 | const results = useAtomValue(pagesAtom); | ||
| 178 | const values = results.flatMap((result) => { | ||
| 179 | const value = Option.getOrNull(AsyncResult.value(result)); | ||
| 180 | return value === null ? [] : [value]; | ||
| 181 | }); | ||
| 182 | const refs = new Map<string, VcsRef>(); | ||
| 183 | for (const value of values) { | ||
| 184 | for (const ref of value.refs) { | ||
| 185 | refs.set(ref.name, ref); | ||
| 186 | } | ||
| 187 | } | ||
| 188 | const first = values[0] ?? null; | ||
| 189 | const last = values.at(-1) ?? null; | ||
| 190 | const data: VcsListRefsResult | null = | ||
| 191 | first === null || last === null | ||
| 192 | ? null | ||
| 193 | : { | ||
| 194 | refs: [...refs.values()], | ||
| 195 | isRepo: first.isRepo, | ||
| 196 | hasPrimaryRemote: first.hasPrimaryRemote, | ||
| 197 | nextCursor: last.nextCursor, | ||
| 198 | totalCount: Math.max(...values.map((value) => value.totalCount)), | ||
| 199 | }; | ||
| 200 | const lastResult = results.at(-1); | ||
| 201 | const isFetchingNextPage = | ||
| 202 | results.length > 1 && | ||
| 203 | lastResult?.waiting === true && | ||
| 204 | Option.isNone(AsyncResult.value(lastResult)); | ||
| 205 | const failed = results.find((result) => result._tag === "Failure"); | ||
| 206 | const error = | ||
| 207 | failed?._tag === "Failure" | ||
| 208 | ? (() => { | ||
| 209 | const cause = Cause.squash(failed.cause); | ||
| 210 | return cause instanceof Error && cause.message.trim().length > 0 | ||
| 211 | ? cause.message | ||
| 212 | : "Failed to load refs."; | ||
| 213 | })() | ||
| 214 | : null; | ||
| 215 | const refresh = useCallback(() => { | ||
| 216 | const firstPage = pageAtoms[0]; | ||
| 217 | setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); | ||
| 218 | if (firstPage !== undefined) { | ||
| 219 | appAtomRegistry.refresh(firstPage); | ||
| 220 | } | ||
| 221 | }, [pageAtoms, targetKey]); | ||
| 222 | const loadNext = useCallback(() => { | ||
| 223 | if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) { | ||
| 224 | return; | ||
| 225 | } | ||
| 226 | setPagination((current) => { | ||
| 227 | const currentCursors = | ||
| 228 | current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS; | ||
| 229 | return currentCursors.includes(data.nextCursor!) | ||
| 230 | ? { targetKey, cursors: currentCursors } | ||
| 231 | : { targetKey, cursors: [...currentCursors, data.nextCursor!] }; | ||
| 232 | }); | ||
| 233 | }, [data?.nextCursor, targetKey]); | ||
| 234 | |||
| 235 | return { | ||
| 236 | data, | ||
| 237 | refs: data?.refs ?? EMPTY_REFS, | ||
| 238 | error, | ||
| 239 | isPending: results.some((result) => result.waiting), | ||
| 240 | isFetchingNextPage, | ||
| 241 | refresh, | ||
| 242 | loadNext, | ||
| 243 | }; | ||
| 244 | } | ||
| 245 | |||
| 128 | export function useComposerPathSearch(target: ComposerPathSearchTarget) { | 246 | export function useComposerPathSearch(target: ComposerPathSearchTarget) { |
| 129 | const normalizedTarget = useMemo( | 247 | const normalizedTarget = useMemo( |
| 130 | () => ({ | 248 | () => ({ |
apps/mobile/src/state/use-composer-drafts.test.ts+125| ... | @@ -1,10 +1,57 @@ | ... | @@ -1,10 +1,57 @@ |
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; |
| 3 | import { vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | const composerDraftFileMocks = vi.hoisted(() => { | ||
| 6 | let document = ""; | ||
| 7 | let releaseRead: (() => void) | null = null; | ||
| 8 | let readBarrier = Promise.resolve(); | ||
| 9 | |||
| 10 | return { | ||
| 11 | blockRead() { | ||
| 12 | readBarrier = new Promise<void>((resolve) => { | ||
| 13 | releaseRead = resolve; | ||
| 14 | }); | ||
| 15 | }, | ||
| 16 | releaseRead() { | ||
| 17 | releaseRead?.(); | ||
| 18 | releaseRead = null; | ||
| 19 | }, | ||
| 20 | setDocument(value: unknown) { | ||
| 21 | document = JSON.stringify(value); | ||
| 22 | }, | ||
| 23 | Directory: class { | ||
| 24 | create() {} | ||
| 25 | }, | ||
| 26 | File: class { | ||
| 27 | exists = true; | ||
| 28 | |||
| 29 | create() {} | ||
| 30 | |||
| 31 | async text() { | ||
| 32 | await readBarrier; | ||
| 33 | return document; | ||
| 34 | } | ||
| 35 | |||
| 36 | write(value: string) { | ||
| 37 | document = value; | ||
| 38 | } | ||
| 39 | }, | ||
| 40 | }; | ||
| 41 | }); | ||
| 42 | |||
| 43 | vi.mock("expo-file-system", () => ({ | ||
| 44 | Directory: composerDraftFileMocks.Directory, | ||
| 45 | File: composerDraftFileMocks.File, | ||
| 46 | Paths: { document: "/documents" }, | ||
| 47 | })); | ||
| 3 | 48 | ||
| 4 | import { appAtomRegistry } from "./atom-registry"; | 49 | import { appAtomRegistry } from "./atom-registry"; |
| 5 | import { | 50 | import { |
| 6 | clearComposerDraftContentState, | 51 | clearComposerDraftContentState, |
| 7 | composerDraftsAtom, | 52 | composerDraftsAtom, |
| 53 | copyComposerDraftContentIfEmpty, | ||
| 54 | copyComposerDraftContentState, | ||
| 8 | decodePersistedComposerDrafts, | 55 | decodePersistedComposerDrafts, |
| 9 | type ComposerDraft, | 56 | type ComposerDraft, |
| 10 | getComposerDraftSnapshot, | 57 | getComposerDraftSnapshot, |
| ... | @@ -165,6 +212,53 @@ describe("mobile composer drafts", () => { | ... | @@ -165,6 +212,53 @@ describe("mobile composer drafts", () => { |
| 165 | expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); | 212 | expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); |
| 166 | }); | 213 | }); |
| 167 | 214 | ||
| 215 | it("carries unfinished content to a newly selected project without overwriting its settings", () => { | ||
| 216 | const sourceKey = "new-task:environment-1:project-1"; | ||
| 217 | const targetKey = "new-task:environment-1:project-2"; | ||
| 218 | const source: ComposerDraft = { | ||
| 219 | text: "Keep this task", | ||
| 220 | attachments: [], | ||
| 221 | importedShareIds: ["share-1"], | ||
| 222 | workspaceSelection: { | ||
| 223 | mode: "worktree", | ||
| 224 | branch: "feature/source", | ||
| 225 | worktreePath: null, | ||
| 226 | }, | ||
| 227 | }; | ||
| 228 | const target: ComposerDraft = { | ||
| 229 | text: "", | ||
| 230 | attachments: [], | ||
| 231 | runtimeMode: "approval-required", | ||
| 232 | }; | ||
| 233 | |||
| 234 | expect( | ||
| 235 | copyComposerDraftContentState( | ||
| 236 | { [sourceKey]: source, [targetKey]: target }, | ||
| 237 | sourceKey, | ||
| 238 | targetKey, | ||
| 239 | ), | ||
| 240 | ).toEqual({ | ||
| 241 | [sourceKey]: source, | ||
| 242 | [targetKey]: { | ||
| 243 | ...target, | ||
| 244 | text: source.text, | ||
| 245 | attachments: source.attachments, | ||
| 246 | importedShareIds: source.importedShareIds, | ||
| 247 | }, | ||
| 248 | }); | ||
| 249 | }); | ||
| 250 | |||
| 251 | it("does not overwrite unfinished content already stored for the selected project", () => { | ||
| 252 | const sourceKey = "new-task:environment-1:project-1"; | ||
| 253 | const targetKey = "new-task:environment-1:project-2"; | ||
| 254 | const drafts: Record<string, ComposerDraft> = { | ||
| 255 | [sourceKey]: { text: "Source task", attachments: [] }, | ||
| 256 | [targetKey]: { text: "Target task", attachments: [] }, | ||
| 257 | }; | ||
| 258 | |||
| 259 | expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); | ||
| 260 | }); | ||
| 261 | |||
| 168 | it("merges shared content into a project draft without duplicating retries", () => { | 262 | it("merges shared content into a project draft without duplicating retries", () => { |
| 169 | const draftKey = "new-task:environment-1:project-1"; | 263 | const draftKey = "new-task:environment-1:project-1"; |
| 170 | const sharedAttachment = { | 264 | const sharedAttachment = { |
| ... | @@ -268,4 +362,35 @@ describe("mobile composer drafts", () => { | ... | @@ -268,4 +362,35 @@ describe("mobile composer drafts", () => { |
| 268 | [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, | 362 | [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, |
| 269 | }); | 363 | }); |
| 270 | }); | 364 | }); |
| 365 | |||
| 366 | it("waits for persisted drafts before copying content between projects", async () => { | ||
| 367 | const sourceKey = "new-task:environment-1:project-1"; | ||
| 368 | const targetKey = "new-task:environment-1:project-2"; | ||
| 369 | const unrelatedKey = "environment-1:thread-1"; | ||
| 370 | const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; | ||
| 371 | const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; | ||
| 372 | const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; | ||
| 373 | |||
| 374 | composerDraftFileMocks.setDocument({ | ||
| 375 | schemaVersion: 1, | ||
| 376 | drafts: { | ||
| 377 | [targetKey]: target, | ||
| 378 | [unrelatedKey]: unrelated, | ||
| 379 | }, | ||
| 380 | }); | ||
| 381 | composerDraftFileMocks.blockRead(); | ||
| 382 | appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); | ||
| 383 | |||
| 384 | const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); | ||
| 385 | expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); | ||
| 386 | |||
| 387 | composerDraftFileMocks.releaseRead(); | ||
| 388 | await copy; | ||
| 389 | |||
| 390 | expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ | ||
| 391 | [sourceKey]: source, | ||
| 392 | [targetKey]: target, | ||
| 393 | [unrelatedKey]: unrelated, | ||
| 394 | }); | ||
| 395 | }); | ||
| 271 | }); | 396 | }); |
apps/mobile/src/state/use-composer-drafts.ts+50-1| ... | @@ -253,7 +253,11 @@ export function ensureComposerDraftsLoaded(): void { | ... | @@ -253,7 +253,11 @@ export function ensureComposerDraftsLoaded(): void { |
| 253 | function updateComposerDrafts( | 253 | function updateComposerDrafts( |
| 254 | update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, | 254 | update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, |
| 255 | ): void { | 255 | ): void { |
| 256 | const next = update(appAtomRegistry.get(composerDraftsAtom)); | 256 | const current = appAtomRegistry.get(composerDraftsAtom); |
| 257 | const next = update(current); | ||
| 258 | if (next === current) { | ||
| 259 | return; | ||
| 260 | } | ||
| 257 | appAtomRegistry.set(composerDraftsAtom, next); | 261 | appAtomRegistry.set(composerDraftsAtom, next); |
| 258 | schedulePersistComposerDrafts(next); | 262 | schedulePersistComposerDrafts(next); |
| 259 | } | 263 | } |
| ... | @@ -412,6 +416,51 @@ export function restoreComposerDraftSnapshotState( | ... | @@ -412,6 +416,51 @@ export function restoreComposerDraftSnapshotState( |
| 412 | return next; | 416 | return next; |
| 413 | } | 417 | } |
| 414 | 418 | ||
| 419 | export function copyComposerDraftContentState( | ||
| 420 | current: Record<string, ComposerDraft>, | ||
| 421 | sourceDraftKey: string, | ||
| 422 | targetDraftKey: string, | ||
| 423 | ): Record<string, ComposerDraft> { | ||
| 424 | if (sourceDraftKey === targetDraftKey) { | ||
| 425 | return current; | ||
| 426 | } | ||
| 427 | const source = normalizeDraft(current[sourceDraftKey]); | ||
| 428 | const target = normalizeDraft(current[targetDraftKey]); | ||
| 429 | const sourceHasContent = | ||
| 430 | source.text.length > 0 || | ||
| 431 | source.attachments.length > 0 || | ||
| 432 | (source.importedShareIds?.length ?? 0) > 0; | ||
| 433 | const targetHasContent = | ||
| 434 | target.text.length > 0 || | ||
| 435 | target.attachments.length > 0 || | ||
| 436 | (target.importedShareIds?.length ?? 0) > 0; | ||
| 437 | if (!sourceHasContent || targetHasContent) { | ||
| 438 | return current; | ||
| 439 | } | ||
| 440 | return { | ||
| 441 | ...current, | ||
| 442 | [targetDraftKey]: { | ||
| 443 | ...target, | ||
| 444 | text: source.text, | ||
| 445 | attachments: source.attachments, | ||
| 446 | ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), | ||
| 447 | }, | ||
| 448 | }; | ||
| 449 | } | ||
| 450 | |||
| 451 | export async function copyComposerDraftContentIfEmpty( | ||
| 452 | sourceDraftKey: string, | ||
| 453 | targetDraftKey: string, | ||
| 454 | ): Promise<void> { | ||
| 455 | ensureComposerDraftsLoaded(); | ||
| 456 | if (loadPromise !== null) { | ||
| 457 | await loadPromise; | ||
| 458 | } | ||
| 459 | updateComposerDrafts((current) => | ||
| 460 | copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey), | ||
| 461 | ); | ||
| 462 | } | ||
| 463 | |||
| 415 | function mergeComposerDraftText(existing: string, incoming: string): string { | 464 | function mergeComposerDraftText(existing: string, incoming: string): string { |
| 416 | if (incoming.length === 0) { | 465 | if (incoming.length === 0) { |
| 417 | return existing; | 466 | return existing; |
docs/user/permission-modes.md+1-2| ... | @@ -44,5 +44,4 @@ with prompting enabled and a restricted workspace while **Full access** disables | ... | @@ -44,5 +44,4 @@ with prompting enabled and a restricted workspace while **Full access** disables |
| 44 | labels above describe what you get; the exact per-provider translation is internal and may | 44 | labels above describe what you get; the exact per-provider translation is internal and may |
| 45 | change. | 45 | change. |
| 46 | 46 | ||
| 47 | Mobile offers the same four modes. It labels the first one **Approve actions** rather than | 47 | Mobile offers the same four modes with the same labels and descriptions. |
| 48 | **Supervised**. |
patches/@react-navigation%2Fnative-stack@7.17.6.patch+22| ... | @@ -100,3 +100,25 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 | ... | @@ -100,3 +100,25 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 |
| 100 | -//# sourceMappingURL=useHeaderConfigProps.js.map | 100 | -//# sourceMappingURL=useHeaderConfigProps.js.map |
| 101 | \ No newline at end of file | 101 | \ No newline at end of file |
| 102 | +//# sourceMappingURL=useHeaderConfigProps.js.map | 102 | +//# sourceMappingURL=useHeaderConfigProps.js.map |
| 103 | diff --git a/lib/module/views/NativeStackView.native.js b/lib/module/views/NativeStackView.native.js | ||
| 104 | index c342e90..5d3e440 100644 | ||
| 105 | --- a/lib/module/views/NativeStackView.native.js | ||
| 106 | +++ b/lib/module/views/NativeStackView.native.js | ||
| 107 | @@ -370,6 +370,17 @@ export function NativeStackView({ | ||
| 108 | return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { | ||
| 109 | children: /*#__PURE__*/_jsx(ScreenStack, { | ||
| 110 | style: styles.container, | ||
| 111 | + onFinishTransitioning: () => { | ||
| 112 | + // Surface UIKit's transition-completion callback to every route of | ||
| 113 | + // this navigator. Unlike transitionEnd, this also fires when a modal | ||
| 114 | + // finishes dismissing — where the presenting screen below receives no | ||
| 115 | + // appearance callbacks — and for a gesture-driven dismissal it fires | ||
| 116 | + // before the state pop, while the modal route is still the focused | ||
| 117 | + // one, so the event must not be targeted at a single route. | ||
| 118 | + navigation.emit({ | ||
| 119 | + type: 'finishTransitioning' | ||
| 120 | + }); | ||
| 121 | + }, | ||
| 122 | children: state.routes.concat(state.preloadedRoutes).map((route, index) => { | ||
| 123 | const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key]; | ||
| 124 | const isFocused = state.index === index; |
patches/react-native-screens@4.25.2.patch+126-50| ... | @@ -226,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -226,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 226 | 226 | ||
| 227 | // appearance does not apply to the tvOS so we need to use lagacy customization | 227 | // appearance does not apply to the tvOS so we need to use lagacy customization |
| 228 | #if TARGET_OS_TV | 228 | #if TARGET_OS_TV |
| 229 | @@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc | 229 | @@ -637,10 +675,458 @@ + (void)updateViewController:(UIViewController *)vc |
| 230 | // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). | 230 | // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). |
| 231 | // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) | 231 | // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) |
| 232 | navitem.title = config.title; | 232 | navitem.title = config.title; |
| ... | @@ -391,40 +391,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -391,40 +391,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 391 | + ]]; | 391 | + ]]; |
| 392 | + [chromeHostView bringSubviewToFront:toolbarHost]; | 392 | + [chromeHostView bringSubviewToFront:toolbarHost]; |
| 393 | + | 393 | + |
| 394 | + void (^configureKeyboardTracking)(UITextField *) = ^(UITextField *textField) { | ||
| 395 | + BOOL isEditing = textField.isFirstResponder; | ||
| 396 | + keyboardAvoidConstraint.priority = | ||
| 397 | + isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow; | ||
| 398 | + restingBottomConstraint.priority = | ||
| 399 | + isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh; | ||
| 400 | + | ||
| 401 | + __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint; | ||
| 402 | + __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint; | ||
| 403 | + __weak UIView *weakChromeHostView = chromeHostView; | ||
| 404 | + NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin"; | ||
| 405 | + NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end"; | ||
| 406 | + [textField removeActionForIdentifier:beginActionIdentifier forControlEvents:UIControlEventEditingDidBegin]; | ||
| 407 | + [textField removeActionForIdentifier:endActionIdentifier forControlEvents:UIControlEventEditingDidEnd]; | ||
| 408 | + [textField addAction:[UIAction actionWithTitle:@"" | ||
| 409 | + image:nil | ||
| 410 | + identifier:beginActionIdentifier | ||
| 411 | + handler:^(__kindof UIAction *_Nonnull action) { | ||
| 412 | + weakRestingBottomConstraint.priority = UILayoutPriorityDefaultLow; | ||
| 413 | + weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; | ||
| 414 | + [weakChromeHostView setNeedsLayout]; | ||
| 415 | + }] | ||
| 416 | + forControlEvents:UIControlEventEditingDidBegin]; | ||
| 417 | + [textField addAction:[UIAction actionWithTitle:@"" | ||
| 418 | + image:nil | ||
| 419 | + identifier:endActionIdentifier | ||
| 420 | + handler:^(__kindof UIAction *_Nonnull action) { | ||
| 421 | + weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; | ||
| 422 | + weakRestingBottomConstraint.priority = UILayoutPriorityDefaultHigh; | ||
| 423 | + [weakChromeHostView setNeedsLayout]; | ||
| 424 | + }] | ||
| 425 | + forControlEvents:UIControlEventEditingDidEnd]; | ||
| 426 | + }; | ||
| 427 | + | ||
| 428 | + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; | 394 | + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; |
| 429 | + glassEffect.interactive = YES; | 395 | + glassEffect.interactive = YES; |
| 430 | + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; | 396 | + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; |
| ... | @@ -438,9 +404,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -438,9 +404,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 438 | + mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil; | 404 | + mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil; |
| 439 | + CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0; | 405 | + CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0; |
| 440 | + CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0; | 406 | + CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0; |
| 407 | + NSLayoutConstraint *glassLeadingConstraint = | ||
| 408 | + [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset]; | ||
| 409 | + NSLayoutConstraint *glassTrailingConstraint = | ||
| 410 | + [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset]; | ||
| 441 | + [NSLayoutConstraint activateConstraints:@[ | 411 | + [NSLayoutConstraint activateConstraints:@[ |
| 442 | + [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset], | 412 | + glassLeadingConstraint, |
| 443 | + [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset], | 413 | + glassTrailingConstraint, |
| 444 | + [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], | 414 | + [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], |
| 445 | + [glassView.heightAnchor constraintEqualToConstant:toolbarHeight], | 415 | + [glassView.heightAnchor constraintEqualToConstant:toolbarHeight], |
| 446 | + ]]; | 416 | + ]]; |
| ... | @@ -490,6 +460,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -490,6 +460,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 490 | + UISearchBar *searchBar = | 460 | + UISearchBar *searchBar = |
| 491 | + !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil; | 461 | + !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil; |
| 492 | + NSString *placeholder = mailSearchToolbarConfig[@"placeholder"]; | 462 | + NSString *placeholder = mailSearchToolbarConfig[@"placeholder"]; |
| 463 | + UITextField *resolvedSearchTextField = nil; | ||
| 493 | + if (searchBar != nil) { | 464 | + if (searchBar != nil) { |
| 494 | + if (placeholder != nil) { | 465 | + if (placeholder != nil) { |
| 495 | + searchBar.placeholder = placeholder; | 466 | + searchBar.placeholder = placeholder; |
| ... | @@ -506,7 +477,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -506,7 +477,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 506 | + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; | 477 | + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; |
| 507 | + searchBar.searchTextField.textColor = UIColor.labelColor; | 478 | + searchBar.searchTextField.textColor = UIColor.labelColor; |
| 508 | + searchBar.searchTextField.tintColor = UIColor.labelColor; | 479 | + searchBar.searchTextField.tintColor = UIColor.labelColor; |
| 509 | + configureKeyboardTracking(searchBar.searchTextField); | 480 | + resolvedSearchTextField = searchBar.searchTextField; |
| 510 | + if (placeholder != nil) { | 481 | + if (placeholder != nil) { |
| 511 | + searchBar.searchTextField.attributedPlaceholder = | 482 | + searchBar.searchTextField.attributedPlaceholder = |
| 512 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; | 483 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; |
| ... | @@ -539,7 +510,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -539,7 +510,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 539 | + searchField.adjustsFontForContentSizeCategory = YES; | 510 | + searchField.adjustsFontForContentSizeCategory = YES; |
| 540 | + searchField.textColor = UIColor.labelColor; | 511 | + searchField.textColor = UIColor.labelColor; |
| 541 | + searchField.tintColor = UIColor.labelColor; | 512 | + searchField.tintColor = UIColor.labelColor; |
| 542 | + configureKeyboardTracking(searchField); | 513 | + resolvedSearchTextField = searchField; |
| 543 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; | 514 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; |
| 544 | + [glassView.contentView addSubview:searchField]; | 515 | + [glassView.contentView addSubview:searchField]; |
| 545 | + [NSLayoutConstraint activateConstraints:@[ | 516 | + [NSLayoutConstraint activateConstraints:@[ |
| ... | @@ -550,8 +521,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -550,8 +521,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 550 | + ]]; | 521 | + ]]; |
| 551 | + } | 522 | + } |
| 552 | + | 523 | + |
| 524 | + UIButton *filterButton = nil; | ||
| 553 | + if (hasFilterButton) { | 525 | + if (hasFilterButton) { |
| 554 | + UIButton *filterButton = makeGlassButton( | 526 | + filterButton = makeGlassButton( |
| 555 | + mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", | 527 | + mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", |
| 556 | + mailSearchToolbarConfig[@"filterButtonId"], | 528 | + mailSearchToolbarConfig[@"filterButtonId"], |
| 557 | + mailSearchToolbarConfig[@"filterMenu"]); | 529 | + mailSearchToolbarConfig[@"filterMenu"]); |
| ... | @@ -565,8 +537,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -565,8 +537,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 565 | + ]]; | 537 | + ]]; |
| 566 | + } | 538 | + } |
| 567 | + | 539 | + |
| 540 | + UIButton *composeButton = nil; | ||
| 568 | + if (hasComposeButton) { | 541 | + if (hasComposeButton) { |
| 569 | + UIButton *composeButton = makeGlassButton( | 542 | + composeButton = makeGlassButton( |
| 570 | + mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil", | 543 | + mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil", |
| 571 | + mailSearchToolbarConfig[@"composeButtonId"], | 544 | + mailSearchToolbarConfig[@"composeButtonId"], |
| 572 | + mailSearchToolbarConfig[@"composeMenu"]); | 545 | + mailSearchToolbarConfig[@"composeMenu"]); |
| ... | @@ -579,6 +552,107 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -579,6 +552,107 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 579 | + [composeButton.heightAnchor constraintEqualToConstant:buttonSize], | 552 | + [composeButton.heightAnchor constraintEqualToConstant:buttonSize], |
| 580 | + ]]; | 553 | + ]]; |
| 581 | + } | 554 | + } |
| 555 | + | ||
| 556 | + BOOL showsSearchDismissButton = | ||
| 557 | + [mailSearchToolbarConfig[@"showsSearchDismissButton"] boolValue] && resolvedSearchTextField != nil; | ||
| 558 | + UIButton *searchDismissButton = nil; | ||
| 559 | + if (showsSearchDismissButton) { | ||
| 560 | + searchDismissButton = makeGlassButton(@"xmark", nil, nil); | ||
| 561 | + searchDismissButton.accessibilityLabel = @"Dismiss search keyboard"; | ||
| 562 | + searchDismissButton.alpha = 0.0; | ||
| 563 | + searchDismissButton.hidden = YES; | ||
| 564 | + searchDismissButton.translatesAutoresizingMaskIntoConstraints = NO; | ||
| 565 | + [toolbarHost addSubview:searchDismissButton]; | ||
| 566 | + [NSLayoutConstraint activateConstraints:@[ | ||
| 567 | + [searchDismissButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor], | ||
| 568 | + [searchDismissButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], | ||
| 569 | + [searchDismissButton.widthAnchor constraintEqualToConstant:buttonSize], | ||
| 570 | + [searchDismissButton.heightAnchor constraintEqualToConstant:buttonSize], | ||
| 571 | + ]]; | ||
| 572 | + __weak UITextField *weakSearchTextField = resolvedSearchTextField; | ||
| 573 | + [searchDismissButton | ||
| 574 | + addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { | ||
| 575 | + [weakSearchTextField resignFirstResponder]; | ||
| 576 | + }] | ||
| 577 | + forControlEvents:UIControlEventTouchUpInside]; | ||
| 578 | + } | ||
| 579 | + | ||
| 580 | + __weak UIButton *weakFilterButton = filterButton; | ||
| 581 | + __weak UIButton *weakComposeButton = composeButton; | ||
| 582 | + __weak UIButton *weakSearchDismissButton = searchDismissButton; | ||
| 583 | + __weak NSLayoutConstraint *weakGlassLeadingConstraint = glassLeadingConstraint; | ||
| 584 | + __weak NSLayoutConstraint *weakGlassTrailingConstraint = glassTrailingConstraint; | ||
| 585 | + __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint; | ||
| 586 | + __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint; | ||
| 587 | + __weak UIView *weakToolbarHost = toolbarHost; | ||
| 588 | + __weak UITextField *weakSearchTextField = resolvedSearchTextField; | ||
| 589 | + void (^setSearchEditingAppearance)(BOOL, BOOL) = ^(BOOL isEditing, BOOL animated) { | ||
| 590 | + weakKeyboardAvoidConstraint.priority = | ||
| 591 | + isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow; | ||
| 592 | + weakRestingBottomConstraint.priority = | ||
| 593 | + isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh; | ||
| 594 | + | ||
| 595 | + if (showsSearchDismissButton) { | ||
| 596 | + if (isEditing) { | ||
| 597 | + weakSearchDismissButton.hidden = NO; | ||
| 598 | + } else { | ||
| 599 | + weakFilterButton.hidden = NO; | ||
| 600 | + weakComposeButton.hidden = NO; | ||
| 601 | + } | ||
| 602 | + weakGlassLeadingConstraint.constant = isEditing ? 0.0 : glassLeadingInset; | ||
| 603 | + weakGlassTrailingConstraint.constant = isEditing ? -sideButtonReserve : glassTrailingInset; | ||
| 604 | + | ||
| 605 | + void (^changes)(void) = ^{ | ||
| 606 | + weakFilterButton.alpha = isEditing ? 0.0 : 1.0; | ||
| 607 | + weakComposeButton.alpha = isEditing ? 0.0 : 1.0; | ||
| 608 | + weakSearchDismissButton.alpha = isEditing ? 1.0 : 0.0; | ||
| 609 | + [weakToolbarHost layoutIfNeeded]; | ||
| 610 | + }; | ||
| 611 | + void (^completion)(BOOL) = ^(BOOL finished) { | ||
| 612 | + if (!finished || weakSearchTextField.isFirstResponder != isEditing) { | ||
| 613 | + return; | ||
| 614 | + } | ||
| 615 | + weakFilterButton.hidden = isEditing; | ||
| 616 | + weakComposeButton.hidden = isEditing; | ||
| 617 | + weakSearchDismissButton.hidden = !isEditing; | ||
| 618 | + }; | ||
| 619 | + if (animated) { | ||
| 620 | + [UIView animateWithDuration:0.2 | ||
| 621 | + delay:0.0 | ||
| 622 | + options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut | ||
| 623 | + animations:changes | ||
| 624 | + completion:completion]; | ||
| 625 | + } else { | ||
| 626 | + changes(); | ||
| 627 | + completion(YES); | ||
| 628 | + } | ||
| 629 | + } | ||
| 630 | + [weakToolbarHost setNeedsLayout]; | ||
| 631 | + }; | ||
| 632 | + | ||
| 633 | + setSearchEditingAppearance(resolvedSearchTextField.isFirstResponder, NO); | ||
| 634 | + NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin"; | ||
| 635 | + NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end"; | ||
| 636 | + [resolvedSearchTextField removeActionForIdentifier:beginActionIdentifier | ||
| 637 | + forControlEvents:UIControlEventEditingDidBegin]; | ||
| 638 | + [resolvedSearchTextField removeActionForIdentifier:endActionIdentifier | ||
| 639 | + forControlEvents:UIControlEventEditingDidEnd]; | ||
| 640 | + [resolvedSearchTextField | ||
| 641 | + addAction:[UIAction actionWithTitle:@"" | ||
| 642 | + image:nil | ||
| 643 | + identifier:beginActionIdentifier | ||
| 644 | + handler:^(__kindof UIAction *_Nonnull action) { | ||
| 645 | + setSearchEditingAppearance(YES, YES); | ||
| 646 | + }] | ||
| 647 | + forControlEvents:UIControlEventEditingDidBegin]; | ||
| 648 | + [resolvedSearchTextField | ||
| 649 | + addAction:[UIAction actionWithTitle:@"" | ||
| 650 | + image:nil | ||
| 651 | + identifier:endActionIdentifier | ||
| 652 | + handler:^(__kindof UIAction *_Nonnull action) { | ||
| 653 | + setSearchEditingAppearance(NO, YES); | ||
| 654 | + }] | ||
| 655 | + forControlEvents:UIControlEventEditingDidEnd]; | ||
| 582 | + } | 656 | + } |
| 583 | +#endif | 657 | +#endif |
| 584 | + } | 658 | + } |
| ... | @@ -615,7 +689,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -615,7 +689,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 615 | 689 | ||
| 616 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items | 690 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items |
| 617 | // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). | 691 | // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). |
| 618 | @@ -773,6 +1185,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | 692 | @@ -773,6 +1259,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * |
| 619 | 693 | ||
| 620 | - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts | 694 | - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts |
| 621 | withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems | 695 | withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems |
| ... | @@ -623,7 +697,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -623,7 +697,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 623 | { | 697 | { |
| 624 | if (dicts.count == 0) { | 698 | if (dicts.count == 0) { |
| 625 | return currentItems; | 699 | return currentItems; |
| 626 | @@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | 700 | @@ -781,7 +1268,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * |
| 627 | [items addObjectsFromArray:currentItems]; | 701 | [items addObjectsFromArray:currentItems]; |
| 628 | for (NSUInteger i = 0; i < dicts.count; i++) { | 702 | for (NSUInteger i = 0; i < dicts.count; i++) { |
| 629 | NSDictionary *dict = dicts[i]; | 703 | NSDictionary *dict = dicts[i]; |
| ... | @@ -822,7 +896,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -822,7 +896,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 822 | RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict | 896 | RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict |
| 823 | action:^(NSString *buttonId) { | 897 | action:^(NSString *buttonId) { |
| 824 | auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( | 898 | auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( |
| 825 | @@ -803,19 +1406,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | 899 | @@ -803,19 +1480,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * |
| 826 | } | 900 | } |
| 827 | imageLoader:_imageLoader]; | 901 | imageLoader:_imageLoader]; |
| 828 | NSNumber *index = dict[@"index"]; | 902 | NSNumber *index = dict[@"index"]; |
| ... | @@ -852,7 +926,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -852,7 +926,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 852 | [items insertObject:item atIndex:index.integerValue]; | 926 | [items insertObject:item atIndex:index.integerValue]; |
| 853 | } else { | 927 | } else { |
| 854 | [items addObject:item]; | 928 | [items addObject:item]; |
| 855 | @@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | 929 | @@ -825,6 +1506,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * |
| 856 | return items; | 930 | return items; |
| 857 | } | 931 | } |
| 858 | 932 | ||
| ... | @@ -900,7 +974,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -900,7 +974,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 900 | RNS_IGNORE_SUPER_CALL_BEGIN | 974 | RNS_IGNORE_SUPER_CALL_BEGIN |
| 901 | - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex | 975 | - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex |
| 902 | { | 976 | { |
| 903 | @@ -1013,6 +1661,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | 977 | @@ -1013,6 +1735,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: |
| 904 | } | 978 | } |
| 905 | 979 | ||
| 906 | _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); | 980 | _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); |
| ... | @@ -909,7 +983,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -909,7 +983,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 909 | if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { | 983 | if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { |
| 910 | _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); | 984 | _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); |
| 911 | } | 985 | } |
| 912 | @@ -1038,6 +1688,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | 986 | @@ -1038,6 +1762,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: |
| 913 | _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; | 987 | _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; |
| 914 | _backButtonDisplayMode = | 988 | _backButtonDisplayMode = |
| 915 | [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; | 989 | [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; |
| ... | @@ -917,7 +991,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 | ... | @@ -917,7 +991,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 917 | 991 | ||
| 918 | if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { | 992 | if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { |
| 919 | _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; | 993 | _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; |
| 920 | @@ -1084,6 +1735,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | 994 | @@ -1084,6 +1809,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: |
| 921 | _headerRightBarButtonItems = array; | 995 | _headerRightBarButtonItems = array; |
| 922 | } | 996 | } |
| 923 | 997 | ||
| ... | @@ -1313,7 +1387,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db | ... | @@ -1313,7 +1387,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db |
| 1313 | /** | 1387 | /** |
| 1314 | * The tint color to apply to the item. | 1388 | * The tint color to apply to the item. |
| 1315 | * | 1389 | * |
| 1316 | @@ -1145,8 +1193,37 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { | 1390 | @@ -1145,8 +1193,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { |
| 1317 | export interface HeaderBarButtonItemSpacing { | 1391 | export interface HeaderBarButtonItemSpacing { |
| 1318 | type: 'spacing'; | 1392 | type: 'spacing'; |
| 1319 | spacing: number; | 1393 | spacing: number; |
| ... | @@ -1344,6 +1418,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db | ... | @@ -1344,6 +1418,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db |
| 1344 | + onSearchTextChange?: ((text: string) => void) | undefined; | 1418 | + onSearchTextChange?: ((text: string) => void) | undefined; |
| 1345 | + placeholder?: string | undefined; | 1419 | + placeholder?: string | undefined; |
| 1346 | + searchTextChangeId?: string | undefined; | 1420 | + searchTextChangeId?: string | undefined; |
| 1421 | + showsSearchDismissButton?: boolean | undefined; | ||
| 1347 | + useFallbackSearchField?: boolean | undefined; | 1422 | + useFallbackSearchField?: boolean | undefined; |
| 1348 | + width?: number | undefined; | 1423 | + width?: number | undefined; |
| 1349 | } | 1424 | } |
| ... | @@ -1653,7 +1728,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 | ... | @@ -1653,7 +1728,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 |
| 1653 | /** | 1728 | /** |
| 1654 | * The tint color to apply to the item. | 1729 | * The tint color to apply to the item. |
| 1655 | * | 1730 | * |
| 1656 | @@ -1279,11 +1327,46 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { | 1731 | @@ -1279,11 +1327,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { |
| 1657 | export interface HeaderBarButtonItemSpacing { | 1732 | export interface HeaderBarButtonItemSpacing { |
| 1658 | type: 'spacing'; | 1733 | type: 'spacing'; |
| 1659 | spacing: number; | 1734 | spacing: number; |
| ... | @@ -1687,6 +1762,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 | ... | @@ -1687,6 +1762,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 |
| 1687 | + onSearchTextChange?: ((text: string) => void) | undefined; | 1762 | + onSearchTextChange?: ((text: string) => void) | undefined; |
| 1688 | + placeholder?: string | undefined; | 1763 | + placeholder?: string | undefined; |
| 1689 | + searchTextChangeId?: string | undefined; | 1764 | + searchTextChangeId?: string | undefined; |
| 1765 | + showsSearchDismissButton?: boolean | undefined; | ||
| 1690 | + useFallbackSearchField?: boolean | undefined; | 1766 | + useFallbackSearchField?: boolean | undefined; |
| 1691 | + width?: number | undefined; | 1767 | + width?: number | undefined; |
| 1692 | } | 1768 | } |
pnpm-lock.yaml+54-54| ... | @@ -80,13 +80,13 @@ patchedDependencies: | ... | @@ -80,13 +80,13 @@ patchedDependencies: |
| 80 | '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa | 80 | '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa |
| 81 | '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 | 81 | '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 |
| 82 | '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 | 82 | '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 |
| 83 | '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 | 83 | '@react-navigation/native-stack@7.17.6': 0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027 |
| 84 | effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 | 84 | effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 |
| 85 | expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f | 85 | expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f |
| 86 | react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 | 86 | react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 |
| 87 | react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 | 87 | react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 |
| 88 | react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 | 88 | react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 |
| 89 | react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e | 89 | react-native-screens@4.25.2: 59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199 |
| 90 | 90 | ||
| 91 | importers: | 91 | importers: |
| 92 | 92 | ||
| ... | @@ -237,7 +237,7 @@ importers: | ... | @@ -237,7 +237,7 @@ importers: |
| 237 | version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 237 | version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 238 | '@react-navigation/native-stack': | 238 | '@react-navigation/native-stack': |
| 239 | specifier: 7.17.6 | 239 | specifier: 7.17.6 |
| 240 | version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d) | 240 | version: 7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be) |
| 241 | '@shikijs/core': | 241 | '@shikijs/core': |
| 242 | specifier: 4.2.0 | 242 | specifier: 4.2.0 |
| 243 | version: 4.2.0 | 243 | version: 4.2.0 |
| ... | @@ -402,7 +402,7 @@ importers: | ... | @@ -402,7 +402,7 @@ importers: |
| 402 | version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 402 | version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 403 | react-native-screens: | 403 | react-native-screens: |
| 404 | specifier: 4.25.2 | 404 | specifier: 4.25.2 |
| 405 | version: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 405 | version: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 406 | react-native-shiki-engine: | 406 | react-native-shiki-engine: |
| 407 | specifier: ^0.3.12 | 407 | specifier: ^0.3.12 |
| 408 | version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 408 | version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| ... | @@ -12275,7 +12275,7 @@ snapshots: | ... | @@ -12275,7 +12275,7 @@ snapshots: |
| 12275 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 12275 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 12276 | zod: 3.25.76 | 12276 | zod: 3.25.76 |
| 12277 | optionalDependencies: | 12277 | optionalDependencies: |
| 12278 | expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) | 12278 | expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865) |
| 12279 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12279 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12280 | transitivePeerDependencies: | 12280 | transitivePeerDependencies: |
| 12281 | - '@expo/dom-webview' | 12281 | - '@expo/dom-webview' |
| ... | @@ -12351,7 +12351,7 @@ snapshots: | ... | @@ -12351,7 +12351,7 @@ snapshots: |
| 12351 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 12351 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 12352 | zod: 3.25.76 | 12352 | zod: 3.25.76 |
| 12353 | optionalDependencies: | 12353 | optionalDependencies: |
| 12354 | expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) | 12354 | expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797) |
| 12355 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) | 12355 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) |
| 12356 | transitivePeerDependencies: | 12356 | transitivePeerDependencies: |
| 12357 | - '@expo/dom-webview' | 12357 | - '@expo/dom-webview' |
| ... | @@ -12691,7 +12691,7 @@ snapshots: | ... | @@ -12691,7 +12691,7 @@ snapshots: |
| 12691 | react: 19.2.3 | 12691 | react: 19.2.3 |
| 12692 | optionalDependencies: | 12692 | optionalDependencies: |
| 12693 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 12693 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12694 | expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) | 12694 | expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865) |
| 12695 | react-dom: 19.2.3(react@19.2.3) | 12695 | react-dom: 19.2.3(react@19.2.3) |
| 12696 | transitivePeerDependencies: | 12696 | transitivePeerDependencies: |
| 12697 | - supports-color | 12697 | - supports-color |
| ... | @@ -12706,7 +12706,7 @@ snapshots: | ... | @@ -12706,7 +12706,7 @@ snapshots: |
| 12706 | react: 19.2.6 | 12706 | react: 19.2.6 |
| 12707 | optionalDependencies: | 12707 | optionalDependencies: |
| 12708 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 12708 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 12709 | expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) | 12709 | expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797) |
| 12710 | react-dom: 19.2.6(react@19.2.6) | 12710 | react-dom: 19.2.6(react@19.2.6) |
| 12711 | transitivePeerDependencies: | 12711 | transitivePeerDependencies: |
| 12712 | - supports-color | 12712 | - supports-color |
| ... | @@ -14291,7 +14291,7 @@ snapshots: | ... | @@ -14291,7 +14291,7 @@ snapshots: |
| 14291 | optionalDependencies: | 14291 | optionalDependencies: |
| 14292 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 14292 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14293 | 14293 | ||
| 14294 | '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)': | 14294 | '@react-navigation/native-stack@7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be)': |
| 14295 | dependencies: | 14295 | dependencies: |
| 14296 | '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) | 14296 | '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) |
| 14297 | '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 14297 | '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| ... | @@ -14299,7 +14299,7 @@ snapshots: | ... | @@ -14299,7 +14299,7 @@ snapshots: |
| 14299 | react: 19.2.3 | 14299 | react: 19.2.3 |
| 14300 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 14300 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14301 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 14301 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14302 | react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 14302 | react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14303 | sf-symbols-typescript: 2.2.0 | 14303 | sf-symbols-typescript: 2.2.0 |
| 14304 | warn-once: 0.1.1 | 14304 | warn-once: 0.1.1 |
| 14305 | transitivePeerDependencies: | 14305 | transitivePeerDependencies: |
| ... | @@ -17080,47 +17080,47 @@ snapshots: | ... | @@ -17080,47 +17080,47 @@ snapshots: |
| 17080 | - supports-color | 17080 | - supports-color |
| 17081 | - typescript | 17081 | - typescript |
| 17082 | 17082 | ||
| 17083 | expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8): | 17083 | expo-router@56.2.11(80beea6a31a5d2003a696c1401258797): |
| 17084 | dependencies: | 17084 | dependencies: |
| 17085 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17085 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17086 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17086 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17087 | '@expo/schema-utils': 56.0.1 | 17087 | '@expo/schema-utils': 56.0.1 |
| 17088 | '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) | 17088 | '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) |
| 17089 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) | 17089 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) |
| 17090 | '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 17090 | '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 17091 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17091 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17092 | '@testing-library/jest-dom': 6.9.1 | 17092 | '@testing-library/jest-dom': 6.9.1 |
| 17093 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) | 17093 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) |
| 17094 | client-only: 0.0.1 | 17094 | client-only: 0.0.1 |
| 17095 | color: 4.2.3 | 17095 | color: 4.2.3 |
| 17096 | debug: 4.4.3 | 17096 | debug: 4.4.3 |
| 17097 | escape-string-regexp: 4.0.0 | 17097 | escape-string-regexp: 4.0.0 |
| 17098 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 17098 | expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) |
| 17099 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 17099 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) |
| 17100 | expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17100 | expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17101 | expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17101 | expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17102 | expo-server: 56.0.5 | 17102 | expo-server: 56.0.5 |
| 17103 | expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17103 | expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17104 | fast-deep-equal: 3.1.3 | 17104 | fast-deep-equal: 3.1.3 |
| 17105 | invariant: 2.2.4 | 17105 | invariant: 2.2.4 |
| 17106 | nanoid: 3.3.12 | 17106 | nanoid: 3.3.12 |
| 17107 | query-string: 7.1.3 | 17107 | query-string: 7.1.3 |
| 17108 | react: 19.2.3 | 17108 | react: 19.2.6 |
| 17109 | react-fast-compare: 3.2.2 | 17109 | react-fast-compare: 3.2.2 |
| 17110 | react-is: 19.2.7 | 17110 | react-is: 19.2.7 |
| 17111 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 17111 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) |
| 17112 | react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) | 17112 | react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) |
| 17113 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17113 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17114 | react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17114 | react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17115 | server-only: 0.0.1 | 17115 | server-only: 0.0.1 |
| 17116 | sf-symbols-typescript: 2.2.0 | 17116 | sf-symbols-typescript: 2.2.0 |
| 17117 | shallowequal: 1.1.0 | 17117 | shallowequal: 1.1.0 |
| 17118 | standard-navigation: 0.0.5 | 17118 | standard-navigation: 0.0.5 |
| 17119 | vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 17119 | vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 17120 | optionalDependencies: | 17120 | optionalDependencies: |
| 17121 | react-dom: 19.2.3(react@19.2.3) | 17121 | react-dom: 19.2.6(react@19.2.6) |
| 17122 | react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17122 | react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17123 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 17123 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) |
| 17124 | transitivePeerDependencies: | 17124 | transitivePeerDependencies: |
| 17125 | - '@babel/core' | 17125 | - '@babel/core' |
| 17126 | - '@testing-library/dom' | 17126 | - '@testing-library/dom' |
| ... | @@ -17131,47 +17131,47 @@ snapshots: | ... | @@ -17131,47 +17131,47 @@ snapshots: |
| 17131 | - supports-color | 17131 | - supports-color |
| 17132 | optional: true | 17132 | optional: true |
| 17133 | 17133 | ||
| 17134 | expo-router@56.2.11(db5c693a26481047569df6781f34db9f): | 17134 | expo-router@56.2.11(e1497a99e5bc5be76c1cdb733671f865): |
| 17135 | dependencies: | 17135 | dependencies: |
| 17136 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17136 | '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17137 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17137 | '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17138 | '@expo/schema-utils': 56.0.1 | 17138 | '@expo/schema-utils': 56.0.1 |
| 17139 | '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) | 17139 | '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) |
| 17140 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) | 17140 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) |
| 17141 | '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 17141 | '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 17142 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17142 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17143 | '@testing-library/jest-dom': 6.9.1 | 17143 | '@testing-library/jest-dom': 6.9.1 |
| 17144 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) | 17144 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) |
| 17145 | client-only: 0.0.1 | 17145 | client-only: 0.0.1 |
| 17146 | color: 4.2.3 | 17146 | color: 4.2.3 |
| 17147 | debug: 4.4.3 | 17147 | debug: 4.4.3 |
| 17148 | escape-string-regexp: 4.0.0 | 17148 | escape-string-regexp: 4.0.0 |
| 17149 | expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) | 17149 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 17150 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) | 17150 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 17151 | expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17151 | expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17152 | expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17152 | expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17153 | expo-server: 56.0.5 | 17153 | expo-server: 56.0.5 |
| 17154 | expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17154 | expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17155 | fast-deep-equal: 3.1.3 | 17155 | fast-deep-equal: 3.1.3 |
| 17156 | invariant: 2.2.4 | 17156 | invariant: 2.2.4 |
| 17157 | nanoid: 3.3.12 | 17157 | nanoid: 3.3.12 |
| 17158 | query-string: 7.1.3 | 17158 | query-string: 7.1.3 |
| 17159 | react: 19.2.6 | 17159 | react: 19.2.3 |
| 17160 | react-fast-compare: 3.2.2 | 17160 | react-fast-compare: 3.2.2 |
| 17161 | react-is: 19.2.7 | 17161 | react-is: 19.2.7 |
| 17162 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) | 17162 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 17163 | react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) | 17163 | react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) |
| 17164 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17164 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17165 | react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17165 | react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17166 | server-only: 0.0.1 | 17166 | server-only: 0.0.1 |
| 17167 | sf-symbols-typescript: 2.2.0 | 17167 | sf-symbols-typescript: 2.2.0 |
| 17168 | shallowequal: 1.1.0 | 17168 | shallowequal: 1.1.0 |
| 17169 | standard-navigation: 0.0.5 | 17169 | standard-navigation: 0.0.5 |
| 17170 | vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 17170 | vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 17171 | optionalDependencies: | 17171 | optionalDependencies: |
| 17172 | react-dom: 19.2.6(react@19.2.6) | 17172 | react-dom: 19.2.3(react@19.2.3) |
| 17173 | react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17173 | react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17174 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | 17174 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 17175 | transitivePeerDependencies: | 17175 | transitivePeerDependencies: |
| 17176 | - '@babel/core' | 17176 | - '@babel/core' |
| 17177 | - '@testing-library/dom' | 17177 | - '@testing-library/dom' |
| ... | @@ -19977,14 +19977,14 @@ snapshots: | ... | @@ -19977,14 +19977,14 @@ snapshots: |
| 19977 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) | 19977 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) |
| 19978 | optional: true | 19978 | optional: true |
| 19979 | 19979 | ||
| 19980 | react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19980 | react-native-screens@4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 19981 | dependencies: | 19981 | dependencies: |
| 19982 | react: 19.2.3 | 19982 | react: 19.2.3 |
| 19983 | react-freeze: 1.0.4(react@19.2.3) | 19983 | react-freeze: 1.0.4(react@19.2.3) |
| 19984 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19984 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 19985 | warn-once: 0.1.1 | 19985 | warn-once: 0.1.1 |
| 19986 | 19986 | ||
| 19987 | react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): | 19987 | react-native-screens@4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): |
| 19988 | dependencies: | 19988 | dependencies: |
| 19989 | react: 19.2.6 | 19989 | react: 19.2.6 |
| 19990 | react-freeze: 1.0.4(react@19.2.6) | 19990 | react-freeze: 1.0.4(react@19.2.6) |