| 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 | 125 | |
| 126 | 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 | 130 | ```bash |
| 131 | T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \ | |
| 132 | --base-dir <base-dir> \ | |
| 133 | --base-url <mobile-origin> \ | |
| 134 | --ttl 15m \ | |
| 135 | --label agent-mobile-<short-device-id> | |
| 131 | .agents/skills/test-t3-mobile/scripts/pair-client.sh \ | |
| 132 | ios <simulator-udid> <server-port> <base-dir> | |
| 133 | ||
| 134 | .agents/skills/test-t3-mobile/scripts/pair-client.sh \ | |
| 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 | |
| 143 | xcrun simctl openurl <simulator-udid> 't3code-dev://connections/new' | |
| 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 | |
| 142 | ```text | |
| 143 | t3code-dev://connections/new?pairingUrl=<encoded-pairing-url>&autoConnect=1 | |
| 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 | 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 | 181 | - **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. |
| 184 | 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 | 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 | 186 | - **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. |
| 187 | 187 | - **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. |
| 188 | 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 | 11 | type NativeStackNavigationOptions, |
| 12 | 12 | } from "@react-navigation/native-stack"; |
| 13 | 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 | 15 | import { useResolveClassNames } from "uniwind"; |
| 16 | 16 | |
| 17 | 17 | import { AppText as Text } from "./components/AppText"; |
| ... | ... | @@ -39,6 +39,15 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; |
| 39 | 39 | import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; |
| 40 | 40 | import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; |
| 41 | 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 | 51 | import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; |
| 43 | 52 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; |
| 44 | 53 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; |
| ... | ... | @@ -62,17 +71,15 @@ import { |
| 62 | 71 | } from "./features/sharing/incoming-share-presentation"; |
| 63 | 72 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; |
| 64 | 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 | 79 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; |
| 66 | 80 | |
| 67 | 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 | 83 | type AppScreenOptions = NativeStackNavigationOptions & { |
| 77 | 84 | readonly unstable_navigationItemStyle?: "editor"; |
| 78 | 85 | }; |
| ... | ... | @@ -91,8 +98,8 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { |
| 91 | 98 | headerShown: true, |
| 92 | 99 | headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED |
| 93 | 100 | ? { backgroundColor: "transparent" } |
| 94 | : SHEET_BACKGROUND_COLOR !== undefined | |
| 95 | ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } | |
| 101 | : NATIVE_SHEET_SURFACE_COLOR !== undefined | |
| 102 | ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } | |
| 96 | 103 | : undefined, |
| 97 | 104 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, |
| 98 | 105 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, |
| ... | ... | @@ -109,10 +116,10 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = { |
| 109 | 116 | headerShadowVisible: false, |
| 110 | 117 | headerShown: true, |
| 111 | 118 | headerStyle: |
| 112 | SHEET_BACKGROUND_COLOR !== undefined | |
| 119 | NATIVE_SHEET_SURFACE_COLOR !== undefined | |
| 113 | 120 | ? // native-stack types this as `string`, but the native side accepts any |
| 114 | 121 | // ColorValue including DynamicColorIOS. |
| 115 | { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } | |
| 122 | { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } | |
| 116 | 123 | : undefined, |
| 117 | 124 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, |
| 118 | 125 | headerTransparent: false, |
| ... | ... | @@ -125,6 +132,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { |
| 125 | 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 | 143 | const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { |
| 129 | 144 | ...SHEET_SOLID_HEADER_OPTIONS, |
| 130 | 145 | headerBackVisible: false, |
| ... | ... | @@ -238,9 +253,16 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; |
| 238 | 253 | const NewTaskSheetStack = createNativeStackNavigator({ |
| 239 | 254 | initialRouteName: "NewTask", |
| 240 | 255 | screenOptions: { |
| 241 | ...GLASS_HEADER_OPTIONS, | |
| 242 | // Sheets read better with the iOS-default centered title (no editor style). | |
| 243 | unstable_navigationItemStyle: undefined, | |
| 256 | ...SHEET_GLASS_HEADER_OPTIONS, | |
| 257 | // The form-sheet host owns the one opaque adaptive surface. Child screens | |
| 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 | 267 | screens: { |
| 246 | 268 | NewTask: createNativeStackScreen({ |
| ... | ... | @@ -253,9 +275,39 @@ const NewTaskSheetStack = createNativeStackNavigator({ |
| 253 | 275 | NewTaskDraft: createNativeStackScreen({ |
| 254 | 276 | screen: NewTaskDraftRouteScreen, |
| 255 | 277 | linking: "draft", |
| 256 | // The draft composer has no scroll view for glass to sample; a solid | |
| 257 | // header also lays the content out below the bar (no manual inset). | |
| 258 | options: SHEET_SOLID_HEADER_OPTIONS, | |
| 278 | options: { | |
| 279 | headerBackVisible: false, | |
| 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 | 312 | AddProject: createNativeStackScreen({ |
| 261 | 313 | screen: AddProjectSourceRoute, |
| ... | ... | @@ -294,6 +346,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ |
| 294 | 346 | "SettingsLegal", |
| 295 | 347 | "SettingsSheet", |
| 296 | 348 | "ThreadReviewComment", |
| 349 | "ThreadSettingsSheet", | |
| 297 | 350 | ]); |
| 298 | 351 | |
| 299 | 352 | /** |
| ... | ... | @@ -356,9 +409,11 @@ function RootStackLayout(props: { |
| 356 | 409 | <HardwareKeyboardCommandProvider pathname={pathname}> |
| 357 | 410 | <ThreadOutboxDrainWorker /> |
| 358 | 411 | <ShowcaseCaptureCoordinator pathname={pathname} /> |
| 359 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> | |
| 360 | {props.children} | |
| 361 | </AdaptiveWorkspaceLayout> | |
| 412 | <ExistingThreadSettingsRouteProvider> | |
| 413 | <AdaptiveWorkspaceLayout pathname={workspacePathname}> | |
| 414 | {props.children} | |
| 415 | </AdaptiveWorkspaceLayout> | |
| 416 | </ExistingThreadSettingsRouteProvider> | |
| 362 | 417 | </HardwareKeyboardCommandProvider> |
| 363 | 418 | ); |
| 364 | 419 | } |
| ... | ... | @@ -440,7 +495,9 @@ export const RootStack = createNativeStackNavigator({ |
| 440 | 495 | options: { |
| 441 | 496 | // Android cannot host the keyboard-driven comment composer inside a |
| 442 | 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 | 501 | sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], |
| 445 | 502 | sheetGrabberVisible: Platform.OS !== "android", |
| 446 | 503 | }, |
| ... | ... | @@ -450,10 +507,7 @@ export const RootStack = createNativeStackNavigator({ |
| 450 | 507 | linking: `${THREAD_LINKING_PREFIX}/files`, |
| 451 | 508 | options: { |
| 452 | 509 | ...GLASS_HEADER_OPTIONS, |
| 453 | contentStyle: | |
| 454 | SHEET_BACKGROUND_COLOR !== undefined | |
| 455 | ? { backgroundColor: SHEET_BACKGROUND_COLOR } | |
| 456 | : undefined, | |
| 510 | contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE, | |
| 457 | 511 | title: "Files", |
| 458 | 512 | }, |
| 459 | 513 | }), |
| ... | ... | @@ -462,11 +516,25 @@ export const RootStack = createNativeStackNavigator({ |
| 462 | 516 | linking: `${THREAD_LINKING_PREFIX}/files/:path*`, |
| 463 | 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 | 533 | GitOverview: createNativeStackScreen({ |
| 466 | 534 | screen: GitOverviewSheet, |
| 467 | 535 | linking: `${THREAD_LINKING_PREFIX}/git`, |
| 468 | 536 | options: { |
| 469 | presentation: "formSheet", | |
| 537 | ...FORM_SHEET_PRESENTATION_OPTIONS, | |
| 470 | 538 | sheetAllowedDetents: [0.55, 0.92], |
| 471 | 539 | sheetGrabberVisible: true, |
| 472 | 540 | }, |
| ... | ... | @@ -475,7 +543,7 @@ export const RootStack = createNativeStackNavigator({ |
| 475 | 543 | screen: GitCommitSheet, |
| 476 | 544 | linking: `${THREAD_LINKING_PREFIX}/git/commit`, |
| 477 | 545 | options: { |
| 478 | presentation: "formSheet", | |
| 546 | ...FORM_SHEET_PRESENTATION_OPTIONS, | |
| 479 | 547 | sheetAllowedDetents: [0.55, 0.92], |
| 480 | 548 | sheetGrabberVisible: true, |
| 481 | 549 | }, |
| ... | ... | @@ -484,7 +552,7 @@ export const RootStack = createNativeStackNavigator({ |
| 484 | 552 | screen: GitBranchesSheet, |
| 485 | 553 | linking: `${THREAD_LINKING_PREFIX}/git/branches`, |
| 486 | 554 | options: { |
| 487 | presentation: "formSheet", | |
| 555 | ...FORM_SHEET_PRESENTATION_OPTIONS, | |
| 488 | 556 | sheetAllowedDetents: [0.55, 0.92], |
| 489 | 557 | sheetGrabberVisible: true, |
| 490 | 558 | }, |
| ... | ... | @@ -493,7 +561,7 @@ export const RootStack = createNativeStackNavigator({ |
| 493 | 561 | screen: GitConfirmSheet, |
| 494 | 562 | linking: `${THREAD_LINKING_PREFIX}/git-confirm`, |
| 495 | 563 | options: { |
| 496 | presentation: "formSheet", | |
| 564 | ...FORM_SHEET_PRESENTATION_OPTIONS, | |
| 497 | 565 | sheetAllowedDetents: [0.45, 0.7], |
| 498 | 566 | sheetGrabberVisible: true, |
| 499 | 567 | }, |
| ... | ... | @@ -509,7 +577,7 @@ export const RootStack = createNativeStackNavigator({ |
| 509 | 577 | ...(Platform.OS === "android" |
| 510 | 578 | ? { presentation: "card" as const } |
| 511 | 579 | : { |
| 512 | presentation: "formSheet" as const, | |
| 580 | ...FORM_SHEET_PRESENTATION_OPTIONS, | |
| 513 | 581 | sheetAllowedDetents: [0.7, 0.92], |
| 514 | 582 | sheetGrabberVisible: true, |
| 515 | 583 | }), |
| ... | ... | @@ -532,7 +600,7 @@ export const RootStack = createNativeStackNavigator({ |
| 532 | 600 | ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), |
| 533 | 601 | title: "Set up T3 Connect", |
| 534 | 602 | gestureEnabled: true, |
| 535 | presentation: "formSheet", | |
| 603 | ...FORM_SHEET_PRESENTATION_OPTIONS, | |
| 536 | 604 | sheetAllowedDetents: [0.6, 0.95], |
| 537 | 605 | sheetGrabberVisible: true, |
| 538 | 606 | }, |
| ... | ... | @@ -547,7 +615,7 @@ export const RootStack = createNativeStackNavigator({ |
| 547 | 615 | ...(Platform.OS === "android" |
| 548 | 616 | ? { presentation: "card" as const, headerShown: false } |
| 549 | 617 | : { |
| 550 | presentation: "formSheet" as const, | |
| 618 | ...FORM_SHEET_PRESENTATION_OPTIONS, | |
| 551 | 619 | sheetAllowedDetents: [0.55, 0.7], |
| 552 | 620 | sheetGrabberVisible: true, |
| 553 | 621 | }), |
| ... | ... | @@ -557,7 +625,7 @@ export const RootStack = createNativeStackNavigator({ |
| 557 | 625 | screen: ConnectionsNewRouteScreen, |
| 558 | 626 | linking: "connections/new", |
| 559 | 627 | options: { |
| 560 | presentation: "formSheet", | |
| 628 | ...FORM_SHEET_PRESENTATION_OPTIONS, | |
| 561 | 629 | sheetAllowedDetents: [0.55, 0.7], |
| 562 | 630 | sheetGrabberVisible: true, |
| 563 | 631 | }, |
| ... | ... | @@ -577,7 +645,7 @@ export const RootStack = createNativeStackNavigator({ |
| 577 | 645 | ...(Platform.OS === "android" |
| 578 | 646 | ? { presentation: "card" as const } |
| 579 | 647 | : { |
| 580 | presentation: "formSheet" as const, | |
| 648 | ...FORM_SHEET_PRESENTATION_OPTIONS, | |
| 581 | 649 | sheetAllowedDetents: [0.92], |
| 582 | 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 | 5 | useColorScheme, |
| 6 | 6 | View, |
| 7 | 7 | type ColorValue, |
| 8 | type StyleProp, | |
| 8 | 9 | type ViewProps, |
| 9 | 10 | type ViewStyle, |
| 10 | 11 | } from "react-native"; |
| 11 | 12 | import { useThemeColor } from "../lib/useThemeColor"; |
| 12 | 13 | |
| 13 | export interface GlassSurfaceProps extends Omit<ViewProps, "className"> { | |
| 14 | interface GlassSurfaceProps extends Omit<ViewProps, "className"> { | |
| 14 | 15 | readonly children: ReactNode; |
| 15 | 16 | readonly glassEffectStyle?: "clear" | "regular" | "none"; |
| 16 | 17 | readonly tintColor?: ColorValue; |
| 17 | 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 | 23 | export function GlassSurface({ |
| ... | ... | @@ -22,6 +25,7 @@ export function GlassSurface({ |
| 22 | 25 | glassEffectStyle = "regular", |
| 23 | 26 | chrome = "default", |
| 24 | 27 | tintColor, |
| 28 | fallbackStyle, | |
| 25 | 29 | style, |
| 26 | 30 | ...props |
| 27 | 31 | }: GlassSurfaceProps) { |
| ... | ... | @@ -67,7 +71,7 @@ export function GlassSurface({ |
| 67 | 71 | } |
| 68 | 72 | |
| 69 | 73 | return ( |
| 70 | <View {...props} style={[surfaceStyle, style]}> | |
| 74 | <View {...props} style={[surfaceStyle, fallbackStyle, style]}> | |
| 71 | 75 | {children} |
| 72 | 76 | </View> |
| 73 | 77 | ); |
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+51-16| ... | ... | @@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera"; |
| 2 | 2 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 3 | 3 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 4 | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | import { useCallback, useEffect, useState } from "react"; | |
| 5 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 6 | 6 | import { Alert, Platform, ScrollView, View } from "react-native"; |
| 7 | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 8 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | ... | @@ -11,12 +11,13 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 11 | 11 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 12 | 12 | import { ErrorBanner } from "../../components/ErrorBanner"; |
| 13 | 13 | import { ConnectionSheetButton } from "./ConnectionSheetButton"; |
| 14 | import { extractPairingUrlFromQrPayload } from "./pairing"; | |
| 14 | import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; | |
| 15 | 15 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 16 | import { buildPairingUrl, parsePairingUrl } from "./pairing"; | |
| 17 | 16 | |
| 18 | 17 | type ConnectionsNewRouteParams = { |
| 19 | 18 | readonly mode?: string; |
| 19 | readonly pairingUrl?: string; | |
| 20 | readonly autoConnect?: string; | |
| 20 | 21 | }; |
| 21 | 22 | |
| 22 | 23 | export function ConnectionsNewRouteScreen({ |
| ... | ... | @@ -30,6 +31,13 @@ export function ConnectionsNewRouteScreen({ |
| 30 | 31 | } = useRemoteConnections(); |
| 31 | 32 | const navigation = useNavigation(); |
| 32 | 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 | 41 | const insets = useSafeAreaInsets(); |
| 34 | 42 | const [hostInput, setHostInput] = useState(""); |
| 35 | 43 | const [codeInput, setCodeInput] = useState(""); |
| ... | ... | @@ -37,6 +45,7 @@ export function ConnectionsNewRouteScreen({ |
| 37 | 45 | const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); |
| 38 | 46 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); |
| 39 | 47 | const [scannerLocked, setScannerLocked] = useState(false); |
| 48 | const attemptedAutoConnectRef = useRef<string | null>(null); | |
| 40 | 49 | |
| 41 | 50 | const headerIconColor = useThemeColor("--color-icon"); |
| 42 | 51 | |
| ... | ... | @@ -48,6 +57,16 @@ export function ConnectionsNewRouteScreen({ |
| 48 | 57 | setCodeInput(code); |
| 49 | 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 | 70 | useEffect(() => { |
| 52 | 71 | if (pairingConnectionError) { |
| 53 | 72 | setIsSubmitting(false); |
| ... | ... | @@ -116,22 +135,38 @@ export function ConnectionsNewRouteScreen({ |
| 116 | 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 | 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); | |
| 123 | onChangeConnectionPairingUrl(pairingUrl); | |
| 124 | const result = await onConnectPress(pairingUrl); | |
| 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); | |
| 162 | useEffect(() => { | |
| 163 | if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) { | |
| 164 | return; | |
| 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 | 171 | return ( |
| 137 | 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 | 356 | onSearchTextChange: props.onSearchQueryChange, |
| 357 | 357 | placeholder: "Search", |
| 358 | 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 | 11 | */ |
| 12 | 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 | 17 | type NativeMailSearchToolbarInput = Omit< |
| 15 | 18 | HeaderBarButtonMailSearchToolbarItem, |
| 16 | 19 | "type" | "useFallbackSearchField" |
apps/mobile/src/features/projects/AddProjectScreen.tsx+41-29| ... | ... | @@ -27,7 +27,7 @@ import { |
| 27 | 27 | inferProjectTitleFromPath, |
| 28 | 28 | } from "@t3tools/client-runtime/state/projects"; |
| 29 | 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 | 31 | import { SymbolView } from "../../components/AppSymbol"; |
| 32 | 32 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 33 | 33 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; |
| ... | ... | @@ -402,13 +402,12 @@ function SourceControlRow(props: { |
| 402 | 402 | icon={icon} |
| 403 | 403 | isFirst={props.isFirst} |
| 404 | 404 | onPress={() => |
| 405 | navigation.navigate("NewTaskSheet", { | |
| 406 | screen: "AddProjectRepository", | |
| 407 | params: { | |
| 405 | navigation.dispatch( | |
| 406 | StackActions.push("AddProjectRepository", { | |
| 408 | 407 | environmentId: props.selectedEnvironmentId, |
| 409 | 408 | source: props.source, |
| 410 | }, | |
| 411 | }) | |
| 409 | }), | |
| 410 | ) | |
| 412 | 411 | } |
| 413 | 412 | /> |
| 414 | 413 | ); |
| ... | ... | @@ -498,12 +497,11 @@ export function AddProjectSourceScreen() { |
| 498 | 497 | } |
| 499 | 498 | isFirst |
| 500 | 499 | onPress={() => |
| 501 | navigation.navigate("NewTaskSheet", { | |
| 502 | screen: "AddProjectLocal", | |
| 503 | params: { | |
| 500 | navigation.dispatch( | |
| 501 | StackActions.push("AddProjectLocal", { | |
| 504 | 502 | environmentId: selectedEnvironment.environmentId, |
| 505 | }, | |
| 506 | }) | |
| 503 | }), | |
| 504 | ) | |
| 507 | 505 | } |
| 508 | 506 | /> |
| 509 | 507 | {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( |
| ... | ... | @@ -547,10 +545,18 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 547 | 545 | if (existing) { |
| 548 | 546 | Alert.alert("Project already exists", existing.title); |
| 549 | 547 | navigation.dispatch( |
| 550 | StackActions.replace("NewTaskDraft", { | |
| 551 | environmentId: existing.environmentId, | |
| 552 | projectId: existing.id, | |
| 553 | title: existing.title, | |
| 548 | CommonActions.reset({ | |
| 549 | index: 0, | |
| 550 | routes: [ | |
| 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 | 562 | return; |
| ... | ... | @@ -571,10 +577,18 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 571 | 577 | return result; |
| 572 | 578 | } |
| 573 | 579 | navigation.dispatch( |
| 574 | StackActions.replace("NewTaskDraft", { | |
| 575 | environmentId: environment.environmentId, | |
| 576 | projectId, | |
| 577 | title: inferProjectTitleFromPath(workspaceRoot), | |
| 580 | CommonActions.reset({ | |
| 581 | index: 0, | |
| 582 | routes: [ | |
| 583 | { | |
| 584 | name: "NewTaskDraft", | |
| 585 | params: { | |
| 586 | environmentId: environment.environmentId, | |
| 587 | projectId, | |
| 588 | title: inferProjectTitleFromPath(workspaceRoot), | |
| 589 | }, | |
| 590 | }, | |
| 591 | ], | |
| 578 | 592 | }), |
| 579 | 593 | ); |
| 580 | 594 | return result; |
| ... | ... | @@ -612,15 +626,14 @@ export function AddProjectRepositoryScreen(props: { |
| 612 | 626 | const provider = addProjectRemoteSourceProvider(source); |
| 613 | 627 | if (!provider) { |
| 614 | 628 | const remoteUrl = repositoryInput.trim(); |
| 615 | navigation.navigate("NewTaskSheet", { | |
| 616 | screen: "AddProjectDestination", | |
| 617 | params: { | |
| 629 | navigation.dispatch( | |
| 630 | StackActions.push("AddProjectDestination", { | |
| 618 | 631 | environmentId: environment.environmentId, |
| 619 | 632 | source, |
| 620 | 633 | remoteUrl, |
| 621 | 634 | repositoryTitle: remoteUrl, |
| 622 | }, | |
| 623 | }); | |
| 635 | }), | |
| 636 | ); | |
| 624 | 637 | setIsSubmitting(false); |
| 625 | 638 | return; |
| 626 | 639 | } |
| ... | ... | @@ -636,15 +649,14 @@ export function AddProjectRepositoryScreen(props: { |
| 636 | 649 | setError(errorMessage(Cause.squash(result.cause))); |
| 637 | 650 | } else { |
| 638 | 651 | const repository = result.value; |
| 639 | navigation.navigate("NewTaskSheet", { | |
| 640 | screen: "AddProjectDestination", | |
| 641 | params: { | |
| 652 | navigation.dispatch( | |
| 653 | StackActions.push("AddProjectDestination", { | |
| 642 | 654 | environmentId: environment.environmentId, |
| 643 | 655 | source, |
| 644 | 656 | remoteUrl: repository.sshUrl, |
| 645 | 657 | repositoryTitle: repository.nameWithOwner, |
| 646 | }, | |
| 647 | }); | |
| 658 | }), | |
| 659 | ); | |
| 648 | 660 | } |
| 649 | 661 | setIsSubmitting(false); |
| 650 | 662 | }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+11-2| ... | ... | @@ -549,7 +549,10 @@ function GeneralSettingsSection() { |
| 549 | 549 | */ |
| 550 | 550 | function LegacySettingsSection() { |
| 551 | 551 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 552 | const preferences = useAtomValue(mobilePreferencesAtom); | |
| 552 | 553 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 554 | const planModeEnabled = | |
| 555 | AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; | |
| 553 | 556 | |
| 554 | 557 | return ( |
| 555 | 558 | <View className="gap-3"> |
| ... | ... | @@ -560,10 +563,16 @@ function LegacySettingsSection() { |
| 560 | 563 | value={!threadListV2Enabled} |
| 561 | 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 | 572 | </SettingsSection> |
| 564 | 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: | |
| 566 | active work renders as cards; settled threads collapse to compact rows. | |
| 574 | Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan | |
| 575 | control; otherwise every task runs in Build mode. | |
| 567 | 576 | </Text> |
| 568 | 577 | </View> |
| 569 | 578 | ); |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+1-1| ... | ... | @@ -18,7 +18,7 @@ import { |
| 18 | 18 | ComposerToolbarButton, |
| 19 | 19 | ComposerToolbarRow, |
| 20 | 20 | ComposerToolbarScroller, |
| 21 | } from "../../components/ComposerToolbarTrigger"; | |
| 21 | } from "../../components/ComposerToolbar"; | |
| 22 | 22 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 23 | 23 | import { EmptyState } from "../../components/EmptyState"; |
| 24 | 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 | import { 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"; | |
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 5 | 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 | 12 | KeyboardStickyView, |
| 8 | 13 | useKeyboardState, |
| 9 | 14 | } from "react-native-keyboard-controller"; |
| ... | ... | @@ -11,7 +16,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 11 | 16 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 12 | 17 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 13 | 18 | |
| 14 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 15 | 19 | import { |
| 16 | 20 | isAtomCommandInterrupted, |
| 17 | 21 | squashAtomCommandFailure, |
| ... | ... | @@ -19,22 +23,24 @@ import { |
| 19 | 23 | |
| 20 | 24 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 21 | 25 | import { |
| 26 | ComposerInlineControl, | |
| 22 | 27 | ComposerToolbarButton, |
| 23 | 28 | ComposerToolbarRow, |
| 24 | 29 | ComposerToolbarScroller, |
| 25 | ComposerToolbarTrigger, | |
| 26 | } from "../../components/ComposerToolbarTrigger"; | |
| 30 | } from "../../components/ComposerToolbar"; | |
| 27 | 31 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 28 | 32 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 29 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | |
| 30 | 33 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 34 | import { SymbolView } from "../../components/AppSymbol"; | |
| 35 | import { AppText as Text } from "../../components/AppText"; | |
| 31 | 36 | import { ComposerSurface } from "./ThreadComposer"; |
| 32 | import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; | |
| 33 | import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; | |
| 37 | import { | |
| 38 | useThreadSettingsSheetPresentation, | |
| 39 | type NavigationWithFinishTransitioning, | |
| 40 | } from "./use-thread-settings-sheet-presentation"; | |
| 34 | 41 | |
| 35 | 42 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 36 | 43 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 37 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | |
| 38 | 44 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 39 | 45 | import { |
| 40 | 46 | clearComposerDraftContent, |
| ... | ... | @@ -49,21 +55,33 @@ import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; |
| 49 | 55 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 50 | 56 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; |
| 51 | 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 | 59 | import { useCreateProjectThread } from "./use-project-actions"; |
| 54 | 60 | import { resolveDraftProjectSelection } from "./new-task-project-selection"; |
| 61 | import { | |
| 62 | resolveNewTaskBranchLabel, | |
| 63 | resolveNewTaskWorkspaceLabel, | |
| 64 | } from "./new-task-context-presentation"; | |
| 55 | 65 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; |
| 56 | 66 | |
| 57 | function formatWorkspaceLabel(input: { | |
| 58 | readonly workspaceMode: string; | |
| 59 | readonly currentBranchName: string | null; | |
| 60 | readonly selectedBranchName: string | null; | |
| 61 | }): string { | |
| 62 | const branchName = input.selectedBranchName ?? input.currentBranchName; | |
| 63 | if (input.workspaceMode === "worktree") { | |
| 64 | return branchName ? `New worktree · ${branchName}` : "New worktree"; | |
| 67 | function NewTaskWorkspaceIcon(props: { | |
| 68 | readonly workspaceMode: "local" | "worktree"; | |
| 69 | readonly worktreePath: string | null; | |
| 70 | }) { | |
| 71 | const iconColor = useThemeColor("--color-icon-muted"); | |
| 72 | ||
| 73 | if (props.workspaceMode === "local" && props.worktreePath === null) { | |
| 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 | 87 | export function NewTaskDraftScreen(props: { |
| ... | ... | @@ -90,7 +108,8 @@ export function NewTaskDraftScreen(props: { |
| 90 | 108 | const insets = useSafeAreaInsets(); |
| 91 | 109 | const colorScheme = useColorScheme(); |
| 92 | 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 | 113 | const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; |
| 95 | 114 | const { connectedEnvironments } = useRemoteConnectionStatus(); |
| 96 | 115 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( |
| ... | ... | @@ -108,6 +127,49 @@ export function NewTaskDraftScreen(props: { |
| 108 | 127 | editorRef: promptInputRef, |
| 109 | 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 | 173 | const [importingShareKey, setImportingShareKey] = useState<string | null>(null); |
| 112 | 174 | const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); |
| 113 | 175 | const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null); |
| ... | ... | @@ -227,9 +289,9 @@ export function NewTaskDraftScreen(props: { |
| 227 | 289 | }, [props.pendingTaskId, cancelEditingPendingTask]); |
| 228 | 290 | |
| 229 | 291 | const foregroundColor = useThemeColor("--color-foreground"); |
| 292 | const projectUnderlineColor = useThemeColor("--color-foreground-muted"); | |
| 230 | 293 | const regularFontFamily = useFontFamily("regular"); |
| 231 | 294 | const bodyText = useScaledTextRole("body"); |
| 232 | const headlineText = useScaledTextRole("headline"); | |
| 233 | 295 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; |
| 234 | 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 | 377 | return; |
| 316 | 378 | } |
| 317 | 379 | loadedBranchesProjectKeyRef.current = projectKey; |
| 318 | void flow.loadBranches(); | |
| 380 | flow.loadBranches(); | |
| 319 | 381 | }, [flow.loadBranches, selectedProject]); |
| 320 | 382 | |
| 321 | 383 | useEffect(() => { |
| ... | ... | @@ -517,121 +579,6 @@ export function NewTaskDraftScreen(props: { |
| 517 | 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 | 582 | const selectedEnvironmentLabel = |
| 636 | 583 | flow.environments.find( |
| 637 | 584 | (environment) => environment.environmentId === flow.selectedEnvironmentId, |
| ... | ... | @@ -640,50 +587,17 @@ export function NewTaskDraftScreen(props: { |
| 640 | 587 | flow.availableBranches.find((branch) => branch.current)?.name ?? |
| 641 | 588 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? |
| 642 | 589 | null; |
| 643 | const settingsSummaryLabel = threadSettingsSummaryLabel({ | |
| 644 | modelLabel: flow.selectedModelOption?.label ?? "Model", | |
| 645 | optionDescriptors: providerOptionDescriptors, | |
| 646 | runtimeMode: flow.runtimeMode, | |
| 647 | interactionMode: flow.interactionMode, | |
| 590 | const selectedBranchName = flow.selectedBranchName ?? currentBranchName; | |
| 591 | const selectedBranchLabel = resolveNewTaskBranchLabel({ | |
| 592 | branchName: selectedBranchName, | |
| 593 | startFromOrigin: flow.startFromOrigin, | |
| 594 | workspaceMode: flow.workspaceMode, | |
| 648 | 595 | }); |
| 649 | const workspaceLabel = useMemo( | |
| 650 | () => | |
| 651 | formatWorkspaceLabel({ | |
| 652 | currentBranchName, | |
| 653 | selectedBranchName: flow.selectedBranchName, | |
| 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 | } | |
| 596 | const workspaceLabel = resolveNewTaskWorkspaceLabel({ | |
| 597 | workspaceMode: flow.workspaceMode, | |
| 598 | worktreePath: flow.selectedWorktreePath, | |
| 599 | }); | |
| 600 | const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; | |
| 687 | 601 | |
| 688 | 602 | async function handlePickImages(): Promise<void> { |
| 689 | 603 | if (isIncomingShareTransferPending) { |
| ... | ... | @@ -733,7 +647,9 @@ export function NewTaskDraftScreen(props: { |
| 733 | 647 | draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; |
| 734 | 648 | const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; |
| 735 | 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 | 653 | const initialMessageText = draft.text.trim(); |
| 738 | 654 | |
| 739 | 655 | if ( |
| ... | ... | @@ -851,7 +767,7 @@ export function NewTaskDraftScreen(props: { |
| 851 | 767 | |
| 852 | 768 | if (!selectedProject) { |
| 853 | 769 | return ( |
| 854 | <View className="flex-1 bg-sheet"> | |
| 770 | <View className="flex-1 bg-sheet" collapsable={false}> | |
| 855 | 771 | {Platform.OS === "android" ? ( |
| 856 | 772 | <> |
| 857 | 773 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| ... | ... | @@ -866,11 +782,6 @@ export function NewTaskDraftScreen(props: { |
| 866 | 782 | |
| 867 | 783 | const isAndroid = Platform.OS === "android"; |
| 868 | 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 | 785 | const canStart = |
| 875 | 786 | Boolean(flow.selectedProject) && |
| 876 | 787 | Boolean(flow.selectedModel) && |
| ... | ... | @@ -882,235 +793,279 @@ export function NewTaskDraftScreen(props: { |
| 882 | 793 | const promptEditor = ( |
| 883 | 794 | <ComposerEditor |
| 884 | 795 | ref={promptInputRef} |
| 885 | // Native autoFocus fires becomeFirstResponder in didMoveToWindow, which | |
| 886 | // forces the iOS keyboard bring-up during the formSheet present | |
| 887 | // animation and stalls it. The runAfterInteractions effect above focuses | |
| 888 | // the editor once the transition settles instead. | |
| 796 | // The context-first screen intentionally opens with the keyboard closed. | |
| 797 | // Focusing is a user action, so presenting the form sheet has one motion. | |
| 889 | 798 | autoFocus={false} |
| 890 | 799 | editable={!isIncomingShareTransferPending} |
| 891 | 800 | multiline |
| 892 | scrollEnabled={isExpanded} | |
| 801 | scrollEnabled | |
| 893 | 802 | value={flow.prompt} |
| 894 | 803 | skills={flow.selectedProviderSkills} |
| 895 | 804 | onChangeText={flow.setPrompt} |
| 896 | 805 | onFocus={() => setIsComposerFocused(true)} |
| 897 | 806 | onBlur={() => setIsComposerFocused(false)} |
| 898 | 807 | onPasteImages={(uris) => void handleNativePasteImages(uris)} |
| 899 | placeholder={`Describe a coding task in ${selectedProject.title}`} | |
| 900 | // Same collapsed centering as ThreadComposer: native vertical gravity | |
| 901 | // in a pill-height box. | |
| 902 | singleLineCentered={!isExpanded} | |
| 903 | contentInsetVertical={isAndroid ? 0 : undefined} | |
| 904 | style={ | |
| 905 | isAndroid | |
| 906 | ? isExpanded | |
| 907 | ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 } | |
| 908 | : { height: 36 } | |
| 909 | : { flex: 1, minHeight: 0 } | |
| 910 | } | |
| 911 | textStyle={ | |
| 912 | isAndroid | |
| 913 | ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily } | |
| 914 | : headlineText | |
| 915 | } | |
| 808 | placeholder="Ask anything…" | |
| 809 | singleLineCentered={false} | |
| 810 | contentInsetVertical={0} | |
| 811 | style={{ | |
| 812 | minHeight: 72, | |
| 813 | maxHeight: 160, | |
| 814 | paddingHorizontal: 4, | |
| 815 | paddingVertical: 4, | |
| 816 | }} | |
| 817 | textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }} | |
| 916 | 818 | /> |
| 917 | 819 | ); |
| 918 | 820 | |
| 919 | const toolbarPills = ( | |
| 920 | <> | |
| 921 | <ComposerToolbarButton | |
| 922 | icon="plus" | |
| 923 | onPress={() => void handlePickImages()} | |
| 924 | showChevron={false} | |
| 925 | disabled={isIncomingShareTransferPending} | |
| 926 | /> | |
| 927 | <ComposerToolbarTrigger | |
| 928 | accessibilityLabel="Thread settings" | |
| 821 | const closeNewTask = () => { | |
| 822 | void KeyboardController.dismiss({ animated: true }); | |
| 823 | const parentNavigation = navigation.getParent(); | |
| 824 | if (parentNavigation) { | |
| 825 | parentNavigation.goBack(); | |
| 826 | return; | |
| 827 | } | |
| 828 | navigation.goBack(); | |
| 829 | }; | |
| 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 | 881 | disabled={isIncomingShareTransferPending} |
| 930 | iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />} | |
| 931 | label={settingsSummaryLabel} | |
| 932 | maxWidth={320} | |
| 933 | onPress={settingsSheetPresentation.open} | |
| 882 | icon="desktopcomputer" | |
| 883 | label={`on ${selectedEnvironmentLabel}`} | |
| 884 | maxWidth={260} | |
| 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 | |
| 936 | actions={environmentMenuActions} | |
| 937 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} | |
| 938 | > | |
| 939 | <ComposerToolbarTrigger | |
| 940 | accessibilityLabel="Environment" | |
| 941 | disabled={isIncomingShareTransferPending} | |
| 942 | icon="desktopcomputer" | |
| 943 | label={selectedEnvironmentLabel} | |
| 944 | /> | |
| 945 | </ControlPillMenu> | |
| 946 | <ControlPillMenu | |
| 947 | actions={workspaceMenuActions} | |
| 948 | onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)} | |
| 891 | </View> | |
| 892 | ); | |
| 893 | const heroViewport = ( | |
| 894 | <View className="flex-1" collapsable={false}> | |
| 895 | <ScrollView | |
| 896 | alwaysBounceVertical={isKeyboardVisible} | |
| 897 | className="flex-1" | |
| 898 | contentInsetAdjustmentBehavior="never" | |
| 899 | contentContainerClassName="grow items-center pb-[236px] pt-12 ios:pt-[72px]" | |
| 900 | keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"} | |
| 901 | keyboardShouldPersistTaps="handled" | |
| 902 | showsVerticalScrollIndicator={false} | |
| 903 | style={{ flex: 1 }} | |
| 904 | testID="new-task-hero-scroll" | |
| 949 | 905 | > |
| 950 | <ComposerToolbarTrigger | |
| 951 | accessibilityLabel="Workspace" | |
| 952 | disabled={isIncomingShareTransferPending} | |
| 953 | icon="point.topleft.down.curvedto.point.bottomright.up" | |
| 954 | label={workspaceLabel} | |
| 955 | /> | |
| 956 | </ControlPillMenu> | |
| 957 | </> | |
| 906 | {hero} | |
| 907 | </ScrollView> | |
| 908 | </View> | |
| 958 | 909 | ); |
| 959 | 910 | |
| 960 | const settingsSheet = ( | |
| 961 | <ThreadSettingsSheet | |
| 962 | visible={settingsSheetPresentation.isVisible} | |
| 963 | onClose={settingsSheetPresentation.close} | |
| 964 | onDismissed={settingsSheetPresentation.onDismissed} | |
| 965 | providerGroups={flow.providerGroups} | |
| 966 | selectedModel={flow.selectedModel} | |
| 967 | onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)} | |
| 968 | optionDescriptors={providerOptionDescriptors} | |
| 969 | onUpdateOptionSelections={flow.setSelectedModelOptions} | |
| 970 | runtimeMode={flow.runtimeMode} | |
| 971 | onUpdateRuntimeMode={flow.setRuntimeMode} | |
| 972 | /> | |
| 911 | const workspaceControls = ( | |
| 912 | <View className="flex-row items-center gap-1 px-2"> | |
| 913 | <ComposerInlineControl | |
| 914 | accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} | |
| 915 | accessibilityLabel={workspaceLabel} | |
| 916 | disabled={isIncomingShareTransferPending} | |
| 917 | iconNode={ | |
| 918 | <NewTaskWorkspaceIcon | |
| 919 | workspaceMode={flow.workspaceMode} | |
| 920 | worktreePath={flow.selectedWorktreePath} | |
| 921 | /> | |
| 922 | } | |
| 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 = ( | |
| 976 | <ComposerToolbarButton | |
| 977 | accessibilityLabel={ | |
| 978 | flow.submitting ? "Starting task" : environmentConnected ? "Start task" : "Queue task" | |
| 979 | } | |
| 980 | icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} | |
| 981 | onPress={() => void handleStart()} | |
| 982 | variant="primary" | |
| 983 | showChevron={false} | |
| 984 | disabled={!canStart} | |
| 985 | /> | |
| 941 | const composerDock = ( | |
| 942 | <View className="bg-sheet px-4 pt-1" style={{ paddingBottom: controlsBottomPadding }}> | |
| 943 | <View className="pb-1">{workspaceControls}</View> | |
| 944 | ||
| 945 | <ComposerSurface | |
| 946 | animateLayout={false} | |
| 947 | isDarkMode={isDarkMode} | |
| 948 | style={{ | |
| 949 | borderRadius: 26, | |
| 950 | minHeight: 140, | |
| 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 | 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 | 1029 | return ( |
| 1004 | <View className="flex-1 bg-screen"> | |
| 1030 | <View className="flex-1 bg-sheet" collapsable={false}> | |
| 1005 | 1031 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 1006 | <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} /> | |
| 1007 | ||
| 1008 | <View className="flex-1" /> | |
| 1032 | <AndroidScreenHeader title="New task" onBack={closeNewTask} /> | |
| 1033 | {heroViewport} | |
| 1009 | 1034 | |
| 1010 | 1035 | <KeyboardStickyView |
| 1011 | enabled={isKeyboardVisible} | |
| 1012 | 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 | |
| 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> | |
| 1039 | {composerDock} | |
| 1078 | 1040 | </KeyboardStickyView> |
| 1079 | {settingsSheet} | |
| 1080 | 1041 | </View> |
| 1081 | 1042 | ); |
| 1082 | 1043 | } |
| 1083 | 1044 | |
| 1084 | 1045 | return ( |
| 1085 | <View className="flex-1 bg-sheet"> | |
| 1086 | <NativeStackScreenOptions options={{ title: selectedProject.title }} /> | |
| 1087 | ||
| 1088 | <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1"> | |
| 1089 | <View className="min-h-0 flex-1 px-5 pt-2">{promptEditor}</View> | |
| 1046 | <View className="flex-1 bg-sheet" collapsable={false}> | |
| 1047 | <NativeStackScreenOptions | |
| 1048 | options={{ | |
| 1049 | headerBackVisible: false, | |
| 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 }}> | |
| 1092 | {flow.attachments.length > 0 ? ( | |
| 1093 | <View className="px-4 pt-3"> | |
| 1094 | <ComposerAttachmentStrip | |
| 1095 | attachments={flow.attachments} | |
| 1096 | onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment} | |
| 1097 | imageSize={88} | |
| 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} | |
| 1062 | {heroViewport} | |
| 1063 | <KeyboardStickyView | |
| 1064 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | |
| 1065 | offset={{ closed: 0, opened: keyboardOpenedOffset }} | |
| 1066 | > | |
| 1067 | {composerDock} | |
| 1068 | </KeyboardStickyView> | |
| 1114 | 1069 | </View> |
| 1115 | 1070 | ); |
| 1116 | 1071 | } |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+33-78| ... | ... | @@ -1,8 +1,13 @@ |
| 1 | 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 | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | 9 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 5 | import { useEffect, useRef, useState } from "react"; | |
| 10 | import { useEffect, useRef } from "react"; | |
| 6 | 11 | import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; |
| 7 | 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 13 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | ... | @@ -14,10 +19,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 14 | 19 | import { useProjects } from "../../state/entities"; |
| 15 | 20 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 16 | 21 | import { useWorkspaceState } from "../../state/workspace"; |
| 17 | import { scopedProjectKey } from "../../lib/scopedEntities"; | |
| 18 | 22 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 19 | 23 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; |
| 20 | 24 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 25 | import { getProjectScopeSelectionTarget } from "./new-task-project-selection"; | |
| 21 | 26 | |
| 22 | 27 | type NewTaskRouteParams = { |
| 23 | 28 | readonly incomingShareId?: string | string[]; |
| ... | ... | @@ -80,7 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { |
| 80 | 85 | |
| 81 | 86 | export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) { |
| 82 | 87 | const projects = useProjects(); |
| 83 | const { projectScopes } = useNewTaskFlow(); | |
| 88 | const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow(); | |
| 84 | 89 | const { state: catalogState } = useWorkspaceState(); |
| 85 | 90 | const navigation = useNavigation(); |
| 86 | 91 | const isFocused = useIsFocused(); |
| ... | ... | @@ -88,7 +93,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 88 | 93 | const insets = useSafeAreaInsets(); |
| 89 | 94 | const chevronColor = useThemeColor("--color-chevron"); |
| 90 | 95 | const accentColor = useThemeColor("--color-icon-muted"); |
| 91 | const [expandedGroupKeys, setExpandedGroupKeys] = useState<ReadonlySet<string>>(() => new Set()); | |
| 92 | 96 | const { getShare, releaseShareReservation } = useIncomingShare(); |
| 93 | 97 | const routeShareId = Array.isArray(route.params?.incomingShareId) |
| 94 | 98 | ? route.params.incomingShareId[0] |
| ... | ... | @@ -126,27 +130,22 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 126 | 130 | return; |
| 127 | 131 | } |
| 128 | 132 | } |
| 129 | navigation.navigate("NewTaskSheet", { | |
| 130 | screen: "NewTaskDraft", | |
| 131 | params: { | |
| 133 | const state = navigation.getState(); | |
| 134 | const previousRoute = state?.routes[state.index - 1]; | |
| 135 | if (previousRoute?.name === "NewTaskDraft") { | |
| 136 | setProject(project); | |
| 137 | navigation.goBack(); | |
| 138 | return; | |
| 139 | } | |
| 140 | ||
| 141 | navigation.dispatch( | |
| 142 | StackActions.push("NewTaskDraft", { | |
| 132 | 143 | environmentId: project.environmentId, |
| 133 | 144 | projectId: project.id, |
| 134 | 145 | title: project.title, |
| 135 | 146 | incomingShareId: incomingShare?.id, |
| 136 | }, | |
| 137 | }); | |
| 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 | }); | |
| 147 | }), | |
| 148 | ); | |
| 150 | 149 | } |
| 151 | 150 | |
| 152 | 151 | useEffect(() => { |
| ... | ... | @@ -169,15 +168,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 169 | 168 | return; |
| 170 | 169 | } |
| 171 | 170 | resumedDestinationKeyRef.current = destinationKey; |
| 172 | navigation.navigate("NewTaskSheet", { | |
| 173 | screen: "NewTaskDraft", | |
| 174 | params: { | |
| 171 | navigation.dispatch( | |
| 172 | StackActions.push("NewTaskDraft", { | |
| 175 | 173 | environmentId: reservedDestinationProject.environmentId, |
| 176 | 174 | projectId: reservedDestinationProject.id, |
| 177 | 175 | title: reservedDestinationProject.title, |
| 178 | 176 | incomingShareId: incomingShare.id, |
| 179 | }, | |
| 180 | }); | |
| 177 | }), | |
| 178 | ); | |
| 181 | 179 | }, [incomingShare, isFocused, navigation, reservedDestinationProject]); |
| 182 | 180 | |
| 183 | 181 | return ( |
| ... | ... | @@ -196,7 +194,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 196 | 194 | { |
| 197 | 195 | accessibilityLabel: "Add project", |
| 198 | 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 | 221 | {catalogState.hasReadyEnvironment ? ( |
| 224 | 222 | <NativeHeaderToolbar.Button |
| 225 | 223 | icon="plus" |
| 226 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | |
| 224 | onPress={() => navigation.dispatch(StackActions.push("AddProject"))} | |
| 227 | 225 | separateBackground |
| 228 | 226 | /> |
| 229 | 227 | ) : null} |
| ... | ... | @@ -263,7 +261,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 263 | 261 | ) : ( |
| 264 | 262 | <Pressable |
| 265 | 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 | 266 | <Text className="text-sm font-t3-bold text-primary-foreground"> |
| 269 | 267 | Add new project |
| ... | ... | @@ -275,22 +273,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 275 | 273 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> |
| 276 | 274 | {projectScopes.map((scope, scopeIndex) => { |
| 277 | 275 | const hasMultipleProjects = scope.projects.length > 1; |
| 278 | const expanded = expandedGroupKeys.has(scope.key); | |
| 279 | const singleProject = hasMultipleProjects ? null : scope.projects[0]; | |
| 276 | const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId); | |
| 280 | 277 | return ( |
| 281 | 278 | <View |
| 282 | 279 | key={scope.key} |
| 283 | 280 | className={cn(scopeIndex > 0 && "border-t border-border-subtle")} |
| 284 | 281 | > |
| 285 | 282 | <Pressable |
| 286 | disabled={singleProject !== null && reservedDestinationProject !== null} | |
| 287 | onPress={() => { | |
| 288 | if (singleProject) { | |
| 289 | void selectProject(singleProject); | |
| 290 | } else { | |
| 291 | toggleGroup(scope.key); | |
| 292 | } | |
| 293 | }} | |
| 283 | disabled={reservedDestinationProject !== null} | |
| 284 | onPress={() => void selectProject(selectionTarget)} | |
| 294 | 285 | className="flex-row items-center gap-3 bg-card px-4 py-3.5" |
| 295 | 286 | > |
| 296 | 287 | <View className="h-7 w-7 items-center justify-center"> |
| ... | ... | @@ -311,52 +302,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 311 | 302 | > |
| 312 | 303 | {hasMultipleProjects |
| 313 | 304 | ? `${scope.projects.length} workspaces` |
| 314 | : singleProject?.workspaceRoot} | |
| 305 | : selectionTarget.workspaceRoot} | |
| 315 | 306 | </Text> |
| 316 | 307 | </View> |
| 317 | 308 | <SymbolView |
| 318 | name={hasMultipleProjects && expanded ? "chevron.down" : "chevron.right"} | |
| 309 | name="chevron.right" | |
| 319 | 310 | size={14} |
| 320 | 311 | tintColor={chevronColor} |
| 321 | 312 | type="monochrome" |
| 322 | 313 | /> |
| 323 | 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 | 315 | </View> |
| 361 | 316 | ); |
| 362 | 317 | })} |
apps/mobile/src/features/threads/ThreadComposer.tsx+121-151| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | |
| 2 | 1 | import type { |
| 3 | 2 | EnvironmentId, |
| 4 | 3 | MessageId, |
| ... | ... | @@ -14,7 +13,7 @@ import { |
| 14 | 13 | serializeComposerFileLink, |
| 15 | 14 | type ComposerTrigger, |
| 16 | 15 | } from "@t3tools/shared/composerTrigger"; |
| 17 | import * as Haptics from "expo-haptics"; | |
| 16 | import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; | |
| 18 | 17 | import type { ReactNode } from "react"; |
| 19 | 18 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 20 | 19 | import { |
| ... | ... | @@ -41,18 +40,19 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 41 | 40 | |
| 42 | 41 | import { AppText as Text } from "../../components/AppText"; |
| 43 | 42 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 43 | import { GlassSurface } from "../../components/GlassSurface"; | |
| 44 | 44 | import { |
| 45 | 45 | ComposerEditor, |
| 46 | 46 | type ComposerEditorHandle, |
| 47 | 47 | type ComposerEditorSelection, |
| 48 | 48 | } from "../../components/ComposerEditor"; |
| 49 | 49 | import { |
| 50 | ComposerInlineControl, | |
| 50 | 51 | ComposerToolbarButton, |
| 51 | 52 | ComposerToolbarRow, |
| 52 | 53 | ComposerToolbarScroller, |
| 53 | ComposerToolbarTrigger, | |
| 54 | } from "../../components/ComposerToolbarTrigger"; | |
| 55 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | |
| 54 | } from "../../components/ComposerToolbar"; | |
| 55 | import { ControlPill } from "../../components/ControlPill"; | |
| 56 | 56 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 57 | 57 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 58 | 58 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| ... | ... | @@ -63,15 +63,17 @@ import { |
| 63 | 63 | normalizeSearchQuery, |
| 64 | 64 | scoreQueryMatch, |
| 65 | 65 | } from "@t3tools/shared/searchRanking"; |
| 66 | import { | |
| 67 | applyProviderOptionSelection, | |
| 68 | resolveProviderOptionDescriptors, | |
| 69 | } from "../../lib/providerOptions"; | |
| 66 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | |
| 70 | 67 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; |
| 71 | 68 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; |
| 72 | import { buildThreadSettingsMenu } from "./thread-settings-menu"; | |
| 73 | import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; | |
| 74 | import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; | |
| 69 | import { | |
| 70 | type ExistingThreadSettingsRouteSession, | |
| 71 | useExistingThreadSettingsRoutePresentation, | |
| 72 | } from "./ThreadSettingsSheet"; | |
| 73 | import { | |
| 74 | useThreadSettingsSheetPresentation, | |
| 75 | type NavigationWithFinishTransitioning, | |
| 76 | } from "./use-thread-settings-sheet-presentation"; | |
| 75 | 77 | |
| 76 | 78 | /** |
| 77 | 79 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). |
| ... | ... | @@ -83,7 +85,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60; |
| 83 | 85 | * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). |
| 84 | 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 | 90 | export interface ThreadComposerProps { |
| 89 | 91 | readonly draftMessage: string; |
| ... | ... | @@ -123,8 +125,8 @@ export interface ThreadComposerProps { |
| 123 | 125 | } |
| 124 | 126 | |
| 125 | 127 | /** |
| 126 | * The pill / card container — renders as LiquidGlassView on supported | |
| 127 | * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. | |
| 128 | * The pill / card container — renders with Expo's native GlassView on supported | |
| 129 | * iOS 26+ devices and keeps the existing opaque fallback elsewhere. | |
| 128 | 130 | * Exported so NewTaskDraftScreen can render the same composer chrome. |
| 129 | 131 | */ |
| 130 | 132 | // One timing for every piece of the expanded↔compact morph so the surface, |
| ... | ... | @@ -140,6 +142,8 @@ export function ComposerSurface(props: { |
| 140 | 142 | readonly children: ReactNode; |
| 141 | 143 | readonly style: ViewStyle; |
| 142 | 144 | readonly isDarkMode: boolean; |
| 145 | /** Existing thread composers morph between pill and card layouts. */ | |
| 146 | readonly animateLayout?: boolean; | |
| 143 | 147 | }) { |
| 144 | 148 | // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself |
| 145 | 149 | // (needed to clip content to the pill shape) would clip the shadow on iOS. |
| ... | ... | @@ -152,35 +156,26 @@ export function ComposerSurface(props: { |
| 152 | 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 | 159 | return ( |
| 171 | <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}> | |
| 172 | <View | |
| 173 | style={[ | |
| 174 | props.style, | |
| 175 | { | |
| 176 | backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | |
| 177 | borderWidth: 1, | |
| 178 | borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | |
| 179 | }, | |
| 180 | ]} | |
| 160 | <Animated.View | |
| 161 | layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION} | |
| 162 | style={shadowStyle} | |
| 163 | > | |
| 164 | <GlassSurface | |
| 165 | chrome="none" | |
| 166 | fallbackStyle={{ | |
| 167 | backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | |
| 168 | borderWidth: 1, | |
| 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 | 177 | {props.children} |
| 183 | </View> | |
| 178 | </GlassSurface> | |
| 184 | 179 | </Animated.View> |
| 185 | 180 | ); |
| 186 | 181 | } |
| ... | ... | @@ -271,6 +266,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 271 | 266 | }); |
| 272 | 267 | |
| 273 | 268 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 269 | const navigation = useNavigation(); | |
| 274 | 270 | const isDarkMode = useColorScheme() === "dark"; |
| 275 | 271 | const foregroundColor = useThemeColor("--color-foreground"); |
| 276 | 272 | const bodyText = useScaledTextRole("body"); |
| ... | ... | @@ -281,14 +277,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 281 | 277 | editorRef: inputRef, |
| 282 | 278 | isEditorFocused: isFocused, |
| 283 | 279 | }); |
| 280 | const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation(); | |
| 281 | const settingsRoutePresentedRef = useRef(false); | |
| 284 | 282 | const wasExpandedBeforePreviewRef = useRef(false); |
| 285 | 283 | const inFlightThreadIdsRef = useRef(new Set<string>()); |
| 286 | 284 | const { onExpandedChange } = props; |
| 287 | 285 | |
| 288 | 286 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); |
| 289 | 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 | |
| 291 | // focus moves between its native editor and the settings modal. | |
| 288 | // Opening and presentation count as active so the composer stays expanded | |
| 289 | // while focus moves between its native editor and the settings picker. | |
| 292 | 290 | const isExpanded = isFocused || settingsSheetPresentation.isActive; |
| 293 | 291 | const canSend = hasContent; |
| 294 | 292 | |
| ... | ... | @@ -334,7 +332,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 334 | 332 | : "Send"; |
| 335 | 333 | const currentModelSelection = props.selectedThread.modelSelection; |
| 336 | 334 | const currentRuntimeMode = props.selectedThread.runtimeMode; |
| 337 | const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; | |
| 338 | 335 | const connectionStatus = composerConnectionStatus({ |
| 339 | 336 | connectionError: props.connectionError, |
| 340 | 337 | connectionState: props.connectionState, |
| ... | ... | @@ -626,67 +623,71 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 626 | 623 | }), |
| 627 | 624 | [currentModelOption?.capabilities, currentModelSelection.options], |
| 628 | 625 | ); |
| 629 | const settingsSummaryLabel = threadSettingsSummaryLabel({ | |
| 630 | modelLabel: currentModelOption?.label ?? currentModelSelection.model, | |
| 631 | optionDescriptors: providerOptionDescriptors, | |
| 632 | runtimeMode: currentRuntimeMode, | |
| 633 | interactionMode: currentInteractionMode, | |
| 634 | }); | |
| 635 | ||
| 636 | // iOS gets a native menu on the trigger pill: the everyday adjustments | |
| 637 | // apply without resigning the keyboard, while "All Settings…" (and the | |
| 638 | // Android trigger) still route through the sheet, which must dismiss it. | |
| 639 | const settingsMenu = useMemo( | |
| 640 | () => | |
| 641 | Platform.OS === "ios" | |
| 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 | }, | |
| 626 | const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id); | |
| 627 | const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>( | |
| 628 | () => ({ | |
| 629 | ownerId: settingsOwnerId, | |
| 630 | providerGroups: threadProviderGroups, | |
| 631 | selectedModel: currentModelSelection, | |
| 632 | onSelectModel: (option) => props.onUpdateModelSelection(option.selection), | |
| 633 | optionDescriptors: providerOptionDescriptors, | |
| 634 | onUpdateOptionSelections: (options) => | |
| 635 | props.onUpdateModelSelection({ ...currentModelSelection, options }), | |
| 636 | runtimeMode: currentRuntimeMode, | |
| 637 | onUpdateRuntimeMode: props.onUpdateRuntimeMode, | |
| 638 | }), | |
| 682 | 639 | [ |
| 683 | 640 | currentModelSelection, |
| 684 | onUpdateModelSelection, | |
| 685 | onUpdateRuntimeMode, | |
| 641 | currentRuntimeMode, | |
| 642 | props.onUpdateModelSelection, | |
| 643 | props.onUpdateRuntimeMode, | |
| 686 | 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 | 692 | return ( |
| 692 | 693 | <Animated.View |
| ... | ... | @@ -739,10 +740,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 739 | 740 | style={ |
| 740 | 741 | isExpanded |
| 741 | 742 | ? { |
| 742 | borderRadius: 20, | |
| 743 | borderRadius: 26, | |
| 744 | minHeight: 140, | |
| 743 | 745 | overflow: "hidden" as const, |
| 746 | paddingBottom: 6, | |
| 744 | 747 | paddingHorizontal: 14, |
| 745 | paddingVertical: 12, | |
| 748 | paddingTop: 14, | |
| 746 | 749 | } |
| 747 | 750 | : { |
| 748 | 751 | borderRadius: 999, |
| ... | ... | @@ -792,7 +795,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 792 | 795 | style={ |
| 793 | 796 | isExpanded |
| 794 | 797 | ? { |
| 795 | minHeight: 80, | |
| 798 | minHeight: 72, | |
| 796 | 799 | maxHeight: 160, |
| 797 | 800 | paddingHorizontal: 4, |
| 798 | 801 | paddingVertical: 4, |
| ... | ... | @@ -841,15 +844,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 841 | 844 | )} |
| 842 | 845 | </Animated.View> |
| 843 | 846 | ) : null} |
| 844 | </ComposerSurface> | |
| 845 | ||
| 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}> | |
| 847 | {isExpanded ? ( | |
| 848 | <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}> | |
| 850 | 849 | <ComposerToolbarScroller |
| 851 | 850 | fadeOpaque={toolbarFadeOpaque} |
| 852 | 851 | fadeTransparent={toolbarFadeTransparent} |
| 852 | contentPaddingRight={8} | |
| 853 | 853 | > |
| 854 | 854 | <ComposerToolbarButton |
| 855 | 855 | accessibilityLabel="Add attachment" |
| ... | ... | @@ -857,31 +857,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 857 | 857 | onPress={() => void props.onPickDraftImages()} |
| 858 | 858 | showChevron={false} |
| 859 | 859 | /> |
| 860 | {settingsMenu ? ( | |
| 861 | <ControlPillMenu | |
| 862 | actions={settingsMenu.actions} | |
| 863 | onPressAction={({ nativeEvent }) => handleSettingsMenuAction(nativeEvent.event)} | |
| 864 | > | |
| 865 | <ComposerToolbarTrigger | |
| 866 | accessibilityLabel="Thread settings" | |
| 867 | iconNode={ | |
| 868 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | |
| 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 | )} | |
| 860 | <ComposerInlineControl | |
| 861 | accessibilityLabel="Model and reasoning settings" | |
| 862 | emphasized | |
| 863 | iconNode={ | |
| 864 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | |
| 865 | } | |
| 866 | label={currentModelOption?.label ?? currentModelSelection.model} | |
| 867 | maxWidth={152} | |
| 868 | onPress={openSettings} | |
| 869 | /> | |
| 885 | 870 | {showStopAction ? ( |
| 886 | 871 | <ComposerToolbarButton |
| 887 | 872 | accessibilityLabel="Stop" |
| ... | ... | @@ -901,8 +886,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 901 | 886 | showChevron={false} |
| 902 | 887 | /> |
| 903 | 888 | </ComposerToolbarRow> |
| 904 | </Animated.View> | |
| 905 | ) : null} | |
| 889 | ) : null} | |
| 890 | </ComposerSurface> | |
| 906 | 891 | |
| 907 | 892 | {/* Queue count */} |
| 908 | 893 | {props.queueCount > 0 ? ( |
| ... | ... | @@ -915,21 +900,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 915 | 900 | ) : null} |
| 916 | 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 | 903 | <ImageViewing |
| 934 | 904 | images={previewImageUri ? [{ uri: previewImageUri }] : []} |
| 935 | 905 | imageIndex={0} |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+1024-437| ... | ... | @@ -1,92 +1,113 @@ |
| 1 | 1 | import type { |
| 2 | 2 | ModelSelection, |
| 3 | ProviderInteractionMode, | |
| 4 | 3 | ProviderOptionDescriptor, |
| 5 | 4 | ProviderOptionSelection, |
| 6 | 5 | RuntimeMode, |
| 7 | 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 | 10 | import { |
| 9 | 11 | getProviderOptionCurrentLabel, |
| 10 | 12 | getProviderOptionCurrentValue, |
| 11 | 13 | getProviderOptionDescriptors, |
| 12 | 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 | 20 | import * as Haptics from "expo-haptics"; |
| 14 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 15 | 21 | import { |
| 16 | Modal, | |
| 17 | Platform, | |
| 18 | Pressable, | |
| 19 | ScrollView, | |
| 20 | Switch, | |
| 21 | useWindowDimensions, | |
| 22 | View, | |
| 23 | } from "react-native"; | |
| 22 | createContext, | |
| 23 | use, | |
| 24 | useCallback, | |
| 25 | useEffect, | |
| 26 | useMemo, | |
| 27 | useState, | |
| 28 | type ReactNode, | |
| 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 | 32 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 25 | 33 | |
| 26 | 34 | import { SymbolView } from "../../components/AppSymbol"; |
| 27 | 35 | import { AppText as Text } from "../../components/AppText"; |
| 36 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; | |
| 28 | 37 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 29 | 38 | import { cn } from "../../lib/cn"; |
| 30 | 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 | 42 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 33 | import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu"; | |
| 34 | import { pendingModelAfterPress } from "./thread-settings-sheet-state"; | |
| 35 | import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; | |
| 43 | import { | |
| 44 | NativeHeaderToolbar, | |
| 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 | |
| 39 | * catalogs and friends) folds behind its header so a 300-model catalog can't | |
| 40 | * bury the list. | |
| 67 | * Everyday harnesses start expanded; every other provider (OpenRouter catalogs | |
| 68 | * and friends) starts folded so a 300-model catalog cannot bury the list. All | |
| 69 | * provider headers remain user-collapsible. | |
| 41 | 70 | */ |
| 42 | 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, | |
| 46 | * covering model, provider options, runtime mode, and plan mode in one label. | |
| 73 | * Keep measured row changes stable, but let catalog mutations use the list's | |
| 74 | * native bounds so a filtered catalog that underflows returns to the top. | |
| 47 | 75 | */ |
| 48 | export function threadSettingsSummaryLabel(input: { | |
| 49 | readonly modelLabel: string; | |
| 50 | readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; | |
| 51 | readonly runtimeMode: RuntimeMode; | |
| 52 | readonly interactionMode: ProviderInteractionMode; | |
| 53 | }): string { | |
| 54 | const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode); | |
| 55 | return [ | |
| 56 | input.modelLabel, | |
| 57 | ...providerOptionValueLabels(input.optionDescriptors), | |
| 58 | ...(runtime ? [runtime.shortLabel] : []), | |
| 59 | ...(input.interactionMode === "plan" ? ["Plan"] : []), | |
| 60 | ].join(" · "); | |
| 61 | } | |
| 62 | ||
| 76 | const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = { | |
| 77 | data: false, | |
| 78 | size: true, | |
| 79 | } as const; | |
| 80 | const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180); | |
| 81 | const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140); | |
| 82 | const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120); | |
| 83 | const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180); | |
| 84 | const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140); | |
| 85 | const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100); | |
| 86 | const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects( | |
| 87 | Platform.OS, | |
| 88 | Platform.Version, | |
| 89 | ); | |
| 63 | 90 | function ModelRow(props: { |
| 64 | 91 | readonly option: ModelOption; |
| 65 | 92 | readonly selected: boolean; |
| 66 | 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 | 98 | return ( |
| 70 | 99 | <Pressable |
| 71 | accessibilityRole="button" | |
| 72 | accessibilityState={{ selected: props.selected }} | |
| 100 | accessibilityLabel={props.option.label} | |
| 101 | accessibilityRole="radio" | |
| 102 | accessibilityState={{ checked: props.selected }} | |
| 73 | 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 | 104 | className={cn( |
| 79 | "mx-2.5 flex-row items-center gap-2 rounded-xl px-3 py-3.5 active:opacity-70", | |
| 80 | props.selected ? "bg-primary" : "bg-transparent", | |
| 105 | "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", | |
| 106 | props.isFirst && "rounded-t-2xl", | |
| 107 | props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle", | |
| 81 | 108 | )} |
| 82 | 109 | > |
| 83 | <Text | |
| 84 | className={cn( | |
| 85 | "shrink text-sm font-t3-medium", | |
| 86 | props.selected ? "text-primary-foreground" : "text-foreground", | |
| 87 | )} | |
| 88 | numberOfLines={1} | |
| 89 | > | |
| 110 | <Text className="min-w-0 shrink text-base font-t3-medium text-foreground" numberOfLines={1}> | |
| 90 | 111 | {props.option.label} |
| 91 | 112 | </Text> |
| 92 | 113 | {props.option.isDefault ? ( |
| ... | ... | @@ -101,17 +122,19 @@ function ModelRow(props: { |
| 101 | 122 | ) : null} |
| 102 | 123 | <View className="flex-1" /> |
| 103 | 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 | 132 | ) : null} |
| 106 | 133 | </Pressable> |
| 107 | 134 | ); |
| 108 | 135 | } |
| 109 | 136 | |
| 110 | /** | |
| 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 | */ | |
| 137 | /** Provider catalog header with its harness logo and disclosure state. */ | |
| 115 | 138 | function ProviderHeader(props: { |
| 116 | 139 | readonly driver: string | undefined; |
| 117 | 140 | readonly label: string; |
| ... | ... | @@ -121,24 +144,10 @@ function ProviderHeader(props: { |
| 121 | 144 | readonly onToggle: () => void; |
| 122 | 145 | }) { |
| 123 | 146 | const iconSubtle = useThemeColor("--color-icon-subtle"); |
| 124 | return ( | |
| 125 | <Pressable | |
| 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 | > | |
| 147 | const content = ( | |
| 148 | <> | |
| 138 | 149 | <ProviderIcon provider={props.driver} size={15} /> |
| 139 | <Text className="text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted"> | |
| 140 | {props.label} | |
| 141 | </Text> | |
| 150 | <Text className="text-sm font-t3-medium text-foreground-muted">{props.label}</Text> | |
| 142 | 151 | {props.collapsible ? ( |
| 143 | 152 | <> |
| 144 | 153 | <View className="flex-1" /> |
| ... | ... | @@ -149,13 +158,33 @@ function ProviderHeader(props: { |
| 149 | 158 | ) : null} |
| 150 | 159 | <SymbolView |
| 151 | 160 | name={props.collapsed ? "chevron.down" : "chevron.up"} |
| 152 | size={11} | |
| 161 | size={12} | |
| 153 | 162 | tintColor={iconSubtle} |
| 154 | 163 | type="monochrome" |
| 155 | 164 | /> |
| 156 | 165 | </> |
| 157 | 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 | 192 | function DisclosureRow(props: { |
| 164 | 193 | readonly label: string; |
| 165 | 194 | readonly value: string | undefined; |
| 166 | readonly disabled?: boolean; | |
| 167 | 195 | readonly onPress: () => void; |
| 196 | readonly isLast?: boolean; | |
| 168 | 197 | }) { |
| 169 | 198 | const iconSubtle = useThemeColor("--color-icon-subtle"); |
| 170 | 199 | return ( |
| 171 | 200 | <Pressable |
| 172 | 201 | accessibilityRole="button" |
| 173 | disabled={props.disabled} | |
| 174 | 202 | onPress={props.onPress} |
| 175 | 203 | className={cn( |
| 176 | "flex-row items-center gap-2 px-5 py-3 active:opacity-70", | |
| 177 | props.disabled && "opacity-40", | |
| 204 | "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", | |
| 205 | !props.isLast && "border-b border-border-subtle", | |
| 178 | 206 | )} |
| 179 | 207 | > |
| 180 | 208 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> |
| ... | ... | @@ -192,31 +220,37 @@ function DisclosureRow(props: { |
| 192 | 220 | /** Single option inside a submenu panel. */ |
| 193 | 221 | function ChoiceRow(props: { |
| 194 | 222 | readonly label: string; |
| 223 | readonly description?: string; | |
| 195 | 224 | readonly selected: boolean; |
| 196 | 225 | readonly onPress: () => void; |
| 226 | readonly isLast: boolean; | |
| 197 | 227 | }) { |
| 198 | const primaryFg = useThemeColor("--color-primary-foreground"); | |
| 228 | const checkmarkColor = useThemeColor("--color-icon"); | |
| 199 | 229 | return ( |
| 200 | 230 | <Pressable |
| 201 | accessibilityRole="button" | |
| 202 | accessibilityState={{ selected: props.selected }} | |
| 231 | accessibilityLabel={props.description ? `${props.label}. ${props.description}` : props.label} | |
| 232 | accessibilityRole="radio" | |
| 233 | accessibilityState={{ checked: props.selected }} | |
| 203 | 234 | onPress={props.onPress} |
| 204 | 235 | className={cn( |
| 205 | "mx-2.5 flex-row items-center rounded-xl px-3 py-3.5 active:opacity-70", | |
| 206 | props.selected ? "bg-primary" : "bg-transparent", | |
| 236 | "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle", | |
| 237 | !props.isLast && "border-b border-border-subtle", | |
| 207 | 238 | )} |
| 208 | 239 | > |
| 209 | <Text | |
| 210 | className={cn( | |
| 211 | "shrink text-sm font-t3-medium", | |
| 212 | props.selected ? "text-primary-foreground" : "text-foreground", | |
| 213 | )} | |
| 214 | > | |
| 215 | {props.label} | |
| 216 | </Text> | |
| 217 | <View className="flex-1" /> | |
| 240 | <View className="min-w-0 flex-1 gap-0.5"> | |
| 241 | <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> | |
| 242 | {props.description ? ( | |
| 243 | <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text> | |
| 244 | ) : null} | |
| 245 | </View> | |
| 218 | 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 | 254 | ) : null} |
| 221 | 255 | </Pressable> |
| 222 | 256 | ); |
| ... | ... | @@ -225,60 +259,31 @@ function ChoiceRow(props: { |
| 225 | 259 | function SwitchRow(props: { |
| 226 | 260 | readonly label: string; |
| 227 | 261 | readonly value: boolean; |
| 228 | readonly disabled?: boolean; | |
| 229 | 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 | 265 | return ( |
| 234 | 266 | <View |
| 235 | 267 | className={cn( |
| 236 | "flex-row items-center justify-between px-5 py-2.5", | |
| 237 | props.disabled && "opacity-40", | |
| 268 | "min-h-11 flex-row items-center justify-between bg-card px-4 py-1", | |
| 269 | !props.isLast && "border-b border-border-subtle", | |
| 238 | 270 | )} |
| 239 | 271 | > |
| 240 | 272 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> |
| 241 | 273 | <Switch |
| 242 | disabled={props.disabled} | |
| 243 | ios_backgroundColor={track} | |
| 274 | accessibilityLabel={props.label} | |
| 244 | 275 | onValueChange={props.onValueChange} |
| 245 | trackColor={{ false: track, true: activeTrack }} | |
| 246 | 276 | value={props.value} |
| 247 | 277 | /> |
| 248 | 278 | </View> |
| 249 | 279 | ); |
| 250 | 280 | } |
| 251 | 281 | |
| 252 | type SubmenuPage = | |
| 282 | type ThreadSettingsSubmenuPage = | |
| 253 | 283 | | { readonly kind: "descriptor"; readonly id: string } |
| 254 | 284 | | { readonly kind: "runtime" }; |
| 255 | 285 | |
| 256 | /** | |
| 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; | |
| 286 | type ThreadSettingsSessionProps = { | |
| 282 | 287 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 283 | 288 | readonly selectedModel: ModelSelection | null; |
| 284 | 289 | readonly onSelectModel: (option: ModelOption) => void; |
| ... | ... | @@ -286,367 +291,949 @@ export function ThreadSettingsSheet(props: { |
| 286 | 291 | readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void; |
| 287 | 292 | readonly runtimeMode: RuntimeMode; |
| 288 | 293 | readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; |
| 289 | }) { | |
| 290 | const insets = useSafeAreaInsets(); | |
| 291 | const { height: windowHeight } = useWindowDimensions(); | |
| 294 | }; | |
| 295 | ||
| 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 | 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 | 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, | |
| 306 | // secondary providers folded. The sheet stays mounted between opens, so | |
| 307 | // state would otherwise stick around. | |
| 308 | useEffect(() => { | |
| 309 | if (props.visible) { | |
| 310 | wasPresentedRef.current = true; | |
| 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; | |
| 373 | const isApplied = useCallback( | |
| 374 | (option: ModelOption) => | |
| 375 | option.selection.instanceId === props.selectedModel?.instanceId && | |
| 376 | option.selection.model === props.selectedModel.model, | |
| 377 | [props.selectedModel], | |
| 378 | ); | |
| 325 | 379 | // The list highlights the staged pick; Save turns it into the applied one. |
| 326 | const isDisplayed = (option: ModelOption) => | |
| 327 | pendingModel ? option.key === pendingModel.key : isApplied(option); | |
| 380 | const isDisplayed = useCallback( | |
| 381 | (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)), | |
| 382 | [isApplied, pendingModel], | |
| 383 | ); | |
| 328 | 384 | |
| 329 | 385 | // While a model is staged, the settings rows describe and edit the staged |
| 330 | 386 | // model's options (kept on its pending selection); Save applies model and |
| 331 | 387 | // options together. Otherwise they edit the applied selection directly. |
| 332 | const displayedDescriptors = pendingModel | |
| 333 | ? pendingModel.capabilities | |
| 334 | ? getProviderOptionDescriptors({ | |
| 335 | caps: pendingModel.capabilities, | |
| 336 | selections: pendingModel.selection.options, | |
| 337 | }) | |
| 338 | : [] | |
| 339 | : props.optionDescriptors; | |
| 340 | ||
| 341 | const hasLegacyModels = props.providerGroups.some((group) => | |
| 342 | group.models.some((model) => model.isLegacy), | |
| 343 | ); | |
| 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 | })(); | |
| 388 | const displayedDescriptors = useMemo( | |
| 389 | () => | |
| 390 | pendingModel | |
| 391 | ? pendingModel.capabilities | |
| 392 | ? getProviderOptionDescriptors({ | |
| 393 | caps: pendingModel.capabilities, | |
| 394 | selections: pendingModel.selection.options, | |
| 395 | }) | |
| 396 | : [] | |
| 397 | : props.optionDescriptors, | |
| 398 | [pendingModel, props.optionDescriptors], | |
| 399 | ); | |
| 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 | 406 | if (pendingModel) { |
| 382 | 407 | void Haptics.selectionAsync(); |
| 383 | 408 | props.onSelectModel(pendingModel); |
| 384 | 409 | } |
| 385 | props.onClose("save"); | |
| 386 | }; | |
| 410 | }, [pendingModel, props.onSelectModel]); | |
| 387 | 411 | |
| 388 | const handleOptionChange = (id: string, value: string | boolean) => { | |
| 389 | const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); | |
| 390 | if (!next) { | |
| 391 | return; | |
| 392 | } | |
| 393 | if (pendingModel) { | |
| 394 | setPendingModel({ | |
| 395 | ...pendingModel, | |
| 396 | selection: { ...pendingModel.selection, options: next }, | |
| 397 | }); | |
| 398 | } else { | |
| 399 | props.onUpdateOptionSelections(next); | |
| 400 | } | |
| 401 | }; | |
| 412 | const applyOptionChange = useCallback( | |
| 413 | (id: string, value: string | boolean) => { | |
| 414 | const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); | |
| 415 | if (!next) { | |
| 416 | return; | |
| 417 | } | |
| 418 | if (pendingModel) { | |
| 419 | setPendingModel({ | |
| 420 | ...pendingModel, | |
| 421 | selection: { ...pendingModel.selection, options: next }, | |
| 422 | }); | |
| 423 | } else { | |
| 424 | props.onUpdateOptionSelections(next); | |
| 425 | } | |
| 426 | }, | |
| 427 | [displayedDescriptors, pendingModel, props.onUpdateOptionSelections], | |
| 428 | ); | |
| 402 | 429 | |
| 403 | const toggleProvider = (providerKey: string) => { | |
| 404 | setExpandedProviders((current) => { | |
| 430 | const toggleProvider = useCallback((providerKey: string) => { | |
| 431 | setProviderExpansionOverrides((current) => { | |
| 405 | 432 | const next = new Set(current); |
| 406 | 433 | if (!next.delete(providerKey)) { |
| 407 | 434 | next.add(providerKey); |
| 408 | 435 | } |
| 409 | 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 | 854 | const activeDescriptor = |
| 414 | submenu?.kind === "descriptor" | |
| 415 | ? displayedDescriptors.find( | |
| 416 | (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, | |
| 855 | descriptorId !== null | |
| 856 | ? session.displayedDescriptors.find( | |
| 857 | (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId, | |
| 417 | 858 | ) |
| 418 | 859 | : undefined; |
| 419 | 860 | |
| 420 | 861 | const submenuContent = |
| 421 | submenu?.kind === "runtime" | |
| 862 | props.submenu.kind === "runtime" | |
| 422 | 863 | ? { |
| 423 | title: "Runtime", | |
| 424 | 864 | rows: RUNTIME_MODE_CHOICES.map((choice) => ({ |
| 425 | 865 | id: choice.mode, |
| 426 | 866 | label: choice.label, |
| 427 | selected: choice.mode === props.runtimeMode, | |
| 867 | description: choice.description, | |
| 868 | selected: choice.mode === session.runtimeMode, | |
| 428 | 869 | onPress: () => { |
| 429 | 870 | void Haptics.selectionAsync(); |
| 430 | props.onUpdateRuntimeMode(choice.mode); | |
| 431 | setSubmenu(null); | |
| 871 | session.onUpdateRuntimeMode(choice.mode); | |
| 872 | props.onSelected(); | |
| 432 | 873 | }, |
| 433 | 874 | })), |
| 434 | 875 | } |
| 435 | 876 | : activeDescriptor?.type === "select" |
| 436 | 877 | ? { |
| 437 | title: activeDescriptor.label, | |
| 438 | 878 | rows: selectableChoices(activeDescriptor).map((choice) => ({ |
| 439 | 879 | id: choice.id, |
| 440 | 880 | label: choice.label, |
| 881 | description: undefined, | |
| 441 | 882 | selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), |
| 442 | 883 | onPress: () => { |
| 443 | 884 | void Haptics.selectionAsync(); |
| 444 | handleOptionChange(activeDescriptor.id, choice.id); | |
| 445 | setSubmenu(null); | |
| 885 | session.applyOptionChange(activeDescriptor.id, choice.id); | |
| 886 | props.onSelected(); | |
| 446 | 887 | }, |
| 447 | 888 | })), |
| 448 | 889 | } |
| 449 | 890 | : null; |
| 450 | 891 | |
| 892 | if (!submenuContent) { | |
| 893 | return <View className="flex-1 bg-sheet" />; | |
| 894 | } | |
| 895 | ||
| 451 | 896 | return ( |
| 452 | <Modal | |
| 453 | transparent | |
| 454 | statusBarTranslucent | |
| 455 | navigationBarTranslucent | |
| 456 | animationType={Platform.OS === "ios" ? "fade" : "none"} | |
| 457 | visible={props.visible} | |
| 458 | onDismiss={notifyDismissed} | |
| 459 | onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")} | |
| 897 | <ScrollView | |
| 898 | className="flex-1 bg-sheet" | |
| 899 | contentContainerStyle={{ | |
| 900 | paddingBottom: insets.bottom + 12, | |
| 901 | paddingHorizontal: 16, | |
| 902 | paddingTop: 16, | |
| 903 | }} | |
| 904 | contentInsetAdjustmentBehavior="automatic" | |
| 905 | showsVerticalScrollIndicator={false} | |
| 460 | 906 | > |
| 461 | <View className="flex-1 justify-end"> | |
| 462 | <Pressable | |
| 463 | accessibilityLabel="Close thread settings" | |
| 464 | className="absolute inset-0 bg-backdrop" | |
| 465 | onPress={() => props.onClose("dismiss")} | |
| 907 | <View className="overflow-hidden rounded-2xl bg-card"> | |
| 908 | {submenuContent.rows.map((row, index) => ( | |
| 909 | <ChoiceRow | |
| 910 | key={row.id} | |
| 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 | |
| 468 | className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet" | |
| 469 | style={{ maxHeight: windowHeight * 0.85 }} | |
| 470 | > | |
| 471 | {/* The grabber doubles as the accessible close control: the dim | |
| 472 | backdrop above a tall sheet is a sliver, and VoiceOver can't | |
| 473 | reach it at all. */} | |
| 474 | <Pressable | |
| 475 | accessibilityLabel="Close thread settings" | |
| 476 | accessibilityRole="button" | |
| 477 | onPress={() => props.onClose("dismiss")} | |
| 478 | className="items-center pb-1 pt-2.5" | |
| 479 | > | |
| 480 | <View className="h-1 w-9 rounded-full bg-subtle-strong" /> | |
| 481 | </Pressable> | |
| 482 | {hasLegacyModels ? ( | |
| 483 | <View className="flex-row justify-end px-4 pb-1.5"> | |
| 484 | <Pressable | |
| 485 | accessibilityRole="button" | |
| 486 | accessibilityState={{ selected: showLegacy }} | |
| 487 | hitSlop={8} | |
| 488 | onPress={() => { | |
| 489 | void Haptics.selectionAsync(); | |
| 490 | setShowLegacyToggle(!showLegacy); | |
| 491 | }} | |
| 492 | className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70" | |
| 493 | > | |
| 494 | <Text className="text-2xs font-t3-medium text-foreground-muted"> | |
| 495 | {showLegacy ? "Hide legacy models" : "Show legacy models"} | |
| 496 | </Text> | |
| 497 | </Pressable> | |
| 498 | </View> | |
| 499 | ) : null} | |
| 500 | {/* Only the model list scrolls. Provider catalogs can run to | |
| 501 | hundreds of models (OpenRouter), so the rows below stay pinned | |
| 502 | and reachable instead of living at the end of that scroll. */} | |
| 503 | <ScrollView | |
| 504 | style={{ flexShrink: 1 }} | |
| 505 | contentContainerStyle={{ paddingBottom: 8 }} | |
| 506 | showsVerticalScrollIndicator={false} | |
| 1008 | ) : null} | |
| 1009 | <NativeStackScreenOptions | |
| 1010 | optionsVersion={[ | |
| 1011 | session.providerFilter, | |
| 1012 | session.providerGroups.map((group) => group.providerKey), | |
| 1013 | session.showLegacy, | |
| 1014 | ]} | |
| 1015 | options={{ | |
| 1016 | unstable_headerToolbarItems: usesNativeMailSearchToolbar | |
| 1017 | ? () => [ | |
| 1018 | createNativeMailSearchToolbarItem({ | |
| 1019 | filterButtonId: "thread-settings-model-filter", | |
| 1020 | filterMenu, | |
| 1021 | filterSystemImageName: hasCustomCatalogFilter | |
| 1022 | ? "line.3.horizontal.decrease.circle.fill" | |
| 1023 | : "line.3.horizontal.decrease", | |
| 1024 | onSearchTextChange: session.setSearchQuery, | |
| 1025 | placeholder: "Find a model", | |
| 1026 | searchTextChangeId: "thread-settings-model-search-text", | |
| 1027 | showsSearchDismissButton: true, | |
| 1028 | }), | |
| 1029 | ] | |
| 1030 | : undefined, | |
| 1031 | headerShown: Platform.OS !== "android", | |
| 1032 | headerSearchBarOptions: | |
| 1033 | Platform.OS === "ios" && !usesNativeMailSearchToolbar | |
| 1034 | ? { | |
| 1035 | autoCapitalize: "none", | |
| 1036 | hideNavigationBar: false, | |
| 1037 | obscureBackground: false, | |
| 1038 | onCancelButtonPress: () => session.setSearchQuery(""), | |
| 1039 | onChangeText: (event) => session.setSearchQuery(event.nativeEvent.text), | |
| 1040 | placeholder: "Find a model", | |
| 1041 | } | |
| 1042 | : undefined, | |
| 1043 | }} | |
| 1044 | /> | |
| 1045 | <ThreadSettingsMainContent | |
| 1046 | onOpenSubmenu={(submenu) => { | |
| 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) => { | |
| 509 | const driver = group.models[0]?.providerDriver; | |
| 510 | const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); | |
| 511 | const visibleModels = showLegacy | |
| 512 | ? group.models | |
| 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" | |
| 1082 | <NativeHeaderToolbar.Menu title="Provider"> | |
| 1083 | <NativeHeaderToolbar.Label>Provider</NativeHeaderToolbar.Label> | |
| 1084 | <NativeHeaderToolbar.MenuAction | |
| 1085 | isOn={session.providerFilter === null} | |
| 1086 | onPress={() => session.setProviderFilter(null)} | |
| 625 | 1087 | > |
| 626 | <View className="h-1 w-9 rounded-full bg-subtle-strong" /> | |
| 627 | </Pressable> | |
| 628 | <Text className="px-5 pb-1.5 text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted"> | |
| 629 | {submenuContent.title} | |
| 630 | </Text> | |
| 631 | <ScrollView | |
| 632 | style={{ maxHeight: windowHeight * 0.5 }} | |
| 633 | contentContainerStyle={{ paddingBottom: insets.bottom + 12 }} | |
| 634 | showsVerticalScrollIndicator={false} | |
| 635 | bounces={false} | |
| 1088 | All providers | |
| 1089 | </NativeHeaderToolbar.MenuAction> | |
| 1090 | {session.providerGroups.map((group) => ( | |
| 1091 | <NativeHeaderToolbar.MenuAction | |
| 1092 | key={group.providerKey} | |
| 1093 | isOn={session.providerFilter === group.providerKey} | |
| 1094 | onPress={() => session.setProviderFilter(group.providerKey)} | |
| 1095 | > | |
| 1096 | {group.providerLabel} | |
| 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) => ( | |
| 638 | <ChoiceRow | |
| 639 | key={row.id} | |
| 640 | label={row.label} | |
| 641 | selected={row.selected} | |
| 642 | onPress={row.onPress} | |
| 643 | /> | |
| 644 | ))} | |
| 645 | </ScrollView> | |
| 646 | </View> | |
| 647 | </View> | |
| 648 | ) : null} | |
| 649 | </View> | |
| 650 | </Modal> | |
| 1105 | Show legacy models | |
| 1106 | </NativeHeaderToolbar.MenuAction> | |
| 1107 | ) : null} | |
| 1108 | </NativeHeaderToolbar.Menu> | |
| 1109 | </NativeHeaderToolbar> | |
| 1110 | ) : null} | |
| 1111 | </> | |
| 1112 | ); | |
| 1113 | } | |
| 1114 | ||
| 1115 | function ThreadSettingsChoiceScreen() { | |
| 1116 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); | |
| 1117 | const route = useRoute<RouteProp<ThreadSettingsPickerStackParams, "ThreadSettingsChoice">>(); | |
| 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 | 42 | import { |
| 43 | 43 | appendComposerDraftAttachments, |
| 44 | 44 | clearComposerDraft, |
| 45 | copyComposerDraftContentIfEmpty, | |
| 45 | 46 | getComposerDraftSnapshot, |
| 46 | 47 | isComposerDraftEmpty, |
| 47 | 48 | removeComposerDraftAttachment, |
| ... | ... | @@ -50,7 +51,7 @@ import { |
| 50 | 51 | updateComposerDraftSettings, |
| 51 | 52 | useComposerDraft, |
| 52 | 53 | } from "../../state/use-composer-drafts"; |
| 53 | import { useBranches } from "../../state/queries"; | |
| 54 | import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; | |
| 54 | 55 | import { |
| 55 | 56 | flattenQueuedThreadMessages, |
| 56 | 57 | threadOutboxManager, |
| ... | ... | @@ -74,10 +75,16 @@ import { |
| 74 | 75 | type HomeProjectScope, |
| 75 | 76 | } from "../home/homeThreadList"; |
| 76 | 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 | 85 | type WorkspaceMode = "local" | "worktree"; |
| 79 | 86 | |
| 80 | const EMPTY_BRANCH_REFS: ReadonlyArray<VcsRef> = []; | |
| 87 | const BRANCH_SEARCH_DEBOUNCE_MS = 150; | |
| 81 | 88 | |
| 82 | 89 | function pendingTaskDraftKey(messageId: string): string { |
| 83 | 90 | return `pending-task:${messageId}`; |
| ... | ... | @@ -96,14 +103,6 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { |
| 96 | 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 | 106 | export function branchBadgeLabel(input: { |
| 108 | 107 | readonly branch: VcsRef; |
| 109 | 108 | readonly project: EnvironmentProject | null; |
| ... | ... | @@ -117,9 +116,6 @@ export function branchBadgeLabel(input: { |
| 117 | 116 | if (input.branch.isDefault) { |
| 118 | 117 | return "default"; |
| 119 | 118 | } |
| 120 | if (input.branch.isRemote) { | |
| 121 | return "remote"; | |
| 122 | } | |
| 123 | 119 | return null; |
| 124 | 120 | } |
| 125 | 121 | |
| ... | ... | @@ -139,9 +135,13 @@ type NewTaskFlowContextValue = { |
| 139 | 135 | readonly submitting: boolean; |
| 140 | 136 | readonly branchQuery: string; |
| 141 | 137 | readonly branchesLoading: boolean; |
| 138 | readonly branchesError: string | null; | |
| 139 | readonly branchesFetchingNextPage: boolean; | |
| 140 | readonly hasMoreBranches: boolean; | |
| 142 | 141 | readonly availableBranches: ReadonlyArray<VcsRef>; |
| 143 | 142 | readonly runtimeMode: RuntimeMode; |
| 144 | 143 | readonly interactionMode: ProviderInteractionMode; |
| 144 | readonly planModeEnabled: boolean; | |
| 145 | 145 | readonly expandedProvider: string | null; |
| 146 | 146 | readonly environments: ReadonlyArray<{ |
| 147 | 147 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -175,7 +175,8 @@ type NewTaskFlowContextValue = { |
| 175 | 175 | readonly clearAttachments: () => void; |
| 176 | 176 | readonly setSubmitting: (value: boolean) => void; |
| 177 | 177 | readonly setBranchQuery: (value: string) => void; |
| 178 | readonly loadBranches: () => Promise<void>; | |
| 178 | readonly loadBranches: () => void; | |
| 179 | readonly loadMoreBranches: () => void; | |
| 179 | 180 | readonly setRuntimeMode: (value: RuntimeMode) => void; |
| 180 | 181 | readonly setInteractionMode: (value: ProviderInteractionMode) => void; |
| 181 | 182 | readonly setSelectedModelOptions: ( |
| ... | ... | @@ -191,6 +192,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 191 | 192 | const threads = useThreadShells(); |
| 192 | 193 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 193 | 194 | const groupingSettings = useMobileProjectGroupingSettings(); |
| 195 | const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); | |
| 194 | 196 | const projectScopes = useMemo( |
| 195 | 197 | () => |
| 196 | 198 | sortHomeProjectScopes({ |
| ... | ... | @@ -219,6 +221,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 219 | 221 | const [branchQuery, setBranchQuery] = useState(""); |
| 220 | 222 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); |
| 221 | 223 | const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null); |
| 224 | const pendingLocalBranchSyncDraftKeysRef = useRef(new Set<string>()); | |
| 222 | 225 | // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act |
| 223 | 226 | // on a task whose editing session already ended this render. |
| 224 | 227 | const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null); |
| ... | ... | @@ -229,6 +232,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 229 | 232 | setSubmitting(false); |
| 230 | 233 | setBranchQuery(""); |
| 231 | 234 | setExpandedProvider(null); |
| 235 | pendingLocalBranchSyncDraftKeysRef.current.clear(); | |
| 232 | 236 | const editing = editingPendingTaskRef.current; |
| 233 | 237 | editingPendingTaskRef.current = null; |
| 234 | 238 | setEditingPendingTask(null); |
| ... | ... | @@ -395,7 +399,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 395 | 399 | selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? |
| 396 | 400 | true; |
| 397 | 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 | 406 | // Stored selections only count while their provider is usable on the |
| 401 | 407 | // server; otherwise the server's default model wins instead of silently |
| ... | ... | @@ -521,18 +527,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 521 | 527 | } |
| 522 | 528 | replaceComposerDraftAttachments(selectedProjectDraftKey, []); |
| 523 | 529 | }, [selectedProjectDraftKey]); |
| 530 | const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS); | |
| 524 | 531 | const branchTarget = useMemo( |
| 525 | 532 | () => ({ |
| 526 | 533 | environmentId: selectedProject?.environmentId ?? null, |
| 527 | 534 | // `|| null` also skips the stand-in project's empty workspaceRoot. |
| 528 | 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); | |
| 534 | const branchesLoading = branchState.isPending; | |
| 535 | const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS; | |
| 540 | const branchState = usePaginatedBranches(branchTarget); | |
| 541 | const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim(); | |
| 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 | 548 | const availableBranches = useMemo( |
| 537 | 549 | () => |
| 538 | 550 | pipe( |
| ... | ... | @@ -554,11 +566,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 554 | 566 | ); |
| 555 | 567 | }, [availableBranches, branchQuery]); |
| 556 | 568 | |
| 557 | const setProject = useCallback((project: EnvironmentProject) => { | |
| 558 | const nextProjectKey = scopedProjectKey(project.environmentId, project.id); | |
| 559 | setSelectedEnvironmentId(project.environmentId); | |
| 560 | setSelectedProjectKey(nextProjectKey); | |
| 561 | }, []); | |
| 569 | const setProject = useCallback( | |
| 570 | (project: EnvironmentProject) => { | |
| 571 | const nextProjectKey = scopedProjectKey(project.environmentId, project.id); | |
| 572 | const nextDraftKey = `new-task:${nextProjectKey}`; | |
| 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 | 585 | const selectEnvironment = useCallback( |
| 564 | 586 | (environmentId: EnvironmentId) => { |
| ... | ... | @@ -596,28 +618,86 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 596 | 618 | if (!selectedProjectDraftKey) { |
| 597 | 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 | 633 | updateComposerDraftSettings(selectedProjectDraftKey, { |
| 600 | 634 | workspaceSelection: { |
| 601 | 635 | mode, |
| 602 | branch: selectedBranchName, | |
| 603 | worktreePath: selectedWorktreePath, | |
| 636 | branch: mode === "local" ? localSelection.branch : selectedBranchName, | |
| 637 | worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, | |
| 604 | 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 | 686 | const selectBranch = useCallback( |
| 612 | 687 | (branch: VcsRef) => { |
| 613 | 688 | if (!selectedProject || !selectedProjectDraftKey) { |
| 614 | 689 | return; |
| 615 | 690 | } |
| 691 | pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); | |
| 616 | 692 | updateComposerDraftSettings(selectedProjectDraftKey, { |
| 617 | 693 | workspaceSelection: { |
| 618 | 694 | mode: workspaceMode, |
| 619 | 695 | branch: branch.name, |
| 620 | worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), | |
| 696 | worktreePath: resolveNewTaskBranchWorktreePath({ | |
| 697 | workspaceMode, | |
| 698 | projectCwd: selectedProject.workspaceRoot, | |
| 699 | branchWorktreePath: branch.worktreePath, | |
| 700 | }), | |
| 621 | 701 | ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), |
| 622 | 702 | }, |
| 623 | 703 | }); |
| ... | ... | @@ -643,7 +723,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 643 | 723 | ); |
| 644 | 724 | |
| 645 | 725 | const refreshBranches = branchState.refresh; |
| 646 | const loadBranches = useCallback(async () => { | |
| 726 | const loadMoreBranches = branchState.loadNext; | |
| 727 | const loadBranches = useCallback(() => { | |
| 647 | 728 | if (!selectedProject) { |
| 648 | 729 | return; |
| 649 | 730 | } |
| ... | ... | @@ -767,7 +848,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 767 | 848 | attachments: draft.attachments, |
| 768 | 849 | modelSelection: draftModelSelection, |
| 769 | 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 | 857 | creation: { |
| 772 | 858 | projectId: selectedProject.id, |
| 773 | 859 | ...(projectTitle !== undefined ? { projectTitle } : {}), |
| ... | ... | @@ -792,6 +878,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 792 | 878 | selectedModel, |
| 793 | 879 | selectedProject, |
| 794 | 880 | selectedProjectDraftKey, |
| 881 | planModeEnabled, | |
| 882 | planModePreferenceLoaded, | |
| 795 | 883 | startFromOrigin, |
| 796 | 884 | workspaceMode, |
| 797 | 885 | ], |
| ... | ... | @@ -904,9 +992,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 904 | 992 | submitting, |
| 905 | 993 | branchQuery, |
| 906 | 994 | branchesLoading, |
| 995 | branchesError: branchState.error, | |
| 996 | branchesFetchingNextPage, | |
| 997 | hasMoreBranches, | |
| 907 | 998 | availableBranches, |
| 908 | 999 | runtimeMode, |
| 909 | 1000 | interactionMode, |
| 1001 | planModeEnabled, | |
| 910 | 1002 | expandedProvider, |
| 911 | 1003 | environments, |
| 912 | 1004 | selectedProject, |
| ... | ... | @@ -935,6 +1027,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 935 | 1027 | setSubmitting, |
| 936 | 1028 | setBranchQuery, |
| 937 | 1029 | loadBranches, |
| 1030 | loadMoreBranches, | |
| 938 | 1031 | setRuntimeMode, |
| 939 | 1032 | setInteractionMode, |
| 940 | 1033 | setSelectedModelOptions, |
| ... | ... | @@ -946,6 +1039,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 946 | 1039 | beginEditingPendingTask, |
| 947 | 1040 | branchQuery, |
| 948 | 1041 | branchesLoading, |
| 1042 | branchState.error, | |
| 1043 | branchesFetchingNextPage, | |
| 949 | 1044 | buildPendingTaskMessage, |
| 950 | 1045 | cancelEditingPendingTask, |
| 951 | 1046 | editingPendingTask, |
| ... | ... | @@ -954,7 +1049,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 954 | 1049 | filteredBranches, |
| 955 | 1050 | finishEditingPendingTask, |
| 956 | 1051 | interactionMode, |
| 1052 | planModeEnabled, | |
| 957 | 1053 | loadBranches, |
| 1054 | loadMoreBranches, | |
| 958 | 1055 | projectScopes, |
| 959 | 1056 | modelOptions, |
| 960 | 1057 | prompt, |
| ... | ... | @@ -963,6 +1060,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 963 | 1060 | reset, |
| 964 | 1061 | runtimeMode, |
| 965 | 1062 | selectedBranchName, |
| 1063 | hasMoreBranches, | |
| 966 | 1064 | selectedEnvironmentId, |
| 967 | 1065 | selectedModel, |
| 968 | 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 | 5 | import type { HomeProjectScope } from "../home/homeThreadList"; |
| 6 | 6 | import { |
| 7 | 7 | getOnlySelectableProject, |
| 8 | getProjectScopeSelectionTarget, | |
| 8 | 9 | resolveDraftProjectSelection, |
| 9 | 10 | } from "./new-task-project-selection"; |
| 10 | 11 | |
| 11 | function makeProject(id: string): EnvironmentProject { | |
| 12 | function makeProject(id: string, environmentId = "environment"): EnvironmentProject { | |
| 12 | 13 | return { |
| 13 | environmentId: EnvironmentId.make("environment"), | |
| 14 | environmentId: EnvironmentId.make(environmentId), | |
| 14 | 15 | id: ProjectId.make(id), |
| 15 | 16 | title: id, |
| 16 | 17 | workspaceRoot: `/work/${id}`, |
| ... | ... | @@ -41,9 +42,25 @@ describe("getOnlySelectableProject", () => { |
| 41 | 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 | 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 | 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 | 84 | const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; |
| 68 | 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 | 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 4 | 5 | import type { HomeProjectScope } from "../home/homeThreadList"; |
| 5 | 6 | |
| 6 | export type DraftProjectSelectionResolution = | |
| 7 | type DraftProjectSelectionResolution = | |
| 7 | 8 | | { readonly kind: "preserve" } |
| 8 | 9 | | { readonly kind: "select"; readonly project: EnvironmentProject } |
| 9 | 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 | 22 | export function getOnlySelectableProject( |
| 12 | 23 | projectScopes: ReadonlyArray<HomeProjectScope>, |
| 13 | 24 | ): EnvironmentProject | null { |
| 14 | 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 | 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 | 3 | import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; |
| 4 | 4 | |
| 5 | 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 | 8 | function modelOption( |
| 9 | 9 | model: string, |
| ... | ... | @@ -28,6 +28,26 @@ function modelOption( |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 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 | 51 | it("clears staging when the applied model is pressed", () => { |
| 32 | 52 | expect( |
| 33 | 53 | pendingModelAfterPress({ |
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+34| ... | ... | @@ -1,5 +1,24 @@ |
| 1 | 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 | 22 | /** Preserve staged provider options when the highlighted model is tapped again. */ |
| 4 | 23 | export function pendingModelAfterPress(input: { |
| 5 | 24 | readonly current: ModelOption | null; |
| ... | ... | @@ -11,3 +30,18 @@ export function pendingModelAfterPress(input: { |
| 11 | 30 | } |
| 12 | 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 | 3 | |
| 4 | 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 | |
| 12 | * the same time. Opening waits for the keyboard dismissal to finish, while | |
| 13 | * focus restoration waits for the modal's dismissal callback. | |
| 40 | * Keeps the custom native composer and the settings sheet from owning focus at | |
| 41 | * the same time. Opening resigns the editor cleanly; a dismissal re-focuses it | |
| 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 | 47 | export function useThreadSettingsSheetPresentation(input: { |
| 16 | 48 | readonly editorRef: RefObject<ComposerEditorHandle | null>; |
| ... | ... | @@ -19,18 +51,36 @@ export function useThreadSettingsSheetPresentation(input: { |
| 19 | 51 | const [phase, setPhase] = useState<PresentationPhase>("closed"); |
| 20 | 52 | const isActiveRef = useRef(false); |
| 21 | 53 | const isMountedRef = useRef(true); |
| 54 | const isEditorFocusedRef = useRef(input.isEditorFocused); | |
| 22 | 55 | const openingIdRef = useRef(0); |
| 23 | const restoreFocusOnSaveRef = useRef(false); | |
| 24 | const shouldRestoreAfterDismissRef = useRef(false); | |
| 56 | const focusRestoreIdRef = useRef(0); | |
| 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( | |
| 27 | () => () => { | |
| 68 | useEffect(() => { | |
| 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 | 77 | isMountedRef.current = false; |
| 29 | 78 | isActiveRef.current = false; |
| 30 | 79 | openingIdRef.current += 1; |
| 31 | }, | |
| 32 | [], | |
| 33 | ); | |
| 80 | focusRestoreIdRef.current += 1; | |
| 81 | clearDismissRestoreTimer(); | |
| 82 | }; | |
| 83 | }, [clearDismissRestoreTimer]); | |
| 34 | 84 | |
| 35 | 85 | const open = useCallback(() => { |
| 36 | 86 | if (isActiveRef.current) { |
| ... | ... | @@ -38,61 +88,107 @@ export function useThreadSettingsSheetPresentation(input: { |
| 38 | 88 | } |
| 39 | 89 | |
| 40 | 90 | isActiveRef.current = true; |
| 41 | restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible(); | |
| 42 | shouldRestoreAfterDismissRef.current = false; | |
| 91 | focusRestoreIdRef.current += 1; | |
| 92 | clearDismissRestoreTimer(); | |
| 93 | restorePendingRef.current = false; | |
| 94 | restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible(); | |
| 43 | 95 | setPhase("opening"); |
| 44 | 96 | |
| 45 | 97 | const openingId = openingIdRef.current + 1; |
| 46 | 98 | openingIdRef.current = openingId; |
| 47 | 99 | |
| 48 | // Keyboard.dismiss() only tracks React Native TextInputs. The composer is | |
| 49 | // a custom native text view, so explicitly resign its first responder too. | |
| 100 | // Start the keyboard transition before the custom native editor resigns | |
| 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 | 104 | input.editorRef.current?.blur(); |
| 51 | void KeyboardController.dismiss().then(() => { | |
| 105 | ||
| 106 | requestAnimationFrame(() => { | |
| 52 | 107 | if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { |
| 53 | 108 | return; |
| 54 | 109 | } |
| 55 | 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) => { | |
| 60 | if (!isActiveRef.current) { | |
| 132 | attemptsRemaining -= 1; | |
| 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 | 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; | |
| 65 | shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current; | |
| 66 | setPhase("closing"); | |
| 67 | }, []); | |
| 68 | ||
| 153 | /** | |
| 154 | * Marks the sheet closed and queues the keyboard's return for the moment | |
| 155 | * the dismissal transition actually completes: the sheet slides away over a | |
| 156 | * resting composer, then the keyboard lifts it in one continuous motion. | |
| 157 | */ | |
| 69 | 158 | const onDismissed = useCallback(() => { |
| 70 | const shouldRestoreFocus = shouldRestoreAfterDismissRef.current; | |
| 71 | shouldRestoreAfterDismissRef.current = false; | |
| 72 | restoreFocusOnSaveRef.current = false; | |
| 73 | 159 | isActiveRef.current = false; |
| 74 | 160 | setPhase("closed"); |
| 75 | 161 | |
| 76 | if (shouldRestoreFocus) { | |
| 77 | input.editorRef.current?.focus(); | |
| 162 | if (!restoreFocusAfterDismissRef.current) { | |
| 163 | return; | |
| 78 | 164 | } |
| 79 | }, [input.editorRef]); | |
| 80 | ||
| 81 | // The new-task screen can have an autofocus queued before the sheet opens. | |
| 82 | // Preserve that intent for Save without allowing it to focus under the modal. | |
| 83 | const restoreFocusAfterSave = useCallback(() => { | |
| 84 | if (isActiveRef.current) { | |
| 85 | restoreFocusOnSaveRef.current = true; | |
| 165 | restoreFocusAfterDismissRef.current = false; | |
| 166 | restorePendingRef.current = true; | |
| 167 | clearDismissRestoreTimer(); | |
| 168 | if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) { | |
| 169 | // A stack transition finished just before this pop reached JS: the pop | |
| 170 | // is the state echo of a gesture-driven dismissal whose animation has | |
| 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 | 187 | return { |
| 90 | 188 | isActive: phase !== "closed", |
| 91 | isActiveRef, | |
| 92 | 189 | isVisible: phase === "visible", |
| 93 | 190 | open, |
| 94 | close, | |
| 95 | 191 | onDismissed, |
| 96 | restoreFocusAfterSave, | |
| 192 | onStackTransitionsFinished, | |
| 97 | 193 | } as const; |
| 98 | 194 | } |
apps/mobile/src/native/native-glass.ts+2-2| ... | ... | @@ -1,9 +1,9 @@ |
| 1 | import { isLiquidGlassSupported } from "@callstack/liquid-glass"; | |
| 1 | import { isGlassEffectAPIAvailable } from "expo-glass-effect"; | |
| 2 | 2 | import { Platform } from "react-native"; |
| 3 | 3 | |
| 4 | 4 | import { supportsNativeLiquidGlass } from "../lib/native-glass-capability"; |
| 5 | 5 | |
| 6 | 6 | export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass( |
| 7 | 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 | 35 | * default flat list — see `resolveThreadListV2Enabled`. |
| 36 | 36 | */ |
| 37 | 37 | readonly legacyThreadListEnabled?: boolean; |
| 38 | /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ | |
| 39 | readonly planModeEnabled?: boolean; | |
| 38 | 40 | } |
| 39 | 41 | |
| 40 | 42 | export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()( |
| ... | ... | @@ -88,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 88 | 90 | projectGroupingMode?: SidebarProjectGroupingMode; |
| 89 | 91 | autoSettleOnMerge?: boolean; |
| 90 | 92 | legacyThreadListEnabled?: boolean; |
| 93 | planModeEnabled?: boolean; | |
| 91 | 94 | } = {}; |
| 92 | 95 | |
| 93 | 96 | if (typeof parsed.liveActivitiesEnabled === "boolean") { |
| ... | ... | @@ -130,6 +133,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 130 | 133 | if (typeof parsed.legacyThreadListEnabled === "boolean") { |
| 131 | 134 | preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; |
| 132 | 135 | } |
| 136 | if (typeof parsed.planModeEnabled === "boolean") { | |
| 137 | preferences.planModeEnabled = parsed.planModeEnabled; | |
| 138 | } | |
| 133 | 139 | return preferences; |
| 134 | 140 | } |
| 135 | 141 |
apps/mobile/src/state/queries.ts+122-4| ... | ... | @@ -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 | 9 | import { |
| 3 | 10 | createThreadSearchResultsAtomFamily, |
| 4 | 11 | makeThreadSearchKey, |
| 5 | 12 | type EnvironmentThreadSearchMatch, |
| 6 | 13 | } from "@t3tools/client-runtime/state/thread-search"; |
| 7 | 14 | import { useAtomValue } from "@effect/atom-react"; |
| 15 | import * as Cause from "effect/Cause"; | |
| 8 | 16 | import * as Option from "effect/Option"; |
| 9 | import { Atom } from "effect/unstable/reactivity"; | |
| 10 | import { useEffect, useMemo, useState } from "react"; | |
| 17 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 18 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 11 | 19 | |
| 20 | import { appAtomRegistry } from "./atom-registry"; | |
| 12 | 21 | import { orchestrationEnvironment } from "./orchestration"; |
| 13 | 22 | import { projectEnvironment } from "./projects"; |
| 14 | 23 | import { useEnvironmentQuery } from "./query"; |
| ... | ... | @@ -24,6 +33,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; |
| 24 | 33 | const COMPOSER_PATH_SEARCH_LIMIT = 20; |
| 25 | 34 | const THREAD_SEARCH_DEBOUNCE_MS = 200; |
| 26 | 35 | const VCS_REF_LIST_LIMIT = 100; |
| 36 | const EMPTY_REFS: ReadonlyArray<VcsRef> = []; | |
| 37 | const INITIAL_BRANCH_CURSORS = [undefined] as const; | |
| 27 | 38 | const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]); |
| 28 | 39 | const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ |
| 29 | 40 | matches: EMPTY_THREAD_SEARCH_MATCHES, |
| ... | ... | @@ -52,7 +63,7 @@ export interface ComposerPathSearchTarget { |
| 52 | 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 | 67 | const [debounced, setDebounced] = useState(value); |
| 57 | 68 | |
| 58 | 69 | useEffect(() => { |
| ... | ... | @@ -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 | 246 | export function useComposerPathSearch(target: ComposerPathSearchTarget) { |
| 129 | 247 | const normalizedTarget = useMemo( |
| 130 | 248 | () => ({ |
apps/mobile/src/state/use-composer-drafts.test.ts+125| ... | ... | @@ -1,10 +1,57 @@ |
| 1 | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | 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 | 49 | import { appAtomRegistry } from "./atom-registry"; |
| 5 | 50 | import { |
| 6 | 51 | clearComposerDraftContentState, |
| 7 | 52 | composerDraftsAtom, |
| 53 | copyComposerDraftContentIfEmpty, | |
| 54 | copyComposerDraftContentState, | |
| 8 | 55 | decodePersistedComposerDrafts, |
| 9 | 56 | type ComposerDraft, |
| 10 | 57 | getComposerDraftSnapshot, |
| ... | ... | @@ -165,6 +212,53 @@ describe("mobile composer drafts", () => { |
| 165 | 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 | 262 | it("merges shared content into a project draft without duplicating retries", () => { |
| 169 | 263 | const draftKey = "new-task:environment-1:project-1"; |
| 170 | 264 | const sharedAttachment = { |
| ... | ... | @@ -268,4 +362,35 @@ describe("mobile composer drafts", () => { |
| 268 | 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 | 253 | function updateComposerDrafts( |
| 254 | 254 | update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, |
| 255 | 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 | 261 | appAtomRegistry.set(composerDraftsAtom, next); |
| 258 | 262 | schedulePersistComposerDrafts(next); |
| 259 | 263 | } |
| ... | ... | @@ -412,6 +416,51 @@ export function restoreComposerDraftSnapshotState( |
| 412 | 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 | 464 | function mergeComposerDraftText(existing: string, incoming: string): string { |
| 416 | 465 | if (incoming.length === 0) { |
| 417 | 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 | 44 | labels above describe what you get; the exact per-provider translation is internal and may |
| 45 | 45 | change. |
| 46 | 46 | |
| 47 | Mobile offers the same four modes. It labels the first one **Approve actions** rather than | |
| 48 | **Supervised**. | |
| 47 | Mobile offers the same four modes with the same labels and descriptions. |
patches/@react-navigation%2Fnative-stack@7.17.6.patch+22| ... | ... | @@ -100,3 +100,25 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 |
| 100 | 100 | -//# sourceMappingURL=useHeaderConfigProps.js.map |
| 101 | 101 | \ No newline at end of file |
| 102 | 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 | 226 | |
| 227 | 227 | // appearance does not apply to the tvOS so we need to use lagacy customization |
| 228 | 228 | #if TARGET_OS_TV |
| 229 | @@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc | |
| 229 | @@ -637,10 +675,458 @@ + (void)updateViewController:(UIViewController *)vc | |
| 230 | 230 | // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). |
| 231 | 231 | // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) |
| 232 | 232 | navitem.title = config.title; |
| ... | ... | @@ -391,40 +391,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 391 | 391 | + ]]; |
| 392 | 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 | 394 | + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; |
| 429 | 395 | + glassEffect.interactive = YES; |
| 430 | 396 | + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; |
| ... | ... | @@ -438,9 +404,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 438 | 404 | + mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil; |
| 439 | 405 | + CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0; |
| 440 | 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 | 411 | + [NSLayoutConstraint activateConstraints:@[ |
| 442 | + [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset], | |
| 443 | + [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset], | |
| 412 | + glassLeadingConstraint, | |
| 413 | + glassTrailingConstraint, | |
| 444 | 414 | + [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], |
| 445 | 415 | + [glassView.heightAnchor constraintEqualToConstant:toolbarHeight], |
| 446 | 416 | + ]]; |
| ... | ... | @@ -490,6 +460,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 490 | 460 | + UISearchBar *searchBar = |
| 491 | 461 | + !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil; |
| 492 | 462 | + NSString *placeholder = mailSearchToolbarConfig[@"placeholder"]; |
| 463 | + UITextField *resolvedSearchTextField = nil; | |
| 493 | 464 | + if (searchBar != nil) { |
| 494 | 465 | + if (placeholder != nil) { |
| 495 | 466 | + searchBar.placeholder = placeholder; |
| ... | ... | @@ -506,7 +477,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 506 | 477 | + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; |
| 507 | 478 | + searchBar.searchTextField.textColor = UIColor.labelColor; |
| 508 | 479 | + searchBar.searchTextField.tintColor = UIColor.labelColor; |
| 509 | + configureKeyboardTracking(searchBar.searchTextField); | |
| 480 | + resolvedSearchTextField = searchBar.searchTextField; | |
| 510 | 481 | + if (placeholder != nil) { |
| 511 | 482 | + searchBar.searchTextField.attributedPlaceholder = |
| 512 | 483 | + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; |
| ... | ... | @@ -539,7 +510,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 539 | 510 | + searchField.adjustsFontForContentSizeCategory = YES; |
| 540 | 511 | + searchField.textColor = UIColor.labelColor; |
| 541 | 512 | + searchField.tintColor = UIColor.labelColor; |
| 542 | + configureKeyboardTracking(searchField); | |
| 513 | + resolvedSearchTextField = searchField; | |
| 543 | 514 | + searchField.translatesAutoresizingMaskIntoConstraints = NO; |
| 544 | 515 | + [glassView.contentView addSubview:searchField]; |
| 545 | 516 | + [NSLayoutConstraint activateConstraints:@[ |
| ... | ... | @@ -550,8 +521,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 550 | 521 | + ]]; |
| 551 | 522 | + } |
| 552 | 523 | + |
| 524 | + UIButton *filterButton = nil; | |
| 553 | 525 | + if (hasFilterButton) { |
| 554 | + UIButton *filterButton = makeGlassButton( | |
| 526 | + filterButton = makeGlassButton( | |
| 555 | 527 | + mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease", |
| 556 | 528 | + mailSearchToolbarConfig[@"filterButtonId"], |
| 557 | 529 | + mailSearchToolbarConfig[@"filterMenu"]); |
| ... | ... | @@ -565,8 +537,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 565 | 537 | + ]]; |
| 566 | 538 | + } |
| 567 | 539 | + |
| 540 | + UIButton *composeButton = nil; | |
| 568 | 541 | + if (hasComposeButton) { |
| 569 | + UIButton *composeButton = makeGlassButton( | |
| 542 | + composeButton = makeGlassButton( | |
| 570 | 543 | + mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil", |
| 571 | 544 | + mailSearchToolbarConfig[@"composeButtonId"], |
| 572 | 545 | + mailSearchToolbarConfig[@"composeMenu"]); |
| ... | ... | @@ -579,6 +552,107 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 579 | 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 | 657 | +#endif |
| 584 | 658 | + } |
| ... | ... | @@ -615,7 +689,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 615 | 689 | |
| 616 | 690 | // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items |
| 617 | 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 | 694 | - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts |
| 621 | 695 | withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems |
| ... | ... | @@ -623,7 +697,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 623 | 697 | { |
| 624 | 698 | if (dicts.count == 0) { |
| 625 | 699 | return currentItems; |
| 626 | @@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 700 | @@ -781,7 +1268,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 627 | 701 | [items addObjectsFromArray:currentItems]; |
| 628 | 702 | for (NSUInteger i = 0; i < dicts.count; i++) { |
| 629 | 703 | NSDictionary *dict = dicts[i]; |
| ... | ... | @@ -822,7 +896,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 822 | 896 | RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict |
| 823 | 897 | action:^(NSString *buttonId) { |
| 824 | 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 | 901 | imageLoader:_imageLoader]; |
| 828 | 902 | NSNumber *index = dict[@"index"]; |
| ... | ... | @@ -852,7 +926,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 852 | 926 | [items insertObject:item atIndex:index.integerValue]; |
| 853 | 927 | } else { |
| 854 | 928 | [items addObject:item]; |
| 855 | @@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 929 | @@ -825,6 +1506,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * | |
| 856 | 930 | return items; |
| 857 | 931 | } |
| 858 | 932 | |
| ... | ... | @@ -900,7 +974,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 900 | 974 | RNS_IGNORE_SUPER_CALL_BEGIN |
| 901 | 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 | 980 | _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); |
| ... | ... | @@ -909,7 +983,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 909 | 983 | if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { |
| 910 | 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 | 987 | _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; |
| 914 | 988 | _backButtonDisplayMode = |
| 915 | 989 | [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; |
| ... | ... | @@ -917,7 +991,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 |
| 917 | 991 | |
| 918 | 992 | if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { |
| 919 | 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 | 995 | _headerRightBarButtonItems = array; |
| 922 | 996 | } |
| 923 | 997 | |
| ... | ... | @@ -1313,7 +1387,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db |
| 1313 | 1387 | /** |
| 1314 | 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 | 1391 | export interface HeaderBarButtonItemSpacing { |
| 1318 | 1392 | type: 'spacing'; |
| 1319 | 1393 | spacing: number; |
| ... | ... | @@ -1344,6 +1418,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db |
| 1344 | 1418 | + onSearchTextChange?: ((text: string) => void) | undefined; |
| 1345 | 1419 | + placeholder?: string | undefined; |
| 1346 | 1420 | + searchTextChangeId?: string | undefined; |
| 1421 | + showsSearchDismissButton?: boolean | undefined; | |
| 1347 | 1422 | + useFallbackSearchField?: boolean | undefined; |
| 1348 | 1423 | + width?: number | undefined; |
| 1349 | 1424 | } |
| ... | ... | @@ -1653,7 +1728,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 |
| 1653 | 1728 | /** |
| 1654 | 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 | 1732 | export interface HeaderBarButtonItemSpacing { |
| 1658 | 1733 | type: 'spacing'; |
| 1659 | 1734 | spacing: number; |
| ... | ... | @@ -1687,6 +1762,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 |
| 1687 | 1762 | + onSearchTextChange?: ((text: string) => void) | undefined; |
| 1688 | 1763 | + placeholder?: string | undefined; |
| 1689 | 1764 | + searchTextChangeId?: string | undefined; |
| 1765 | + showsSearchDismissButton?: boolean | undefined; | |
| 1690 | 1766 | + useFallbackSearchField?: boolean | undefined; |
| 1691 | 1767 | + width?: number | undefined; |
| 1692 | 1768 | } |
pnpm-lock.yaml+54-54| ... | ... | @@ -80,13 +80,13 @@ patchedDependencies: |
| 80 | 80 | '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa |
| 81 | 81 | '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 |
| 82 | 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 | 84 | effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 |
| 85 | 85 | expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f |
| 86 | 86 | react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 |
| 87 | 87 | react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 |
| 88 | 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 | 91 | importers: |
| 92 | 92 | |
| ... | ... | @@ -237,7 +237,7 @@ importers: |
| 237 | 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 | 238 | '@react-navigation/native-stack': |
| 239 | 239 | specifier: 7.17.6 |
| 240 | version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d) | |
| 240 | version: 7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be) | |
| 241 | 241 | '@shikijs/core': |
| 242 | 242 | specifier: 4.2.0 |
| 243 | 243 | version: 4.2.0 |
| ... | ... | @@ -402,7 +402,7 @@ importers: |
| 402 | 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 | 403 | react-native-screens: |
| 404 | 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 | 406 | react-native-shiki-engine: |
| 407 | 407 | specifier: ^0.3.12 |
| 408 | 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 | 12275 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 12276 | 12276 | zod: 3.25.76 |
| 12277 | 12277 | optionalDependencies: |
| 12278 | expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) | |
| 12278 | expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865) | |
| 12279 | 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 | 12280 | transitivePeerDependencies: |
| 12281 | 12281 | - '@expo/dom-webview' |
| ... | ... | @@ -12351,7 +12351,7 @@ snapshots: |
| 12351 | 12351 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 12352 | 12352 | zod: 3.25.76 |
| 12353 | 12353 | optionalDependencies: |
| 12354 | expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) | |
| 12354 | expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797) | |
| 12355 | 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 | 12356 | transitivePeerDependencies: |
| 12357 | 12357 | - '@expo/dom-webview' |
| ... | ... | @@ -12691,7 +12691,7 @@ snapshots: |
| 12691 | 12691 | react: 19.2.3 |
| 12692 | 12692 | optionalDependencies: |
| 12693 | 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 | 12695 | react-dom: 19.2.3(react@19.2.3) |
| 12696 | 12696 | transitivePeerDependencies: |
| 12697 | 12697 | - supports-color |
| ... | ... | @@ -12706,7 +12706,7 @@ snapshots: |
| 12706 | 12706 | react: 19.2.6 |
| 12707 | 12707 | optionalDependencies: |
| 12708 | 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 | 12710 | react-dom: 19.2.6(react@19.2.6) |
| 12711 | 12711 | transitivePeerDependencies: |
| 12712 | 12712 | - supports-color |
| ... | ... | @@ -14291,7 +14291,7 @@ snapshots: |
| 14291 | 14291 | optionalDependencies: |
| 14292 | 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 | 14295 | dependencies: |
| 14296 | 14296 | '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) |
| 14297 | 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 | 14299 | react: 19.2.3 |
| 14300 | 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 | 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 | 14303 | sf-symbols-typescript: 2.2.0 |
| 14304 | 14304 | warn-once: 0.1.1 |
| 14305 | 14305 | transitivePeerDependencies: |
| ... | ... | @@ -17080,47 +17080,47 @@ snapshots: |
| 17080 | 17080 | - supports-color |
| 17081 | 17081 | - typescript |
| 17082 | 17082 | |
| 17083 | expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8): | |
| 17083 | expo-router@56.2.11(80beea6a31a5d2003a696c1401258797): | |
| 17084 | 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) | |
| 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) | |
| 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.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 | 17087 | '@expo/schema-utils': 56.0.1 |
| 17088 | '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) | |
| 17089 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(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.3(react@19.2.3))(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.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 17088 | '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) | |
| 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.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.6)(utf-8-validate@6.0.6))(react@19.2.6) | |
| 17092 | 17092 | '@testing-library/jest-dom': 6.9.1 |
| 17093 | 17093 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) |
| 17094 | 17094 | client-only: 0.0.1 |
| 17095 | 17095 | color: 4.2.3 |
| 17096 | 17096 | debug: 4.4.3 |
| 17097 | 17097 | escape-string-regexp: 4.0.0 |
| 17098 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | |
| 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)) | |
| 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) | |
| 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) | |
| 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.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.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.6)(utf-8-validate@6.0.6))(react@19.2.6) | |
| 17102 | 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 | 17104 | fast-deep-equal: 3.1.3 |
| 17105 | 17105 | invariant: 2.2.4 |
| 17106 | 17106 | nanoid: 3.3.12 |
| 17107 | 17107 | query-string: 7.1.3 |
| 17108 | react: 19.2.3 | |
| 17108 | react: 19.2.6 | |
| 17109 | 17109 | react-fast-compare: 3.2.2 |
| 17110 | 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) | |
| 17112 | react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) | |
| 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) | |
| 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) | |
| 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(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.6)(utf-8-validate@6.0.6))(react@19.2.6) | |
| 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 | 17115 | server-only: 0.0.1 |
| 17116 | 17116 | sf-symbols-typescript: 2.2.0 |
| 17117 | 17117 | shallowequal: 1.1.0 |
| 17118 | 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 | 17120 | optionalDependencies: |
| 17121 | react-dom: 19.2.3(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.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.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) | |
| 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.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.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 | 17124 | transitivePeerDependencies: |
| 17125 | 17125 | - '@babel/core' |
| 17126 | 17126 | - '@testing-library/dom' |
| ... | ... | @@ -17131,47 +17131,47 @@ snapshots: |
| 17131 | 17131 | - supports-color |
| 17132 | 17132 | optional: true |
| 17133 | 17133 | |
| 17134 | expo-router@56.2.11(db5c693a26481047569df6781f34db9f): | |
| 17134 | expo-router@56.2.11(e1497a99e5bc5be76c1cdb733671f865): | |
| 17135 | 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) | |
| 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) | |
| 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.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 | 17138 | '@expo/schema-utils': 56.0.1 |
| 17139 | '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) | |
| 17140 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(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.6(react@19.2.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.6)(utf-8-validate@6.0.6))(react@19.2.6) | |
| 17139 | '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) | |
| 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.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.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 17143 | 17143 | '@testing-library/jest-dom': 6.9.1 |
| 17144 | 17144 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) |
| 17145 | 17145 | client-only: 0.0.1 |
| 17146 | 17146 | color: 4.2.3 |
| 17147 | 17147 | debug: 4.4.3 |
| 17148 | 17148 | escape-string-regexp: 4.0.0 |
| 17149 | expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) | |
| 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)) | |
| 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) | |
| 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) | |
| 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.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.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.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 17153 | 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 | 17155 | fast-deep-equal: 3.1.3 |
| 17156 | 17156 | invariant: 2.2.4 |
| 17157 | 17157 | nanoid: 3.3.12 |
| 17158 | 17158 | query-string: 7.1.3 |
| 17159 | react: 19.2.6 | |
| 17159 | react: 19.2.3 | |
| 17160 | 17160 | react-fast-compare: 3.2.2 |
| 17161 | 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) | |
| 17163 | react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) | |
| 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) | |
| 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) | |
| 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(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.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 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 | 17166 | server-only: 0.0.1 |
| 17167 | 17167 | sf-symbols-typescript: 2.2.0 |
| 17168 | 17168 | shallowequal: 1.1.0 |
| 17169 | 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 | 17171 | optionalDependencies: |
| 17172 | react-dom: 19.2.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.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.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) | |
| 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.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.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 | 17175 | transitivePeerDependencies: |
| 17176 | 17176 | - '@babel/core' |
| 17177 | 17177 | - '@testing-library/dom' |
| ... | ... | @@ -19977,14 +19977,14 @@ snapshots: |
| 19977 | 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 | 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 | 19981 | dependencies: |
| 19982 | 19982 | react: 19.2.3 |
| 19983 | 19983 | react-freeze: 1.0.4(react@19.2.3) |
| 19984 | 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 | 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 | 19988 | dependencies: |
| 19989 | 19989 | react: 19.2.6 |
| 19990 | 19990 | react-freeze: 1.0.4(react@19.2.6) |