authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-14 01:59:27+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-13 23:59:27+00:00
log85389b9883a2c7b31022573563b59981d697c3b4
tree33fd8531eaed5b9d83417ec8789c370ddcffa40e
parentdb1507e986591ae8e82f8fa1e173a9013309c64e
signature Signed by PGP key B5690EEEBB952194

Nest mobile task settings in bottom sheets (#6224)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

42 files changed, 3898 insertions(+), 2082 deletions(-)

.agents/skills/test-t3-mobile/SKILL.md+16-16
...@@ -125,31 +125,29 @@ Do not start, stop, erase, or reconfigure an emulator owned by another task. Tra...@@ -125,31 +125,29 @@ Do not start, stop, erase, or reconfigure an emulator owned by another task. Tra
125125
126## Pair each client once126## Pair each client once
127127
128Issue a fresh credential against the running backend's exact base directory:128Use 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:
129129
130```bash130```bash
131T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \131.agents/skills/test-t3-mobile/scripts/pair-client.sh \
132 --base-dir <base-dir> \132 ios <simulator-udid> <server-port> <base-dir>
133 --base-url <mobile-origin> \133
134 --ttl 15m \134.agents/skills/test-t3-mobile/scripts/pair-client.sh \
135 --label agent-mobile-<short-device-id>135 android <emulator-serial> <server-port> <base-dir>
136```136```
137137
138In PowerShell, set `$env:T3CODE_PORT = "<server-port>"` first and run the `node ... auth pairing create` command without the leading assignment.138Run 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.
139139
140If the visible Add Environment action is not exposed as a semantic target, open the app's registered route instead of guessing coordinates:140The helper opens this registered route:
141141
142```bash142```text
143xcrun simctl openurl <simulator-udid> 't3code-dev://connections/new'143t3code-dev://connections/new?pairingUrl=<encoded-pairing-url>&autoConnect=1
144adb -s <emulator-serial> shell am start -W \
145 -a android.intent.action.VIEW \
146 -d 't3code-dev://connections/new' \
147 com.t3tools.t3code.dev
148```144```
149145
150Run only the command for the selected platform.146The 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
148Do 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.
151149
152In 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.150Verify the expected seeded projects appear before exercising the affected flow.
153151
154Pairing 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.152Pairing 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.
155153
...@@ -183,6 +181,8 @@ Keep local verification focused. Do not turn this workflow into a full repositor...@@ -183,6 +181,8 @@ Keep local verification focused. Do not turn this workflow into a full repositor
183- **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app.181- **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app.
184- **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory.182- **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory.
185- **A second client cannot pair:** pairing tokens are single-use; issue another token.183- **A second client cannot pair:** pairing tokens are single-use; issue another token.
184- **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`.
185- **Pairing text changes case or punctuation:** do not retry semantic typing. Use `scripts/pair-client.sh`; the simulator keyboard layout and HID input path are not reliable for credentials.
186- **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`.186- **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`.
187- **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL.187- **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL.
188- **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator.188- **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator.
.agents/skills/test-t3-mobile/scripts/pair-client.sh created+72
...@@ -0,0 +1,72 @@
1#!/usr/bin/env bash
2
3set -euo pipefail
4
5usage() {
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
12platform="$1"
13device_id="$2"
14server_port="$3"
15base_dir="$4"
16url_scheme="${5:-t3code-dev}"
17
18case "$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 ;;
28esac
29
30repo_root="$(git rev-parse --show-toplevel)"
31cd "$repo_root"
32
33if ! 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
42fi
43
44pairing_url="$(printf '%s\n' "$pairing_output" | sed -n 's/^Pair URL: //p' | tail -n 1)"
45if [[ -z "$pairing_url" ]]; then
46 echo "Could not parse the mobile pairing URL." >&2
47 exit 1
48fi
49
50deep_link="$(PAIRING_URL="$pairing_url" URL_SCHEME="$url_scheme" node - <<'NODE'
51const query = new URLSearchParams({
52 pairingUrl: process.env.PAIRING_URL,
53 autoConnect: "1",
54});
55process.stdout.write(`${process.env.URL_SCHEME}://connections/new?${query}`);
56NODE
57)"
58
59case "$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 ;;
70esac
71
72echo "Opened the existing Add Environment route with a fresh pairing credential."
apps/mobile/src/Stack.tsx+103-35
...@@ -11,7 +11,7 @@ import {...@@ -11,7 +11,7 @@ import {
11 type NativeStackNavigationOptions,11 type NativeStackNavigationOptions,
12} from "@react-navigation/native-stack";12} from "@react-navigation/native-stack";
13import { useEffect, useRef } from "react";13import { useEffect, useRef } from "react";
14import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native";14import { Platform, Pressable, ScrollView, StyleSheet } from "react-native";
15import { useResolveClassNames } from "uniwind";15import { useResolveClassNames } from "uniwind";
1616
17import { AppText as Text } from "./components/AppText";17import { AppText as Text } from "./components/AppText";
...@@ -39,6 +39,15 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";...@@ -39,6 +39,15 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
39import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";39import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
40import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";40import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";
41import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen";41import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen";
42import {
43 NewTaskBranchPickerRouteScreen,
44 NewTaskEnvironmentPickerRouteScreen,
45} from "./features/threads/NewTaskContextPickerScreens";
46import {
47 ExistingThreadSettingsRouteProvider,
48 ExistingThreadSettingsRouteScreen,
49 NewTaskThreadSettingsRouteScreen,
50} from "./features/threads/ThreadSettingsSheet";
42import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";51import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
43import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";52import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
44import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";53import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
...@@ -62,17 +71,15 @@ import {...@@ -62,17 +71,15 @@ import {
62} from "./features/sharing/incoming-share-presentation";71} from "./features/sharing/incoming-share-presentation";
63import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";72import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
64import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";73import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
74import {
75 FORM_SHEET_PRESENTATION_OPTIONS,
76 NATIVE_SHEET_SURFACE_COLOR,
77 NATIVE_SHEET_SURFACE_CONTENT_STYLE,
78} from "./native/sheet-surface";
65import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";79import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
6680
67const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);81const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
6882
69// Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header
70// background stay STATIC config while still adapting to appearance changes.
71const 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
76type AppScreenOptions = NativeStackNavigationOptions & {83type AppScreenOptions = NativeStackNavigationOptions & {
77 readonly unstable_navigationItemStyle?: "editor";84 readonly unstable_navigationItemStyle?: "editor";
78};85};
...@@ -91,8 +98,8 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {...@@ -91,8 +98,8 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
91 headerShown: true,98 headerShown: true,
92 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED99 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED
93 ? { backgroundColor: "transparent" }100 ? { backgroundColor: "transparent" }
94 : SHEET_BACKGROUND_COLOR !== undefined101 : NATIVE_SHEET_SURFACE_COLOR !== undefined
95 ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string }102 ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
96 : undefined,103 : undefined,
97 headerTitleStyle: { fontSize: 18, fontWeight: "800" },104 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
98 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,105 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
...@@ -109,10 +116,10 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = {...@@ -109,10 +116,10 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = {
109 headerShadowVisible: false,116 headerShadowVisible: false,
110 headerShown: true,117 headerShown: true,
111 headerStyle:118 headerStyle:
112 SHEET_BACKGROUND_COLOR !== undefined119 NATIVE_SHEET_SURFACE_COLOR !== undefined
113 ? // native-stack types this as `string`, but the native side accepts any120 ? // native-stack types this as `string`, but the native side accepts any
114 // ColorValue including DynamicColorIOS.121 // ColorValue including DynamicColorIOS.
115 { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string }122 { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
116 : undefined,123 : undefined,
117 headerTitleStyle: { fontSize: 18, fontWeight: "800" },124 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
118 headerTransparent: false,125 headerTransparent: false,
...@@ -125,6 +132,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = {...@@ -125,6 +132,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = {
125 unstable_navigationItemStyle: undefined,132 unstable_navigationItemStyle: undefined,
126};133};
127134
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.
138const SHEET_GLASS_HEADER_OPTIONS: AppScreenOptions = {
139 ...GLASS_HEADER_OPTIONS,
140 unstable_navigationItemStyle: undefined,
141};
142
128const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {143const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
129 ...SHEET_SOLID_HEADER_OPTIONS,144 ...SHEET_SOLID_HEADER_OPTIONS,
130 headerBackVisible: false,145 headerBackVisible: false,
...@@ -238,9 +253,16 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";...@@ -238,9 +253,16 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";
238const NewTaskSheetStack = createNativeStackNavigator({253const NewTaskSheetStack = createNativeStackNavigator({
239 initialRouteName: "NewTask",254 initialRouteName: "NewTask",
240 screenOptions: {255 screenOptions: {
241 ...GLASS_HEADER_OPTIONS,256 ...SHEET_GLASS_HEADER_OPTIONS,
242 // Sheets read better with the iOS-default centered title (no editor style).257 // The form-sheet host owns the one opaque adaptive surface. Child screens
243 unstable_navigationItemStyle: undefined,258 // and the navigation bar stay transparent over it, avoiding visible color
259 // slabs as view controllers move horizontally.
260 contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined,
261 // UIKit's default push adds a dimming shadow and independently transitions
262 // the navigation bar. Both read as mismatched sheet backgrounds here.
263 // simple_push retains native push/pop gestures without either artifact.
264 animation: Platform.OS === "ios" ? "simple_push" : undefined,
265 animationDuration: Platform.OS === "ios" ? 350 : undefined,
244 },266 },
245 screens: {267 screens: {
246 NewTask: createNativeStackScreen({268 NewTask: createNativeStackScreen({
...@@ -253,9 +275,39 @@ const NewTaskSheetStack = createNativeStackNavigator({...@@ -253,9 +275,39 @@ const NewTaskSheetStack = createNativeStackNavigator({
253 NewTaskDraft: createNativeStackScreen({275 NewTaskDraft: createNativeStackScreen({
254 screen: NewTaskDraftRouteScreen,276 screen: NewTaskDraftRouteScreen,
255 linking: "draft",277 linking: "draft",
256 // The draft composer has no scroll view for glass to sample; a solid278 options: {
257 // header also lays the content out below the bar (no manual inset).279 headerBackVisible: false,
258 options: SHEET_SOLID_HEADER_OPTIONS,280 title: "",
281 },
282 }),
283 NewTaskEnvironment: createNativeStackScreen({
284 screen: NewTaskEnvironmentPickerRouteScreen,
285 linking: "draft/environment",
286 options: {
287 title: "Environment",
288 },
289 }),
290 NewTaskBranch: createNativeStackScreen({
291 screen: NewTaskBranchPickerRouteScreen,
292 linking: "draft/branch",
293 options: {
294 title: "Branch",
295 },
296 }),
297 ThreadSettings: createNativeStackScreen({
298 screen: NewTaskThreadSettingsRouteScreen,
299 linking: "draft/settings",
300 options: {
301 gestureEnabled: true,
302 headerShown: false,
303 ...(Platform.OS === "android"
304 ? { presentation: "card" as const }
305 : {
306 ...FORM_SHEET_PRESENTATION_OPTIONS,
307 sheetAllowedDetents: [1],
308 sheetGrabberVisible: true,
309 }),
310 },
259 }),311 }),
260 AddProject: createNativeStackScreen({312 AddProject: createNativeStackScreen({
261 screen: AddProjectSourceRoute,313 screen: AddProjectSourceRoute,
...@@ -294,6 +346,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([...@@ -294,6 +346,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
294 "SettingsLegal",346 "SettingsLegal",
295 "SettingsSheet",347 "SettingsSheet",
296 "ThreadReviewComment",348 "ThreadReviewComment",
349 "ThreadSettingsSheet",
297]);350]);
298351
299/**352/**
...@@ -356,9 +409,11 @@ function RootStackLayout(props: {...@@ -356,9 +409,11 @@ function RootStackLayout(props: {
356 <HardwareKeyboardCommandProvider pathname={pathname}>409 <HardwareKeyboardCommandProvider pathname={pathname}>
357 <ThreadOutboxDrainWorker />410 <ThreadOutboxDrainWorker />
358 <ShowcaseCaptureCoordinator pathname={pathname} />411 <ShowcaseCaptureCoordinator pathname={pathname} />
359 <AdaptiveWorkspaceLayout pathname={workspacePathname}>412 <ExistingThreadSettingsRouteProvider>
360 {props.children}413 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
361 </AdaptiveWorkspaceLayout>414 {props.children}
415 </AdaptiveWorkspaceLayout>
416 </ExistingThreadSettingsRouteProvider>
362 </HardwareKeyboardCommandProvider>417 </HardwareKeyboardCommandProvider>
363 );418 );
364}419}
...@@ -440,7 +495,9 @@ export const RootStack = createNativeStackNavigator({...@@ -440,7 +495,9 @@ export const RootStack = createNativeStackNavigator({
440 options: {495 options: {
441 // Android cannot host the keyboard-driven comment composer inside a496 // Android cannot host the keyboard-driven comment composer inside a
442 // formSheet; use a full-screen modal there instead.497 // formSheet; use a full-screen modal there instead.
443 presentation: Platform.OS === "android" ? "fullScreenModal" : "formSheet",498 ...(Platform.OS === "android"
499 ? { presentation: "fullScreenModal" as const }
500 : FORM_SHEET_PRESENTATION_OPTIONS),
444 sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92],501 sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92],
445 sheetGrabberVisible: Platform.OS !== "android",502 sheetGrabberVisible: Platform.OS !== "android",
446 },503 },
...@@ -450,10 +507,7 @@ export const RootStack = createNativeStackNavigator({...@@ -450,10 +507,7 @@ export const RootStack = createNativeStackNavigator({
450 linking: `${THREAD_LINKING_PREFIX}/files`,507 linking: `${THREAD_LINKING_PREFIX}/files`,
451 options: {508 options: {
452 ...GLASS_HEADER_OPTIONS,509 ...GLASS_HEADER_OPTIONS,
453 contentStyle:510 contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE,
454 SHEET_BACKGROUND_COLOR !== undefined
455 ? { backgroundColor: SHEET_BACKGROUND_COLOR }
456 : undefined,
457 title: "Files",511 title: "Files",
458 },512 },
459 }),513 }),
...@@ -462,11 +516,25 @@ export const RootStack = createNativeStackNavigator({...@@ -462,11 +516,25 @@ export const RootStack = createNativeStackNavigator({
462 linking: `${THREAD_LINKING_PREFIX}/files/:path*`,516 linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
463 options: SOLID_HEADER_OPTIONS,517 options: SOLID_HEADER_OPTIONS,
464 }),518 }),
519 ThreadSettingsSheet: createNativeStackScreen({
520 screen: ExistingThreadSettingsRouteScreen,
521 options: {
522 gestureEnabled: true,
523 headerShown: false,
524 ...(Platform.OS === "android"
525 ? { presentation: "card" as const }
526 : {
527 ...FORM_SHEET_PRESENTATION_OPTIONS,
528 sheetAllowedDetents: [1],
529 sheetGrabberVisible: true,
530 }),
531 },
532 }),
465 GitOverview: createNativeStackScreen({533 GitOverview: createNativeStackScreen({
466 screen: GitOverviewSheet,534 screen: GitOverviewSheet,
467 linking: `${THREAD_LINKING_PREFIX}/git`,535 linking: `${THREAD_LINKING_PREFIX}/git`,
468 options: {536 options: {
469 presentation: "formSheet",537 ...FORM_SHEET_PRESENTATION_OPTIONS,
470 sheetAllowedDetents: [0.55, 0.92],538 sheetAllowedDetents: [0.55, 0.92],
471 sheetGrabberVisible: true,539 sheetGrabberVisible: true,
472 },540 },
...@@ -475,7 +543,7 @@ export const RootStack = createNativeStackNavigator({...@@ -475,7 +543,7 @@ export const RootStack = createNativeStackNavigator({
475 screen: GitCommitSheet,543 screen: GitCommitSheet,
476 linking: `${THREAD_LINKING_PREFIX}/git/commit`,544 linking: `${THREAD_LINKING_PREFIX}/git/commit`,
477 options: {545 options: {
478 presentation: "formSheet",546 ...FORM_SHEET_PRESENTATION_OPTIONS,
479 sheetAllowedDetents: [0.55, 0.92],547 sheetAllowedDetents: [0.55, 0.92],
480 sheetGrabberVisible: true,548 sheetGrabberVisible: true,
481 },549 },
...@@ -484,7 +552,7 @@ export const RootStack = createNativeStackNavigator({...@@ -484,7 +552,7 @@ export const RootStack = createNativeStackNavigator({
484 screen: GitBranchesSheet,552 screen: GitBranchesSheet,
485 linking: `${THREAD_LINKING_PREFIX}/git/branches`,553 linking: `${THREAD_LINKING_PREFIX}/git/branches`,
486 options: {554 options: {
487 presentation: "formSheet",555 ...FORM_SHEET_PRESENTATION_OPTIONS,
488 sheetAllowedDetents: [0.55, 0.92],556 sheetAllowedDetents: [0.55, 0.92],
489 sheetGrabberVisible: true,557 sheetGrabberVisible: true,
490 },558 },
...@@ -493,7 +561,7 @@ export const RootStack = createNativeStackNavigator({...@@ -493,7 +561,7 @@ export const RootStack = createNativeStackNavigator({
493 screen: GitConfirmSheet,561 screen: GitConfirmSheet,
494 linking: `${THREAD_LINKING_PREFIX}/git-confirm`,562 linking: `${THREAD_LINKING_PREFIX}/git-confirm`,
495 options: {563 options: {
496 presentation: "formSheet",564 ...FORM_SHEET_PRESENTATION_OPTIONS,
497 sheetAllowedDetents: [0.45, 0.7],565 sheetAllowedDetents: [0.45, 0.7],
498 sheetGrabberVisible: true,566 sheetGrabberVisible: true,
499 },567 },
...@@ -509,7 +577,7 @@ export const RootStack = createNativeStackNavigator({...@@ -509,7 +577,7 @@ export const RootStack = createNativeStackNavigator({
509 ...(Platform.OS === "android"577 ...(Platform.OS === "android"
510 ? { presentation: "card" as const }578 ? { presentation: "card" as const }
511 : {579 : {
512 presentation: "formSheet" as const,580 ...FORM_SHEET_PRESENTATION_OPTIONS,
513 sheetAllowedDetents: [0.7, 0.92],581 sheetAllowedDetents: [0.7, 0.92],
514 sheetGrabberVisible: true,582 sheetGrabberVisible: true,
515 }),583 }),
...@@ -532,7 +600,7 @@ export const RootStack = createNativeStackNavigator({...@@ -532,7 +600,7 @@ export const RootStack = createNativeStackNavigator({
532 ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS),600 ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS),
533 title: "Set up T3 Connect",601 title: "Set up T3 Connect",
534 gestureEnabled: true,602 gestureEnabled: true,
535 presentation: "formSheet",603 ...FORM_SHEET_PRESENTATION_OPTIONS,
536 sheetAllowedDetents: [0.6, 0.95],604 sheetAllowedDetents: [0.6, 0.95],
537 sheetGrabberVisible: true,605 sheetGrabberVisible: true,
538 },606 },
...@@ -547,7 +615,7 @@ export const RootStack = createNativeStackNavigator({...@@ -547,7 +615,7 @@ export const RootStack = createNativeStackNavigator({
547 ...(Platform.OS === "android"615 ...(Platform.OS === "android"
548 ? { presentation: "card" as const, headerShown: false }616 ? { presentation: "card" as const, headerShown: false }
549 : {617 : {
550 presentation: "formSheet" as const,618 ...FORM_SHEET_PRESENTATION_OPTIONS,
551 sheetAllowedDetents: [0.55, 0.7],619 sheetAllowedDetents: [0.55, 0.7],
552 sheetGrabberVisible: true,620 sheetGrabberVisible: true,
553 }),621 }),
...@@ -557,7 +625,7 @@ export const RootStack = createNativeStackNavigator({...@@ -557,7 +625,7 @@ export const RootStack = createNativeStackNavigator({
557 screen: ConnectionsNewRouteScreen,625 screen: ConnectionsNewRouteScreen,
558 linking: "connections/new",626 linking: "connections/new",
559 options: {627 options: {
560 presentation: "formSheet",628 ...FORM_SHEET_PRESENTATION_OPTIONS,
561 sheetAllowedDetents: [0.55, 0.7],629 sheetAllowedDetents: [0.55, 0.7],
562 sheetGrabberVisible: true,630 sheetGrabberVisible: true,
563 },631 },
...@@ -577,7 +645,7 @@ export const RootStack = createNativeStackNavigator({...@@ -577,7 +645,7 @@ export const RootStack = createNativeStackNavigator({
577 ...(Platform.OS === "android"645 ...(Platform.OS === "android"
578 ? { presentation: "card" as const }646 ? { presentation: "card" as const }
579 : {647 : {
580 presentation: "formSheet" as const,648 ...FORM_SHEET_PRESENTATION_OPTIONS,
581 sheetAllowedDetents: [0.92],649 sheetAllowedDetents: [0.92],
582 sheetGrabberVisible: true,650 sheetGrabberVisible: true,
583 }),651 }),
apps/mobile/src/components/ComposerToolbar.tsx created+311
...@@ -0,0 +1,311 @@
1import type { ComponentProps, ReactNode } from "react";
2import { useCallback, useMemo, useState } from "react";
3import {
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
15import { useThemeColor } from "../lib/useThemeColor";
16import { cn } from "../lib/cn";
17import { AppText as Text } from "./AppText";
18import { SymbolView } from "./AppSymbol";
19
20const COMPOSER_TOOLBAR_GAP = 8;
21const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
22const 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 */
29export 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
87export 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
111export 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
200export 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 @@
1import type { ComponentProps, ReactNode } from "react";
2import { useCallback, useMemo, useState } from "react";
3import {
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
15import { useThemeColor } from "../lib/useThemeColor";
16import { cn } from "../lib/cn";
17import { AppText as Text } from "./AppText";
18import { SymbolView } from "./AppSymbol";
19
20export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44;
21export const COMPOSER_TOOLBAR_GAP = 8;
22export const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
23const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4;
24
25export 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
49export 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
138export 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
251export const ComposerToolbarTrigger = ComposerToolbarButton;
apps/mobile/src/components/GlassSurface.tsx+6-2
...@@ -5,16 +5,19 @@ import {...@@ -5,16 +5,19 @@ import {
5 useColorScheme,5 useColorScheme,
6 View,6 View,
7 type ColorValue,7 type ColorValue,
8 type StyleProp,
8 type ViewProps,9 type ViewProps,
9 type ViewStyle,10 type ViewStyle,
10} from "react-native";11} from "react-native";
11import { useThemeColor } from "../lib/useThemeColor";12import { useThemeColor } from "../lib/useThemeColor";
1213
13export interface GlassSurfaceProps extends Omit<ViewProps, "className"> {14interface GlassSurfaceProps extends Omit<ViewProps, "className"> {
14 readonly children: ReactNode;15 readonly children: ReactNode;
15 readonly glassEffectStyle?: "clear" | "regular" | "none";16 readonly glassEffectStyle?: "clear" | "regular" | "none";
16 readonly tintColor?: ColorValue;17 readonly tintColor?: ColorValue;
17 readonly chrome?: "default" | "none";18 readonly chrome?: "default" | "none";
19 /** Styling used only when native Liquid Glass is unavailable. */
20 readonly fallbackStyle?: StyleProp<ViewStyle>;
18}21}
1922
20export function GlassSurface({23export function GlassSurface({
...@@ -22,6 +25,7 @@ export function GlassSurface({...@@ -22,6 +25,7 @@ export function GlassSurface({
22 glassEffectStyle = "regular",25 glassEffectStyle = "regular",
23 chrome = "default",26 chrome = "default",
24 tintColor,27 tintColor,
28 fallbackStyle,
25 style,29 style,
26 ...props30 ...props
27}: GlassSurfaceProps) {31}: GlassSurfaceProps) {
...@@ -67,7 +71,7 @@ export function GlassSurface({...@@ -67,7 +71,7 @@ export function GlassSurface({
67 }71 }
6872
69 return (73 return (
70 <View {...props} style={[surfaceStyle, style]}>74 <View {...props} style={[surfaceStyle, fallbackStyle, style]}>
71 {children}75 {children}
72 </View>76 </View>
73 );77 );
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+51-16
...@@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera";...@@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera";
2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
3import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";3import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
4import { AsyncResult } from "effect/unstable/reactivity";4import { AsyncResult } from "effect/unstable/reactivity";
5import { useCallback, useEffect, useState } from "react";5import { useCallback, useEffect, useRef, useState } from "react";
6import { Alert, Platform, ScrollView, View } from "react-native";6import { Alert, Platform, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useThemeColor } from "../../lib/useThemeColor";8import { useThemeColor } from "../../lib/useThemeColor";
...@@ -11,12 +11,13 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";...@@ -11,12 +11,13 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
11import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";11import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
12import { ErrorBanner } from "../../components/ErrorBanner";12import { ErrorBanner } from "../../components/ErrorBanner";
13import { ConnectionSheetButton } from "./ConnectionSheetButton";13import { ConnectionSheetButton } from "./ConnectionSheetButton";
14import { extractPairingUrlFromQrPayload } from "./pairing";14import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";
15import { useRemoteConnections } from "../../state/use-remote-environment-registry";15import { useRemoteConnections } from "../../state/use-remote-environment-registry";
16import { buildPairingUrl, parsePairingUrl } from "./pairing";
1716
18type ConnectionsNewRouteParams = {17type ConnectionsNewRouteParams = {
19 readonly mode?: string;18 readonly mode?: string;
19 readonly pairingUrl?: string;
20 readonly autoConnect?: string;
20};21};
2122
22export function ConnectionsNewRouteScreen({23export function ConnectionsNewRouteScreen({
...@@ -30,6 +31,13 @@ export function ConnectionsNewRouteScreen({...@@ -30,6 +31,13 @@ export function ConnectionsNewRouteScreen({
30 } = useRemoteConnections();31 } = useRemoteConnections();
31 const navigation = useNavigation();32 const navigation = useNavigation();
32 const params = route.params ?? {};33 const params = route.params ?? {};
34 // Deep-link prefill exists for development automation only. A production
35 // link must not arrive with attacker-chosen host and token already filled.
36 const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : "";
37 const shouldAutoConnect =
38 __DEV__ &&
39 routePairingUrl.length > 0 &&
40 (params.autoConnect === "1" || params.autoConnect === "true");
33 const insets = useSafeAreaInsets();41 const insets = useSafeAreaInsets();
34 const [hostInput, setHostInput] = useState("");42 const [hostInput, setHostInput] = useState("");
35 const [codeInput, setCodeInput] = useState("");43 const [codeInput, setCodeInput] = useState("");
...@@ -37,6 +45,7 @@ export function ConnectionsNewRouteScreen({...@@ -37,6 +45,7 @@ export function ConnectionsNewRouteScreen({
37 const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");45 const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
38 const [cameraPermission, requestCameraPermission] = useCameraPermissions();46 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
39 const [scannerLocked, setScannerLocked] = useState(false);47 const [scannerLocked, setScannerLocked] = useState(false);
48 const attemptedAutoConnectRef = useRef<string | null>(null);
4049
41 const headerIconColor = useThemeColor("--color-icon");50 const headerIconColor = useThemeColor("--color-icon");
4251
...@@ -48,6 +57,16 @@ export function ConnectionsNewRouteScreen({...@@ -48,6 +57,16 @@ export function ConnectionsNewRouteScreen({
48 setCodeInput(code);57 setCodeInput(code);
49 }, [connectionPairingUrl]);58 }, [connectionPairingUrl]);
5059
60 useEffect(() => {
61 if (routePairingUrl.length === 0) {
62 return;
63 }
64
65 const { host, code } = parsePairingUrl(routePairingUrl);
66 setHostInput(host);
67 setCodeInput(code);
68 }, [routePairingUrl]);
69
51 useEffect(() => {70 useEffect(() => {
52 if (pairingConnectionError) {71 if (pairingConnectionError) {
53 setIsSubmitting(false);72 setIsSubmitting(false);
...@@ -116,22 +135,38 @@ export function ConnectionsNewRouteScreen({...@@ -116,22 +135,38 @@ export function ConnectionsNewRouteScreen({
116 [onChangeConnectionPairingUrl, scannerLocked],135 [onChangeConnectionPairingUrl, scannerLocked],
117 );136 );
118137
138 const connectAndClose = useCallback(
139 async (pairingUrl: string, replaceWithHome: boolean) => {
140 setIsSubmitting(true);
141 onChangeConnectionPairingUrl(pairingUrl);
142 try {
143 const result = await onConnectPress(pairingUrl);
144 if (AsyncResult.isSuccess(result)) {
145 if (replaceWithHome || !navigation.canGoBack()) {
146 navigation.dispatch(StackActions.replace("Home"));
147 } else {
148 navigation.goBack();
149 }
150 }
151 } finally {
152 setIsSubmitting(false);
153 }
154 },
155 [navigation, onChangeConnectionPairingUrl, onConnectPress],
156 );
157
119 const handleSubmit = useCallback(async () => {158 const handleSubmit = useCallback(async () => {
120 setIsSubmitting(true);159 await connectAndClose(buildPairingUrl(hostInput, codeInput), false);
160 }, [codeInput, connectAndClose, hostInput]);
121161
122 const pairingUrl = buildPairingUrl(hostInput, codeInput);162 useEffect(() => {
123 onChangeConnectionPairingUrl(pairingUrl);163 if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) {
124 const result = await onConnectPress(pairingUrl);164 return;
125 if (AsyncResult.isSuccess(result)) {
126 if (navigation.canGoBack()) {
127 navigation.goBack();
128 } else {
129 navigation.dispatch(StackActions.replace("Home"));
130 }
131 } else {
132 setIsSubmitting(false);
133 }165 }
134 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]);166
167 attemptedAutoConnectRef.current = routePairingUrl;
168 void connectAndClose(routePairingUrl, true);
169 }, [connectAndClose, routePairingUrl, shouldAutoConnect]);
135170
136 return (171 return (
137 <View collapsable={false} className="flex-1 bg-sheet">172 <View collapsable={false} className="flex-1 bg-sheet">
apps/mobile/src/features/home/HomeHeader.tsx+1
...@@ -356,6 +356,7 @@ function IosHomeHeader(props: HomeHeaderProps) {...@@ -356,6 +356,7 @@ function IosHomeHeader(props: HomeHeaderProps) {
356 onSearchTextChange: props.onSearchQueryChange,356 onSearchTextChange: props.onSearchQueryChange,
357 placeholder: "Search",357 placeholder: "Search",
358 searchTextChangeId: "home-search-text",358 searchTextChangeId: "home-search-text",
359 showsSearchDismissButton: true,
359 }),360 }),
360 ],361 ],
361 }362 }
apps/mobile/src/features/layout/native-mail-search-toolbar.ts+3
...@@ -11,6 +11,9 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";...@@ -11,6 +11,9 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
11 */11 */
12export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED;12export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED;
1313
14/** Clearance for scroll content that must come to rest above the floating toolbar. */
15export const NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET = 56;
16
14type NativeMailSearchToolbarInput = Omit<17type NativeMailSearchToolbarInput = Omit<
15 HeaderBarButtonMailSearchToolbarItem,18 HeaderBarButtonMailSearchToolbarItem,
16 "type" | "useFallbackSearchField"19 "type" | "useFallbackSearchField"
apps/mobile/src/features/projects/AddProjectScreen.tsx+41-29
...@@ -27,7 +27,7 @@ import {...@@ -27,7 +27,7 @@ import {
27 inferProjectTitleFromPath,27 inferProjectTitleFromPath,
28} from "@t3tools/client-runtime/state/projects";28} from "@t3tools/client-runtime/state/projects";
29import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";29import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
30import { StackActions, useNavigation } from "@react-navigation/native";30import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
31import { SymbolView } from "../../components/AppSymbol";31import { SymbolView } from "../../components/AppSymbol";
32import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";32import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
33import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";33import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
...@@ -402,13 +402,12 @@ function SourceControlRow(props: {...@@ -402,13 +402,12 @@ function SourceControlRow(props: {
402 icon={icon}402 icon={icon}
403 isFirst={props.isFirst}403 isFirst={props.isFirst}
404 onPress={() =>404 onPress={() =>
405 navigation.navigate("NewTaskSheet", {405 navigation.dispatch(
406 screen: "AddProjectRepository",406 StackActions.push("AddProjectRepository", {
407 params: {
408 environmentId: props.selectedEnvironmentId,407 environmentId: props.selectedEnvironmentId,
409 source: props.source,408 source: props.source,
410 },409 }),
411 })410 )
412 }411 }
413 />412 />
414 );413 );
...@@ -498,12 +497,11 @@ export function AddProjectSourceScreen() {...@@ -498,12 +497,11 @@ export function AddProjectSourceScreen() {
498 }497 }
499 isFirst498 isFirst
500 onPress={() =>499 onPress={() =>
501 navigation.navigate("NewTaskSheet", {500 navigation.dispatch(
502 screen: "AddProjectLocal",501 StackActions.push("AddProjectLocal", {
503 params: {
504 environmentId: selectedEnvironment.environmentId,502 environmentId: selectedEnvironment.environmentId,
505 },503 }),
506 })504 )
507 }505 }
508 />506 />
509 {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map(507 {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map(
...@@ -547,10 +545,18 @@ function useCreateProject(environment: EnvironmentOption | null) {...@@ -547,10 +545,18 @@ function useCreateProject(environment: EnvironmentOption | null) {
547 if (existing) {545 if (existing) {
548 Alert.alert("Project already exists", existing.title);546 Alert.alert("Project already exists", existing.title);
549 navigation.dispatch(547 navigation.dispatch(
550 StackActions.replace("NewTaskDraft", {548 CommonActions.reset({
551 environmentId: existing.environmentId,549 index: 0,
552 projectId: existing.id,550 routes: [
553 title: existing.title,551 {
552 name: "NewTaskDraft",
553 params: {
554 environmentId: existing.environmentId,
555 projectId: existing.id,
556 title: existing.title,
557 },
558 },
559 ],
554 }),560 }),
555 );561 );
556 return;562 return;
...@@ -571,10 +577,18 @@ function useCreateProject(environment: EnvironmentOption | null) {...@@ -571,10 +577,18 @@ function useCreateProject(environment: EnvironmentOption | null) {
571 return result;577 return result;
572 }578 }
573 navigation.dispatch(579 navigation.dispatch(
574 StackActions.replace("NewTaskDraft", {580 CommonActions.reset({
575 environmentId: environment.environmentId,581 index: 0,
576 projectId,582 routes: [
577 title: inferProjectTitleFromPath(workspaceRoot),583 {
584 name: "NewTaskDraft",
585 params: {
586 environmentId: environment.environmentId,
587 projectId,
588 title: inferProjectTitleFromPath(workspaceRoot),
589 },
590 },
591 ],
578 }),592 }),
579 );593 );
580 return result;594 return result;
...@@ -612,15 +626,14 @@ export function AddProjectRepositoryScreen(props: {...@@ -612,15 +626,14 @@ export function AddProjectRepositoryScreen(props: {
612 const provider = addProjectRemoteSourceProvider(source);626 const provider = addProjectRemoteSourceProvider(source);
613 if (!provider) {627 if (!provider) {
614 const remoteUrl = repositoryInput.trim();628 const remoteUrl = repositoryInput.trim();
615 navigation.navigate("NewTaskSheet", {629 navigation.dispatch(
616 screen: "AddProjectDestination",630 StackActions.push("AddProjectDestination", {
617 params: {
618 environmentId: environment.environmentId,631 environmentId: environment.environmentId,
619 source,632 source,
620 remoteUrl,633 remoteUrl,
621 repositoryTitle: remoteUrl,634 repositoryTitle: remoteUrl,
622 },635 }),
623 });636 );
624 setIsSubmitting(false);637 setIsSubmitting(false);
625 return;638 return;
626 }639 }
...@@ -636,15 +649,14 @@ export function AddProjectRepositoryScreen(props: {...@@ -636,15 +649,14 @@ export function AddProjectRepositoryScreen(props: {
636 setError(errorMessage(Cause.squash(result.cause)));649 setError(errorMessage(Cause.squash(result.cause)));
637 } else {650 } else {
638 const repository = result.value;651 const repository = result.value;
639 navigation.navigate("NewTaskSheet", {652 navigation.dispatch(
640 screen: "AddProjectDestination",653 StackActions.push("AddProjectDestination", {
641 params: {
642 environmentId: environment.environmentId,654 environmentId: environment.environmentId,
643 source,655 source,
644 remoteUrl: repository.sshUrl,656 remoteUrl: repository.sshUrl,
645 repositoryTitle: repository.nameWithOwner,657 repositoryTitle: repository.nameWithOwner,
646 },658 }),
647 });659 );
648 }660 }
649 setIsSubmitting(false);661 setIsSubmitting(false);
650 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);662 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+11-2
...@@ -549,7 +549,10 @@ function GeneralSettingsSection() {...@@ -549,7 +549,10 @@ function GeneralSettingsSection() {
549 */549 */
550function LegacySettingsSection() {550function LegacySettingsSection() {
551 const savePreferences = useAtomSet(updateMobilePreferencesAtom);551 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
552 const preferences = useAtomValue(mobilePreferencesAtom);
552 const threadListV2Enabled = useThreadListV2Enabled();553 const threadListV2Enabled = useThreadListV2Enabled();
554 const planModeEnabled =
555 AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true;
553556
554 return (557 return (
555 <View className="gap-3">558 <View className="gap-3">
...@@ -560,10 +563,16 @@ function LegacySettingsSection() {...@@ -560,10 +563,16 @@ function LegacySettingsSection() {
560 value={!threadListV2Enabled}563 value={!threadListV2Enabled}
561 onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })}564 onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })}
562 />565 />
566 <SettingsSwitchRow
567 icon="hammer"
568 label="Plan Mode"
569 value={planModeEnabled}
570 onValueChange={(value) => savePreferences({ planModeEnabled: value })}
571 />
563 </SettingsSection>572 </SettingsSection>
564 <Text className="px-2 text-sm text-foreground-muted">573 <Text className="px-2 text-sm text-foreground-muted">
565 Brings back the original grouped thread list. The default list is flat, in creation order:574 Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan
566 active work renders as cards; settled threads collapse to compact rows.575 control; otherwise every task runs in Build mode.
567 </Text>576 </Text>
568 </View>577 </View>
569 );578 );
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+1-1
...@@ -18,7 +18,7 @@ import {...@@ -18,7 +18,7 @@ import {
18 ComposerToolbarButton,18 ComposerToolbarButton,
19 ComposerToolbarRow,19 ComposerToolbarRow,
20 ComposerToolbarScroller,20 ComposerToolbarScroller,
21} from "../../components/ComposerToolbarTrigger";21} from "../../components/ComposerToolbar";
22import { ControlPillMenu } from "../../components/ControlPill";22import { ControlPillMenu } from "../../components/ControlPill";
23import { EmptyState } from "../../components/EmptyState";23import { EmptyState } from "../../components/EmptyState";
24import { GlassSurface } from "../../components/GlassSurface";24import { GlassSurface } from "../../components/GlassSurface";
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx created+473
...@@ -0,0 +1,473 @@
1import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
2import { LegendList } from "@legendapp/list/react-native";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import * as Haptics from "expo-haptics";
8import { useNavigation } from "@react-navigation/native";
9import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
10import {
11 ActivityIndicator,
12 Alert,
13 Platform,
14 Pressable,
15 ScrollView,
16 Switch,
17 TextInput,
18 View,
19} from "react-native";
20import { useSafeAreaInsets } from "react-native-safe-area-context";
21
22import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
23import { SymbolView } from "../../components/AppSymbol";
24import { AppText as Text } from "../../components/AppText";
25import { cn } from "../../lib/cn";
26import { useFontFamily } from "../../lib/useFontFamily";
27import { useThemeColor } from "../../lib/useThemeColor";
28import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
29import { useAtomCommand } from "../../state/use-atom-command";
30import { vcsEnvironment } from "../../state/vcs";
31import {
32 createNativeMailSearchToolbarItem,
33 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
34 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
35} from "../layout/native-mail-search-toolbar";
36import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
37import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";
38
39function 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
90function 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
109function 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
140function PickerSurface(props: { readonly children: ReactNode }) {
141 return <View className="overflow-hidden rounded-2xl bg-card">{props.children}</View>;
142}
143
144export 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
190export function NewTaskBranchPickerRouteScreen() {
191 const flow = useNewTaskFlow();
192 const navigation = useNavigation();
193 const insets = useSafeAreaInsets();
194 const placeholderColor = useThemeColor("--color-placeholder");
195 const foregroundColor = useThemeColor("--color-foreground");
196 const fontFamily = useFontFamily("regular");
197 const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
198 const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null);
199 const selectingBranchNameRef = useRef<string | null>(null);
200 const allowSelectionNavigationRef = useRef(false);
201 const mountedRef = useRef(true);
202 const screenTitle = flow.workspaceMode === "worktree" ? "Base branch" : "Branch";
203 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
204 const selectedBranchName =
205 flow.selectedBranchName ??
206 flow.availableBranches.find((branch) => branch.current)?.name ??
207 flow.availableBranches.find((branch) => branch.isDefault)?.name ??
208 null;
209 const branchListContentStyle = useMemo(
210 () => ({
211 paddingBottom: usesNativeMailSearchToolbar
212 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16
213 : Platform.OS === "ios"
214 ? 16
215 : Math.max(insets.bottom, 16) + 16,
216 paddingHorizontal: 16,
217 paddingTop: 12,
218 }),
219 [insets.bottom, usesNativeMailSearchToolbar],
220 );
221
222 useEffect(() => {
223 mountedRef.current = true;
224 return () => {
225 mountedRef.current = false;
226 flow.setBranchQuery("");
227 };
228 }, [flow.setBranchQuery]);
229
230 useEffect(
231 () =>
232 navigation.addListener("beforeRemove", (event) => {
233 if (selectingBranchNameRef.current !== null && !allowSelectionNavigationRef.current) {
234 event.preventDefault();
235 }
236 }),
237 [navigation],
238 );
239
240 const selectBranch = useCallback(
241 async (branch: VcsRef) => {
242 if (selectingBranchNameRef.current !== null) {
243 return;
244 }
245 selectingBranchNameRef.current = branch.name;
246 void Haptics.selectionAsync();
247
248 try {
249 let selectedBranch = branch;
250 const needsCheckout = shouldCheckoutNewTaskBranch({
251 branchIsCurrent: branch.current,
252 branchWorktreePath: branch.worktreePath,
253 workspaceMode: flow.workspaceMode,
254 });
255 if (needsCheckout && flow.selectedProject) {
256 setSwitchingBranchName(branch.name);
257 const result = await switchRef({
258 environmentId: flow.selectedProject.environmentId,
259 input: {
260 cwd: flow.selectedProject.workspaceRoot,
261 refName: branch.name,
262 },
263 });
264 if (result._tag === "Failure") {
265 if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) {
266 const error = squashAtomCommandFailure(result);
267 Alert.alert(
268 "Could not switch branch",
269 error instanceof Error ? error.message : "The branch could not be checked out.",
270 );
271 }
272 return;
273 }
274 selectedBranch = {
275 ...branch,
276 current: true,
277 isRemote: false,
278 name: result.value.refName ?? branch.name,
279 };
280 }
281
282 // The checkout has already changed the repository. Persist the matching
283 // draft selection even if the native sheet was dismissed while the
284 // command was in flight; only visible-screen work is focus-gated below.
285 flow.selectBranch(selectedBranch);
286 if (!mountedRef.current || !navigation.isFocused()) {
287 return;
288 }
289 flow.setBranchQuery("");
290 allowSelectionNavigationRef.current = true;
291 navigation.goBack();
292 } finally {
293 selectingBranchNameRef.current = null;
294 allowSelectionNavigationRef.current = false;
295 if (mountedRef.current) {
296 setSwitchingBranchName(null);
297 }
298 }
299 },
300 [
301 flow.selectBranch,
302 flow.selectedProject,
303 flow.setBranchQuery,
304 flow.workspaceMode,
305 navigation,
306 switchRef,
307 ],
308 );
309
310 const renderBranch = useCallback(
311 ({ item, index }: { readonly item: VcsRef; readonly index: number }) => (
312 <BranchSelectionRow
313 badge={branchBadgeLabel({ branch: item, project: flow.selectedProject })}
314 branch={item}
315 disabled={switchingBranchName !== null}
316 isFirst={index === 0}
317 isLast={index === flow.filteredBranches.length - 1}
318 onSelect={selectBranch}
319 selected={selectedBranchName === item.name}
320 />
321 ),
322 [
323 flow.filteredBranches.length,
324 flow.selectedProject,
325 selectBranch,
326 selectedBranchName,
327 switchingBranchName,
328 ],
329 );
330
331 const branchListHeader =
332 flow.workspaceMode === "worktree" ? (
333 <View className="mb-3 overflow-hidden rounded-2xl">
334 <ToggleRow
335 onValueChange={flow.setStartFromOrigin}
336 title="Start from origin"
337 value={flow.startFromOrigin}
338 />
339 </View>
340 ) : null;
341
342 const branchContent =
343 flow.filteredBranches.length === 0 ? (
344 <ScrollView
345 className="flex-1 bg-sheet"
346 contentInsetAdjustmentBehavior="automatic"
347 contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 12 }}
348 scrollEnabled={false}
349 showsVerticalScrollIndicator={false}
350 >
351 {branchListHeader}
352 <View
353 className="flex-1 items-center justify-center gap-3 px-4"
354 style={{
355 marginBottom: usesNativeMailSearchToolbar
356 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET
357 : 0,
358 }}
359 >
360 {flow.branchesLoading ? <ActivityIndicator /> : null}
361 <Text className="text-center text-sm text-foreground-muted">
362 {flow.branchesLoading
363 ? "Loading branches…"
364 : flow.branchesError
365 ? flow.branchesError
366 : flow.branchQuery
367 ? "No matching branches"
368 : "No branches available"}
369 </Text>
370 {!flow.branchesLoading && flow.branchesError ? (
371 <Pressable
372 accessibilityRole="button"
373 className="rounded-full bg-card px-4 py-2 active:opacity-70"
374 onPress={flow.loadBranches}
375 >
376 <Text className="text-sm font-t3-medium text-foreground">Try again</Text>
377 </Pressable>
378 ) : null}
379 </View>
380 </ScrollView>
381 ) : (
382 <LegendList
383 alwaysBounceVertical={false}
384 automaticallyAdjustsScrollIndicatorInsets
385 automaticallyAdjustKeyboardInsets={Platform.OS === "ios"}
386 className="flex-1 bg-sheet"
387 contentInsetAdjustmentBehavior="automatic"
388 contentContainerStyle={branchListContentStyle}
389 data={flow.filteredBranches}
390 keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
391 keyboardShouldPersistTaps="handled"
392 keyExtractor={(branch) =>
393 `${branch.remoteName ?? "local"}:${branch.name}:${branch.worktreePath ?? ""}`
394 }
395 ListHeaderComponent={branchListHeader}
396 ListFooterComponent={
397 flow.branchesFetchingNextPage ? (
398 <View className="items-center py-4">
399 <ActivityIndicator />
400 </View>
401 ) : null
402 }
403 onEndReached={flow.hasMoreBranches ? flow.loadMoreBranches : undefined}
404 onEndReachedThreshold={0.35}
405 renderItem={renderBranch}
406 showsVerticalScrollIndicator={false}
407 />
408 );
409
410 if (Platform.OS === "android") {
411 return (
412 <View className="flex-1 bg-sheet" collapsable={false}>
413 <NativeStackScreenOptions options={{ headerShown: false }} />
414 <AndroidScreenHeader title={screenTitle} onBack={() => navigation.goBack()} />
415 <View className="px-4 pb-2 pt-3">
416 <TextInput
417 autoCapitalize="none"
418 autoCorrect={false}
419 className="h-11 rounded-xl bg-card px-4 text-base text-foreground"
420 onChangeText={flow.setBranchQuery}
421 placeholder="Find a branch"
422 placeholderTextColor={placeholderColor}
423 style={{ color: foregroundColor, fontFamily }}
424 value={flow.branchQuery}
425 />
426 </View>
427 {branchContent}
428 </View>
429 );
430 }
431
432 return (
433 <>
434 <NativeStackScreenOptions
435 options={{
436 headerShown: true,
437 title: screenTitle,
438 unstable_headerToolbarItems: usesNativeMailSearchToolbar
439 ? () => [
440 createNativeMailSearchToolbarItem({
441 onSearchTextChange: flow.setBranchQuery,
442 placeholder: "Find a branch",
443 searchTextChangeId: "new-task-branch-search-text",
444 showsSearchDismissButton: true,
445 }),
446 ]
447 : undefined,
448 headerSearchBarOptions: usesNativeMailSearchToolbar
449 ? undefined
450 : {
451 allowToolbarIntegration: true,
452 autoCapitalize: "none",
453 hideNavigationBar: false,
454 obscureBackground: false,
455 placeholder: "Find a branch",
456 onChangeText: (event) => {
457 flow.setBranchQuery(event.nativeEvent.text);
458 },
459 onCancelButtonPress: () => {
460 flow.setBranchQuery("");
461 },
462 },
463 }}
464 />
465 {usesNativeMailSearchToolbar ? null : (
466 <NativeHeaderToolbar placement="bottom">
467 <NativeHeaderToolbar.SearchBarSlot />
468 </NativeHeaderToolbar>
469 )}
470 {branchContent}
471 </>
472 );
473}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+339-384
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1import { NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
5import {2import {
6 KeyboardAvoidingView,3 StackActions,
4 useFocusEffect,
5 useNavigation,
6 usePreventRemove,
7} from "@react-navigation/native";
8import { useCallback, useEffect, useRef, useState } from "react";
9import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native";
10import {
11 KeyboardController,
7 KeyboardStickyView,12 KeyboardStickyView,
8 useKeyboardState,13 useKeyboardState,
9} from "react-native-keyboard-controller";14} from "react-native-keyboard-controller";
...@@ -11,7 +16,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -11,7 +16,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
11import { useThemeColor } from "../../lib/useThemeColor";16import { useThemeColor } from "../../lib/useThemeColor";
12import { useFontFamily } from "../../lib/useFontFamily";17import { useFontFamily } from "../../lib/useFontFamily";
1318
14import { EnvironmentId } from "@t3tools/contracts";
15import {19import {
16 isAtomCommandInterrupted,20 isAtomCommandInterrupted,
17 squashAtomCommandFailure,21 squashAtomCommandFailure,
...@@ -19,22 +23,24 @@ import {...@@ -19,22 +23,24 @@ import {
1923
20import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";24import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
21import {25import {
26 ComposerInlineControl,
22 ComposerToolbarButton,27 ComposerToolbarButton,
23 ComposerToolbarRow,28 ComposerToolbarRow,
24 ComposerToolbarScroller,29 ComposerToolbarScroller,
25 ComposerToolbarTrigger,30} from "../../components/ComposerToolbar";
26} from "../../components/ComposerToolbarTrigger";
27import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";31import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
28import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";32import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
29import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
30import { ProviderIcon } from "../../components/ProviderIcon";33import { ProviderIcon } from "../../components/ProviderIcon";
34import { SymbolView } from "../../components/AppSymbol";
35import { AppText as Text } from "../../components/AppText";
31import { ComposerSurface } from "./ThreadComposer";36import { ComposerSurface } from "./ThreadComposer";
32import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";37import {
33import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";38 useThreadSettingsSheetPresentation,
39 type NavigationWithFinishTransitioning,
40} from "./use-thread-settings-sheet-presentation";
3441
35import { makeTurnCommandMetadata } from "../../lib/commandMetadata";42import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
36import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";43import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
37import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
38import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";44import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
39import {45import {
40 clearComposerDraftContent,46 clearComposerDraftContent,
...@@ -49,21 +55,33 @@ import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";...@@ -49,21 +55,33 @@ import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
49import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";55import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
50import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";56import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
51import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";57import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
52import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";58import { useNewTaskFlow } from "./new-task-flow-provider";
53import { useCreateProjectThread } from "./use-project-actions";59import { useCreateProjectThread } from "./use-project-actions";
54import { resolveDraftProjectSelection } from "./new-task-project-selection";60import { resolveDraftProjectSelection } from "./new-task-project-selection";
61import {
62 resolveNewTaskBranchLabel,
63 resolveNewTaskWorkspaceLabel,
64} from "./new-task-context-presentation";
55import { useIncomingShare } from "../sharing/IncomingShareProvider";65import { useIncomingShare } from "../sharing/IncomingShareProvider";
5666
57function formatWorkspaceLabel(input: {67function NewTaskWorkspaceIcon(props: {
58 readonly workspaceMode: string;68 readonly workspaceMode: "local" | "worktree";
59 readonly currentBranchName: string | null;69 readonly worktreePath: string | null;
60 readonly selectedBranchName: string | null;70}) {
61}): string {71 const iconColor = useThemeColor("--color-icon-muted");
62 const branchName = input.selectedBranchName ?? input.currentBranchName;72
63 if (input.workspaceMode === "worktree") {73 if (props.workspaceMode === "local" && props.worktreePath === null) {
64 return branchName ? `New worktree · ${branchName}` : "New worktree";74 return <SymbolView name="folder" size={16} tintColor={iconColor} type="monochrome" />;
65 }75 }
66 return branchName ? `Current · ${branchName}` : "Current checkout";76
77 return (
78 <View className="size-4">
79 <SymbolView name="folder" size={16} tintColor={iconColor} type="monochrome" />
80 <View className="absolute -right-1 -bottom-1">
81 <SymbolView name="arrow.triangle.branch" size={9} tintColor={iconColor} type="monochrome" />
82 </View>
83 </View>
84 );
67}85}
6886
69export function NewTaskDraftScreen(props: {87export function NewTaskDraftScreen(props: {
...@@ -90,7 +108,8 @@ export function NewTaskDraftScreen(props: {...@@ -90,7 +108,8 @@ export function NewTaskDraftScreen(props: {
90 const insets = useSafeAreaInsets();108 const insets = useSafeAreaInsets();
91 const colorScheme = useColorScheme();109 const colorScheme = useColorScheme();
92 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);110 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
93 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);111 const controlsBottomPadding = Math.max(insets.bottom, 10);
112 const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);
94 const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow;113 const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow;
95 const { connectedEnvironments } = useRemoteConnectionStatus();114 const { connectedEnvironments } = useRemoteConnectionStatus();
96 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(115 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
...@@ -108,6 +127,49 @@ export function NewTaskDraftScreen(props: {...@@ -108,6 +127,49 @@ export function NewTaskDraftScreen(props: {
108 editorRef: promptInputRef,127 editorRef: promptInputRef,
109 isEditorFocused: isComposerFocused,128 isEditorFocused: isComposerFocused,
110 });129 });
130 useEffect(() => {
131 if (Platform.OS !== "ios") {
132 return;
133 }
134
135 navigation.getParent()?.setOptions({ gestureEnabled: !isKeyboardVisible });
136 }, [isKeyboardVisible, navigation]);
137 useEffect(() => {
138 return () => {
139 if (Platform.OS === "ios") {
140 navigation.getParent()?.setOptions({ gestureEnabled: true });
141 }
142 };
143 }, [navigation]);
144 const settingsRoutePresentedRef = useRef(false);
145 useEffect(() => {
146 if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) {
147 return;
148 }
149
150 settingsRoutePresentedRef.current = true;
151 navigation.dispatch(StackActions.push("ThreadSettings"));
152 }, [navigation, settingsSheetPresentation.isVisible]);
153 useFocusEffect(
154 useCallback(() => {
155 if (!settingsRoutePresentedRef.current) {
156 return;
157 }
158
159 settingsRoutePresentedRef.current = false;
160 settingsSheetPresentation.onDismissed();
161 }, [settingsSheetPresentation.onDismissed]),
162 );
163 useEffect(
164 () =>
165 // UIKit's completion callback for the sheet dismissal, surfaced by the
166 // native-stack patch. This is when the queued keyboard restore runs.
167 (navigation as unknown as NavigationWithFinishTransitioning).addListener(
168 "finishTransitioning",
169 settingsSheetPresentation.onStackTransitionsFinished,
170 ),
171 [navigation, settingsSheetPresentation.onStackTransitionsFinished],
172 );
111 const [importingShareKey, setImportingShareKey] = useState<string | null>(null);173 const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
112 const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);174 const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
113 const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);175 const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
...@@ -227,9 +289,9 @@ export function NewTaskDraftScreen(props: {...@@ -227,9 +289,9 @@ export function NewTaskDraftScreen(props: {
227 }, [props.pendingTaskId, cancelEditingPendingTask]);289 }, [props.pendingTaskId, cancelEditingPendingTask]);
228290
229 const foregroundColor = useThemeColor("--color-foreground");291 const foregroundColor = useThemeColor("--color-foreground");
292 const projectUnderlineColor = useThemeColor("--color-foreground-muted");
230 const regularFontFamily = useFontFamily("regular");293 const regularFontFamily = useFontFamily("regular");
231 const bodyText = useScaledTextRole("body");294 const bodyText = useScaledTextRole("body");
232 const headlineText = useScaledTextRole("headline");
233 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";295 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
234 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";296 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
235297
...@@ -315,7 +377,7 @@ export function NewTaskDraftScreen(props: {...@@ -315,7 +377,7 @@ export function NewTaskDraftScreen(props: {
315 return;377 return;
316 }378 }
317 loadedBranchesProjectKeyRef.current = projectKey;379 loadedBranchesProjectKeyRef.current = projectKey;
318 void flow.loadBranches();380 flow.loadBranches();
319 }, [flow.loadBranches, selectedProject]);381 }, [flow.loadBranches, selectedProject]);
320382
321 useEffect(() => {383 useEffect(() => {
...@@ -517,121 +579,6 @@ export function NewTaskDraftScreen(props: {...@@ -517,121 +579,6 @@ export function NewTaskDraftScreen(props: {
517 shareImportAttempt,579 shareImportAttempt,
518 ]);580 ]);
519581
520 useEffect(() => {
521 // Android starts with the collapsed composer pill (like an open thread)
522 // and only expands/focuses when tapped.
523 if (!selectedProject || Platform.OS === "android") {
524 return;
525 }
526
527 let focusFrame: ReturnType<typeof requestAnimationFrame> | null = null;
528 const interaction = InteractionManager.runAfterInteractions(() => {
529 focusFrame = requestAnimationFrame(() => {
530 // The delayed focus can land after the settings sheet opened, which
531 // would pop the keyboard underneath its modal.
532 if (!settingsSheetPresentation.isActiveRef.current) {
533 promptInputRef.current?.focus();
534 } else {
535 settingsSheetPresentation.restoreFocusAfterSave();
536 }
537 });
538 });
539
540 return () => {
541 interaction.cancel();
542 if (focusFrame !== null) {
543 cancelAnimationFrame(focusFrame);
544 }
545 };
546 }, [
547 selectedProject,
548 settingsSheetPresentation.isActiveRef,
549 settingsSheetPresentation.restoreFocusAfterSave,
550 ]);
551
552 const environmentMenuActions = useMemo(
553 () =>
554 flow.environments.map((environment) => ({
555 id: `environment:${environment.environmentId}`,
556 title: environment.environmentLabel,
557 attributes: isIncomingShareTransferPending ? { disabled: true } : undefined,
558 state:
559 flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined,
560 })),
561 [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending],
562 );
563
564 const providerOptionDescriptors = useMemo(
565 () =>
566 resolveProviderOptionDescriptors({
567 capabilities: flow.selectedModelOption?.capabilities,
568 selections: flow.selectedModel?.options,
569 }),
570 [flow.selectedModel?.options, flow.selectedModelOption?.capabilities],
571 );
572
573 const workspaceMenuActions = useMemo(() => {
574 const branchActions =
575 flow.availableBranches.length === 0
576 ? [
577 {
578 id: "workspace:branch:none",
579 title: flow.branchesLoading ? "Loading branches…" : "No branches available",
580 attributes: { disabled: true },
581 },
582 ]
583 : flow.availableBranches.slice(0, 12).map((branch) => {
584 const badge = branchBadgeLabel({
585 branch,
586 project: flow.selectedProject,
587 });
588
589 return {
590 id: `workspace:branch:${branch.name}`,
591 title: branch.name,
592 subtitle: badge ? badge.toUpperCase() : undefined,
593 state: flow.selectedBranchName === branch.name ? ("on" as const) : undefined,
594 };
595 });
596
597 return [
598 {
599 id: "workspace:mode",
600 title: "Mode",
601 subtitle: flow.workspaceMode === "local" ? "Current checkout" : "New worktree",
602 subactions: (["local", "worktree"] as const).map((value) => ({
603 id: `workspace:mode:${value}`,
604 title: value === "local" ? "Current checkout" : "New worktree",
605 state: flow.workspaceMode === value ? ("on" as const) : undefined,
606 })),
607 },
608 {
609 id: "workspace:branch",
610 title: "Branch",
611 subtitle: flow.selectedBranchName ?? "Choose branch",
612 subactions: branchActions,
613 },
614 ...(flow.workspaceMode === "worktree"
615 ? [
616 {
617 id: "workspace:start-from-origin",
618 title: "Start from origin",
619 subtitle: "Base the worktree on the latest origin branch",
620 image: "arrow.triangle.pull",
621 state: flow.startFromOrigin ? ("on" as const) : undefined,
622 },
623 ]
624 : []),
625 ];
626 }, [
627 flow.availableBranches,
628 flow.branchesLoading,
629 flow.selectedBranchName,
630 flow.selectedProject,
631 flow.startFromOrigin,
632 flow.workspaceMode,
633 ]);
634
635 const selectedEnvironmentLabel =582 const selectedEnvironmentLabel =
636 flow.environments.find(583 flow.environments.find(
637 (environment) => environment.environmentId === flow.selectedEnvironmentId,584 (environment) => environment.environmentId === flow.selectedEnvironmentId,
...@@ -640,50 +587,17 @@ export function NewTaskDraftScreen(props: {...@@ -640,50 +587,17 @@ export function NewTaskDraftScreen(props: {
640 flow.availableBranches.find((branch) => branch.current)?.name ??587 flow.availableBranches.find((branch) => branch.current)?.name ??
641 flow.availableBranches.find((branch) => branch.isDefault)?.name ??588 flow.availableBranches.find((branch) => branch.isDefault)?.name ??
642 null;589 null;
643 const settingsSummaryLabel = threadSettingsSummaryLabel({590 const selectedBranchName = flow.selectedBranchName ?? currentBranchName;
644 modelLabel: flow.selectedModelOption?.label ?? "Model",591 const selectedBranchLabel = resolveNewTaskBranchLabel({
645 optionDescriptors: providerOptionDescriptors,592 branchName: selectedBranchName,
646 runtimeMode: flow.runtimeMode,593 startFromOrigin: flow.startFromOrigin,
647 interactionMode: flow.interactionMode,594 workspaceMode: flow.workspaceMode,
648 });595 });
649 const workspaceLabel = useMemo(596 const workspaceLabel = resolveNewTaskWorkspaceLabel({
650 () =>597 workspaceMode: flow.workspaceMode,
651 formatWorkspaceLabel({598 worktreePath: flow.selectedWorktreePath,
652 currentBranchName,599 });
653 selectedBranchName: flow.selectedBranchName,600 const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;
654 workspaceMode: flow.workspaceMode,
655 }),
656 [currentBranchName, flow.selectedBranchName, flow.workspaceMode],
657 );
658 function handleEnvironmentMenuAction(event: string) {
659 if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
660 return;
661 }
662 flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length)));
663 }
664
665 function handleWorkspaceMenuAction(event: string) {
666 if (isIncomingShareTransferPending) {
667 return;
668 }
669 if (event.startsWith("workspace:mode:")) {
670 flow.setWorkspaceMode(
671 event.slice("workspace:mode:".length) as Parameters<typeof flow.setWorkspaceMode>[0],
672 );
673 return;
674 }
675 if (event === "workspace:start-from-origin") {
676 flow.setStartFromOrigin(!flow.startFromOrigin);
677 return;
678 }
679 if (event.startsWith("workspace:branch:")) {
680 const branchName = event.slice("workspace:branch:".length);
681 const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);
682 if (branch) {
683 flow.selectBranch(branch);
684 }
685 }
686 }
687601
688 async function handlePickImages(): Promise<void> {602 async function handlePickImages(): Promise<void> {
689 if (isIncomingShareTransferPending) {603 if (isIncomingShareTransferPending) {
...@@ -733,7 +647,9 @@ export function NewTaskDraftScreen(props: {...@@ -733,7 +647,9 @@ export function NewTaskDraftScreen(props: {
733 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;647 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
734 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;648 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
735 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;649 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
736 const interactionMode = draft.interactionMode ?? flow.interactionMode;650 const interactionMode = flow.planModeEnabled
651 ? (draft.interactionMode ?? flow.interactionMode)
652 : "default";
737 const initialMessageText = draft.text.trim();653 const initialMessageText = draft.text.trim();
738654
739 if (655 if (
...@@ -851,7 +767,7 @@ export function NewTaskDraftScreen(props: {...@@ -851,7 +767,7 @@ export function NewTaskDraftScreen(props: {
851767
852 if (!selectedProject) {768 if (!selectedProject) {
853 return (769 return (
854 <View className="flex-1 bg-sheet">770 <View className="flex-1 bg-sheet" collapsable={false}>
855 {Platform.OS === "android" ? (771 {Platform.OS === "android" ? (
856 <>772 <>
857 <NativeStackScreenOptions options={{ headerShown: false }} />773 <NativeStackScreenOptions options={{ headerShown: false }} />
...@@ -866,11 +782,6 @@ export function NewTaskDraftScreen(props: {...@@ -866,11 +782,6 @@ export function NewTaskDraftScreen(props: {
866782
867 const isAndroid = Platform.OS === "android";783 const isAndroid = Platform.OS === "android";
868 const isDarkMode = colorScheme === "dark";784 const isDarkMode = colorScheme === "dark";
869 // Android expansion follows native editor focus so relayout cannot race
870 // the touch gesture that opens the keyboard.
871 // The settings sheet dismisses the keyboard, so its flag keeps the Android
872 // draft composer expanded through the blur (mirrors ThreadComposer).
873 const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
874 const canStart =785 const canStart =
875 Boolean(flow.selectedProject) &&786 Boolean(flow.selectedProject) &&
876 Boolean(flow.selectedModel) &&787 Boolean(flow.selectedModel) &&
...@@ -882,235 +793,279 @@ export function NewTaskDraftScreen(props: {...@@ -882,235 +793,279 @@ export function NewTaskDraftScreen(props: {
882 const promptEditor = (793 const promptEditor = (
883 <ComposerEditor794 <ComposerEditor
884 ref={promptInputRef}795 ref={promptInputRef}
885 // Native autoFocus fires becomeFirstResponder in didMoveToWindow, which796 // The context-first screen intentionally opens with the keyboard closed.
886 // forces the iOS keyboard bring-up during the formSheet present797 // Focusing is a user action, so presenting the form sheet has one motion.
887 // animation and stalls it. The runAfterInteractions effect above focuses
888 // the editor once the transition settles instead.
889 autoFocus={false}798 autoFocus={false}
890 editable={!isIncomingShareTransferPending}799 editable={!isIncomingShareTransferPending}
891 multiline800 multiline
892 scrollEnabled={isExpanded}801 scrollEnabled
893 value={flow.prompt}802 value={flow.prompt}
894 skills={flow.selectedProviderSkills}803 skills={flow.selectedProviderSkills}
895 onChangeText={flow.setPrompt}804 onChangeText={flow.setPrompt}
896 onFocus={() => setIsComposerFocused(true)}805 onFocus={() => setIsComposerFocused(true)}
897 onBlur={() => setIsComposerFocused(false)}806 onBlur={() => setIsComposerFocused(false)}
898 onPasteImages={(uris) => void handleNativePasteImages(uris)}807 onPasteImages={(uris) => void handleNativePasteImages(uris)}
899 placeholder={`Describe a coding task in ${selectedProject.title}`}808 placeholder="Ask anything…"
900 // Same collapsed centering as ThreadComposer: native vertical gravity809 singleLineCentered={false}
901 // in a pill-height box.810 contentInsetVertical={0}
902 singleLineCentered={!isExpanded}811 style={{
903 contentInsetVertical={isAndroid ? 0 : undefined}812 minHeight: 72,
904 style={813 maxHeight: 160,
905 isAndroid814 paddingHorizontal: 4,
906 ? isExpanded815 paddingVertical: 4,
907 ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 }816 }}
908 : { height: 36 }817 textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }}
909 : { flex: 1, minHeight: 0 }
910 }
911 textStyle={
912 isAndroid
913 ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }
914 : headlineText
915 }
916 />818 />
917 );819 );
918820
919 const toolbarPills = (821 const closeNewTask = () => {
920 <>822 void KeyboardController.dismiss({ animated: true });
921 <ComposerToolbarButton823 const parentNavigation = navigation.getParent();
922 icon="plus"824 if (parentNavigation) {
923 onPress={() => void handlePickImages()}825 parentNavigation.goBack();
924 showChevron={false}826 return;
925 disabled={isIncomingShareTransferPending}827 }
926 />828 navigation.goBack();
927 <ComposerToolbarTrigger829 };
928 accessibilityLabel="Thread settings"830 const chooseProject = () => {
831 if (isIncomingShareTransferPending) {
832 return;
833 }
834 promptInputRef.current?.blur();
835 void KeyboardController.dismiss({ animated: true });
836 navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId }));
837 };
838 const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => {
839 if (isIncomingShareTransferPending) {
840 return;
841 }
842 promptInputRef.current?.blur();
843 void KeyboardController.dismiss({ animated: true });
844 navigation.dispatch(StackActions.push(routeName));
845 };
846
847 const hero = (
848 <View className="items-center gap-6 px-6" testID="new-task-hero">
849 <View className="w-full items-center gap-1.5">
850 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">
851 What should we build
852 </Text>
853 <View className="max-w-full flex-row items-center justify-center">
854 <Text className="text-2xl font-t3-medium tracking-tight text-foreground">in </Text>
855 <Pressable
856 accessibilityHint="Opens the project picker"
857 accessibilityLabel={`Change project from ${selectedProject.title}`}
858 accessibilityRole="button"
859 disabled={isIncomingShareTransferPending}
860 onPress={chooseProject}
861 className="min-w-0 max-w-[250px] active:opacity-65"
862 style={{
863 borderBottomColor: projectUnderlineColor,
864 borderBottomWidth: 1,
865 }}
866 >
867 <Text
868 className="text-2xl font-t3-medium tracking-tight text-foreground"
869 numberOfLines={1}
870 >
871 {selectedProject.title}
872 </Text>
873 </Pressable>
874 <Text className="text-2xl font-t3-medium tracking-tight text-foreground">?</Text>
875 </View>
876 </View>
877
878 <ComposerInlineControl
879 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
880 chevronDirection="right"
929 disabled={isIncomingShareTransferPending}881 disabled={isIncomingShareTransferPending}
930 iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}882 icon="desktopcomputer"
931 label={settingsSummaryLabel}883 label={`on ${selectedEnvironmentLabel}`}
932 maxWidth={320}884 maxWidth={260}
933 onPress={settingsSheetPresentation.open}885 onPress={
886 flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined
887 }
888 showChevron={flow.environments.length > 1}
889 static={flow.environments.length <= 1}
934 />890 />
935 <ControlPillMenu891 </View>
936 actions={environmentMenuActions}892 );
937 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}893 const heroViewport = (
938 >894 <View className="flex-1" collapsable={false}>
939 <ComposerToolbarTrigger895 <ScrollView
940 accessibilityLabel="Environment"896 alwaysBounceVertical={isKeyboardVisible}
941 disabled={isIncomingShareTransferPending}897 className="flex-1"
942 icon="desktopcomputer"898 contentInsetAdjustmentBehavior="never"
943 label={selectedEnvironmentLabel}899 contentContainerClassName="grow items-center pb-[236px] pt-12 ios:pt-[72px]"
944 />900 keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
945 </ControlPillMenu>901 keyboardShouldPersistTaps="handled"
946 <ControlPillMenu902 showsVerticalScrollIndicator={false}
947 actions={workspaceMenuActions}903 style={{ flex: 1 }}
948 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}904 testID="new-task-hero-scroll"
949 >905 >
950 <ComposerToolbarTrigger906 {hero}
951 accessibilityLabel="Workspace"907 </ScrollView>
952 disabled={isIncomingShareTransferPending}908 </View>
953 icon="point.topleft.down.curvedto.point.bottomright.up"
954 label={workspaceLabel}
955 />
956 </ControlPillMenu>
957 </>
958 );909 );
959910
960 const settingsSheet = (911 const workspaceControls = (
961 <ThreadSettingsSheet912 <View className="flex-row items-center gap-1 px-2">
962 visible={settingsSheetPresentation.isVisible}913 <ComposerInlineControl
963 onClose={settingsSheetPresentation.close}914 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
964 onDismissed={settingsSheetPresentation.onDismissed}915 accessibilityLabel={workspaceLabel}
965 providerGroups={flow.providerGroups}916 disabled={isIncomingShareTransferPending}
966 selectedModel={flow.selectedModel}917 iconNode={
967 onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}918 <NewTaskWorkspaceIcon
968 optionDescriptors={providerOptionDescriptors}919 workspaceMode={flow.workspaceMode}
969 onUpdateOptionSelections={flow.setSelectedModelOptions}920 worktreePath={flow.selectedWorktreePath}
970 runtimeMode={flow.runtimeMode}921 />
971 onUpdateRuntimeMode={flow.setRuntimeMode}922 }
972 />923 label={workspaceLabel}
924 maxWidth={flow.workspaceMode === "local" ? 220 : 148}
925 onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
926 showChevron={false}
927 />
928
929 <ComposerInlineControl
930 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
931 chevronDirection="right"
932 disabled={isIncomingShareTransferPending}
933 icon="arrow.triangle.branch"
934 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
935 maxWidth={190}
936 onPress={() => openContextPicker("NewTaskBranch")}
937 />
938 </View>
973 );939 );
974940
975 const startButton = (941 const composerDock = (
976 <ComposerToolbarButton942 <View className="bg-sheet px-4 pt-1" style={{ paddingBottom: controlsBottomPadding }}>
977 accessibilityLabel={943 <View className="pb-1">{workspaceControls}</View>
978 flow.submitting ? "Starting task" : environmentConnected ? "Start task" : "Queue task"944
979 }945 <ComposerSurface
980 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}946 animateLayout={false}
981 onPress={() => void handleStart()}947 isDarkMode={isDarkMode}
982 variant="primary"948 style={{
983 showChevron={false}949 borderRadius: 26,
984 disabled={!canStart}950 minHeight: 140,
985 />951 overflow: "hidden",
952 paddingBottom: 6,
953 paddingHorizontal: 14,
954 paddingTop: 14,
955 }}
956 >
957 {flow.attachments.length > 0 ? (
958 <View className="pb-2.5">
959 <ComposerAttachmentStrip
960 attachments={flow.attachments}
961 imageBorderRadius={16}
962 imageSize={72}
963 onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment}
964 />
965 </View>
966 ) : null}
967
968 {promptEditor}
969
970 <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}>
971 <ComposerToolbarScroller
972 fadeOpaque={sheetFadeOpaque}
973 fadeTransparent={sheetFadeTransparent}
974 contentPaddingRight={8}
975 >
976 <ComposerToolbarButton
977 accessibilityLabel="Add attachment"
978 disabled={isIncomingShareTransferPending}
979 icon="plus"
980 onPress={() => void handlePickImages()}
981 showChevron={false}
982 />
983 <ComposerInlineControl
984 accessibilityLabel="Model and reasoning settings"
985 disabled={isIncomingShareTransferPending}
986 emphasized
987 iconNode={
988 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
989 }
990 label={flow.selectedModelOption?.label ?? "Choose model"}
991 maxWidth={152}
992 onPress={settingsSheetPresentation.open}
993 />
994 {flow.planModeEnabled ? (
995 <ComposerInlineControl
996 accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`}
997 accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`}
998 disabled={isIncomingShareTransferPending}
999 emphasized
1000 icon={
1001 flow.interactionMode === "plan"
1002 ? { ios: "list.bullet.clipboard", android: "auto_awesome" }
1003 : { ios: "hammer", android: "construction" }
1004 }
1005 label={flow.interactionMode === "plan" ? "Plan" : "Build"}
1006 onPress={() =>
1007 flow.setInteractionMode(flow.interactionMode === "plan" ? "default" : "plan")
1008 }
1009 showChevron={false}
1010 />
1011 ) : null}
1012 </ComposerToolbarScroller>
1013 <ComposerToolbarButton
1014 accessibilityLabel={
1015 flow.submitting ? "Starting task" : environmentConnected ? "Start task" : "Queue task"
1016 }
1017 disabled={!canStart}
1018 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
1019 onPress={() => void handleStart()}
1020 showChevron={false}
1021 variant="primary"
1022 />
1023 </ComposerToolbarRow>
1024 </ComposerSurface>
1025 </View>
986 );1026 );
9871027
988 if (isAndroid) {1028 if (isAndroid) {
989 // The draft is a thread that doesn't exist yet, so it mirrors the thread
990 // page: in-screen header, empty feed canvas above, and the same floating
991 // composer chrome as ThreadComposer (collapsed pill → expanded card).
992 //
993 // Composer positioning mirrors ThreadDetailScreen's floating overlay
994 // (KeyboardStickyView, absolute bottom overlay) rather than
995 // KeyboardAvoidingView's automaticOffset+padding: automaticOffset
996 // resolves the composer's on-screen frame via a native
997 // viewPositionInWindow measurement, which this app's Android
998 // edge-to-edge setup (KeyboardProvider's native content-view margin
999 // handling neutralizes windowSoftInputMode="adjustResize" while active)
1000 // makes unreliable — the composer stayed under the keyboard instead of
1001 // translating above it. KeyboardStickyView sticks directly to the
1002 // animated keyboard height instead, sidestepping that measurement.
1003 return (1029 return (
1004 <View className="flex-1 bg-screen">1030 <View className="flex-1 bg-sheet" collapsable={false}>
1005 <NativeStackScreenOptions options={{ headerShown: false }} />1031 <NativeStackScreenOptions options={{ headerShown: false }} />
1006 <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} />1032 <AndroidScreenHeader title="New task" onBack={closeNewTask} />
10071033 {heroViewport}
1008 <View className="flex-1" />
10091034
1010 <KeyboardStickyView1035 <KeyboardStickyView
1011 enabled={isKeyboardVisible}
1012 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}1036 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1013 offset={{ closed: 0, opened: 0 }}1037 offset={{ closed: 0, opened: keyboardOpenedOffset }}
1014 >1038 >
1015 <View1039 {composerDock}
1016 className="px-4 pt-2"
1017 style={{
1018 paddingBottom: controlsBottomPadding,
1019 experimental_backgroundImage: isDarkMode
1020 ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)"
1021 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",
1022 }}
1023 >
1024 <ComposerSurface
1025 isDarkMode={isDarkMode}
1026 style={
1027 isExpanded
1028 ? {
1029 borderRadius: 20,
1030 overflow: "hidden",
1031 paddingHorizontal: 14,
1032 paddingVertical: 12,
1033 }
1034 : {
1035 borderRadius: 999,
1036 overflow: "hidden",
1037 flexDirection: "row",
1038 alignItems: "center",
1039 paddingLeft: 18,
1040 paddingRight: 5,
1041 paddingVertical: 5,
1042 }
1043 }
1044 >
1045 {isExpanded && flow.attachments.length > 0 ? (
1046 <View className="pb-2.5">
1047 <ComposerAttachmentStrip
1048 attachments={flow.attachments}
1049 onRemove={
1050 isIncomingShareTransferPending ? () => undefined : flow.removeAttachment
1051 }
1052 />
1053 </View>
1054 ) : null}
1055 <View className={isExpanded ? undefined : "min-w-0 flex-1"}>{promptEditor}</View>
1056 {!isExpanded ? (
1057 <ControlPill
1058 icon="arrow.up"
1059 variant="primary"
1060 disabled={!canStart}
1061 onPress={() => void handleStart()}
1062 />
1063 ) : null}
1064 </ComposerSurface>
1065
1066 {isExpanded ? (
1067 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
1068 <ComposerToolbarScroller
1069 fadeOpaque={isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"}
1070 fadeTransparent={isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"}
1071 >
1072 {toolbarPills}
1073 </ComposerToolbarScroller>
1074 {startButton}
1075 </ComposerToolbarRow>
1076 ) : null}
1077 </View>
1078 </KeyboardStickyView>1040 </KeyboardStickyView>
1079 {settingsSheet}
1080 </View>1041 </View>
1081 );1042 );
1082 }1043 }
10831044
1084 return (1045 return (
1085 <View className="flex-1 bg-sheet">1046 <View className="flex-1 bg-sheet" collapsable={false}>
1086 <NativeStackScreenOptions options={{ title: selectedProject.title }} />1047 <NativeStackScreenOptions
10871048 options={{
1088 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">1049 headerBackVisible: false,
1089 <View className="min-h-0 flex-1 px-5 pt-2">{promptEditor}</View>1050 headerShadowVisible: false,
1051 title: "",
1052 }}
1053 />
1054 <NativeHeaderToolbar placement="left">
1055 <NativeHeaderToolbar.Button
1056 accessibilityLabel="Cancel new task"
1057 label="Cancel"
1058 onPress={closeNewTask}
1059 />
1060 </NativeHeaderToolbar>
10901061
1091 <View className="border-t border-border" style={{ paddingBottom: controlsBottomPadding }}>1062 {heroViewport}
1092 {flow.attachments.length > 0 ? (1063 <KeyboardStickyView
1093 <View className="px-4 pt-3">1064 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1094 <ComposerAttachmentStrip1065 offset={{ closed: 0, opened: keyboardOpenedOffset }}
1095 attachments={flow.attachments}1066 >
1096 onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment}1067 {composerDock}
1097 imageSize={88}1068 </KeyboardStickyView>
1098 imageBorderRadius={20}
1099 />
1100 </View>
1101 ) : null}
1102 <ComposerToolbarRow paddingBottom={controlsBottomPadding} paddingHorizontal={6}>
1103 <ComposerToolbarScroller
1104 fadeOpaque={sheetFadeOpaque}
1105 fadeTransparent={sheetFadeTransparent}
1106 >
1107 {toolbarPills}
1108 </ComposerToolbarScroller>
1109 {startButton}
1110 </ComposerToolbarRow>
1111 </View>
1112 </KeyboardAvoidingView>
1113 {settingsSheet}
1114 </View>1069 </View>
1115 );1070 );
1116}1071}
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+33-78
...@@ -1,8 +1,13 @@...@@ -1,8 +1,13 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";2import {
3 StackActions,
4 useIsFocused,
5 useNavigation,
6 type StaticScreenProps,
7} from "@react-navigation/native";
3import { SymbolView } from "../../components/AppSymbol";8import { SymbolView } from "../../components/AppSymbol";
4import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";9import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import { useEffect, useRef, useState } from "react";10import { useEffect, useRef } from "react";
6import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";11import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";12import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useThemeColor } from "../../lib/useThemeColor";13import { useThemeColor } from "../../lib/useThemeColor";
...@@ -14,10 +19,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";...@@ -14,10 +19,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
14import { useProjects } from "../../state/entities";19import { useProjects } from "../../state/entities";
15import type { WorkspaceState } from "../../state/workspaceModel";20import type { WorkspaceState } from "../../state/workspaceModel";
16import { useWorkspaceState } from "../../state/workspace";21import { useWorkspaceState } from "../../state/workspace";
17import { scopedProjectKey } from "../../lib/scopedEntities";
18import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";22import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
19import { useIncomingShare } from "../sharing/IncomingShareProvider";23import { useIncomingShare } from "../sharing/IncomingShareProvider";
20import { useNewTaskFlow } from "./new-task-flow-provider";24import { useNewTaskFlow } from "./new-task-flow-provider";
25import { getProjectScopeSelectionTarget } from "./new-task-project-selection";
2126
22type NewTaskRouteParams = {27type NewTaskRouteParams = {
23 readonly incomingShareId?: string | string[];28 readonly incomingShareId?: string | string[];
...@@ -80,7 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {...@@ -80,7 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {
8085
81export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {86export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {
82 const projects = useProjects();87 const projects = useProjects();
83 const { projectScopes } = useNewTaskFlow();88 const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow();
84 const { state: catalogState } = useWorkspaceState();89 const { state: catalogState } = useWorkspaceState();
85 const navigation = useNavigation();90 const navigation = useNavigation();
86 const isFocused = useIsFocused();91 const isFocused = useIsFocused();
...@@ -88,7 +93,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -88,7 +93,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
88 const insets = useSafeAreaInsets();93 const insets = useSafeAreaInsets();
89 const chevronColor = useThemeColor("--color-chevron");94 const chevronColor = useThemeColor("--color-chevron");
90 const accentColor = useThemeColor("--color-icon-muted");95 const accentColor = useThemeColor("--color-icon-muted");
91 const [expandedGroupKeys, setExpandedGroupKeys] = useState<ReadonlySet<string>>(() => new Set());
92 const { getShare, releaseShareReservation } = useIncomingShare();96 const { getShare, releaseShareReservation } = useIncomingShare();
93 const routeShareId = Array.isArray(route.params?.incomingShareId)97 const routeShareId = Array.isArray(route.params?.incomingShareId)
94 ? route.params.incomingShareId[0]98 ? route.params.incomingShareId[0]
...@@ -126,27 +130,22 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -126,27 +130,22 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
126 return;130 return;
127 }131 }
128 }132 }
129 navigation.navigate("NewTaskSheet", {133 const state = navigation.getState();
130 screen: "NewTaskDraft",134 const previousRoute = state?.routes[state.index - 1];
131 params: {135 if (previousRoute?.name === "NewTaskDraft") {
136 setProject(project);
137 navigation.goBack();
138 return;
139 }
140
141 navigation.dispatch(
142 StackActions.push("NewTaskDraft", {
132 environmentId: project.environmentId,143 environmentId: project.environmentId,
133 projectId: project.id,144 projectId: project.id,
134 title: project.title,145 title: project.title,
135 incomingShareId: incomingShare?.id,146 incomingShareId: incomingShare?.id,
136 },147 }),
137 });148 );
138 }
139
140 function toggleGroup(groupKey: string): void {
141 setExpandedGroupKeys((current) => {
142 const next = new Set(current);
143 if (next.has(groupKey)) {
144 next.delete(groupKey);
145 } else {
146 next.add(groupKey);
147 }
148 return next;
149 });
150 }149 }
151150
152 useEffect(() => {151 useEffect(() => {
...@@ -169,15 +168,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -169,15 +168,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
169 return;168 return;
170 }169 }
171 resumedDestinationKeyRef.current = destinationKey;170 resumedDestinationKeyRef.current = destinationKey;
172 navigation.navigate("NewTaskSheet", {171 navigation.dispatch(
173 screen: "NewTaskDraft",172 StackActions.push("NewTaskDraft", {
174 params: {
175 environmentId: reservedDestinationProject.environmentId,173 environmentId: reservedDestinationProject.environmentId,
176 projectId: reservedDestinationProject.id,174 projectId: reservedDestinationProject.id,
177 title: reservedDestinationProject.title,175 title: reservedDestinationProject.title,
178 incomingShareId: incomingShare.id,176 incomingShareId: incomingShare.id,
179 },177 }),
180 });178 );
181 }, [incomingShare, isFocused, navigation, reservedDestinationProject]);179 }, [incomingShare, isFocused, navigation, reservedDestinationProject]);
182180
183 return (181 return (
...@@ -196,7 +194,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -196,7 +194,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
196 {194 {
197 accessibilityLabel: "Add project",195 accessibilityLabel: "Add project",
198 icon: "plus",196 icon: "plus",
199 onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }),197 onPress: () => navigation.dispatch(StackActions.push("AddProject")),
200 },198 },
201 ]199 ]
202 : []200 : []
...@@ -223,7 +221,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -223,7 +221,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
223 {catalogState.hasReadyEnvironment ? (221 {catalogState.hasReadyEnvironment ? (
224 <NativeHeaderToolbar.Button222 <NativeHeaderToolbar.Button
225 icon="plus"223 icon="plus"
226 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}224 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
227 separateBackground225 separateBackground
228 />226 />
229 ) : null}227 ) : null}
...@@ -263,7 +261,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -263,7 +261,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
263 ) : (261 ) : (
264 <Pressable262 <Pressable
265 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"263 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
266 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}264 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
267 >265 >
268 <Text className="text-sm font-t3-bold text-primary-foreground">266 <Text className="text-sm font-t3-bold text-primary-foreground">
269 Add new project267 Add new project
...@@ -275,22 +273,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -275,22 +273,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
275 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">273 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
276 {projectScopes.map((scope, scopeIndex) => {274 {projectScopes.map((scope, scopeIndex) => {
277 const hasMultipleProjects = scope.projects.length > 1;275 const hasMultipleProjects = scope.projects.length > 1;
278 const expanded = expandedGroupKeys.has(scope.key);276 const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId);
279 const singleProject = hasMultipleProjects ? null : scope.projects[0];
280 return (277 return (
281 <View278 <View
282 key={scope.key}279 key={scope.key}
283 className={cn(scopeIndex > 0 && "border-t border-border-subtle")}280 className={cn(scopeIndex > 0 && "border-t border-border-subtle")}
284 >281 >
285 <Pressable282 <Pressable
286 disabled={singleProject !== null && reservedDestinationProject !== null}283 disabled={reservedDestinationProject !== null}
287 onPress={() => {284 onPress={() => void selectProject(selectionTarget)}
288 if (singleProject) {
289 void selectProject(singleProject);
290 } else {
291 toggleGroup(scope.key);
292 }
293 }}
294 className="flex-row items-center gap-3 bg-card px-4 py-3.5"285 className="flex-row items-center gap-3 bg-card px-4 py-3.5"
295 >286 >
296 <View className="h-7 w-7 items-center justify-center">287 <View className="h-7 w-7 items-center justify-center">
...@@ -311,52 +302,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -311,52 +302,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
311 >302 >
312 {hasMultipleProjects303 {hasMultipleProjects
313 ? `${scope.projects.length} workspaces`304 ? `${scope.projects.length} workspaces`
314 : singleProject?.workspaceRoot}305 : selectionTarget.workspaceRoot}
315 </Text>306 </Text>
316 </View>307 </View>
317 <SymbolView308 <SymbolView
318 name={hasMultipleProjects && expanded ? "chevron.down" : "chevron.right"}309 name="chevron.right"
319 size={14}310 size={14}
320 tintColor={chevronColor}311 tintColor={chevronColor}
321 type="monochrome"312 type="monochrome"
322 />313 />
323 </Pressable>314 </Pressable>
324 {hasMultipleProjects && expanded
325 ? scope.projects.map((project) => (
326 <Pressable
327 key={scopedProjectKey(project.environmentId, project.id)}
328 disabled={reservedDestinationProject !== null}
329 onPress={() => void selectProject(project)}
330 className="flex-row items-center gap-3 border-t border-border-subtle bg-card py-3 pr-4 pl-10"
331 >
332 <ProjectFavicon
333 environmentId={project.environmentId}
334 faviconPath={project.faviconPath}
335 size={18}
336 projectTitle={project.title}
337 workspaceRoot={project.workspaceRoot}
338 />
339 <View className="min-w-0 flex-1">
340 <Text className="text-sm font-t3-bold text-foreground">
341 {project.title}
342 </Text>
343 <Text
344 className="text-xs text-foreground-muted"
345 ellipsizeMode="middle"
346 numberOfLines={1}
347 >
348 {project.workspaceRoot}
349 </Text>
350 </View>
351 <SymbolView
352 name="chevron.right"
353 size={14}
354 tintColor={chevronColor}
355 type="monochrome"
356 />
357 </Pressable>
358 ))
359 : null}
360 </View>315 </View>
361 );316 );
362 })}317 })}
apps/mobile/src/features/threads/ThreadComposer.tsx+121-151
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import type {1import type {
3 EnvironmentId,2 EnvironmentId,
4 MessageId,3 MessageId,
...@@ -14,7 +13,7 @@ import {...@@ -14,7 +13,7 @@ import {
14 serializeComposerFileLink,13 serializeComposerFileLink,
15 type ComposerTrigger,14 type ComposerTrigger,
16} from "@t3tools/shared/composerTrigger";15} from "@t3tools/shared/composerTrigger";
17import * as Haptics from "expo-haptics";16import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
18import type { ReactNode } from "react";17import type { ReactNode } from "react";
19import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";18import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
20import {19import {
...@@ -41,18 +40,19 @@ import { scopedThreadKey } from "../../lib/scopedEntities";...@@ -41,18 +40,19 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
4140
42import { AppText as Text } from "../../components/AppText";41import { AppText as Text } from "../../components/AppText";
43import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";42import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
43import { GlassSurface } from "../../components/GlassSurface";
44import {44import {
45 ComposerEditor,45 ComposerEditor,
46 type ComposerEditorHandle,46 type ComposerEditorHandle,
47 type ComposerEditorSelection,47 type ComposerEditorSelection,
48} from "../../components/ComposerEditor";48} from "../../components/ComposerEditor";
49import {49import {
50 ComposerInlineControl,
50 ComposerToolbarButton,51 ComposerToolbarButton,
51 ComposerToolbarRow,52 ComposerToolbarRow,
52 ComposerToolbarScroller,53 ComposerToolbarScroller,
53 ComposerToolbarTrigger,54} from "../../components/ComposerToolbar";
54} from "../../components/ComposerToolbarTrigger";55import { ControlPill } from "../../components/ControlPill";
55import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
56import { ProviderIcon } from "../../components/ProviderIcon";56import { ProviderIcon } from "../../components/ProviderIcon";
57import type { DraftComposerImageAttachment } from "../../lib/composerImages";57import type { DraftComposerImageAttachment } from "../../lib/composerImages";
58import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";58import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
...@@ -63,15 +63,17 @@ import {...@@ -63,15 +63,17 @@ import {
63 normalizeSearchQuery,63 normalizeSearchQuery,
64 scoreQueryMatch,64 scoreQueryMatch,
65} from "@t3tools/shared/searchRanking";65} from "@t3tools/shared/searchRanking";
66import {66import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
67 applyProviderOptionSelection,
68 resolveProviderOptionDescriptors,
69} from "../../lib/providerOptions";
70import { useComposerPathSearch } from "../../state/use-composer-path-search";67import { useComposerPathSearch } from "../../state/use-composer-path-search";
71import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";68import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
72import { buildThreadSettingsMenu } from "./thread-settings-menu";69import {
73import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";70 type ExistingThreadSettingsRouteSession,
74import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";71 useExistingThreadSettingsRoutePresentation,
72} from "./ThreadSettingsSheet";
73import {
74 useThreadSettingsSheetPresentation,
75 type NavigationWithFinishTransitioning,
76} from "./use-thread-settings-sheet-presentation";
7577
76/**78/**
77 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).79 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
...@@ -83,7 +85,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60;...@@ -83,7 +85,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60;
83 * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset).85 * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset).
84 * Used by the parent to compute the larger feed bottom inset when the composer is focused.86 * Used by the parent to compute the larger feed bottom inset when the composer is focused.
85 */87 */
86export const COMPOSER_EXPANDED_CHROME = 174;88export const COMPOSER_EXPANDED_CHROME = 156;
8789
88export interface ThreadComposerProps {90export interface ThreadComposerProps {
89 readonly draftMessage: string;91 readonly draftMessage: string;
...@@ -123,8 +125,8 @@ export interface ThreadComposerProps {...@@ -123,8 +125,8 @@ export interface ThreadComposerProps {
123}125}
124126
125/**127/**
126 * The pill / card container — renders as LiquidGlassView on supported128 * The pill / card container — renders with Expo's native GlassView on supported
127 * iOS 26+ devices (progressive blur, native morph), opaque View otherwise.129 * iOS 26+ devices and keeps the existing opaque fallback elsewhere.
128 * Exported so NewTaskDraftScreen can render the same composer chrome.130 * Exported so NewTaskDraftScreen can render the same composer chrome.
129 */131 */
130// One timing for every piece of the expanded↔compact morph so the surface,132// One timing for every piece of the expanded↔compact morph so the surface,
...@@ -140,6 +142,8 @@ export function ComposerSurface(props: {...@@ -140,6 +142,8 @@ export function ComposerSurface(props: {
140 readonly children: ReactNode;142 readonly children: ReactNode;
141 readonly style: ViewStyle;143 readonly style: ViewStyle;
142 readonly isDarkMode: boolean;144 readonly isDarkMode: boolean;
145 /** Existing thread composers morph between pill and card layouts. */
146 readonly animateLayout?: boolean;
143}) {147}) {
144 // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself148 // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself
145 // (needed to clip content to the pill shape) would clip the shadow on iOS.149 // (needed to clip content to the pill shape) would clip the shadow on iOS.
...@@ -152,35 +156,26 @@ export function ComposerSurface(props: {...@@ -152,35 +156,26 @@ export function ComposerSurface(props: {
152 elevation: 10,156 elevation: 10,
153 };157 };
154158
155 if (isLiquidGlassSupported) {
156 return (
157 <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}>
158 <LiquidGlassView
159 effect="regular"
160 interactive
161 colorScheme={props.isDarkMode ? "dark" : "light"}
162 style={props.style}
163 >
164 {props.children}
165 </LiquidGlassView>
166 </Animated.View>
167 );
168 }
169
170 return (159 return (
171 <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}>160 <Animated.View
172 <View161 layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION}
173 style={[162 style={shadowStyle}
174 props.style,163 >
175 {164 <GlassSurface
176 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",165 chrome="none"
177 borderWidth: 1,166 fallbackStyle={{
178 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",167 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",
179 },168 borderWidth: 1,
180 ]}169 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",
170 }}
171 glassEffectStyle="regular"
172 // The composer is a passive material containing interactive controls.
173 // Expo GlassView defaults to non-interactive and both layouts share it.
174 tintColor="transparent"
175 style={props.style}
181 >176 >
182 {props.children}177 {props.children}
183 </View>178 </GlassSurface>
184 </Animated.View>179 </Animated.View>
185 );180 );
186}181}
...@@ -271,6 +266,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(...@@ -271,6 +266,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
271});266});
272267
273export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {268export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
269 const navigation = useNavigation();
274 const isDarkMode = useColorScheme() === "dark";270 const isDarkMode = useColorScheme() === "dark";
275 const foregroundColor = useThemeColor("--color-foreground");271 const foregroundColor = useThemeColor("--color-foreground");
276 const bodyText = useScaledTextRole("body");272 const bodyText = useScaledTextRole("body");
...@@ -281,14 +277,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -281,14 +277,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
281 editorRef: inputRef,277 editorRef: inputRef,
282 isEditorFocused: isFocused,278 isEditorFocused: isFocused,
283 });279 });
280 const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation();
281 const settingsRoutePresentedRef = useRef(false);
284 const wasExpandedBeforePreviewRef = useRef(false);282 const wasExpandedBeforePreviewRef = useRef(false);
285 const inFlightThreadIdsRef = useRef(new Set<string>());283 const inFlightThreadIdsRef = useRef(new Set<string>());
286 const { onExpandedChange } = props;284 const { onExpandedChange } = props;
287285
288 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);286 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
289 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;287 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
290 // Opening and closing count as active so the composer stays expanded while288 // Opening and presentation count as active so the composer stays expanded
291 // focus moves between its native editor and the settings modal.289 // while focus moves between its native editor and the settings picker.
292 const isExpanded = isFocused || settingsSheetPresentation.isActive;290 const isExpanded = isFocused || settingsSheetPresentation.isActive;
293 const canSend = hasContent;291 const canSend = hasContent;
294292
...@@ -334,7 +332,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -334,7 +332,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
334 : "Send";332 : "Send";
335 const currentModelSelection = props.selectedThread.modelSelection;333 const currentModelSelection = props.selectedThread.modelSelection;
336 const currentRuntimeMode = props.selectedThread.runtimeMode;334 const currentRuntimeMode = props.selectedThread.runtimeMode;
337 const currentInteractionMode = props.selectedThread.interactionMode ?? "default";
338 const connectionStatus = composerConnectionStatus({335 const connectionStatus = composerConnectionStatus({
339 connectionError: props.connectionError,336 connectionError: props.connectionError,
340 connectionState: props.connectionState,337 connectionState: props.connectionState,
...@@ -626,67 +623,71 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -626,67 +623,71 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
626 }),623 }),
627 [currentModelOption?.capabilities, currentModelSelection.options],624 [currentModelOption?.capabilities, currentModelSelection.options],
628 );625 );
629 const settingsSummaryLabel = threadSettingsSummaryLabel({626 const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id);
630 modelLabel: currentModelOption?.label ?? currentModelSelection.model,627 const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>(
631 optionDescriptors: providerOptionDescriptors,628 () => ({
632 runtimeMode: currentRuntimeMode,629 ownerId: settingsOwnerId,
633 interactionMode: currentInteractionMode,630 providerGroups: threadProviderGroups,
634 });631 selectedModel: currentModelSelection,
635632 onSelectModel: (option) => props.onUpdateModelSelection(option.selection),
636 // iOS gets a native menu on the trigger pill: the everyday adjustments633 optionDescriptors: providerOptionDescriptors,
637 // apply without resigning the keyboard, while "All Settings…" (and the634 onUpdateOptionSelections: (options) =>
638 // Android trigger) still route through the sheet, which must dismiss it.635 props.onUpdateModelSelection({ ...currentModelSelection, options }),
639 const settingsMenu = useMemo(636 runtimeMode: currentRuntimeMode,
640 () =>637 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
641 Platform.OS === "ios"638 }),
642 ? buildThreadSettingsMenu({
643 providerGroups: threadProviderGroups,
644 selectedModel: currentModelSelection,
645 optionDescriptors: providerOptionDescriptors,
646 runtimeMode: currentRuntimeMode,
647 })
648 : null,
649 [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode],
650 );
651
652 const onUpdateModelSelection = props.onUpdateModelSelection;
653 const onUpdateRuntimeMode = props.onUpdateRuntimeMode;
654 const handleSettingsMenuAction = useCallback(
655 (eventId: string) => {
656 const event = settingsMenu?.events.get(eventId);
657 if (!event) {
658 return;
659 }
660 switch (event.type) {
661 case "select-model":
662 void Haptics.selectionAsync();
663 onUpdateModelSelection(event.option.selection);
664 return;
665 case "set-option": {
666 const options = applyProviderOptionSelection(providerOptionDescriptors, {
667 id: event.optionId,
668 value: event.value,
669 });
670 if (options) {
671 void Haptics.selectionAsync();
672 onUpdateModelSelection({ ...currentModelSelection, options });
673 }
674 return;
675 }
676 case "set-runtime":
677 void Haptics.selectionAsync();
678 onUpdateRuntimeMode(event.mode);
679 return;
680 }
681 },
682 [639 [
683 currentModelSelection,640 currentModelSelection,
684 onUpdateModelSelection,641 currentRuntimeMode,
685 onUpdateRuntimeMode,642 props.onUpdateModelSelection,
643 props.onUpdateRuntimeMode,
686 providerOptionDescriptors,644 providerOptionDescriptors,
687 settingsMenu,645 settingsOwnerId,
646 threadProviderGroups,
688 ],647 ],
689 );648 );
649 const openSettings = useCallback(() => {
650 settingsRoutePresentation.present(settingsRouteSession);
651 settingsSheetPresentation.open();
652 }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.open]);
653
654 useEffect(() => {
655 if (settingsSheetPresentation.isActive) {
656 settingsRoutePresentation.present(settingsRouteSession);
657 }
658 }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]);
659
660 useEffect(() => {
661 if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) {
662 return;
663 }
664
665 settingsRoutePresentedRef.current = true;
666 navigation.dispatch(StackActions.push("ThreadSettingsSheet"));
667 }, [navigation, settingsSheetPresentation.isVisible]);
668
669 useFocusEffect(
670 useCallback(() => {
671 if (!settingsRoutePresentedRef.current) {
672 return;
673 }
674
675 settingsRoutePresentedRef.current = false;
676 settingsSheetPresentation.onDismissed();
677 settingsRoutePresentation.clear(settingsOwnerId);
678 }, [settingsOwnerId, settingsRoutePresentation.clear, settingsSheetPresentation.onDismissed]),
679 );
680
681 useEffect(
682 () =>
683 // UIKit's completion callback for the sheet dismissal, surfaced by the
684 // native-stack patch. This is when the queued keyboard restore runs.
685 (navigation as unknown as NavigationWithFinishTransitioning).addListener(
686 "finishTransitioning",
687 settingsSheetPresentation.onStackTransitionsFinished,
688 ),
689 [navigation, settingsSheetPresentation.onStackTransitionsFinished],
690 );
690691
691 return (692 return (
692 <Animated.View693 <Animated.View
...@@ -739,10 +740,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -739,10 +740,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
739 style={740 style={
740 isExpanded741 isExpanded
741 ? {742 ? {
742 borderRadius: 20,743 borderRadius: 26,
744 minHeight: 140,
743 overflow: "hidden" as const,745 overflow: "hidden" as const,
746 paddingBottom: 6,
744 paddingHorizontal: 14,747 paddingHorizontal: 14,
745 paddingVertical: 12,748 paddingTop: 14,
746 }749 }
747 : {750 : {
748 borderRadius: 999,751 borderRadius: 999,
...@@ -792,7 +795,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -792,7 +795,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
792 style={795 style={
793 isExpanded796 isExpanded
794 ? {797 ? {
795 minHeight: 80,798 minHeight: 72,
796 maxHeight: 160,799 maxHeight: 160,
797 paddingHorizontal: 4,800 paddingHorizontal: 4,
798 paddingVertical: 4,801 paddingVertical: 4,
...@@ -841,15 +844,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -841,15 +844,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
841 )}844 )}
842 </Animated.View>845 </Animated.View>
843 ) : null}846 ) : null}
844 </ComposerSurface>847 {isExpanded ? (
845848 <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}>
846 {isExpanded ? (
847 // Toolbar row — matches draft page layout (expanded only)
848 <Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)}>
849 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
850 <ComposerToolbarScroller849 <ComposerToolbarScroller
851 fadeOpaque={toolbarFadeOpaque}850 fadeOpaque={toolbarFadeOpaque}
852 fadeTransparent={toolbarFadeTransparent}851 fadeTransparent={toolbarFadeTransparent}
852 contentPaddingRight={8}
853 >853 >
854 <ComposerToolbarButton854 <ComposerToolbarButton
855 accessibilityLabel="Add attachment"855 accessibilityLabel="Add attachment"
...@@ -857,31 +857,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -857,31 +857,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
857 onPress={() => void props.onPickDraftImages()}857 onPress={() => void props.onPickDraftImages()}
858 showChevron={false}858 showChevron={false}
859 />859 />
860 {settingsMenu ? (860 <ComposerInlineControl
861 <ControlPillMenu861 accessibilityLabel="Model and reasoning settings"
862 actions={settingsMenu.actions}862 emphasized
863 onPressAction={({ nativeEvent }) => handleSettingsMenuAction(nativeEvent.event)}863 iconNode={
864 >864 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
865 <ComposerToolbarTrigger865 }
866 accessibilityLabel="Thread settings"866 label={currentModelOption?.label ?? currentModelSelection.model}
867 iconNode={867 maxWidth={152}
868 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />868 onPress={openSettings}
869 }869 />
870 label={settingsSummaryLabel}
871 maxWidth={320}
872 />
873 </ControlPillMenu>
874 ) : (
875 <ComposerToolbarTrigger
876 accessibilityLabel="Thread settings"
877 iconNode={
878 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
879 }
880 label={settingsSummaryLabel}
881 maxWidth={320}
882 onPress={settingsSheetPresentation.open}
883 />
884 )}
885 {showStopAction ? (870 {showStopAction ? (
886 <ComposerToolbarButton871 <ComposerToolbarButton
887 accessibilityLabel="Stop"872 accessibilityLabel="Stop"
...@@ -901,8 +886,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -901,8 +886,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
901 showChevron={false}886 showChevron={false}
902 />887 />
903 </ComposerToolbarRow>888 </ComposerToolbarRow>
904 </Animated.View>889 ) : null}
905 ) : null}890 </ComposerSurface>
906891
907 {/* Queue count */}892 {/* Queue count */}
908 {props.queueCount > 0 ? (893 {props.queueCount > 0 ? (
...@@ -915,21 +900,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -915,21 +900,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
915 ) : null}900 ) : null}
916 </Animated.View>901 </Animated.View>
917902
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 <ImageViewing903 <ImageViewing
934 images={previewImageUri ? [{ uri: previewImageUri }] : []}904 images={previewImageUri ? [{ uri: previewImageUri }] : []}
935 imageIndex={0}905 imageIndex={0}
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+1024-437
...@@ -1,92 +1,113 @@...@@ -1,92 +1,113 @@
1import type {1import type {
2 ModelSelection,2 ModelSelection,
3 ProviderInteractionMode,
4 ProviderOptionDescriptor,3 ProviderOptionDescriptor,
5 ProviderOptionSelection,4 ProviderOptionSelection,
6 RuntimeMode,5 RuntimeMode,
7} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
8import { AnimatedLegendList } from "@legendapp/list/reanimated";
9import { HeaderHeightContext } from "@react-navigation/elements";
8import {10import {
9 getProviderOptionCurrentLabel,11 getProviderOptionCurrentLabel,
10 getProviderOptionCurrentValue,12 getProviderOptionCurrentValue,
11 getProviderOptionDescriptors,13 getProviderOptionDescriptors,
12} from "@t3tools/shared/model";14} from "@t3tools/shared/model";
15import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native";
16import {
17 createNativeStackNavigator,
18 type NativeStackNavigationProp,
19} from "@react-navigation/native-stack";
13import * as Haptics from "expo-haptics";20import * as Haptics from "expo-haptics";
14import { useCallback, useEffect, useRef, useState } from "react";
15import {21import {
16 Modal,22 createContext,
17 Platform,23 use,
18 Pressable,24 useCallback,
19 ScrollView,25 useEffect,
20 Switch,26 useMemo,
21 useWindowDimensions,27 useState,
22 View,28 type ReactNode,
23} from "react-native";29} from "react";
30import { Platform, Pressable, ScrollView, Switch, TextInput, View } from "react-native";
31import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
24import { useSafeAreaInsets } from "react-native-safe-area-context";32import { useSafeAreaInsets } from "react-native-safe-area-context";
2533
26import { SymbolView } from "../../components/AppSymbol";34import { SymbolView } from "../../components/AppSymbol";
27import { AppText as Text } from "../../components/AppText";35import { AppText as Text } from "../../components/AppText";
36import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
28import { ProviderIcon } from "../../components/ProviderIcon";37import { ProviderIcon } from "../../components/ProviderIcon";
29import { cn } from "../../lib/cn";38import { cn } from "../../lib/cn";
30import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";39import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
31import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";40import { applyProviderOptionSelection } from "../../lib/providerOptions";
41import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
32import { useThemeColor } from "../../lib/useThemeColor";42import { useThemeColor } from "../../lib/useThemeColor";
33import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu";43import {
34import { pendingModelAfterPress } from "./thread-settings-sheet-state";44 NativeHeaderToolbar,
35import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";45 NativeStackScreenOptions,
46 nativeHeaderScrollEdgeEffects,
47} from "../../native/StackHeader";
48import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
49import {
50 NATIVE_SHEET_SURFACE_COLOR,
51 NATIVE_SHEET_SURFACE_CONTENT_STYLE,
52} from "../../native/sheet-surface";
53import { useNewTaskFlow } from "./new-task-flow-provider";
54import {
55 createNativeMailSearchToolbarItem,
56 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
57 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
58} from "../layout/native-mail-search-toolbar";
59import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";
60import {
61 modelMatchesCatalogQuery,
62 pendingModelAfterPress,
63 providerSectionIsCollapsed,
64} from "./thread-settings-sheet-state";
3665
37/**66/**
38 * The everyday harnesses stay expanded; every other provider (OpenRouter67 * Everyday harnesses start expanded; every other provider (OpenRouter catalogs
39 * catalogs and friends) folds behind its header so a 300-model catalog can't68 * and friends) starts folded so a 300-model catalog cannot bury the list. All
40 * bury the list.69 * provider headers remain user-collapsible.
41 */70 */
42const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]);71const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]);
43
44/**72/**
45 * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill,73 * Keep measured row changes stable, but let catalog mutations use the list's
46 * covering model, provider options, runtime mode, and plan mode in one label.74 * native bounds so a filtered catalog that underflows returns to the top.
47 */75 */
48export function threadSettingsSummaryLabel(input: {76const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = {
49 readonly modelLabel: string;77 data: false,
50 readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;78 size: true,
51 readonly runtimeMode: RuntimeMode;79} as const;
52 readonly interactionMode: ProviderInteractionMode;80const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180);
53}): string {81const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140);
54 const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode);82const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120);
55 return [83const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180);
56 input.modelLabel,84const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140);
57 ...providerOptionValueLabels(input.optionDescriptors),85const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100);
58 ...(runtime ? [runtime.shortLabel] : []),86const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(
59 ...(input.interactionMode === "plan" ? ["Plan"] : []),87 Platform.OS,
60 ].join(" · ");88 Platform.Version,
61}89);
62
63function ModelRow(props: {90function ModelRow(props: {
64 readonly option: ModelOption;91 readonly option: ModelOption;
65 readonly selected: boolean;92 readonly selected: boolean;
66 readonly onPress: () => void;93 readonly onPress: () => void;
94 readonly isFirst: boolean;
95 readonly isLast: boolean;
67}) {96}) {
68 const primaryFg = useThemeColor("--color-primary-foreground");97 const checkmarkColor = useThemeColor("--color-icon");
69 return (98 return (
70 <Pressable99 <Pressable
71 accessibilityRole="button"100 accessibilityLabel={props.option.label}
72 accessibilityState={{ selected: props.selected }}101 accessibilityRole="radio"
102 accessibilityState={{ checked: props.selected }}
73 onPress={props.onPress}103 onPress={props.onPress}
74 // Selected rows get the same primary treatment as the submenu rows.
75 // Subtle backgrounds (bg-subtle-strong) get overridden by the OS
76 // selection chrome on iOS 26, so use the explicit high-contrast style
77 // everywhere instead.
78 className={cn(104 className={cn(
79 "mx-2.5 flex-row items-center gap-2 rounded-xl px-3 py-3.5 active:opacity-70",105 "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
80 props.selected ? "bg-primary" : "bg-transparent",106 props.isFirst && "rounded-t-2xl",
107 props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle",
81 )}108 )}
82 >109 >
83 <Text110 <Text className="min-w-0 shrink text-base font-t3-medium text-foreground" numberOfLines={1}>
84 className={cn(
85 "shrink text-sm font-t3-medium",
86 props.selected ? "text-primary-foreground" : "text-foreground",
87 )}
88 numberOfLines={1}
89 >
90 {props.option.label}111 {props.option.label}
91 </Text>112 </Text>
92 {props.option.isDefault ? (113 {props.option.isDefault ? (
...@@ -101,17 +122,19 @@ function ModelRow(props: {...@@ -101,17 +122,19 @@ function ModelRow(props: {
101 ) : null}122 ) : null}
102 <View className="flex-1" />123 <View className="flex-1" />
103 {props.selected ? (124 {props.selected ? (
104 <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" />125 <SymbolView
126 name="checkmark"
127 size={16}
128 tintColor={checkmarkColor}
129 type="monochrome"
130 weight="semibold"
131 />
105 ) : null}132 ) : null}
106 </Pressable>133 </Pressable>
107 );134 );
108}135}
109136
110/**137/** Provider catalog header with its harness logo and disclosure state. */
111 * Provider section header with the harness logo. Secondary providers render
112 * as a tappable fold (count + chevron while collapsed); primary providers
113 * and the group holding the current selection are static headers.
114 */
115function ProviderHeader(props: {138function ProviderHeader(props: {
116 readonly driver: string | undefined;139 readonly driver: string | undefined;
117 readonly label: string;140 readonly label: string;
...@@ -121,24 +144,10 @@ function ProviderHeader(props: {...@@ -121,24 +144,10 @@ function ProviderHeader(props: {
121 readonly onToggle: () => void;144 readonly onToggle: () => void;
122}) {145}) {
123 const iconSubtle = useThemeColor("--color-icon-subtle");146 const iconSubtle = useThemeColor("--color-icon-subtle");
124 return (147 const content = (
125 <Pressable148 <>
126 accessibilityRole={props.collapsible ? "button" : "header"}
127 accessibilityState={props.collapsible ? { expanded: !props.collapsed } : undefined}
128 accessibilityLabel={
129 props.collapsible ? `${props.label}, ${props.modelCount} models` : props.label
130 }
131 disabled={!props.collapsible}
132 onPress={props.onToggle}
133 className={cn(
134 "mx-2.5 flex-row items-center gap-2 rounded-xl px-3",
135 props.collapsible ? "py-3.5 active:opacity-70" : "pb-2 pt-4",
136 )}
137 >
138 <ProviderIcon provider={props.driver} size={15} />149 <ProviderIcon provider={props.driver} size={15} />
139 <Text className="text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted">150 <Text className="text-sm font-t3-medium text-foreground-muted">{props.label}</Text>
140 {props.label}
141 </Text>
142 {props.collapsible ? (151 {props.collapsible ? (
143 <>152 <>
144 <View className="flex-1" />153 <View className="flex-1" />
...@@ -149,13 +158,33 @@ function ProviderHeader(props: {...@@ -149,13 +158,33 @@ function ProviderHeader(props: {
149 ) : null}158 ) : null}
150 <SymbolView159 <SymbolView
151 name={props.collapsed ? "chevron.down" : "chevron.up"}160 name={props.collapsed ? "chevron.down" : "chevron.up"}
152 size={11}161 size={12}
153 tintColor={iconSubtle}162 tintColor={iconSubtle}
154 type="monochrome"163 type="monochrome"
155 />164 />
156 </>165 </>
157 ) : null}166 ) : null}
158 </Pressable>167 </>
168 );
169
170 if (props.collapsible) {
171 return (
172 <Pressable
173 accessibilityLabel={`${props.label}, ${props.modelCount} models`}
174 accessibilityRole="button"
175 accessibilityState={{ expanded: !props.collapsed }}
176 className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60"
177 onPress={props.onToggle}
178 >
179 {content}
180 </Pressable>
181 );
182 }
183
184 return (
185 <View accessibilityRole="header" className="mx-4 min-h-9 flex-row items-center gap-2 px-1 pt-1">
186 {content}
187 </View>
159 );188 );
160}189}
161190
...@@ -163,18 +192,17 @@ function ProviderHeader(props: {...@@ -163,18 +192,17 @@ function ProviderHeader(props: {
163function DisclosureRow(props: {192function DisclosureRow(props: {
164 readonly label: string;193 readonly label: string;
165 readonly value: string | undefined;194 readonly value: string | undefined;
166 readonly disabled?: boolean;
167 readonly onPress: () => void;195 readonly onPress: () => void;
196 readonly isLast?: boolean;
168}) {197}) {
169 const iconSubtle = useThemeColor("--color-icon-subtle");198 const iconSubtle = useThemeColor("--color-icon-subtle");
170 return (199 return (
171 <Pressable200 <Pressable
172 accessibilityRole="button"201 accessibilityRole="button"
173 disabled={props.disabled}
174 onPress={props.onPress}202 onPress={props.onPress}
175 className={cn(203 className={cn(
176 "flex-row items-center gap-2 px-5 py-3 active:opacity-70",204 "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
177 props.disabled && "opacity-40",205 !props.isLast && "border-b border-border-subtle",
178 )}206 )}
179 >207 >
180 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>208 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
...@@ -192,31 +220,37 @@ function DisclosureRow(props: {...@@ -192,31 +220,37 @@ function DisclosureRow(props: {
192/** Single option inside a submenu panel. */220/** Single option inside a submenu panel. */
193function ChoiceRow(props: {221function ChoiceRow(props: {
194 readonly label: string;222 readonly label: string;
223 readonly description?: string;
195 readonly selected: boolean;224 readonly selected: boolean;
196 readonly onPress: () => void;225 readonly onPress: () => void;
226 readonly isLast: boolean;
197}) {227}) {
198 const primaryFg = useThemeColor("--color-primary-foreground");228 const checkmarkColor = useThemeColor("--color-icon");
199 return (229 return (
200 <Pressable230 <Pressable
201 accessibilityRole="button"231 accessibilityLabel={props.description ? `${props.label}. ${props.description}` : props.label}
202 accessibilityState={{ selected: props.selected }}232 accessibilityRole="radio"
233 accessibilityState={{ checked: props.selected }}
203 onPress={props.onPress}234 onPress={props.onPress}
204 className={cn(235 className={cn(
205 "mx-2.5 flex-row items-center rounded-xl px-3 py-3.5 active:opacity-70",236 "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle",
206 props.selected ? "bg-primary" : "bg-transparent",237 !props.isLast && "border-b border-border-subtle",
207 )}238 )}
208 >239 >
209 <Text240 <View className="min-w-0 flex-1 gap-0.5">
210 className={cn(241 <Text className="text-base font-t3-medium text-foreground">{props.label}</Text>
211 "shrink text-sm font-t3-medium",242 {props.description ? (
212 props.selected ? "text-primary-foreground" : "text-foreground",243 <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text>
213 )}244 ) : null}
214 >245 </View>
215 {props.label}
216 </Text>
217 <View className="flex-1" />
218 {props.selected ? (246 {props.selected ? (
219 <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" />247 <SymbolView
248 name="checkmark"
249 size={16}
250 tintColor={checkmarkColor}
251 type="monochrome"
252 weight="semibold"
253 />
220 ) : null}254 ) : null}
221 </Pressable>255 </Pressable>
222 );256 );
...@@ -225,60 +259,31 @@ function ChoiceRow(props: {...@@ -225,60 +259,31 @@ function ChoiceRow(props: {
225function SwitchRow(props: {259function SwitchRow(props: {
226 readonly label: string;260 readonly label: string;
227 readonly value: boolean;261 readonly value: boolean;
228 readonly disabled?: boolean;
229 readonly onValueChange: (value: boolean) => void;262 readonly onValueChange: (value: boolean) => void;
263 readonly isLast?: boolean;
230}) {264}) {
231 const activeTrack = String(useThemeColor("--color-switch-active"));
232 const track = String(useThemeColor("--color-secondary-border"));
233 return (265 return (
234 <View266 <View
235 className={cn(267 className={cn(
236 "flex-row items-center justify-between px-5 py-2.5",268 "min-h-11 flex-row items-center justify-between bg-card px-4 py-1",
237 props.disabled && "opacity-40",269 !props.isLast && "border-b border-border-subtle",
238 )}270 )}
239 >271 >
240 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>272 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
241 <Switch273 <Switch
242 disabled={props.disabled}274 accessibilityLabel={props.label}
243 ios_backgroundColor={track}
244 onValueChange={props.onValueChange}275 onValueChange={props.onValueChange}
245 trackColor={{ false: track, true: activeTrack }}
246 value={props.value}276 value={props.value}
247 />277 />
248 </View>278 </View>
249 );279 );
250}280}
251281
252type SubmenuPage =282type ThreadSettingsSubmenuPage =
253 | { readonly kind: "descriptor"; readonly id: string }283 | { readonly kind: "descriptor"; readonly id: string }
254 | { readonly kind: "runtime" };284 | { readonly kind: "runtime" };
255285
256/**286type ThreadSettingsSessionProps = {
257 * Unified thread settings: the sheet is the provider-grouped model list
258 * (primary harnesses expanded, other providers folded, legacy behind the
259 * top-right pill) with a Save button, plus compact disclosure rows whose
260 * single-choice submenus stack in a small panel over the sheet so it never
261 * changes size. Model changes stage until Save — while staged, the settings
262 * rows edit the staged model's options and Save applies everything together.
263 *
264 * Callers control which harnesses are offered via providerGroups: an
265 * existing thread must pass only its own provider's group, since a session
266 * can't switch harness mid-thread.
267 *
268 * Rendered through an RN Modal (not the root OverlayPortal) so it also
269 * presents above natively-presented form sheets like the new-task draft.
270 * Callers must dismiss the keyboard when opening — the iOS keyboard window
271 * would otherwise cover the lower half of the sheet.
272 */
273export function ThreadSettingsSheet(props: {
274 readonly visible: boolean;
275 /**
276 * "save" = the Save/Done button (the user is finished configuring);
277 * "dismiss" = backdrop, grabber, or system back. Hosts only restore the
278 * keyboard for "save" so a stray tap outside a control never pops it.
279 */
280 readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
281 readonly onDismissed: () => void;
282 readonly providerGroups: ReadonlyArray<ProviderGroup>;287 readonly providerGroups: ReadonlyArray<ProviderGroup>;
283 readonly selectedModel: ModelSelection | null;288 readonly selectedModel: ModelSelection | null;
284 readonly onSelectModel: (option: ModelOption) => void;289 readonly onSelectModel: (option: ModelOption) => void;
...@@ -286,367 +291,949 @@ export function ThreadSettingsSheet(props: {...@@ -286,367 +291,949 @@ export function ThreadSettingsSheet(props: {
286 readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void;291 readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void;
287 readonly runtimeMode: RuntimeMode;292 readonly runtimeMode: RuntimeMode;
288 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;293 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
289}) {294};
290 const insets = useSafeAreaInsets();295
291 const { height: windowHeight } = useWindowDimensions();296export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & {
297 readonly ownerId: string;
298};
299
300type ExistingThreadSettingsRouteContextValue = {
301 readonly session: ExistingThreadSettingsRouteSession | null;
302 readonly present: (session: ExistingThreadSettingsRouteSession) => void;
303 readonly clear: (ownerId: string) => void;
304};
305
306const ExistingThreadSettingsRouteContext =
307 createContext<ExistingThreadSettingsRouteContextValue | null>(null);
308
309/** Bridges the active thread's settings state into the root native sheet route. */
310export 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
327export 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
337type 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
359const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue | null>(null);
360
361/** Owns the staged model and option state for one picker presentation. */
362function ThreadSettingsSessionProvider(
363 props: ThreadSettingsSessionProps & { readonly children: ReactNode },
364) {
292 const [showLegacyToggle, setShowLegacyToggle] = useState(false);365 const [showLegacyToggle, setShowLegacyToggle] = useState(false);
293 const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());366 const [providerFilter, setProviderFilter] = useState<string | null>(null);
367 const [searchQuery, setSearchQuery] = useState("");
368 const [providerExpansionOverrides, setProviderExpansionOverrides] = useState<ReadonlySet<string>>(
369 () => new Set(),
370 );
294 const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);371 const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
295 const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
296 const wasPresentedRef = useRef(false);
297 const notifyDismissed = useCallback(() => {
298 if (!wasPresentedRef.current) {
299 return;
300 }
301 wasPresentedRef.current = false;
302 props.onDismissed();
303 }, [props.onDismissed]);
304372
305 // Every open starts fresh: no staged model, no submenu, legacy hidden,373 const isApplied = useCallback(
306 // secondary providers folded. The sheet stays mounted between opens, so374 (option: ModelOption) =>
307 // state would otherwise stick around.375 option.selection.instanceId === props.selectedModel?.instanceId &&
308 useEffect(() => {376 option.selection.model === props.selectedModel.model,
309 if (props.visible) {377 [props.selectedModel],
310 wasPresentedRef.current = true;378 );
311 setShowLegacyToggle(false);
312 setExpandedProviders(new Set());
313 setPendingModel(null);
314 setSubmenu(null);
315 } else if (Platform.OS === "android" && wasPresentedRef.current) {
316 // React Native only emits Modal.onDismiss on iOS. Android uses no exit
317 // animation below, so the post-commit effect is its dismissal boundary.
318 notifyDismissed();
319 }
320 }, [notifyDismissed, props.visible]);
321
322 const isApplied = (option: ModelOption) =>
323 option.selection.instanceId === props.selectedModel?.instanceId &&
324 option.selection.model === props.selectedModel.model;
325 // The list highlights the staged pick; Save turns it into the applied one.379 // The list highlights the staged pick; Save turns it into the applied one.
326 const isDisplayed = (option: ModelOption) =>380 const isDisplayed = useCallback(
327 pendingModel ? option.key === pendingModel.key : isApplied(option);381 (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)),
382 [isApplied, pendingModel],
383 );
328384
329 // While a model is staged, the settings rows describe and edit the staged385 // While a model is staged, the settings rows describe and edit the staged
330 // model's options (kept on its pending selection); Save applies model and386 // model's options (kept on its pending selection); Save applies model and
331 // options together. Otherwise they edit the applied selection directly.387 // options together. Otherwise they edit the applied selection directly.
332 const displayedDescriptors = pendingModel388 const displayedDescriptors = useMemo(
333 ? pendingModel.capabilities389 () =>
334 ? getProviderOptionDescriptors({390 pendingModel
335 caps: pendingModel.capabilities,391 ? pendingModel.capabilities
336 selections: pendingModel.selection.options,392 ? getProviderOptionDescriptors({
337 })393 caps: pendingModel.capabilities,
338 : []394 selections: pendingModel.selection.options,
339 : props.optionDescriptors;395 })
340396 : []
341 const hasLegacyModels = props.providerGroups.some((group) =>397 : props.optionDescriptors,
342 group.models.some((model) => model.isLegacy),398 [pendingModel, props.optionDescriptors],
343 );399 );
344 // Legacy stays hidden unless the pill is toggled this open; a highlighted
345 // legacy model is exempted from the filter instead of forcing the whole
346 // legacy list visible.
347 const showLegacy = showLegacyToggle;
348
349 // Stable settings rows: the union of descriptors across the primary
350 // harnesses' current models (plus whatever the displayed model advertises)
351 // always renders, with unsupported rows disabled instead of vanishing when
352 // the selection changes. Keyed by label, not id — Claude and Codex use
353 // different ids for the same "Reasoning" concept.
354 const descriptorTemplate = (() => {
355 const seen = new Map<string, { type: "select" | "boolean" }>();
356 for (const group of props.providerGroups) {
357 const driver = group.models[0]?.providerDriver;
358 if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) {
359 continue;
360 }
361 for (const model of group.models) {
362 if (model.isLegacy) {
363 continue;
364 }
365 for (const descriptor of model.capabilities?.optionDescriptors ?? []) {
366 if (!seen.has(descriptor.label)) {
367 seen.set(descriptor.label, { type: descriptor.type });
368 }
369 }
370 }
371 }
372 for (const descriptor of displayedDescriptors) {
373 if (!seen.has(descriptor.label)) {
374 seen.set(descriptor.label, { type: descriptor.type });
375 }
376 }
377 return [...seen.entries()].map(([label, entry]) => ({ label, ...entry }));
378 })();
379400
380 const handleSave = () => {401 const hasLegacyModels = useMemo(
402 () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)),
403 [props.providerGroups],
404 );
405 const commitPendingModel = useCallback(() => {
381 if (pendingModel) {406 if (pendingModel) {
382 void Haptics.selectionAsync();407 void Haptics.selectionAsync();
383 props.onSelectModel(pendingModel);408 props.onSelectModel(pendingModel);
384 }409 }
385 props.onClose("save");410 }, [pendingModel, props.onSelectModel]);
386 };
387411
388 const handleOptionChange = (id: string, value: string | boolean) => {412 const applyOptionChange = useCallback(
389 const next = applyProviderOptionSelection(displayedDescriptors, { id, value });413 (id: string, value: string | boolean) => {
390 if (!next) {414 const next = applyProviderOptionSelection(displayedDescriptors, { id, value });
391 return;415 if (!next) {
392 }416 return;
393 if (pendingModel) {417 }
394 setPendingModel({418 if (pendingModel) {
395 ...pendingModel,419 setPendingModel({
396 selection: { ...pendingModel.selection, options: next },420 ...pendingModel,
397 });421 selection: { ...pendingModel.selection, options: next },
398 } else {422 });
399 props.onUpdateOptionSelections(next);423 } else {
400 }424 props.onUpdateOptionSelections(next);
401 };425 }
426 },
427 [displayedDescriptors, pendingModel, props.onUpdateOptionSelections],
428 );
402429
403 const toggleProvider = (providerKey: string) => {430 const toggleProvider = useCallback((providerKey: string) => {
404 setExpandedProviders((current) => {431 setProviderExpansionOverrides((current) => {
405 const next = new Set(current);432 const next = new Set(current);
406 if (!next.delete(providerKey)) {433 if (!next.delete(providerKey)) {
407 next.add(providerKey);434 next.add(providerKey);
408 }435 }
409 return next;436 return next;
410 });437 });
411 };438 }, []);
439
440 const pressModel = useCallback(
441 (option: ModelOption) => {
442 void Haptics.selectionAsync();
443 setPendingModel((current) =>
444 pendingModelAfterPress({
445 current,
446 pressed: option,
447 pressedIsApplied: isApplied(option),
448 }),
449 );
450 },
451 [isApplied],
452 );
453
454 const value = useMemo<ThreadSettingsSessionValue>(
455 () => ({
456 providerGroups: props.providerGroups,
457 runtimeMode: props.runtimeMode,
458 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
459 displayedDescriptors,
460 providerExpansionOverrides,
461 hasLegacyModels,
462 pendingModel,
463 providerFilter,
464 searchQuery,
465 showLegacy: showLegacyToggle,
466 applyOptionChange,
467 commitPendingModel,
468 isApplied,
469 isDisplayed,
470 pressModel,
471 setProviderFilter,
472 setSearchQuery,
473 setShowLegacy: setShowLegacyToggle,
474 toggleProvider,
475 }),
476 [
477 applyOptionChange,
478 commitPendingModel,
479 displayedDescriptors,
480 providerExpansionOverrides,
481 hasLegacyModels,
482 isApplied,
483 isDisplayed,
484 pendingModel,
485 pressModel,
486 providerFilter,
487 props.onUpdateRuntimeMode,
488 props.providerGroups,
489 props.runtimeMode,
490 searchQuery,
491 showLegacyToggle,
492 toggleProvider,
493 ],
494 );
495
496 return (
497 <ThreadSettingsSessionContext.Provider value={value}>
498 {props.children}
499 </ThreadSettingsSessionContext.Provider>
500 );
501}
502
503function 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
511type 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
521type 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
543function 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
565function 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
586function 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
657function 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. */
741function 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. */
846function 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;
412853
413 const activeDescriptor =854 const activeDescriptor =
414 submenu?.kind === "descriptor"855 descriptorId !== null
415 ? displayedDescriptors.find(856 ? session.displayedDescriptors.find(
416 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,857 (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId,
417 )858 )
418 : undefined;859 : undefined;
419860
420 const submenuContent =861 const submenuContent =
421 submenu?.kind === "runtime"862 props.submenu.kind === "runtime"
422 ? {863 ? {
423 title: "Runtime",
424 rows: RUNTIME_MODE_CHOICES.map((choice) => ({864 rows: RUNTIME_MODE_CHOICES.map((choice) => ({
425 id: choice.mode,865 id: choice.mode,
426 label: choice.label,866 label: choice.label,
427 selected: choice.mode === props.runtimeMode,867 description: choice.description,
868 selected: choice.mode === session.runtimeMode,
428 onPress: () => {869 onPress: () => {
429 void Haptics.selectionAsync();870 void Haptics.selectionAsync();
430 props.onUpdateRuntimeMode(choice.mode);871 session.onUpdateRuntimeMode(choice.mode);
431 setSubmenu(null);872 props.onSelected();
432 },873 },
433 })),874 })),
434 }875 }
435 : activeDescriptor?.type === "select"876 : activeDescriptor?.type === "select"
436 ? {877 ? {
437 title: activeDescriptor.label,
438 rows: selectableChoices(activeDescriptor).map((choice) => ({878 rows: selectableChoices(activeDescriptor).map((choice) => ({
439 id: choice.id,879 id: choice.id,
440 label: choice.label,880 label: choice.label,
881 description: undefined,
441 selected: choice.id === getProviderOptionCurrentValue(activeDescriptor),882 selected: choice.id === getProviderOptionCurrentValue(activeDescriptor),
442 onPress: () => {883 onPress: () => {
443 void Haptics.selectionAsync();884 void Haptics.selectionAsync();
444 handleOptionChange(activeDescriptor.id, choice.id);885 session.applyOptionChange(activeDescriptor.id, choice.id);
445 setSubmenu(null);886 props.onSelected();
446 },887 },
447 })),888 })),
448 }889 }
449 : null;890 : null;
450891
892 if (!submenuContent) {
893 return <View className="flex-1 bg-sheet" />;
894 }
895
451 return (896 return (
452 <Modal897 <ScrollView
453 transparent898 className="flex-1 bg-sheet"
454 statusBarTranslucent899 contentContainerStyle={{
455 navigationBarTranslucent900 paddingBottom: insets.bottom + 12,
456 animationType={Platform.OS === "ios" ? "fade" : "none"}901 paddingHorizontal: 16,
457 visible={props.visible}902 paddingTop: 16,
458 onDismiss={notifyDismissed}903 }}
459 onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}904 contentInsetAdjustmentBehavior="automatic"
905 showsVerticalScrollIndicator={false}
460 >906 >
461 <View className="flex-1 justify-end">907 <View className="overflow-hidden rounded-2xl bg-card">
462 <Pressable908 {submenuContent.rows.map((row, index) => (
463 accessibilityLabel="Close thread settings"909 <ChoiceRow
464 className="absolute inset-0 bg-backdrop"910 key={row.id}
465 onPress={() => props.onClose("dismiss")}911 description={row.description}
912 isLast={index === submenuContent.rows.length - 1}
913 label={row.label}
914 selected={row.selected}
915 onPress={row.onPress}
916 />
917 ))}
918 </View>
919 </ScrollView>
920 );
921}
922
923type ThreadSettingsPickerStackParams = {
924 ThreadSettingsModels: undefined;
925 ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string };
926};
927
928type ThreadSettingsPickerPresentation = {
929 readonly onClose: () => void;
930};
931
932const ThreadSettingsPickerStack = createNativeStackNavigator<ThreadSettingsPickerStackParams>();
933const ThreadSettingsPickerPresentationContext =
934 createContext<ThreadSettingsPickerPresentation | null>(null);
935
936function 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
946function 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 <View1008 ) : null}
468 className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet"1009 <NativeStackScreenOptions
469 style={{ maxHeight: windowHeight * 0.85 }}1010 optionsVersion={[
470 >1011 session.providerFilter,
471 {/* The grabber doubles as the accessible close control: the dim1012 session.providerGroups.map((group) => group.providerKey),
472 backdrop above a tall sheet is a sliver, and VoiceOver can't1013 session.showLegacy,
473 reach it at all. */}1014 ]}
474 <Pressable1015 options={{
475 accessibilityLabel="Close thread settings"1016 unstable_headerToolbarItems: usesNativeMailSearchToolbar
476 accessibilityRole="button"1017 ? () => [
477 onPress={() => props.onClose("dismiss")}1018 createNativeMailSearchToolbarItem({
478 className="items-center pb-1 pt-2.5"1019 filterButtonId: "thread-settings-model-filter",
479 >1020 filterMenu,
480 <View className="h-1 w-9 rounded-full bg-subtle-strong" />1021 filterSystemImageName: hasCustomCatalogFilter
481 </Pressable>1022 ? "line.3.horizontal.decrease.circle.fill"
482 {hasLegacyModels ? (1023 : "line.3.horizontal.decrease",
483 <View className="flex-row justify-end px-4 pb-1.5">1024 onSearchTextChange: session.setSearchQuery,
484 <Pressable1025 placeholder: "Find a model",
485 accessibilityRole="button"1026 searchTextChangeId: "thread-settings-model-search-text",
486 accessibilityState={{ selected: showLegacy }}1027 showsSearchDismissButton: true,
487 hitSlop={8}1028 }),
488 onPress={() => {1029 ]
489 void Haptics.selectionAsync();1030 : undefined,
490 setShowLegacyToggle(!showLegacy);1031 headerShown: Platform.OS !== "android",
491 }}1032 headerSearchBarOptions:
492 className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70"1033 Platform.OS === "ios" && !usesNativeMailSearchToolbar
493 >1034 ? {
494 <Text className="text-2xs font-t3-medium text-foreground-muted">1035 autoCapitalize: "none",
495 {showLegacy ? "Hide legacy models" : "Show legacy models"}1036 hideNavigationBar: false,
496 </Text>1037 obscureBackground: false,
497 </Pressable>1038 onCancelButtonPress: () => session.setSearchQuery(""),
498 </View>1039 onChangeText: (event) => session.setSearchQuery(event.nativeEvent.text),
499 ) : null}1040 placeholder: "Find a model",
500 {/* Only the model list scrolls. Provider catalogs can run to1041 }
501 hundreds of models (OpenRouter), so the rows below stay pinned1042 : undefined,
502 and reachable instead of living at the end of that scroll. */}1043 }}
503 <ScrollView1044 />
504 style={{ flexShrink: 1 }}1045 <ThreadSettingsMainContent
505 contentContainerStyle={{ paddingBottom: 8 }}1046 onOpenSubmenu={(submenu) => {
506 showsVerticalScrollIndicator={false}1047 const title =
1048 submenu.kind === "runtime"
1049 ? "Runtime"
1050 : (session.displayedDescriptors.find(
1051 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
1052 )?.label ?? "Option");
1053 navigation.navigate("ThreadSettingsChoice", { ...submenu, title });
1054 }}
1055 />
1056 <NativeHeaderToolbar placement="left">
1057 <NativeHeaderToolbar.Button
1058 accessibilityLabel="Cancel thread settings"
1059 label="Cancel"
1060 onPress={presentation.onClose}
1061 />
1062 </NativeHeaderToolbar>
1063 <NativeHeaderToolbar placement="right">
1064 <NativeHeaderToolbar.Button
1065 accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"}
1066 label={session.pendingModel ? "Save" : "Done"}
1067 onPress={commitAndClose}
1068 />
1069 </NativeHeaderToolbar>
1070 {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? (
1071 <NativeHeaderToolbar placement="bottom">
1072 <NativeHeaderToolbar.Menu
1073 accessibilityLabel="Filter models"
1074 icon={
1075 hasCustomCatalogFilter
1076 ? "line.3.horizontal.decrease.circle.fill"
1077 : "line.3.horizontal.decrease.circle"
1078 }
1079 separateBackground
1080 title="Model filters"
507 >1081 >
508 {props.providerGroups.map((group) => {1082 <NativeHeaderToolbar.Menu title="Provider">
509 const driver = group.models[0]?.providerDriver;1083 <NativeHeaderToolbar.Label>Provider</NativeHeaderToolbar.Label>
510 const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver);1084 <NativeHeaderToolbar.MenuAction
511 const visibleModels = showLegacy1085 isOn={session.providerFilter === null}
512 ? group.models1086 onPress={() => session.setProviderFilter(null)}
513 : group.models.filter((model) => !model.isLegacy || isDisplayed(model));
514 if (visibleModels.length === 0) {
515 return null;
516 }
517 const containsSelection = group.models.some(isDisplayed);
518 const collapsible = !isPrimary && !containsSelection;
519 const collapsed = collapsible && !expandedProviders.has(group.providerKey);
520 return (
521 <View key={group.providerKey}>
522 <ProviderHeader
523 driver={driver}
524 label={group.providerLabel}
525 collapsible={collapsible}
526 collapsed={collapsed}
527 modelCount={visibleModels.length}
528 onToggle={() => toggleProvider(group.providerKey)}
529 />
530 {collapsed
531 ? null
532 : visibleModels.map((option) => (
533 <ModelRow
534 key={option.key}
535 option={option}
536 selected={isDisplayed(option)}
537 onPress={() => {
538 void Haptics.selectionAsync();
539 // Re-tapping the applied model cancels staging.
540 setPendingModel((current) =>
541 pendingModelAfterPress({
542 current,
543 pressed: option,
544 pressedIsApplied: isApplied(option),
545 }),
546 );
547 }}
548 />
549 ))}
550 </View>
551 );
552 })}
553 </ScrollView>
554
555 <View className="mx-5 h-px bg-border" />
556
557 <View style={{ paddingBottom: insets.bottom + 12 }}>
558 {descriptorTemplate.map((entry) => {
559 const live = displayedDescriptors.find(
560 (descriptor) => descriptor.label === entry.label,
561 );
562 if ((live?.type ?? entry.type) === "select") {
563 return (
564 <DisclosureRow
565 key={entry.label}
566 label={entry.label}
567 value={live ? getProviderOptionCurrentLabel(live) : undefined}
568 disabled={!live}
569 onPress={() => {
570 if (live) {
571 setSubmenu({ kind: "descriptor", id: live.id });
572 }
573 }}
574 />
575 );
576 }
577 return (
578 <SwitchRow
579 key={entry.label}
580 label={entry.label}
581 value={live?.type === "boolean" ? (live.currentValue ?? false) : false}
582 disabled={!live}
583 onValueChange={(value) => {
584 if (live) {
585 handleOptionChange(live.id, value);
586 }
587 }}
588 />
589 );
590 })}
591 <DisclosureRow
592 label="Runtime"
593 value={
594 RUNTIME_MODE_CHOICES.find((choice) => choice.mode === props.runtimeMode)?.label
595 }
596 onPress={() => setSubmenu({ kind: "runtime" })}
597 />
598 <Pressable
599 accessibilityRole="button"
600 onPress={handleSave}
601 className="mx-4 mt-2 h-12 items-center justify-center rounded-full bg-primary active:opacity-80"
602 >
603 <Text className="text-sm font-t3-bold text-primary-foreground">
604 {pendingModel ? "Save" : "Done"}
605 </Text>
606 </Pressable>
607 </View>
608 </View>
609
610 {/* Submenus stack over the sheet instead of replacing its content,
611 so the main sheet keeps its size while drilling in and out. */}
612 {submenuContent ? (
613 <View className="absolute inset-0 justify-end">
614 <Pressable
615 accessibilityLabel={`Close ${submenuContent.title}`}
616 className="absolute inset-0 bg-backdrop"
617 onPress={() => setSubmenu(null)}
618 />
619 <View className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet">
620 <Pressable
621 accessibilityLabel={`Close ${submenuContent.title}`}
622 accessibilityRole="button"
623 onPress={() => setSubmenu(null)}
624 className="items-center pb-1 pt-2.5"
625 >1087 >
626 <View className="h-1 w-9 rounded-full bg-subtle-strong" />1088 All providers
627 </Pressable>1089 </NativeHeaderToolbar.MenuAction>
628 <Text className="px-5 pb-1.5 text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted">1090 {session.providerGroups.map((group) => (
629 {submenuContent.title}1091 <NativeHeaderToolbar.MenuAction
630 </Text>1092 key={group.providerKey}
631 <ScrollView1093 isOn={session.providerFilter === group.providerKey}
632 style={{ maxHeight: windowHeight * 0.5 }}1094 onPress={() => session.setProviderFilter(group.providerKey)}
633 contentContainerStyle={{ paddingBottom: insets.bottom + 12 }}1095 >
634 showsVerticalScrollIndicator={false}1096 {group.providerLabel}
635 bounces={false}1097 </NativeHeaderToolbar.MenuAction>
1098 ))}
1099 </NativeHeaderToolbar.Menu>
1100 {session.hasLegacyModels ? (
1101 <NativeHeaderToolbar.MenuAction
1102 isOn={session.showLegacy}
1103 onPress={() => session.setShowLegacy(!session.showLegacy)}
636 >1104 >
637 {submenuContent.rows.map((row) => (1105 Show legacy models
638 <ChoiceRow1106 </NativeHeaderToolbar.MenuAction>
639 key={row.id}1107 ) : null}
640 label={row.label}1108 </NativeHeaderToolbar.Menu>
641 selected={row.selected}1109 </NativeHeaderToolbar>
642 onPress={row.onPress}1110 ) : null}
643 />1111 </>
644 ))}1112 );
645 </ScrollView>1113}
646 </View>1114
647 </View>1115function ThreadSettingsChoiceScreen() {
648 ) : null}1116 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();
649 </View>1117 const route = useRoute<RouteProp<ThreadSettingsPickerStackParams, "ThreadSettingsChoice">>();
650 </Modal>1118
1119 return (
1120 <>
1121 <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} />
1122 {Platform.OS === "android" ? (
1123 <AndroidSheetHeader title={route.params.title} onBack={() => navigation.goBack()} />
1124 ) : null}
1125 <ThreadSettingsChoiceContent submenu={route.params} onSelected={() => navigation.goBack()} />
1126 </>
1127 );
1128}
1129
1130function 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. */
1183export 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 */
1214export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";
4
5describe("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 @@
1import {
2 DEFAULT_PROVIDER_INTERACTION_MODE,
3 type ProviderInteractionMode,
4} from "@t3tools/contracts";
5
6export function resolveLegacyPlanModeEnabled(input: {
7 readonly loaded: boolean;
8 readonly preference: boolean | undefined;
9}): boolean {
10 return input.loaded && input.preference === true;
11}
12
13export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 resolveNewTaskBranchWorktreePath,
5 resolveNewTaskBranchLabel,
6 resolveNewTaskLocalWorkspaceSelection,
7} from "./new-task-context-presentation";
8
9describe("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
56describe("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
88describe("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 @@
1type WorkspaceMode = "local" | "worktree";
2
3export 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
13export 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
28export 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
60export 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
77export function shouldCheckoutNewTaskBranch(input: {
78 readonly branchIsCurrent: boolean;
79 readonly branchWorktreePath: string | null | undefined;
80 readonly workspaceMode: WorkspaceMode;
81}): boolean {
82 return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath;
83}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+129-31
...@@ -42,6 +42,7 @@ import { useEnvironmentQuery } from "../../state/query";...@@ -42,6 +42,7 @@ import { useEnvironmentQuery } from "../../state/query";
42import {42import {
43 appendComposerDraftAttachments,43 appendComposerDraftAttachments,
44 clearComposerDraft,44 clearComposerDraft,
45 copyComposerDraftContentIfEmpty,
45 getComposerDraftSnapshot,46 getComposerDraftSnapshot,
46 isComposerDraftEmpty,47 isComposerDraftEmpty,
47 removeComposerDraftAttachment,48 removeComposerDraftAttachment,
...@@ -50,7 +51,7 @@ import {...@@ -50,7 +51,7 @@ import {
50 updateComposerDraftSettings,51 updateComposerDraftSettings,
51 useComposerDraft,52 useComposerDraft,
52} from "../../state/use-composer-drafts";53} from "../../state/use-composer-drafts";
53import { useBranches } from "../../state/queries";54import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
54import {55import {
55 flattenQueuedThreadMessages,56 flattenQueuedThreadMessages,
56 threadOutboxManager,57 threadOutboxManager,
...@@ -74,10 +75,16 @@ import {...@@ -74,10 +75,16 @@ import {
74 type HomeProjectScope,75 type HomeProjectScope,
75} from "../home/homeThreadList";76} from "../home/homeThreadList";
76import { useMobileProjectGroupingSettings } from "../../state/project-grouping";77import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
78import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";
79import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
80import {
81 resolveNewTaskBranchWorktreePath,
82 resolveNewTaskLocalWorkspaceSelection,
83} from "./new-task-context-presentation";
7784
78type WorkspaceMode = "local" | "worktree";85type WorkspaceMode = "local" | "worktree";
7986
80const EMPTY_BRANCH_REFS: ReadonlyArray<VcsRef> = [];87const BRANCH_SEARCH_DEBOUNCE_MS = 150;
8188
82function pendingTaskDraftKey(messageId: string): string {89function pendingTaskDraftKey(messageId: string): string {
83 return `pending-task:${messageId}`;90 return `pending-task:${messageId}`;
...@@ -96,14 +103,6 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null {...@@ -96,14 +103,6 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null {
96 return message?.creation !== undefined ? message : null;103 return message?.creation !== undefined ? message : null;
97}104}
98105
99function 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
107export function branchBadgeLabel(input: {106export function branchBadgeLabel(input: {
108 readonly branch: VcsRef;107 readonly branch: VcsRef;
109 readonly project: EnvironmentProject | null;108 readonly project: EnvironmentProject | null;
...@@ -117,9 +116,6 @@ export function branchBadgeLabel(input: {...@@ -117,9 +116,6 @@ export function branchBadgeLabel(input: {
117 if (input.branch.isDefault) {116 if (input.branch.isDefault) {
118 return "default";117 return "default";
119 }118 }
120 if (input.branch.isRemote) {
121 return "remote";
122 }
123 return null;119 return null;
124}120}
125121
...@@ -139,9 +135,13 @@ type NewTaskFlowContextValue = {...@@ -139,9 +135,13 @@ type NewTaskFlowContextValue = {
139 readonly submitting: boolean;135 readonly submitting: boolean;
140 readonly branchQuery: string;136 readonly branchQuery: string;
141 readonly branchesLoading: boolean;137 readonly branchesLoading: boolean;
138 readonly branchesError: string | null;
139 readonly branchesFetchingNextPage: boolean;
140 readonly hasMoreBranches: boolean;
142 readonly availableBranches: ReadonlyArray<VcsRef>;141 readonly availableBranches: ReadonlyArray<VcsRef>;
143 readonly runtimeMode: RuntimeMode;142 readonly runtimeMode: RuntimeMode;
144 readonly interactionMode: ProviderInteractionMode;143 readonly interactionMode: ProviderInteractionMode;
144 readonly planModeEnabled: boolean;
145 readonly expandedProvider: string | null;145 readonly expandedProvider: string | null;
146 readonly environments: ReadonlyArray<{146 readonly environments: ReadonlyArray<{
147 readonly environmentId: EnvironmentId;147 readonly environmentId: EnvironmentId;
...@@ -175,7 +175,8 @@ type NewTaskFlowContextValue = {...@@ -175,7 +175,8 @@ type NewTaskFlowContextValue = {
175 readonly clearAttachments: () => void;175 readonly clearAttachments: () => void;
176 readonly setSubmitting: (value: boolean) => void;176 readonly setSubmitting: (value: boolean) => void;
177 readonly setBranchQuery: (value: string) => void;177 readonly setBranchQuery: (value: string) => void;
178 readonly loadBranches: () => Promise<void>;178 readonly loadBranches: () => void;
179 readonly loadMoreBranches: () => void;
179 readonly setRuntimeMode: (value: RuntimeMode) => void;180 readonly setRuntimeMode: (value: RuntimeMode) => void;
180 readonly setInteractionMode: (value: ProviderInteractionMode) => void;181 readonly setInteractionMode: (value: ProviderInteractionMode) => void;
181 readonly setSelectedModelOptions: (182 readonly setSelectedModelOptions: (
...@@ -191,6 +192,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -191,6 +192,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
191 const threads = useThreadShells();192 const threads = useThreadShells();
192 const { savedConnectionsById } = useSavedRemoteConnections();193 const { savedConnectionsById } = useSavedRemoteConnections();
193 const groupingSettings = useMobileProjectGroupingSettings();194 const groupingSettings = useMobileProjectGroupingSettings();
195 const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState();
194 const projectScopes = useMemo(196 const projectScopes = useMemo(
195 () =>197 () =>
196 sortHomeProjectScopes({198 sortHomeProjectScopes({
...@@ -219,6 +221,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -219,6 +221,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
219 const [branchQuery, setBranchQuery] = useState("");221 const [branchQuery, setBranchQuery] = useState("");
220 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);222 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
221 const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null);223 const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null);
224 const pendingLocalBranchSyncDraftKeysRef = useRef(new Set<string>());
222 // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act225 // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act
223 // on a task whose editing session already ended this render.226 // on a task whose editing session already ended this render.
224 const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null);227 const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null);
...@@ -229,6 +232,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -229,6 +232,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
229 setSubmitting(false);232 setSubmitting(false);
230 setBranchQuery("");233 setBranchQuery("");
231 setExpandedProvider(null);234 setExpandedProvider(null);
235 pendingLocalBranchSyncDraftKeysRef.current.clear();
232 const editing = editingPendingTaskRef.current;236 const editing = editingPendingTaskRef.current;
233 editingPendingTaskRef.current = null;237 editingPendingTaskRef.current = null;
234 setEditingPendingTask(null);238 setEditingPendingTask(null);
...@@ -395,7 +399,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -395,7 +399,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
395 selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??399 selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
396 true;400 true;
397 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;401 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
398 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;402 const interactionMode = planModeEnabled
403 ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
404 : DEFAULT_PROVIDER_INTERACTION_MODE;
399405
400 // Stored selections only count while their provider is usable on the406 // Stored selections only count while their provider is usable on the
401 // server; otherwise the server's default model wins instead of silently407 // server; otherwise the server's default model wins instead of silently
...@@ -521,18 +527,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -521,18 +527,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
521 }527 }
522 replaceComposerDraftAttachments(selectedProjectDraftKey, []);528 replaceComposerDraftAttachments(selectedProjectDraftKey, []);
523 }, [selectedProjectDraftKey]);529 }, [selectedProjectDraftKey]);
530 const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS);
524 const branchTarget = useMemo(531 const branchTarget = useMemo(
525 () => ({532 () => ({
526 environmentId: selectedProject?.environmentId ?? null,533 environmentId: selectedProject?.environmentId ?? null,
527 // `|| null` also skips the stand-in project's empty workspaceRoot.534 // `|| null` also skips the stand-in project's empty workspaceRoot.
528 cwd: selectedProject?.workspaceRoot || null,535 cwd: selectedProject?.workspaceRoot || null,
529 query: null,536 query: debouncedBranchQuery,
530 }),537 }),
531 [selectedProject?.environmentId, selectedProject?.workspaceRoot],538 [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot],
532 );539 );
533 const branchState = useBranches(branchTarget);540 const branchState = usePaginatedBranches(branchTarget);
534 const branchesLoading = branchState.isPending;541 const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim();
535 const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS;542 const branchesLoading =
543 branchSearchIsDebouncing || (branchState.isPending && branchState.data === null);
544 const branchesFetchingNextPage = branchState.isFetchingNextPage;
545 const hasMoreBranches =
546 branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined;
547 const allBranchRefs = branchState.refs;
536 const availableBranches = useMemo(548 const availableBranches = useMemo(
537 () =>549 () =>
538 pipe(550 pipe(
...@@ -554,11 +566,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -554,11 +566,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
554 );566 );
555 }, [availableBranches, branchQuery]);567 }, [availableBranches, branchQuery]);
556568
557 const setProject = useCallback((project: EnvironmentProject) => {569 const setProject = useCallback(
558 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);570 (project: EnvironmentProject) => {
559 setSelectedEnvironmentId(project.environmentId);571 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
560 setSelectedProjectKey(nextProjectKey);572 const nextDraftKey = `new-task:${nextProjectKey}`;
561 }, []);573 if (
574 selectedProjectDraftKey?.startsWith("new-task:") &&
575 selectedProjectDraftKey !== nextDraftKey
576 ) {
577 void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
578 }
579 setSelectedEnvironmentId(project.environmentId);
580 setSelectedProjectKey(nextProjectKey);
581 },
582 [selectedProjectDraftKey],
583 );
562584
563 const selectEnvironment = useCallback(585 const selectEnvironment = useCallback(
564 (environmentId: EnvironmentId) => {586 (environmentId: EnvironmentId) => {
...@@ -596,28 +618,86 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -596,28 +618,86 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
596 if (!selectedProjectDraftKey) {618 if (!selectedProjectDraftKey) {
597 return;619 return;
598 }620 }
621 if (!selectedProject) {
622 return;
623 }
624 const localSelection = resolveNewTaskLocalWorkspaceSelection({
625 branches: availableBranches,
626 projectCwd: selectedProject.workspaceRoot,
627 });
628 if (mode === "local" && localSelection.awaitsCurrentBranch) {
629 pendingLocalBranchSyncDraftKeysRef.current.add(selectedProjectDraftKey);
630 } else {
631 pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
632 }
599 updateComposerDraftSettings(selectedProjectDraftKey, {633 updateComposerDraftSettings(selectedProjectDraftKey, {
600 workspaceSelection: {634 workspaceSelection: {
601 mode,635 mode,
602 branch: selectedBranchName,636 branch: mode === "local" ? localSelection.branch : selectedBranchName,
603 worktreePath: selectedWorktreePath,637 worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath,
604 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),638 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
605 },639 },
606 });640 });
607 },641 },
608 [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath],642 [
643 availableBranches,
644 draftStartFromOrigin,
645 selectedBranchName,
646 selectedProject,
647 selectedProjectDraftKey,
648 selectedWorktreePath,
649 ],
609 );650 );
610651
652 useEffect(() => {
653 if (
654 workspaceMode !== "local" ||
655 !selectedProject ||
656 !selectedProjectDraftKey ||
657 !pendingLocalBranchSyncDraftKeysRef.current.has(selectedProjectDraftKey)
658 ) {
659 return;
660 }
661 const localSelection = resolveNewTaskLocalWorkspaceSelection({
662 branches: availableBranches,
663 projectCwd: selectedProject.workspaceRoot,
664 });
665 if (localSelection.awaitsCurrentBranch) {
666 return;
667 }
668
669 pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
670 updateComposerDraftSettings(selectedProjectDraftKey, {
671 workspaceSelection: {
672 mode: "local",
673 branch: localSelection.branch,
674 worktreePath: localSelection.worktreePath,
675 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
676 },
677 });
678 }, [
679 availableBranches,
680 draftStartFromOrigin,
681 selectedProject,
682 selectedProjectDraftKey,
683 workspaceMode,
684 ]);
685
611 const selectBranch = useCallback(686 const selectBranch = useCallback(
612 (branch: VcsRef) => {687 (branch: VcsRef) => {
613 if (!selectedProject || !selectedProjectDraftKey) {688 if (!selectedProject || !selectedProjectDraftKey) {
614 return;689 return;
615 }690 }
691 pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
616 updateComposerDraftSettings(selectedProjectDraftKey, {692 updateComposerDraftSettings(selectedProjectDraftKey, {
617 workspaceSelection: {693 workspaceSelection: {
618 mode: workspaceMode,694 mode: workspaceMode,
619 branch: branch.name,695 branch: branch.name,
620 worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),696 worktreePath: resolveNewTaskBranchWorktreePath({
697 workspaceMode,
698 projectCwd: selectedProject.workspaceRoot,
699 branchWorktreePath: branch.worktreePath,
700 }),
621 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),701 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
622 },702 },
623 });703 });
...@@ -643,7 +723,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -643,7 +723,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
643 );723 );
644724
645 const refreshBranches = branchState.refresh;725 const refreshBranches = branchState.refresh;
646 const loadBranches = useCallback(async () => {726 const loadMoreBranches = branchState.loadNext;
727 const loadBranches = useCallback(() => {
647 if (!selectedProject) {728 if (!selectedProject) {
648 return;729 return;
649 }730 }
...@@ -767,7 +848,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -767,7 +848,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
767 attachments: draft.attachments,848 attachments: draft.attachments,
768 modelSelection: draftModelSelection,849 modelSelection: draftModelSelection,
769 runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,850 runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,
770 interactionMode: draft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,851 interactionMode: resolvePendingTaskInteractionMode({
852 preferenceLoaded: planModePreferenceLoaded,
853 planModeEnabled,
854 draftInteractionMode: draft.interactionMode,
855 queuedInteractionMode: editingPendingTask?.interactionMode,
856 }),
771 creation: {857 creation: {
772 projectId: selectedProject.id,858 projectId: selectedProject.id,
773 ...(projectTitle !== undefined ? { projectTitle } : {}),859 ...(projectTitle !== undefined ? { projectTitle } : {}),
...@@ -792,6 +878,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -792,6 +878,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
792 selectedModel,878 selectedModel,
793 selectedProject,879 selectedProject,
794 selectedProjectDraftKey,880 selectedProjectDraftKey,
881 planModeEnabled,
882 planModePreferenceLoaded,
795 startFromOrigin,883 startFromOrigin,
796 workspaceMode,884 workspaceMode,
797 ],885 ],
...@@ -904,9 +992,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -904,9 +992,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
904 submitting,992 submitting,
905 branchQuery,993 branchQuery,
906 branchesLoading,994 branchesLoading,
995 branchesError: branchState.error,
996 branchesFetchingNextPage,
997 hasMoreBranches,
907 availableBranches,998 availableBranches,
908 runtimeMode,999 runtimeMode,
909 interactionMode,1000 interactionMode,
1001 planModeEnabled,
910 expandedProvider,1002 expandedProvider,
911 environments,1003 environments,
912 selectedProject,1004 selectedProject,
...@@ -935,6 +1027,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -935,6 +1027,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
935 setSubmitting,1027 setSubmitting,
936 setBranchQuery,1028 setBranchQuery,
937 loadBranches,1029 loadBranches,
1030 loadMoreBranches,
938 setRuntimeMode,1031 setRuntimeMode,
939 setInteractionMode,1032 setInteractionMode,
940 setSelectedModelOptions,1033 setSelectedModelOptions,
...@@ -946,6 +1039,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -946,6 +1039,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
946 beginEditingPendingTask,1039 beginEditingPendingTask,
947 branchQuery,1040 branchQuery,
948 branchesLoading,1041 branchesLoading,
1042 branchState.error,
1043 branchesFetchingNextPage,
949 buildPendingTaskMessage,1044 buildPendingTaskMessage,
950 cancelEditingPendingTask,1045 cancelEditingPendingTask,
951 editingPendingTask,1046 editingPendingTask,
...@@ -954,7 +1049,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -954,7 +1049,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
954 filteredBranches,1049 filteredBranches,
955 finishEditingPendingTask,1050 finishEditingPendingTask,
956 interactionMode,1051 interactionMode,
1052 planModeEnabled,
957 loadBranches,1053 loadBranches,
1054 loadMoreBranches,
958 projectScopes,1055 projectScopes,
959 modelOptions,1056 modelOptions,
960 prompt,1057 prompt,
...@@ -963,6 +1060,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -963,6 +1060,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
963 reset,1060 reset,
964 runtimeMode,1061 runtimeMode,
965 selectedBranchName,1062 selectedBranchName,
1063 hasMoreBranches,
966 selectedEnvironmentId,1064 selectedEnvironmentId,
967 selectedModel,1065 selectedModel,
968 selectedModelKey,1066 selectedModelKey,
apps/mobile/src/features/threads/new-task-project-selection.test.ts+24-6
...@@ -5,12 +5,13 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";...@@ -5,12 +5,13 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import type { HomeProjectScope } from "../home/homeThreadList";5import type { HomeProjectScope } from "../home/homeThreadList";
6import {6import {
7 getOnlySelectableProject,7 getOnlySelectableProject,
8 getProjectScopeSelectionTarget,
8 resolveDraftProjectSelection,9 resolveDraftProjectSelection,
9} from "./new-task-project-selection";10} from "./new-task-project-selection";
1011
11function makeProject(id: string): EnvironmentProject {12function makeProject(id: string, environmentId = "environment"): EnvironmentProject {
12 return {13 return {
13 environmentId: EnvironmentId.make("environment"),14 environmentId: EnvironmentId.make(environmentId),
14 id: ProjectId.make(id),15 id: ProjectId.make(id),
15 title: id,16 title: id,
16 workspaceRoot: `/work/${id}`,17 workspaceRoot: `/work/${id}`,
...@@ -41,9 +42,25 @@ describe("getOnlySelectableProject", () => {...@@ -41,9 +42,25 @@ describe("getOnlySelectableProject", () => {
41 expect(getOnlySelectableProject([makeScope([project])])).toBe(project);42 expect(getOnlySelectableProject([makeScope([project])])).toBe(project);
42 });43 });
4344
44 it("does not auto-select a representative when one group has multiple clones", () => {45 it("selects the representative when one logical project has multiple workspaces", () => {
45 const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];46 const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];
46 expect(getOnlySelectableProject([makeScope(projects)])).toBeNull();47 expect(getOnlySelectableProject([makeScope(projects)])).toBe(projects[0]);
48 });
49});
50
51describe("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});
4966
...@@ -63,10 +80,11 @@ describe("resolveDraftProjectSelection", () => {...@@ -63,10 +80,11 @@ describe("resolveDraftProjectSelection", () => {
63 });80 });
64 });81 });
6582
66 it("opens the picker for multiple physical projects in one logical group", () => {83 it("selects one logical project even when it has multiple physical workspaces", () => {
67 const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];84 const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];
68 expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({85 expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({
69 kind: "pick",86 kind: "select",
87 project: projects[0],
70 });88 });
71 });89 });
7290
apps/mobile/src/features/threads/new-task-project-selection.ts+13-2
...@@ -1,18 +1,29 @@...@@ -1,18 +1,29 @@
1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import type { EnvironmentId } from "@t3tools/contracts";
23
3import { scopedProjectKey } from "../../lib/scopedEntities";4import { scopedProjectKey } from "../../lib/scopedEntities";
4import type { HomeProjectScope } from "../home/homeThreadList";5import type { HomeProjectScope } from "../home/homeThreadList";
56
6export type DraftProjectSelectionResolution =7type DraftProjectSelectionResolution =
7 | { readonly kind: "preserve" }8 | { readonly kind: "preserve" }
8 | { readonly kind: "select"; readonly project: EnvironmentProject }9 | { readonly kind: "select"; readonly project: EnvironmentProject }
9 | { readonly kind: "pick" };10 | { readonly kind: "pick" };
1011
12export 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
11export function getOnlySelectableProject(22export function getOnlySelectableProject(
12 projectScopes: ReadonlyArray<HomeProjectScope>,23 projectScopes: ReadonlyArray<HomeProjectScope>,
13): EnvironmentProject | null {24): EnvironmentProject | null {
14 const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null;25 const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null;
15 return onlyScope?.projects.length === 1 ? (onlyScope.projects[0] ?? null) : null;26 return onlyScope?.representative ?? null;
16}27}
1728
18export function resolveDraftProjectSelection(29export function resolveDraftProjectSelection(
apps/mobile/src/features/threads/thread-settings-menu.test.ts deleted-284
...@@ -1,284 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts";
4
5import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
6import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu";
7
8function 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
31function 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
43const 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
58const fastModeDescriptor: ProviderOptionDescriptor = {
59 id: "fastMode",
60 label: "Fast mode",
61 type: "boolean",
62 currentValue: false,
63};
64
65function 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
79function eventFor(menu: ReturnType<typeof buildThreadSettingsMenu>, id: string | undefined) {
80 return id === undefined ? undefined : menu.events.get(id);
81}
82
83describe("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 @@
1import type { MenuAction } from "@react-native-menu/menu";
2import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts";
3import {
4 getProviderOptionCurrentLabel,
5 getProviderOptionCurrentValue,
6} from "@t3tools/shared/model";
7
8import 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 */
17export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]);
18
19export 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
30export 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
39export 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
44export 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 */
61export 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 @@
1import type { ProviderOptionDescriptor } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { selectableChoices } from "./thread-settings-options";
5
6const 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
21describe("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 @@
1import 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 */
10const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]);
11
12export 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
39export function selectableChoices(
40 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>,
41) {
42 const injected = new Set(descriptor.promptInjectedValues ?? []);
43 return descriptor.options.filter(
44 (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id),
45 );
46}
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts+21-1
...@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
3import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";3import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";
44
5import type { ModelOption } from "../../lib/modelOptions";5import type { ModelOption } from "../../lib/modelOptions";
6import { pendingModelAfterPress } from "./thread-settings-sheet-state";6import { modelMatchesCatalogQuery, pendingModelAfterPress } from "./thread-settings-sheet-state";
77
8function modelOption(8function modelOption(
9 model: string,9 model: string,
...@@ -28,6 +28,26 @@ function modelOption(...@@ -28,6 +28,26 @@ function modelOption(
28}28}
2929
30describe("thread settings sheet state", () => {30describe("thread settings sheet state", () => {
31 it("matches visible model and provider terms", () => {
32 const model = modelOption("gpt-next");
33
34 expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "NEXT" })).toBe(true);
35 expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "codex" })).toBe(true);
36 expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "claude" })).toBe(
37 false,
38 );
39 });
40
41 it("treats whitespace-only catalog searches as empty", () => {
42 expect(
43 modelMatchesCatalogQuery({
44 model: modelOption("gpt-next"),
45 providerLabel: "Codex",
46 query: " ",
47 }),
48 ).toBe(true);
49 });
50
31 it("clears staging when the applied model is pressed", () => {51 it("clears staging when the applied model is pressed", () => {
32 expect(52 expect(
33 pendingModelAfterPress({53 pendingModelAfterPress({
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+34
...@@ -1,5 +1,24 @@...@@ -1,5 +1,24 @@
1import type { ModelOption } from "../../lib/modelOptions";1import type { ModelOption } from "../../lib/modelOptions";
22
3/** Match the terms a user can actually see or recognize in the model picker. */
4export function modelMatchesCatalogQuery(input: {
5 readonly model: ModelOption;
6 readonly providerLabel: string;
7 readonly query: string;
8}): boolean {
9 const query = input.query.trim().toLocaleLowerCase();
10 if (query.length === 0) {
11 return true;
12 }
13
14 return [
15 input.model.label,
16 input.model.subtitle,
17 input.model.selection.model,
18 input.providerLabel,
19 ].some((value) => value.toLocaleLowerCase().includes(query));
20}
21
3/** Preserve staged provider options when the highlighted model is tapped again. */22/** Preserve staged provider options when the highlighted model is tapped again. */
4export function pendingModelAfterPress(input: {23export function pendingModelAfterPress(input: {
5 readonly current: ModelOption | null;24 readonly current: ModelOption | null;
...@@ -11,3 +30,18 @@ export function pendingModelAfterPress(input: {...@@ -11,3 +30,18 @@ export function pendingModelAfterPress(input: {
11 }30 }
12 return input.current?.key === input.pressed.key ? input.current : input.pressed;31 return input.current?.key === input.pressed.key ? input.current : input.pressed;
13}32}
33
34/**
35 * Primary and selected providers start open; all other catalogs start closed.
36 * A user's disclosure tap inverts that default until the picker is dismissed.
37 */
38export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";
3
4import { mobilePreferencesAtom } from "../../state/preferences";
5import { 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 */
12export function useLegacyPlanModeEnabled(): boolean {
13 return useLegacyPlanModeState().enabled;
14}
15
16export function useLegacyPlanModeState(): { readonly enabled: boolean; readonly loaded: boolean } {
17 const preferences = useAtomValue(mobilePreferencesAtom);
18 const loaded = AsyncResult.isSuccess(preferences);
19 return {
20 enabled: resolveLegacyPlanModeEnabled({
21 loaded,
22 preference: loaded ? preferences.value.planModeEnabled : undefined,
23 }),
24 loaded,
25 };
26}
apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts+137-41
...@@ -3,14 +3,46 @@ import { KeyboardController } from "react-native-keyboard-controller";...@@ -3,14 +3,46 @@ import { KeyboardController } from "react-native-keyboard-controller";
33
4import type { ComposerEditorHandle } from "../../components/ComposerEditor";4import type { ComposerEditorHandle } from "../../components/ComposerEditor";
55
6export type ThreadSettingsSheetCloseReason = "save" | "dismiss";6type PresentationPhase = "closed" | "opening" | "visible";
77
8type 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 */
12export 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 */
26const 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 */
37const NATIVE_DISMISSAL_ECHO_WINDOW_MS = 150;
938
10/**39/**
11 * Keeps the custom native composer and the settings modal from owning focus at40 * Keeps the custom native composer and the settings sheet from owning focus at
12 * the same time. Opening waits for the keyboard dismissal to finish, while41 * the same time. Opening resigns the editor cleanly; a dismissal re-focuses it
13 * focus restoration waits for the modal's dismissal callback.42 * once the sheet has fully landed. A plain blur/focus pair costs one keyboard
43 * animation each way — keepFocus-style inputView overrides are avoided because
44 * removing them forces UIKit to reload input views, replaying the keyboard's
45 * show as a visible collapse/re-open.
14 */46 */
15export function useThreadSettingsSheetPresentation(input: {47export function useThreadSettingsSheetPresentation(input: {
16 readonly editorRef: RefObject<ComposerEditorHandle | null>;48 readonly editorRef: RefObject<ComposerEditorHandle | null>;
...@@ -19,18 +51,36 @@ export function useThreadSettingsSheetPresentation(input: {...@@ -19,18 +51,36 @@ export function useThreadSettingsSheetPresentation(input: {
19 const [phase, setPhase] = useState<PresentationPhase>("closed");51 const [phase, setPhase] = useState<PresentationPhase>("closed");
20 const isActiveRef = useRef(false);52 const isActiveRef = useRef(false);
21 const isMountedRef = useRef(true);53 const isMountedRef = useRef(true);
54 const isEditorFocusedRef = useRef(input.isEditorFocused);
22 const openingIdRef = useRef(0);55 const openingIdRef = useRef(0);
23 const restoreFocusOnSaveRef = useRef(false);56 const focusRestoreIdRef = useRef(0);
24 const shouldRestoreAfterDismissRef = useRef(false);57 const restoreFocusAfterDismissRef = useRef(false);
58 const restorePendingRef = useRef(false);
59 const lastStackTransitionFinishedAtRef = useRef(0);
60 const dismissRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
61 const clearDismissRestoreTimer = useCallback(() => {
62 if (dismissRestoreTimerRef.current !== null) {
63 clearTimeout(dismissRestoreTimerRef.current);
64 dismissRestoreTimerRef.current = null;
65 }
66 }, []);
2567
26 useEffect(68 useEffect(() => {
27 () => () => {69 isEditorFocusedRef.current = input.isEditorFocused;
70 }, [input.isEditorFocused]);
71
72 useEffect(() => {
73 // React Strict Mode and Fast Refresh both run an effect cleanup/setup
74 // cycle without recreating refs. Re-arm the mounted guard on every setup.
75 isMountedRef.current = true;
76 return () => {
28 isMountedRef.current = false;77 isMountedRef.current = false;
29 isActiveRef.current = false;78 isActiveRef.current = false;
30 openingIdRef.current += 1;79 openingIdRef.current += 1;
31 },80 focusRestoreIdRef.current += 1;
32 [],81 clearDismissRestoreTimer();
33 );82 };
83 }, [clearDismissRestoreTimer]);
3484
35 const open = useCallback(() => {85 const open = useCallback(() => {
36 if (isActiveRef.current) {86 if (isActiveRef.current) {
...@@ -38,61 +88,107 @@ export function useThreadSettingsSheetPresentation(input: {...@@ -38,61 +88,107 @@ export function useThreadSettingsSheetPresentation(input: {
38 }88 }
3989
40 isActiveRef.current = true;90 isActiveRef.current = true;
41 restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible();91 focusRestoreIdRef.current += 1;
42 shouldRestoreAfterDismissRef.current = false;92 clearDismissRestoreTimer();
93 restorePendingRef.current = false;
94 restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible();
43 setPhase("opening");95 setPhase("opening");
4496
45 const openingId = openingIdRef.current + 1;97 const openingId = openingIdRef.current + 1;
46 openingIdRef.current = openingId;98 openingIdRef.current = openingId;
4799
48 // Keyboard.dismiss() only tracks React Native TextInputs. The composer is100 // Start the keyboard transition before the custom native editor resigns
49 // a custom native text view, so explicitly resign its first responder too.101 // first responder, then present the sheet on the next frame. The sheet and
102 // keyboard animate together instead of serializing two native transitions.
103 void KeyboardController.dismiss({ animated: true });
50 input.editorRef.current?.blur();104 input.editorRef.current?.blur();
51 void KeyboardController.dismiss().then(() => {105
106 requestAnimationFrame(() => {
52 if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) {107 if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) {
53 return;108 return;
54 }109 }
55 setPhase("visible");110 setPhase("visible");
56 });111 });
57 }, [input.editorRef, input.isEditorFocused]);112 }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]);
113
114 const restoreEditorFocus = useCallback(() => {
115 const focusRestoreId = focusRestoreIdRef.current + 1;
116 focusRestoreIdRef.current = focusRestoreId;
117 let attemptsRemaining = 20;
118
119 // Restoration runs after the dismissal transition, so the first attempt
120 // normally succeeds; the retries are insurance against UIKit briefly
121 // refusing first-responder status right at the transition boundary.
122 const restoreFocus = () => {
123 if (
124 !isMountedRef.current ||
125 focusRestoreIdRef.current !== focusRestoreId ||
126 isEditorFocusedRef.current ||
127 attemptsRemaining <= 0
128 ) {
129 return;
130 }
58131
59 const close = useCallback((reason: ThreadSettingsSheetCloseReason) => {132 attemptsRemaining -= 1;
60 if (!isActiveRef.current) {133 input.editorRef.current?.focus();
134 setTimeout(restoreFocus, 50);
135 };
136 requestAnimationFrame(restoreFocus);
137 }, [input.editorRef]);
138
139 /** Runs the queued restore once — whichever completion signal arrives first. */
140 const runPendingDismissalRestore = useCallback(() => {
141 if (!restorePendingRef.current) {
61 return;142 return;
62 }143 }
144 restorePendingRef.current = false;
145 clearDismissRestoreTimer();
146 // A reopened sheet owns focus again; drop the stale restore request.
147 if (!isMountedRef.current || isActiveRef.current) {
148 return;
149 }
150 restoreEditorFocus();
151 }, [clearDismissRestoreTimer, restoreEditorFocus]);
63152
64 openingIdRef.current += 1;153 /**
65 shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current;154 * Marks the sheet closed and queues the keyboard's return for the moment
66 setPhase("closing");155 * the dismissal transition actually completes: the sheet slides away over a
67 }, []);156 * resting composer, then the keyboard lifts it in one continuous motion.
68157 */
69 const onDismissed = useCallback(() => {158 const onDismissed = useCallback(() => {
70 const shouldRestoreFocus = shouldRestoreAfterDismissRef.current;
71 shouldRestoreAfterDismissRef.current = false;
72 restoreFocusOnSaveRef.current = false;
73 isActiveRef.current = false;159 isActiveRef.current = false;
74 setPhase("closed");160 setPhase("closed");
75161
76 if (shouldRestoreFocus) {162 if (!restoreFocusAfterDismissRef.current) {
77 input.editorRef.current?.focus();163 return;
78 }164 }
79 }, [input.editorRef]);165 restoreFocusAfterDismissRef.current = false;
80166 restorePendingRef.current = true;
81 // The new-task screen can have an autofocus queued before the sheet opens.167 clearDismissRestoreTimer();
82 // Preserve that intent for Save without allowing it to focus under the modal.168 if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) {
83 const restoreFocusAfterSave = useCallback(() => {169 // A stack transition finished just before this pop reached JS: the pop
84 if (isActiveRef.current) {170 // is the state echo of a gesture-driven dismissal whose animation has
85 restoreFocusOnSaveRef.current = true;171 // already completed. The sheet is gone — bring the keyboard back now.
172 runPendingDismissalRestore();
173 return;
86 }174 }
87 }, []);175 dismissRestoreTimerRef.current = setTimeout(() => {
176 dismissRestoreTimerRef.current = null;
177 runPendingDismissalRestore();
178 }, SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS);
179 }, [clearDismissRestoreTimer, runPendingDismissalRestore]);
180
181 /** Wire to the navigator's `finishTransitioning` event. */
182 const onStackTransitionsFinished = useCallback(() => {
183 lastStackTransitionFinishedAtRef.current = Date.now();
184 runPendingDismissalRestore();
185 }, [runPendingDismissalRestore]);
88186
89 return {187 return {
90 isActive: phase !== "closed",188 isActive: phase !== "closed",
91 isActiveRef,
92 isVisible: phase === "visible",189 isVisible: phase === "visible",
93 open,190 open,
94 close,
95 onDismissed,191 onDismissed,
96 restoreFocusAfterSave,192 onStackTransitionsFinished,
97 } as const;193 } as const;
98}194}
apps/mobile/src/native/native-glass.ts+2-2
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import { isLiquidGlassSupported } from "@callstack/liquid-glass";1import { isGlassEffectAPIAvailable } from "expo-glass-effect";
2import { Platform } from "react-native";2import { Platform } from "react-native";
33
4import { supportsNativeLiquidGlass } from "../lib/native-glass-capability";4import { supportsNativeLiquidGlass } from "../lib/native-glass-capability";
55
6export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass(6export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass(
7 Platform.OS,7 Platform.OS,
8 isLiquidGlassSupported,8 isGlassEffectAPIAvailable(),
9);9);
apps/mobile/src/native/sheet-surface.ts created+28
...@@ -0,0 +1,28 @@
1import { 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 */
10export const NATIVE_SHEET_SURFACE_COLOR: ColorValue | undefined =
11 Platform.OS === "ios" ? DynamicColorIOS({ light: "#f2f2f7", dark: "#0e0e0e" }) : undefined;
12
13export 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 */
23export const FORM_SHEET_PRESENTATION_OPTIONS = {
24 presentation: "formSheet" as const,
25 ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined
26 ? null
27 : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }),
28};
apps/mobile/src/persistence/mobile-preferences.ts+6
...@@ -35,6 +35,8 @@ export interface Preferences {...@@ -35,6 +35,8 @@ export interface Preferences {
35 * default flat list — see `resolveThreadListV2Enabled`.35 * default flat list — see `resolveThreadListV2Enabled`.
36 */36 */
37 readonly legacyThreadListEnabled?: boolean;37 readonly legacyThreadListEnabled?: boolean;
38 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
39 readonly planModeEnabled?: boolean;
38}40}
3941
40export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(42export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
...@@ -88,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -88,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
88 projectGroupingMode?: SidebarProjectGroupingMode;90 projectGroupingMode?: SidebarProjectGroupingMode;
89 autoSettleOnMerge?: boolean;91 autoSettleOnMerge?: boolean;
90 legacyThreadListEnabled?: boolean;92 legacyThreadListEnabled?: boolean;
93 planModeEnabled?: boolean;
91 } = {};94 } = {};
9295
93 if (typeof parsed.liveActivitiesEnabled === "boolean") {96 if (typeof parsed.liveActivitiesEnabled === "boolean") {
...@@ -130,6 +133,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -130,6 +133,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
130 if (typeof parsed.legacyThreadListEnabled === "boolean") {133 if (typeof parsed.legacyThreadListEnabled === "boolean") {
131 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;134 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
132 }135 }
136 if (typeof parsed.planModeEnabled === "boolean") {
137 preferences.planModeEnabled = parsed.planModeEnabled;
138 }
133 return preferences;139 return preferences;
134}140}
135141
apps/mobile/src/state/queries.ts+122-4
...@@ -1,14 +1,23 @@...@@ -1,14 +1,23 @@
1import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";1import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
2import type {
3 EnvironmentId,
4 OrchestrationThread,
5 ThreadId,
6 VcsListRefsResult,
7 VcsRef,
8} from "@t3tools/contracts";
2import {9import {
3 createThreadSearchResultsAtomFamily,10 createThreadSearchResultsAtomFamily,
4 makeThreadSearchKey,11 makeThreadSearchKey,
5 type EnvironmentThreadSearchMatch,12 type EnvironmentThreadSearchMatch,
6} from "@t3tools/client-runtime/state/thread-search";13} from "@t3tools/client-runtime/state/thread-search";
7import { useAtomValue } from "@effect/atom-react";14import { useAtomValue } from "@effect/atom-react";
15import * as Cause from "effect/Cause";
8import * as Option from "effect/Option";16import * as Option from "effect/Option";
9import { Atom } from "effect/unstable/reactivity";17import { AsyncResult, Atom } from "effect/unstable/reactivity";
10import { useEffect, useMemo, useState } from "react";18import { useCallback, useEffect, useMemo, useState } from "react";
1119
20import { appAtomRegistry } from "./atom-registry";
12import { orchestrationEnvironment } from "./orchestration";21import { orchestrationEnvironment } from "./orchestration";
13import { projectEnvironment } from "./projects";22import { projectEnvironment } from "./projects";
14import { useEnvironmentQuery } from "./query";23import { useEnvironmentQuery } from "./query";
...@@ -24,6 +33,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;...@@ -24,6 +33,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;
24const COMPOSER_PATH_SEARCH_LIMIT = 20;33const COMPOSER_PATH_SEARCH_LIMIT = 20;
25const THREAD_SEARCH_DEBOUNCE_MS = 200;34const THREAD_SEARCH_DEBOUNCE_MS = 200;
26const VCS_REF_LIST_LIMIT = 100;35const VCS_REF_LIST_LIMIT = 100;
36const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
37const INITIAL_BRANCH_CURSORS = [undefined] as const;
27const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);38const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);
28const EMPTY_THREAD_SEARCH_ATOM = Atom.make({39const EMPTY_THREAD_SEARCH_ATOM = Atom.make({
29 matches: EMPTY_THREAD_SEARCH_MATCHES,40 matches: EMPTY_THREAD_SEARCH_MATCHES,
...@@ -52,7 +63,7 @@ export interface ComposerPathSearchTarget {...@@ -52,7 +63,7 @@ export interface ComposerPathSearchTarget {
52 readonly query: string | null;63 readonly query: string | null;
53}64}
5465
55function useDebouncedValue<A>(value: A, delayMs: number): A {66export function useDebouncedValue<A>(value: A, delayMs: number): A {
56 const [debounced, setDebounced] = useState(value);67 const [debounced, setDebounced] = useState(value);
5768
58 useEffect(() => {69 useEffect(() => {
...@@ -125,6 +136,113 @@ export function useBranches(input: {...@@ -125,6 +136,113 @@ export function useBranches(input: {
125 );136 );
126}137}
127138
139export 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
128export function useComposerPathSearch(target: ComposerPathSearchTarget) {246export function useComposerPathSearch(target: ComposerPathSearchTarget) {
129 const normalizedTarget = useMemo(247 const normalizedTarget = useMemo(
130 () => ({248 () => ({
apps/mobile/src/state/use-composer-drafts.test.ts+125
...@@ -1,10 +1,57 @@...@@ -1,10 +1,57 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
3import { vi } from "vite-plus/test";
4
5const 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
43vi.mock("expo-file-system", () => ({
44 Directory: composerDraftFileMocks.Directory,
45 File: composerDraftFileMocks.File,
46 Paths: { document: "/documents" },
47}));
348
4import { appAtomRegistry } from "./atom-registry";49import { appAtomRegistry } from "./atom-registry";
5import {50import {
6 clearComposerDraftContentState,51 clearComposerDraftContentState,
7 composerDraftsAtom,52 composerDraftsAtom,
53 copyComposerDraftContentIfEmpty,
54 copyComposerDraftContentState,
8 decodePersistedComposerDrafts,55 decodePersistedComposerDrafts,
9 type ComposerDraft,56 type ComposerDraft,
10 getComposerDraftSnapshot,57 getComposerDraftSnapshot,
...@@ -165,6 +212,53 @@ describe("mobile composer drafts", () => {...@@ -165,6 +212,53 @@ describe("mobile composer drafts", () => {
165 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);212 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
166 });213 });
167214
215 it("carries unfinished content to a newly selected project without overwriting its settings", () => {
216 const sourceKey = "new-task:environment-1:project-1";
217 const targetKey = "new-task:environment-1:project-2";
218 const source: ComposerDraft = {
219 text: "Keep this task",
220 attachments: [],
221 importedShareIds: ["share-1"],
222 workspaceSelection: {
223 mode: "worktree",
224 branch: "feature/source",
225 worktreePath: null,
226 },
227 };
228 const target: ComposerDraft = {
229 text: "",
230 attachments: [],
231 runtimeMode: "approval-required",
232 };
233
234 expect(
235 copyComposerDraftContentState(
236 { [sourceKey]: source, [targetKey]: target },
237 sourceKey,
238 targetKey,
239 ),
240 ).toEqual({
241 [sourceKey]: source,
242 [targetKey]: {
243 ...target,
244 text: source.text,
245 attachments: source.attachments,
246 importedShareIds: source.importedShareIds,
247 },
248 });
249 });
250
251 it("does not overwrite unfinished content already stored for the selected project", () => {
252 const sourceKey = "new-task:environment-1:project-1";
253 const targetKey = "new-task:environment-1:project-2";
254 const drafts: Record<string, ComposerDraft> = {
255 [sourceKey]: { text: "Source task", attachments: [] },
256 [targetKey]: { text: "Target task", attachments: [] },
257 };
258
259 expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts);
260 });
261
168 it("merges shared content into a project draft without duplicating retries", () => {262 it("merges shared content into a project draft without duplicating retries", () => {
169 const draftKey = "new-task:environment-1:project-1";263 const draftKey = "new-task:environment-1:project-1";
170 const sharedAttachment = {264 const sharedAttachment = {
...@@ -268,4 +362,35 @@ describe("mobile composer drafts", () => {...@@ -268,4 +362,35 @@ describe("mobile composer drafts", () => {
268 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,362 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
269 });363 });
270 });364 });
365
366 it("waits for persisted drafts before copying content between projects", async () => {
367 const sourceKey = "new-task:environment-1:project-1";
368 const targetKey = "new-task:environment-1:project-2";
369 const unrelatedKey = "environment-1:thread-1";
370 const source = { text: "Current task", attachments: [] } satisfies ComposerDraft;
371 const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft;
372 const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft;
373
374 composerDraftFileMocks.setDocument({
375 schemaVersion: 1,
376 drafts: {
377 [targetKey]: target,
378 [unrelatedKey]: unrelated,
379 },
380 });
381 composerDraftFileMocks.blockRead();
382 appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source });
383
384 const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey);
385 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source });
386
387 composerDraftFileMocks.releaseRead();
388 await copy;
389
390 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
391 [sourceKey]: source,
392 [targetKey]: target,
393 [unrelatedKey]: unrelated,
394 });
395 });
271});396});
apps/mobile/src/state/use-composer-drafts.ts+50-1
...@@ -253,7 +253,11 @@ export function ensureComposerDraftsLoaded(): void {...@@ -253,7 +253,11 @@ export function ensureComposerDraftsLoaded(): void {
253function updateComposerDrafts(253function updateComposerDrafts(
254 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,254 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,
255): void {255): void {
256 const next = update(appAtomRegistry.get(composerDraftsAtom));256 const current = appAtomRegistry.get(composerDraftsAtom);
257 const next = update(current);
258 if (next === current) {
259 return;
260 }
257 appAtomRegistry.set(composerDraftsAtom, next);261 appAtomRegistry.set(composerDraftsAtom, next);
258 schedulePersistComposerDrafts(next);262 schedulePersistComposerDrafts(next);
259}263}
...@@ -412,6 +416,51 @@ export function restoreComposerDraftSnapshotState(...@@ -412,6 +416,51 @@ export function restoreComposerDraftSnapshotState(
412 return next;416 return next;
413}417}
414418
419export 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
451export 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
415function mergeComposerDraftText(existing: string, incoming: string): string {464function mergeComposerDraftText(existing: string, incoming: string): string {
416 if (incoming.length === 0) {465 if (incoming.length === 0) {
417 return existing;466 return existing;
docs/user/permission-modes.md+1-2
...@@ -44,5 +44,4 @@ with prompting enabled and a restricted workspace while **Full access** disables...@@ -44,5 +44,4 @@ with prompting enabled and a restricted workspace while **Full access** disables
44labels above describe what you get; the exact per-provider translation is internal and may44labels above describe what you get; the exact per-provider translation is internal and may
45change.45change.
4646
47Mobile offers the same four modes. It labels the first one **Approve actions** rather than47Mobile offers the same four modes with the same labels and descriptions.
48**Supervised**.
patches/@react-navigation%2Fnative-stack@7.17.6.patch+22
...@@ -100,3 +100,25 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117...@@ -100,3 +100,25 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
100-//# sourceMappingURL=useHeaderConfigProps.js.map100-//# sourceMappingURL=useHeaderConfigProps.js.map
101\ No newline at end of file101\ No newline at end of file
102+//# sourceMappingURL=useHeaderConfigProps.js.map102+//# sourceMappingURL=useHeaderConfigProps.js.map
103diff --git a/lib/module/views/NativeStackView.native.js b/lib/module/views/NativeStackView.native.js
104index c342e90..5d3e440 100644
105--- a/lib/module/views/NativeStackView.native.js
106+++ b/lib/module/views/NativeStackView.native.js
107@@ -370,6 +370,17 @@ export function NativeStackView({
108 return /*#__PURE__*/_jsx(SafeAreaProviderCompat, {
109 children: /*#__PURE__*/_jsx(ScreenStack, {
110 style: styles.container,
111+ onFinishTransitioning: () => {
112+ // Surface UIKit's transition-completion callback to every route of
113+ // this navigator. Unlike transitionEnd, this also fires when a modal
114+ // finishes dismissing — where the presenting screen below receives no
115+ // appearance callbacks — and for a gesture-driven dismissal it fires
116+ // before the state pop, while the modal route is still the focused
117+ // one, so the event must not be targeted at a single route.
118+ navigation.emit({
119+ type: 'finishTransitioning'
120+ });
121+ },
122 children: state.routes.concat(state.preloadedRoutes).map((route, index) => {
123 const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key];
124 const isFocused = state.index === index;
patches/react-native-screens@4.25.2.patch+126-50
...@@ -226,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -226,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
226 226
227 // appearance does not apply to the tvOS so we need to use lagacy customization227 // appearance does not apply to the tvOS so we need to use lagacy customization
228 #if TARGET_OS_TV228 #if TARGET_OS_TV
229@@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc229@@ -637,10 +675,458 @@ + (void)updateViewController:(UIViewController *)vc
230 // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).230 // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).
231 // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)231 // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)
232 navitem.title = config.title;232 navitem.title = config.title;
...@@ -391,40 +391,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -391,40 +391,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
391+ ]];391+ ]];
392+ [chromeHostView bringSubviewToFront:toolbarHost];392+ [chromeHostView bringSubviewToFront:toolbarHost];
393+393+
394+ void (^configureKeyboardTracking)(UITextField *) = ^(UITextField *textField) {
395+ BOOL isEditing = textField.isFirstResponder;
396+ keyboardAvoidConstraint.priority =
397+ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow;
398+ restingBottomConstraint.priority =
399+ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh;
400+
401+ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint;
402+ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint;
403+ __weak UIView *weakChromeHostView = chromeHostView;
404+ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin";
405+ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end";
406+ [textField removeActionForIdentifier:beginActionIdentifier forControlEvents:UIControlEventEditingDidBegin];
407+ [textField removeActionForIdentifier:endActionIdentifier forControlEvents:UIControlEventEditingDidEnd];
408+ [textField addAction:[UIAction actionWithTitle:@""
409+ image:nil
410+ identifier:beginActionIdentifier
411+ handler:^(__kindof UIAction *_Nonnull action) {
412+ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultLow;
413+ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh;
414+ [weakChromeHostView setNeedsLayout];
415+ }]
416+ forControlEvents:UIControlEventEditingDidBegin];
417+ [textField addAction:[UIAction actionWithTitle:@""
418+ image:nil
419+ identifier:endActionIdentifier
420+ handler:^(__kindof UIAction *_Nonnull action) {
421+ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow;
422+ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultHigh;
423+ [weakChromeHostView setNeedsLayout];
424+ }]
425+ forControlEvents:UIControlEventEditingDidEnd];
426+ };
427+
428+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];394+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];
429+ glassEffect.interactive = YES;395+ glassEffect.interactive = YES;
430+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];396+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];
...@@ -438,9 +404,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -438,9 +404,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
438+ mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil;404+ mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil;
439+ CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0;405+ CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0;
440+ CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0;406+ CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0;
407+ NSLayoutConstraint *glassLeadingConstraint =
408+ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset];
409+ NSLayoutConstraint *glassTrailingConstraint =
410+ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset];
441+ [NSLayoutConstraint activateConstraints:@[411+ [NSLayoutConstraint activateConstraints:@[
442+ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset],412+ glassLeadingConstraint,
443+ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset],413+ glassTrailingConstraint,
444+ [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],414+ [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
445+ [glassView.heightAnchor constraintEqualToConstant:toolbarHeight],415+ [glassView.heightAnchor constraintEqualToConstant:toolbarHeight],
446+ ]];416+ ]];
...@@ -490,6 +460,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -490,6 +460,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
490+ UISearchBar *searchBar =460+ UISearchBar *searchBar =
491+ !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil;461+ !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil;
492+ NSString *placeholder = mailSearchToolbarConfig[@"placeholder"];462+ NSString *placeholder = mailSearchToolbarConfig[@"placeholder"];
463+ UITextField *resolvedSearchTextField = nil;
493+ if (searchBar != nil) {464+ if (searchBar != nil) {
494+ if (placeholder != nil) {465+ if (placeholder != nil) {
495+ searchBar.placeholder = placeholder;466+ searchBar.placeholder = placeholder;
...@@ -506,7 +477,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -506,7 +477,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
506+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;477+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;
507+ searchBar.searchTextField.textColor = UIColor.labelColor;478+ searchBar.searchTextField.textColor = UIColor.labelColor;
508+ searchBar.searchTextField.tintColor = UIColor.labelColor;479+ searchBar.searchTextField.tintColor = UIColor.labelColor;
509+ configureKeyboardTracking(searchBar.searchTextField);480+ resolvedSearchTextField = searchBar.searchTextField;
510+ if (placeholder != nil) {481+ if (placeholder != nil) {
511+ searchBar.searchTextField.attributedPlaceholder =482+ searchBar.searchTextField.attributedPlaceholder =
512+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];483+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
...@@ -539,7 +510,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -539,7 +510,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
539+ searchField.adjustsFontForContentSizeCategory = YES;510+ searchField.adjustsFontForContentSizeCategory = YES;
540+ searchField.textColor = UIColor.labelColor;511+ searchField.textColor = UIColor.labelColor;
541+ searchField.tintColor = UIColor.labelColor;512+ searchField.tintColor = UIColor.labelColor;
542+ configureKeyboardTracking(searchField);513+ resolvedSearchTextField = searchField;
543+ searchField.translatesAutoresizingMaskIntoConstraints = NO;514+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
544+ [glassView.contentView addSubview:searchField];515+ [glassView.contentView addSubview:searchField];
545+ [NSLayoutConstraint activateConstraints:@[516+ [NSLayoutConstraint activateConstraints:@[
...@@ -550,8 +521,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -550,8 +521,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
550+ ]];521+ ]];
551+ }522+ }
552+523+
524+ UIButton *filterButton = nil;
553+ if (hasFilterButton) {525+ if (hasFilterButton) {
554+ UIButton *filterButton = makeGlassButton(526+ filterButton = makeGlassButton(
555+ mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",527+ mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
556+ mailSearchToolbarConfig[@"filterButtonId"],528+ mailSearchToolbarConfig[@"filterButtonId"],
557+ mailSearchToolbarConfig[@"filterMenu"]);529+ mailSearchToolbarConfig[@"filterMenu"]);
...@@ -565,8 +537,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -565,8 +537,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
565+ ]];537+ ]];
566+ }538+ }
567+539+
540+ UIButton *composeButton = nil;
568+ if (hasComposeButton) {541+ if (hasComposeButton) {
569+ UIButton *composeButton = makeGlassButton(542+ composeButton = makeGlassButton(
570+ mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil",543+ mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil",
571+ mailSearchToolbarConfig[@"composeButtonId"],544+ mailSearchToolbarConfig[@"composeButtonId"],
572+ mailSearchToolbarConfig[@"composeMenu"]);545+ mailSearchToolbarConfig[@"composeMenu"]);
...@@ -579,6 +552,107 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -579,6 +552,107 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
579+ [composeButton.heightAnchor constraintEqualToConstant:buttonSize],552+ [composeButton.heightAnchor constraintEqualToConstant:buttonSize],
580+ ]];553+ ]];
581+ }554+ }
555+
556+ BOOL showsSearchDismissButton =
557+ [mailSearchToolbarConfig[@"showsSearchDismissButton"] boolValue] && resolvedSearchTextField != nil;
558+ UIButton *searchDismissButton = nil;
559+ if (showsSearchDismissButton) {
560+ searchDismissButton = makeGlassButton(@"xmark", nil, nil);
561+ searchDismissButton.accessibilityLabel = @"Dismiss search keyboard";
562+ searchDismissButton.alpha = 0.0;
563+ searchDismissButton.hidden = YES;
564+ searchDismissButton.translatesAutoresizingMaskIntoConstraints = NO;
565+ [toolbarHost addSubview:searchDismissButton];
566+ [NSLayoutConstraint activateConstraints:@[
567+ [searchDismissButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor],
568+ [searchDismissButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
569+ [searchDismissButton.widthAnchor constraintEqualToConstant:buttonSize],
570+ [searchDismissButton.heightAnchor constraintEqualToConstant:buttonSize],
571+ ]];
572+ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
573+ [searchDismissButton
574+ addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
575+ [weakSearchTextField resignFirstResponder];
576+ }]
577+ forControlEvents:UIControlEventTouchUpInside];
578+ }
579+
580+ __weak UIButton *weakFilterButton = filterButton;
581+ __weak UIButton *weakComposeButton = composeButton;
582+ __weak UIButton *weakSearchDismissButton = searchDismissButton;
583+ __weak NSLayoutConstraint *weakGlassLeadingConstraint = glassLeadingConstraint;
584+ __weak NSLayoutConstraint *weakGlassTrailingConstraint = glassTrailingConstraint;
585+ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint;
586+ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint;
587+ __weak UIView *weakToolbarHost = toolbarHost;
588+ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
589+ void (^setSearchEditingAppearance)(BOOL, BOOL) = ^(BOOL isEditing, BOOL animated) {
590+ weakKeyboardAvoidConstraint.priority =
591+ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow;
592+ weakRestingBottomConstraint.priority =
593+ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh;
594+
595+ if (showsSearchDismissButton) {
596+ if (isEditing) {
597+ weakSearchDismissButton.hidden = NO;
598+ } else {
599+ weakFilterButton.hidden = NO;
600+ weakComposeButton.hidden = NO;
601+ }
602+ weakGlassLeadingConstraint.constant = isEditing ? 0.0 : glassLeadingInset;
603+ weakGlassTrailingConstraint.constant = isEditing ? -sideButtonReserve : glassTrailingInset;
604+
605+ void (^changes)(void) = ^{
606+ weakFilterButton.alpha = isEditing ? 0.0 : 1.0;
607+ weakComposeButton.alpha = isEditing ? 0.0 : 1.0;
608+ weakSearchDismissButton.alpha = isEditing ? 1.0 : 0.0;
609+ [weakToolbarHost layoutIfNeeded];
610+ };
611+ void (^completion)(BOOL) = ^(BOOL finished) {
612+ if (!finished || weakSearchTextField.isFirstResponder != isEditing) {
613+ return;
614+ }
615+ weakFilterButton.hidden = isEditing;
616+ weakComposeButton.hidden = isEditing;
617+ weakSearchDismissButton.hidden = !isEditing;
618+ };
619+ if (animated) {
620+ [UIView animateWithDuration:0.2
621+ delay:0.0
622+ options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut
623+ animations:changes
624+ completion:completion];
625+ } else {
626+ changes();
627+ completion(YES);
628+ }
629+ }
630+ [weakToolbarHost setNeedsLayout];
631+ };
632+
633+ setSearchEditingAppearance(resolvedSearchTextField.isFirstResponder, NO);
634+ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin";
635+ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end";
636+ [resolvedSearchTextField removeActionForIdentifier:beginActionIdentifier
637+ forControlEvents:UIControlEventEditingDidBegin];
638+ [resolvedSearchTextField removeActionForIdentifier:endActionIdentifier
639+ forControlEvents:UIControlEventEditingDidEnd];
640+ [resolvedSearchTextField
641+ addAction:[UIAction actionWithTitle:@""
642+ image:nil
643+ identifier:beginActionIdentifier
644+ handler:^(__kindof UIAction *_Nonnull action) {
645+ setSearchEditingAppearance(YES, YES);
646+ }]
647+ forControlEvents:UIControlEventEditingDidBegin];
648+ [resolvedSearchTextField
649+ addAction:[UIAction actionWithTitle:@""
650+ image:nil
651+ identifier:endActionIdentifier
652+ handler:^(__kindof UIAction *_Nonnull action) {
653+ setSearchEditingAppearance(NO, YES);
654+ }]
655+ forControlEvents:UIControlEventEditingDidEnd];
582+ }656+ }
583+#endif657+#endif
584+ }658+ }
...@@ -615,7 +689,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -615,7 +689,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
615 689
616 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items690 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items
617 // (setting nav bar visibility should be done after `navitem.*BarButtonItems`).691 // (setting nav bar visibility should be done after `navitem.*BarButtonItems`).
618@@ -773,6 +1185,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *692@@ -773,6 +1259,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
619 693
620 - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts694 - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts
621 withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems695 withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems
...@@ -623,7 +697,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -623,7 +697,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
623 {697 {
624 if (dicts.count == 0) {698 if (dicts.count == 0) {
625 return currentItems;699 return currentItems;
626@@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *700@@ -781,7 +1268,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
627 [items addObjectsFromArray:currentItems];701 [items addObjectsFromArray:currentItems];
628 for (NSUInteger i = 0; i < dicts.count; i++) {702 for (NSUInteger i = 0; i < dicts.count; i++) {
629 NSDictionary *dict = dicts[i];703 NSDictionary *dict = dicts[i];
...@@ -822,7 +896,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -822,7 +896,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
822 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict896 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict
823 action:^(NSString *buttonId) {897 action:^(NSString *buttonId) {
824 auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(898 auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
825@@ -803,19 +1406,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *899@@ -803,19 +1480,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
826 }900 }
827 imageLoader:_imageLoader];901 imageLoader:_imageLoader];
828 NSNumber *index = dict[@"index"];902 NSNumber *index = dict[@"index"];
...@@ -852,7 +926,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -852,7 +926,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
852 [items insertObject:item atIndex:index.integerValue];926 [items insertObject:item atIndex:index.integerValue];
853 } else {927 } else {
854 [items addObject:item];928 [items addObject:item];
855@@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *929@@ -825,6 +1506,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
856 return items;930 return items;
857 }931 }
858 932
...@@ -900,7 +974,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -900,7 +974,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
900 RNS_IGNORE_SUPER_CALL_BEGIN974 RNS_IGNORE_SUPER_CALL_BEGIN
901 - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex975 - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex
902 {976 {
903@@ -1013,6 +1661,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::977@@ -1013,6 +1735,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
904 }978 }
905 979
906 _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);980 _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);
...@@ -909,7 +983,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -909,7 +983,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
909 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {983 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {
910 _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);984 _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);
911 }985 }
912@@ -1038,6 +1688,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::986@@ -1038,6 +1762,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
913 _disableBackButtonMenu = newScreenProps.disableBackButtonMenu;987 _disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
914 _backButtonDisplayMode =988 _backButtonDisplayMode =
915 [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];989 [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];
...@@ -917,7 +991,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21...@@ -917,7 +991,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
917 991
918 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {992 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {
919 _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle];993 _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle];
920@@ -1084,6 +1735,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::994@@ -1084,6 +1809,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
921 _headerRightBarButtonItems = array;995 _headerRightBarButtonItems = array;
922 }996 }
923 997
...@@ -1313,7 +1387,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db...@@ -1313,7 +1387,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db
1313 /**1387 /**
1314 * The tint color to apply to the item.1388 * The tint color to apply to the item.
1315 *1389 *
1316@@ -1145,8 +1193,37 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {1390@@ -1145,8 +1193,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
1317 export interface HeaderBarButtonItemSpacing {1391 export interface HeaderBarButtonItemSpacing {
1318 type: 'spacing';1392 type: 'spacing';
1319 spacing: number;1393 spacing: number;
...@@ -1344,6 +1418,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db...@@ -1344,6 +1418,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db
1344+ onSearchTextChange?: ((text: string) => void) | undefined;1418+ onSearchTextChange?: ((text: string) => void) | undefined;
1345+ placeholder?: string | undefined;1419+ placeholder?: string | undefined;
1346+ searchTextChangeId?: string | undefined;1420+ searchTextChangeId?: string | undefined;
1421+ showsSearchDismissButton?: boolean | undefined;
1347+ useFallbackSearchField?: boolean | undefined;1422+ useFallbackSearchField?: boolean | undefined;
1348+ width?: number | undefined;1423+ width?: number | undefined;
1349 }1424 }
...@@ -1653,7 +1728,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13...@@ -1653,7 +1728,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13
1653 /**1728 /**
1654 * The tint color to apply to the item.1729 * The tint color to apply to the item.
1655 *1730 *
1656@@ -1279,11 +1327,46 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {1731@@ -1279,11 +1327,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
1657 export interface HeaderBarButtonItemSpacing {1732 export interface HeaderBarButtonItemSpacing {
1658 type: 'spacing';1733 type: 'spacing';
1659 spacing: number;1734 spacing: number;
...@@ -1687,6 +1762,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13...@@ -1687,6 +1762,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13
1687+ onSearchTextChange?: ((text: string) => void) | undefined;1762+ onSearchTextChange?: ((text: string) => void) | undefined;
1688+ placeholder?: string | undefined;1763+ placeholder?: string | undefined;
1689+ searchTextChangeId?: string | undefined;1764+ searchTextChangeId?: string | undefined;
1765+ showsSearchDismissButton?: boolean | undefined;
1690+ useFallbackSearchField?: boolean | undefined;1766+ useFallbackSearchField?: boolean | undefined;
1691+ width?: number | undefined;1767+ width?: number | undefined;
1692 }1768 }
pnpm-lock.yaml+54-54
...@@ -80,13 +80,13 @@ patchedDependencies:...@@ -80,13 +80,13 @@ patchedDependencies:
80 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa80 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
81 '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a004581 '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045
82 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a78482 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784
83 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c71727383 '@react-navigation/native-stack@7.17.6': 0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027
84 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb684 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
85 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f85 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f
86 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d386 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
87 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae00887 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008
88 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b608842267588 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
89 react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e89 react-native-screens@4.25.2: 59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199
9090
91importers:91importers:
9292
...@@ -237,7 +237,7 @@ importers:...@@ -237,7 +237,7 @@ importers:
237 version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)237 version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
238 '@react-navigation/native-stack':238 '@react-navigation/native-stack':
239 specifier: 7.17.6239 specifier: 7.17.6
240 version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)240 version: 7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be)
241 '@shikijs/core':241 '@shikijs/core':
242 specifier: 4.2.0242 specifier: 4.2.0
243 version: 4.2.0243 version: 4.2.0
...@@ -402,7 +402,7 @@ importers:...@@ -402,7 +402,7 @@ importers:
402 version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)402 version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
403 react-native-screens:403 react-native-screens:
404 specifier: 4.25.2404 specifier: 4.25.2
405 version: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)405 version: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
406 react-native-shiki-engine:406 react-native-shiki-engine:
407 specifier: ^0.3.12407 specifier: ^0.3.12
408 version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)408 version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
...@@ -12275,7 +12275,7 @@ snapshots:...@@ -12275,7 +12275,7 @@ snapshots:
12275 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)12275 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
12276 zod: 3.25.7612276 zod: 3.25.76
12277 optionalDependencies:12277 optionalDependencies:
12278 expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8)12278 expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865)
12279 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)12279 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
12280 transitivePeerDependencies:12280 transitivePeerDependencies:
12281 - '@expo/dom-webview'12281 - '@expo/dom-webview'
...@@ -12351,7 +12351,7 @@ snapshots:...@@ -12351,7 +12351,7 @@ snapshots:
12351 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)12351 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
12352 zod: 3.25.7612352 zod: 3.25.76
12353 optionalDependencies:12353 optionalDependencies:
12354 expo-router: 56.2.11(db5c693a26481047569df6781f34db9f)12354 expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797)
12355 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)12355 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)
12356 transitivePeerDependencies:12356 transitivePeerDependencies:
12357 - '@expo/dom-webview'12357 - '@expo/dom-webview'
...@@ -12691,7 +12691,7 @@ snapshots:...@@ -12691,7 +12691,7 @@ snapshots:
12691 react: 19.2.312691 react: 19.2.3
12692 optionalDependencies:12692 optionalDependencies:
12693 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)12693 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
12694 expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8)12694 expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865)
12695 react-dom: 19.2.3(react@19.2.3)12695 react-dom: 19.2.3(react@19.2.3)
12696 transitivePeerDependencies:12696 transitivePeerDependencies:
12697 - supports-color12697 - supports-color
...@@ -12706,7 +12706,7 @@ snapshots:...@@ -12706,7 +12706,7 @@ snapshots:
12706 react: 19.2.612706 react: 19.2.6
12707 optionalDependencies:12707 optionalDependencies:
12708 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)12708 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
12709 expo-router: 56.2.11(db5c693a26481047569df6781f34db9f)12709 expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797)
12710 react-dom: 19.2.6(react@19.2.6)12710 react-dom: 19.2.6(react@19.2.6)
12711 transitivePeerDependencies:12711 transitivePeerDependencies:
12712 - supports-color12712 - supports-color
...@@ -14291,7 +14291,7 @@ snapshots:...@@ -14291,7 +14291,7 @@ snapshots:
14291 optionalDependencies:14291 optionalDependencies:
14292 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)14292 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1429314293
14294 '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)':14294 '@react-navigation/native-stack@7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be)':
14295 dependencies:14295 dependencies:
14296 '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)14296 '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)
14297 '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)14297 '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
...@@ -14299,7 +14299,7 @@ snapshots:...@@ -14299,7 +14299,7 @@ snapshots:
14299 react: 19.2.314299 react: 19.2.3
14300 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)14300 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
14301 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)14301 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
14302 react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)14302 react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
14303 sf-symbols-typescript: 2.2.014303 sf-symbols-typescript: 2.2.0
14304 warn-once: 0.1.114304 warn-once: 0.1.1
14305 transitivePeerDependencies:14305 transitivePeerDependencies:
...@@ -17080,47 +17080,47 @@ snapshots:...@@ -17080,47 +17080,47 @@ snapshots:
17080 - supports-color17080 - supports-color
17081 - typescript17081 - typescript
1708217082
17083 expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8):17083 expo-router@56.2.11(80beea6a31a5d2003a696c1401258797):
17084 dependencies:17084 dependencies:
17085 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17085 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17086 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17086 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17087 '@expo/schema-utils': 56.0.117087 '@expo/schema-utils': 56.0.1
17088 '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)17088 '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5)
17089 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3)17089 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6)
17090 '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)17090 '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
17091 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17091 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17092 '@testing-library/jest-dom': 6.9.117092 '@testing-library/jest-dom': 6.9.1
17093 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)17093 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
17094 client-only: 0.0.117094 client-only: 0.0.1
17095 color: 4.2.317095 color: 4.2.3
17096 debug: 4.4.317096 debug: 4.4.3
17097 escape-string-regexp: 4.0.017097 escape-string-regexp: 4.0.0
17098 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)17098 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
17099 expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))17099 expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))
17100 expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17100 expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17101 expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17101 expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17102 expo-server: 56.0.517102 expo-server: 56.0.5
17103 expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17103 expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17104 fast-deep-equal: 3.1.317104 fast-deep-equal: 3.1.3
17105 invariant: 2.2.417105 invariant: 2.2.4
17106 nanoid: 3.3.1217106 nanoid: 3.3.12
17107 query-string: 7.1.317107 query-string: 7.1.3
17108 react: 19.2.317108 react: 19.2.6
17109 react-fast-compare: 3.2.217109 react-fast-compare: 3.2.2
17110 react-is: 19.2.717110 react-is: 19.2.7
17111 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)17111 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)
17112 react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5)17112 react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)
17113 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17113 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17114 react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17114 react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17115 server-only: 0.0.117115 server-only: 0.0.1
17116 sf-symbols-typescript: 2.2.017116 sf-symbols-typescript: 2.2.0
17117 shallowequal: 1.1.017117 shallowequal: 1.1.0
17118 standard-navigation: 0.0.517118 standard-navigation: 0.0.5
17119 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)17119 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
17120 optionalDependencies:17120 optionalDependencies:
17121 react-dom: 19.2.3(react@19.2.3)17121 react-dom: 19.2.6(react@19.2.6)
17122 react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17122 react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17123 react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)17123 react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17124 transitivePeerDependencies:17124 transitivePeerDependencies:
17125 - '@babel/core'17125 - '@babel/core'
17126 - '@testing-library/dom'17126 - '@testing-library/dom'
...@@ -17131,47 +17131,47 @@ snapshots:...@@ -17131,47 +17131,47 @@ snapshots:
17131 - supports-color17131 - supports-color
17132 optional: true17132 optional: true
1713317133
17134 expo-router@56.2.11(db5c693a26481047569df6781f34db9f):17134 expo-router@56.2.11(e1497a99e5bc5be76c1cdb733671f865):
17135 dependencies:17135 dependencies:
17136 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17136 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17137 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17137 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17138 '@expo/schema-utils': 56.0.117138 '@expo/schema-utils': 56.0.1
17139 '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5)17139 '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)
17140 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6)17140 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3)
17141 '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)17141 '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
17142 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17142 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17143 '@testing-library/jest-dom': 6.9.117143 '@testing-library/jest-dom': 6.9.1
17144 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)17144 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
17145 client-only: 0.0.117145 client-only: 0.0.1
17146 color: 4.2.317146 color: 4.2.3
17147 debug: 4.4.317147 debug: 4.4.3
17148 escape-string-regexp: 4.0.017148 escape-string-regexp: 4.0.0
17149 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)17149 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
17150 expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))17150 expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
17151 expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17151 expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17152 expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17152 expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17153 expo-server: 56.0.517153 expo-server: 56.0.5
17154 expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17154 expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17155 fast-deep-equal: 3.1.317155 fast-deep-equal: 3.1.3
17156 invariant: 2.2.417156 invariant: 2.2.4
17157 nanoid: 3.3.1217157 nanoid: 3.3.12
17158 query-string: 7.1.317158 query-string: 7.1.3
17159 react: 19.2.617159 react: 19.2.3
17160 react-fast-compare: 3.2.217160 react-fast-compare: 3.2.2
17161 react-is: 19.2.717161 react-is: 19.2.7
17162 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)17162 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
17163 react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)17163 react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5)
17164 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17164 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17165 react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17165 react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17166 server-only: 0.0.117166 server-only: 0.0.1
17167 sf-symbols-typescript: 2.2.017167 sf-symbols-typescript: 2.2.0
17168 shallowequal: 1.1.017168 shallowequal: 1.1.0
17169 standard-navigation: 0.0.517169 standard-navigation: 0.0.5
17170 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)17170 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
17171 optionalDependencies:17171 optionalDependencies:
17172 react-dom: 19.2.6(react@19.2.6)17172 react-dom: 19.2.3(react@19.2.3)
17173 react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17173 react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17174 react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)17174 react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17175 transitivePeerDependencies:17175 transitivePeerDependencies:
17176 - '@babel/core'17176 - '@babel/core'
17177 - '@testing-library/dom'17177 - '@testing-library/dom'
...@@ -19977,14 +19977,14 @@ snapshots:...@@ -19977,14 +19977,14 @@ snapshots:
19977 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)19977 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)
19978 optional: true19978 optional: true
1997919979
19980 react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):19980 react-native-screens@4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
19981 dependencies:19981 dependencies:
19982 react: 19.2.319982 react: 19.2.3
19983 react-freeze: 1.0.4(react@19.2.3)19983 react-freeze: 1.0.4(react@19.2.3)
19984 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)19984 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
19985 warn-once: 0.1.119985 warn-once: 0.1.1
1998619986
19987 react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6):19987 react-native-screens@4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6):
19988 dependencies:19988 dependencies:
19989 react: 19.2.619989 react: 19.2.6
19990 react-freeze: 1.0.4(react@19.2.6)19990 react-freeze: 1.0.4(react@19.2.6)