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
125125
126126## 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
130130```bash
131T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \
132 --base-dir <base-dir> \
133 --base-url <mobile-origin> \
134 --ttl 15m \
135 --label agent-mobile-<short-device-id>
131.agents/skills/test-t3-mobile/scripts/pair-client.sh \
132 ios <simulator-udid> <server-port> <base-dir>
133
134.agents/skills/test-t3-mobile/scripts/pair-client.sh \
135 android <emulator-serial> <server-port> <base-dir>
136136```
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```bash
143xcrun simctl openurl <simulator-udid> 't3code-dev://connections/new'
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
142```text
143t3code-dev://connections/new?pairingUrl=<encoded-pairing-url>&autoConnect=1
148144```
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
154152Pairing 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
183181- **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app.
184182- **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory.
185183- **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.
186186- **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`.
187187- **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL.
188188- **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 {
1111 type NativeStackNavigationOptions,
1212} from "@react-navigation/native-stack";
1313import { useEffect, useRef } from "react";
14import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native";
14import { Platform, Pressable, ScrollView, StyleSheet } from "react-native";
1515import { useResolveClassNames } from "uniwind";
1616
1717import { AppText as Text } from "./components/AppText";
......@@ -39,6 +39,15 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
3939import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
4040import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";
4141import { 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";
4251import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
4352import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
4453import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
......@@ -62,17 +71,15 @@ import {
6271} from "./features/sharing/incoming-share-presentation";
6372import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
6473import { 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";
6579import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
6680
6781const 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
7683type AppScreenOptions = NativeStackNavigationOptions & {
7784 readonly unstable_navigationItemStyle?: "editor";
7885};
......@@ -91,8 +98,8 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
9198 headerShown: true,
9299 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED
93100 ? { backgroundColor: "transparent" }
94 : SHEET_BACKGROUND_COLOR !== undefined
95 ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string }
101 : NATIVE_SHEET_SURFACE_COLOR !== undefined
102 ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
96103 : undefined,
97104 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
98105 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
......@@ -109,10 +116,10 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = {
109116 headerShadowVisible: false,
110117 headerShown: true,
111118 headerStyle:
112 SHEET_BACKGROUND_COLOR !== undefined
119 NATIVE_SHEET_SURFACE_COLOR !== undefined
113120 ? // native-stack types this as `string`, but the native side accepts any
114121 // ColorValue including DynamicColorIOS.
115 { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string }
122 { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
116123 : undefined,
117124 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
118125 headerTransparent: false,
......@@ -125,6 +132,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = {
125132 unstable_navigationItemStyle: undefined,
126133};
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
128143const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
129144 ...SHEET_SOLID_HEADER_OPTIONS,
130145 headerBackVisible: false,
......@@ -238,9 +253,16 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";
238253const NewTaskSheetStack = createNativeStackNavigator({
239254 initialRouteName: "NewTask",
240255 screenOptions: {
241 ...GLASS_HEADER_OPTIONS,
242 // Sheets read better with the iOS-default centered title (no editor style).
243 unstable_navigationItemStyle: undefined,
256 ...SHEET_GLASS_HEADER_OPTIONS,
257 // The form-sheet host owns the one opaque adaptive surface. Child screens
258 // and the navigation bar stay transparent over it, avoiding visible color
259 // slabs as view controllers move horizontally.
260 contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined,
261 // UIKit's default push adds a dimming shadow and independently transitions
262 // the navigation bar. Both read as mismatched sheet backgrounds here.
263 // simple_push retains native push/pop gestures without either artifact.
264 animation: Platform.OS === "ios" ? "simple_push" : undefined,
265 animationDuration: Platform.OS === "ios" ? 350 : undefined,
244266 },
245267 screens: {
246268 NewTask: createNativeStackScreen({
......@@ -253,9 +275,39 @@ const NewTaskSheetStack = createNativeStackNavigator({
253275 NewTaskDraft: createNativeStackScreen({
254276 screen: NewTaskDraftRouteScreen,
255277 linking: "draft",
256 // The draft composer has no scroll view for glass to sample; a solid
257 // header also lays the content out below the bar (no manual inset).
258 options: SHEET_SOLID_HEADER_OPTIONS,
278 options: {
279 headerBackVisible: false,
280 title: "",
281 },
282 }),
283 NewTaskEnvironment: createNativeStackScreen({
284 screen: NewTaskEnvironmentPickerRouteScreen,
285 linking: "draft/environment",
286 options: {
287 title: "Environment",
288 },
289 }),
290 NewTaskBranch: createNativeStackScreen({
291 screen: NewTaskBranchPickerRouteScreen,
292 linking: "draft/branch",
293 options: {
294 title: "Branch",
295 },
296 }),
297 ThreadSettings: createNativeStackScreen({
298 screen: NewTaskThreadSettingsRouteScreen,
299 linking: "draft/settings",
300 options: {
301 gestureEnabled: true,
302 headerShown: false,
303 ...(Platform.OS === "android"
304 ? { presentation: "card" as const }
305 : {
306 ...FORM_SHEET_PRESENTATION_OPTIONS,
307 sheetAllowedDetents: [1],
308 sheetGrabberVisible: true,
309 }),
310 },
259311 }),
260312 AddProject: createNativeStackScreen({
261313 screen: AddProjectSourceRoute,
......@@ -294,6 +346,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
294346 "SettingsLegal",
295347 "SettingsSheet",
296348 "ThreadReviewComment",
349 "ThreadSettingsSheet",
297350]);
298351
299352/**
......@@ -356,9 +409,11 @@ function RootStackLayout(props: {
356409 <HardwareKeyboardCommandProvider pathname={pathname}>
357410 <ThreadOutboxDrainWorker />
358411 <ShowcaseCaptureCoordinator pathname={pathname} />
359 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
360 {props.children}
361 </AdaptiveWorkspaceLayout>
412 <ExistingThreadSettingsRouteProvider>
413 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
414 {props.children}
415 </AdaptiveWorkspaceLayout>
416 </ExistingThreadSettingsRouteProvider>
362417 </HardwareKeyboardCommandProvider>
363418 );
364419}
......@@ -440,7 +495,9 @@ export const RootStack = createNativeStackNavigator({
440495 options: {
441496 // Android cannot host the keyboard-driven comment composer inside a
442497 // 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),
444501 sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92],
445502 sheetGrabberVisible: Platform.OS !== "android",
446503 },
......@@ -450,10 +507,7 @@ export const RootStack = createNativeStackNavigator({
450507 linking: `${THREAD_LINKING_PREFIX}/files`,
451508 options: {
452509 ...GLASS_HEADER_OPTIONS,
453 contentStyle:
454 SHEET_BACKGROUND_COLOR !== undefined
455 ? { backgroundColor: SHEET_BACKGROUND_COLOR }
456 : undefined,
510 contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE,
457511 title: "Files",
458512 },
459513 }),
......@@ -462,11 +516,25 @@ export const RootStack = createNativeStackNavigator({
462516 linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
463517 options: SOLID_HEADER_OPTIONS,
464518 }),
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 }),
465533 GitOverview: createNativeStackScreen({
466534 screen: GitOverviewSheet,
467535 linking: `${THREAD_LINKING_PREFIX}/git`,
468536 options: {
469 presentation: "formSheet",
537 ...FORM_SHEET_PRESENTATION_OPTIONS,
470538 sheetAllowedDetents: [0.55, 0.92],
471539 sheetGrabberVisible: true,
472540 },
......@@ -475,7 +543,7 @@ export const RootStack = createNativeStackNavigator({
475543 screen: GitCommitSheet,
476544 linking: `${THREAD_LINKING_PREFIX}/git/commit`,
477545 options: {
478 presentation: "formSheet",
546 ...FORM_SHEET_PRESENTATION_OPTIONS,
479547 sheetAllowedDetents: [0.55, 0.92],
480548 sheetGrabberVisible: true,
481549 },
......@@ -484,7 +552,7 @@ export const RootStack = createNativeStackNavigator({
484552 screen: GitBranchesSheet,
485553 linking: `${THREAD_LINKING_PREFIX}/git/branches`,
486554 options: {
487 presentation: "formSheet",
555 ...FORM_SHEET_PRESENTATION_OPTIONS,
488556 sheetAllowedDetents: [0.55, 0.92],
489557 sheetGrabberVisible: true,
490558 },
......@@ -493,7 +561,7 @@ export const RootStack = createNativeStackNavigator({
493561 screen: GitConfirmSheet,
494562 linking: `${THREAD_LINKING_PREFIX}/git-confirm`,
495563 options: {
496 presentation: "formSheet",
564 ...FORM_SHEET_PRESENTATION_OPTIONS,
497565 sheetAllowedDetents: [0.45, 0.7],
498566 sheetGrabberVisible: true,
499567 },
......@@ -509,7 +577,7 @@ export const RootStack = createNativeStackNavigator({
509577 ...(Platform.OS === "android"
510578 ? { presentation: "card" as const }
511579 : {
512 presentation: "formSheet" as const,
580 ...FORM_SHEET_PRESENTATION_OPTIONS,
513581 sheetAllowedDetents: [0.7, 0.92],
514582 sheetGrabberVisible: true,
515583 }),
......@@ -532,7 +600,7 @@ export const RootStack = createNativeStackNavigator({
532600 ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS),
533601 title: "Set up T3 Connect",
534602 gestureEnabled: true,
535 presentation: "formSheet",
603 ...FORM_SHEET_PRESENTATION_OPTIONS,
536604 sheetAllowedDetents: [0.6, 0.95],
537605 sheetGrabberVisible: true,
538606 },
......@@ -547,7 +615,7 @@ export const RootStack = createNativeStackNavigator({
547615 ...(Platform.OS === "android"
548616 ? { presentation: "card" as const, headerShown: false }
549617 : {
550 presentation: "formSheet" as const,
618 ...FORM_SHEET_PRESENTATION_OPTIONS,
551619 sheetAllowedDetents: [0.55, 0.7],
552620 sheetGrabberVisible: true,
553621 }),
......@@ -557,7 +625,7 @@ export const RootStack = createNativeStackNavigator({
557625 screen: ConnectionsNewRouteScreen,
558626 linking: "connections/new",
559627 options: {
560 presentation: "formSheet",
628 ...FORM_SHEET_PRESENTATION_OPTIONS,
561629 sheetAllowedDetents: [0.55, 0.7],
562630 sheetGrabberVisible: true,
563631 },
......@@ -577,7 +645,7 @@ export const RootStack = createNativeStackNavigator({
577645 ...(Platform.OS === "android"
578646 ? { presentation: "card" as const }
579647 : {
580 presentation: "formSheet" as const,
648 ...FORM_SHEET_PRESENTATION_OPTIONS,
581649 sheetAllowedDetents: [0.92],
582650 sheetGrabberVisible: true,
583651 }),
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 {
55 useColorScheme,
66 View,
77 type ColorValue,
8 type StyleProp,
89 type ViewProps,
910 type ViewStyle,
1011} from "react-native";
1112import { useThemeColor } from "../lib/useThemeColor";
1213
13export interface GlassSurfaceProps extends Omit<ViewProps, "className"> {
14interface GlassSurfaceProps extends Omit<ViewProps, "className"> {
1415 readonly children: ReactNode;
1516 readonly glassEffectStyle?: "clear" | "regular" | "none";
1617 readonly tintColor?: ColorValue;
1718 readonly chrome?: "default" | "none";
19 /** Styling used only when native Liquid Glass is unavailable. */
20 readonly fallbackStyle?: StyleProp<ViewStyle>;
1821}
1922
2023export function GlassSurface({
......@@ -22,6 +25,7 @@ export function GlassSurface({
2225 glassEffectStyle = "regular",
2326 chrome = "default",
2427 tintColor,
28 fallbackStyle,
2529 style,
2630 ...props
2731}: GlassSurfaceProps) {
......@@ -67,7 +71,7 @@ export function GlassSurface({
6771 }
6872
6973 return (
70 <View {...props} style={[surfaceStyle, style]}>
74 <View {...props} style={[surfaceStyle, fallbackStyle, style]}>
7175 {children}
7276 </View>
7377 );
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+51-16
......@@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera";
22import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
33import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
44import { AsyncResult } from "effect/unstable/reactivity";
5import { useCallback, useEffect, useState } from "react";
5import { useCallback, useEffect, useRef, useState } from "react";
66import { Alert, Platform, ScrollView, View } from "react-native";
77import { useSafeAreaInsets } from "react-native-safe-area-context";
88import { useThemeColor } from "../../lib/useThemeColor";
......@@ -11,12 +11,13 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1111import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
1212import { ErrorBanner } from "../../components/ErrorBanner";
1313import { ConnectionSheetButton } from "./ConnectionSheetButton";
14import { extractPairingUrlFromQrPayload } from "./pairing";
14import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";
1515import { useRemoteConnections } from "../../state/use-remote-environment-registry";
16import { buildPairingUrl, parsePairingUrl } from "./pairing";
1716
1817type ConnectionsNewRouteParams = {
1918 readonly mode?: string;
19 readonly pairingUrl?: string;
20 readonly autoConnect?: string;
2021};
2122
2223export function ConnectionsNewRouteScreen({
......@@ -30,6 +31,13 @@ export function ConnectionsNewRouteScreen({
3031 } = useRemoteConnections();
3132 const navigation = useNavigation();
3233 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");
3341 const insets = useSafeAreaInsets();
3442 const [hostInput, setHostInput] = useState("");
3543 const [codeInput, setCodeInput] = useState("");
......@@ -37,6 +45,7 @@ export function ConnectionsNewRouteScreen({
3745 const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
3846 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
3947 const [scannerLocked, setScannerLocked] = useState(false);
48 const attemptedAutoConnectRef = useRef<string | null>(null);
4049
4150 const headerIconColor = useThemeColor("--color-icon");
4251
......@@ -48,6 +57,16 @@ export function ConnectionsNewRouteScreen({
4857 setCodeInput(code);
4958 }, [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
5170 useEffect(() => {
5271 if (pairingConnectionError) {
5372 setIsSubmitting(false);
......@@ -116,22 +135,38 @@ export function ConnectionsNewRouteScreen({
116135 [onChangeConnectionPairingUrl, scannerLocked],
117136 );
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
119158 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);
123 onChangeConnectionPairingUrl(pairingUrl);
124 const result = await onConnectPress(pairingUrl);
125 if (AsyncResult.isSuccess(result)) {
126 if (navigation.canGoBack()) {
127 navigation.goBack();
128 } else {
129 navigation.dispatch(StackActions.replace("Home"));
130 }
131 } else {
132 setIsSubmitting(false);
162 useEffect(() => {
163 if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) {
164 return;
133165 }
134 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]);
166
167 attemptedAutoConnectRef.current = routePairingUrl;
168 void connectAndClose(routePairingUrl, true);
169 }, [connectAndClose, routePairingUrl, shouldAutoConnect]);
135170
136171 return (
137172 <View collapsable={false} className="flex-1 bg-sheet">
apps/mobile/src/features/home/HomeHeader.tsx+1
......@@ -356,6 +356,7 @@ function IosHomeHeader(props: HomeHeaderProps) {
356356 onSearchTextChange: props.onSearchQueryChange,
357357 placeholder: "Search",
358358 searchTextChangeId: "home-search-text",
359 showsSearchDismissButton: true,
359360 }),
360361 ],
361362 }
apps/mobile/src/features/layout/native-mail-search-toolbar.ts+3
......@@ -11,6 +11,9 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
1111 */
1212export 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
1417type NativeMailSearchToolbarInput = Omit<
1518 HeaderBarButtonMailSearchToolbarItem,
1619 "type" | "useFallbackSearchField"
apps/mobile/src/features/projects/AddProjectScreen.tsx+41-29
......@@ -27,7 +27,7 @@ import {
2727 inferProjectTitleFromPath,
2828} from "@t3tools/client-runtime/state/projects";
2929import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
30import { StackActions, useNavigation } from "@react-navigation/native";
30import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
3131import { SymbolView } from "../../components/AppSymbol";
3232import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3333import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
......@@ -402,13 +402,12 @@ function SourceControlRow(props: {
402402 icon={icon}
403403 isFirst={props.isFirst}
404404 onPress={() =>
405 navigation.navigate("NewTaskSheet", {
406 screen: "AddProjectRepository",
407 params: {
405 navigation.dispatch(
406 StackActions.push("AddProjectRepository", {
408407 environmentId: props.selectedEnvironmentId,
409408 source: props.source,
410 },
411 })
409 }),
410 )
412411 }
413412 />
414413 );
......@@ -498,12 +497,11 @@ export function AddProjectSourceScreen() {
498497 }
499498 isFirst
500499 onPress={() =>
501 navigation.navigate("NewTaskSheet", {
502 screen: "AddProjectLocal",
503 params: {
500 navigation.dispatch(
501 StackActions.push("AddProjectLocal", {
504502 environmentId: selectedEnvironment.environmentId,
505 },
506 })
503 }),
504 )
507505 }
508506 />
509507 {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map(
......@@ -547,10 +545,18 @@ function useCreateProject(environment: EnvironmentOption | null) {
547545 if (existing) {
548546 Alert.alert("Project already exists", existing.title);
549547 navigation.dispatch(
550 StackActions.replace("NewTaskDraft", {
551 environmentId: existing.environmentId,
552 projectId: existing.id,
553 title: existing.title,
548 CommonActions.reset({
549 index: 0,
550 routes: [
551 {
552 name: "NewTaskDraft",
553 params: {
554 environmentId: existing.environmentId,
555 projectId: existing.id,
556 title: existing.title,
557 },
558 },
559 ],
554560 }),
555561 );
556562 return;
......@@ -571,10 +577,18 @@ function useCreateProject(environment: EnvironmentOption | null) {
571577 return result;
572578 }
573579 navigation.dispatch(
574 StackActions.replace("NewTaskDraft", {
575 environmentId: environment.environmentId,
576 projectId,
577 title: inferProjectTitleFromPath(workspaceRoot),
580 CommonActions.reset({
581 index: 0,
582 routes: [
583 {
584 name: "NewTaskDraft",
585 params: {
586 environmentId: environment.environmentId,
587 projectId,
588 title: inferProjectTitleFromPath(workspaceRoot),
589 },
590 },
591 ],
578592 }),
579593 );
580594 return result;
......@@ -612,15 +626,14 @@ export function AddProjectRepositoryScreen(props: {
612626 const provider = addProjectRemoteSourceProvider(source);
613627 if (!provider) {
614628 const remoteUrl = repositoryInput.trim();
615 navigation.navigate("NewTaskSheet", {
616 screen: "AddProjectDestination",
617 params: {
629 navigation.dispatch(
630 StackActions.push("AddProjectDestination", {
618631 environmentId: environment.environmentId,
619632 source,
620633 remoteUrl,
621634 repositoryTitle: remoteUrl,
622 },
623 });
635 }),
636 );
624637 setIsSubmitting(false);
625638 return;
626639 }
......@@ -636,15 +649,14 @@ export function AddProjectRepositoryScreen(props: {
636649 setError(errorMessage(Cause.squash(result.cause)));
637650 } else {
638651 const repository = result.value;
639 navigation.navigate("NewTaskSheet", {
640 screen: "AddProjectDestination",
641 params: {
652 navigation.dispatch(
653 StackActions.push("AddProjectDestination", {
642654 environmentId: environment.environmentId,
643655 source,
644656 remoteUrl: repository.sshUrl,
645657 repositoryTitle: repository.nameWithOwner,
646 },
647 });
658 }),
659 );
648660 }
649661 setIsSubmitting(false);
650662 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+11-2
......@@ -549,7 +549,10 @@ function GeneralSettingsSection() {
549549 */
550550function LegacySettingsSection() {
551551 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
552 const preferences = useAtomValue(mobilePreferencesAtom);
552553 const threadListV2Enabled = useThreadListV2Enabled();
554 const planModeEnabled =
555 AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true;
553556
554557 return (
555558 <View className="gap-3">
......@@ -560,10 +563,16 @@ function LegacySettingsSection() {
560563 value={!threadListV2Enabled}
561564 onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })}
562565 />
566 <SettingsSwitchRow
567 icon="hammer"
568 label="Plan Mode"
569 value={planModeEnabled}
570 onValueChange={(value) => savePreferences({ planModeEnabled: value })}
571 />
563572 </SettingsSection>
564573 <Text className="px-2 text-sm text-foreground-muted">
565 Brings back the original grouped thread list. The default list is flat, in creation order:
566 active work renders as cards; settled threads collapse to compact rows.
574 Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan
575 control; otherwise every task runs in Build mode.
567576 </Text>
568577 </View>
569578 );
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+1-1
......@@ -18,7 +18,7 @@ import {
1818 ComposerToolbarButton,
1919 ComposerToolbarRow,
2020 ComposerToolbarScroller,
21} from "../../components/ComposerToolbarTrigger";
21} from "../../components/ComposerToolbar";
2222import { ControlPillMenu } from "../../components/ControlPill";
2323import { EmptyState } from "../../components/EmptyState";
2424import { 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 @@
1import { 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";
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
52import {
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,
712 KeyboardStickyView,
813 useKeyboardState,
914} from "react-native-keyboard-controller";
......@@ -11,7 +16,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
1116import { useThemeColor } from "../../lib/useThemeColor";
1217import { useFontFamily } from "../../lib/useFontFamily";
1318
14import { EnvironmentId } from "@t3tools/contracts";
1519import {
1620 isAtomCommandInterrupted,
1721 squashAtomCommandFailure,
......@@ -19,22 +23,24 @@ import {
1923
2024import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
2125import {
26 ComposerInlineControl,
2227 ComposerToolbarButton,
2328 ComposerToolbarRow,
2429 ComposerToolbarScroller,
25 ComposerToolbarTrigger,
26} from "../../components/ComposerToolbarTrigger";
30} from "../../components/ComposerToolbar";
2731import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
2832import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
29import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
3033import { ProviderIcon } from "../../components/ProviderIcon";
34import { SymbolView } from "../../components/AppSymbol";
35import { AppText as Text } from "../../components/AppText";
3136import { ComposerSurface } from "./ThreadComposer";
32import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
33import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";
37import {
38 useThreadSettingsSheetPresentation,
39 type NavigationWithFinishTransitioning,
40} from "./use-thread-settings-sheet-presentation";
3441
3542import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
3643import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
37import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
3844import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
3945import {
4046 clearComposerDraftContent,
......@@ -49,21 +55,33 @@ import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
4955import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
5056import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
5157import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
52import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
58import { useNewTaskFlow } from "./new-task-flow-provider";
5359import { useCreateProjectThread } from "./use-project-actions";
5460import { resolveDraftProjectSelection } from "./new-task-project-selection";
61import {
62 resolveNewTaskBranchLabel,
63 resolveNewTaskWorkspaceLabel,
64} from "./new-task-context-presentation";
5565import { useIncomingShare } from "../sharing/IncomingShareProvider";
5666
57function formatWorkspaceLabel(input: {
58 readonly workspaceMode: string;
59 readonly currentBranchName: string | null;
60 readonly selectedBranchName: string | null;
61}): string {
62 const branchName = input.selectedBranchName ?? input.currentBranchName;
63 if (input.workspaceMode === "worktree") {
64 return branchName ? `New worktree · ${branchName}` : "New worktree";
67function NewTaskWorkspaceIcon(props: {
68 readonly workspaceMode: "local" | "worktree";
69 readonly worktreePath: string | null;
70}) {
71 const iconColor = useThemeColor("--color-icon-muted");
72
73 if (props.workspaceMode === "local" && props.worktreePath === null) {
74 return <SymbolView name="folder" size={16} tintColor={iconColor} type="monochrome" />;
6575 }
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 );
6785}
6886
6987export function NewTaskDraftScreen(props: {
......@@ -90,7 +108,8 @@ export function NewTaskDraftScreen(props: {
90108 const insets = useSafeAreaInsets();
91109 const colorScheme = useColorScheme();
92110 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);
94113 const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow;
95114 const { connectedEnvironments } = useRemoteConnectionStatus();
96115 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
......@@ -108,6 +127,49 @@ export function NewTaskDraftScreen(props: {
108127 editorRef: promptInputRef,
109128 isEditorFocused: isComposerFocused,
110129 });
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 );
111173 const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
112174 const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
113175 const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
......@@ -227,9 +289,9 @@ export function NewTaskDraftScreen(props: {
227289 }, [props.pendingTaskId, cancelEditingPendingTask]);
228290
229291 const foregroundColor = useThemeColor("--color-foreground");
292 const projectUnderlineColor = useThemeColor("--color-foreground-muted");
230293 const regularFontFamily = useFontFamily("regular");
231294 const bodyText = useScaledTextRole("body");
232 const headlineText = useScaledTextRole("headline");
233295 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
234296 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
235297
......@@ -315,7 +377,7 @@ export function NewTaskDraftScreen(props: {
315377 return;
316378 }
317379 loadedBranchesProjectKeyRef.current = projectKey;
318 void flow.loadBranches();
380 flow.loadBranches();
319381 }, [flow.loadBranches, selectedProject]);
320382
321383 useEffect(() => {
......@@ -517,121 +579,6 @@ export function NewTaskDraftScreen(props: {
517579 shareImportAttempt,
518580 ]);
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
635582 const selectedEnvironmentLabel =
636583 flow.environments.find(
637584 (environment) => environment.environmentId === flow.selectedEnvironmentId,
......@@ -640,50 +587,17 @@ export function NewTaskDraftScreen(props: {
640587 flow.availableBranches.find((branch) => branch.current)?.name ??
641588 flow.availableBranches.find((branch) => branch.isDefault)?.name ??
642589 null;
643 const settingsSummaryLabel = threadSettingsSummaryLabel({
644 modelLabel: flow.selectedModelOption?.label ?? "Model",
645 optionDescriptors: providerOptionDescriptors,
646 runtimeMode: flow.runtimeMode,
647 interactionMode: flow.interactionMode,
590 const selectedBranchName = flow.selectedBranchName ?? currentBranchName;
591 const selectedBranchLabel = resolveNewTaskBranchLabel({
592 branchName: selectedBranchName,
593 startFromOrigin: flow.startFromOrigin,
594 workspaceMode: flow.workspaceMode,
648595 });
649 const workspaceLabel = useMemo(
650 () =>
651 formatWorkspaceLabel({
652 currentBranchName,
653 selectedBranchName: flow.selectedBranchName,
654 workspaceMode: flow.workspaceMode,
655 }),
656 [currentBranchName, flow.selectedBranchName, flow.workspaceMode],
657 );
658 function handleEnvironmentMenuAction(event: string) {
659 if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
660 return;
661 }
662 flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length)));
663 }
664
665 function handleWorkspaceMenuAction(event: string) {
666 if (isIncomingShareTransferPending) {
667 return;
668 }
669 if (event.startsWith("workspace:mode:")) {
670 flow.setWorkspaceMode(
671 event.slice("workspace:mode:".length) as Parameters<typeof flow.setWorkspaceMode>[0],
672 );
673 return;
674 }
675 if (event === "workspace:start-from-origin") {
676 flow.setStartFromOrigin(!flow.startFromOrigin);
677 return;
678 }
679 if (event.startsWith("workspace:branch:")) {
680 const branchName = event.slice("workspace:branch:".length);
681 const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);
682 if (branch) {
683 flow.selectBranch(branch);
684 }
685 }
686 }
596 const workspaceLabel = resolveNewTaskWorkspaceLabel({
597 workspaceMode: flow.workspaceMode,
598 worktreePath: flow.selectedWorktreePath,
599 });
600 const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;
687601
688602 async function handlePickImages(): Promise<void> {
689603 if (isIncomingShareTransferPending) {
......@@ -733,7 +647,9 @@ export function NewTaskDraftScreen(props: {
733647 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
734648 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
735649 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";
737653 const initialMessageText = draft.text.trim();
738654
739655 if (
......@@ -851,7 +767,7 @@ export function NewTaskDraftScreen(props: {
851767
852768 if (!selectedProject) {
853769 return (
854 <View className="flex-1 bg-sheet">
770 <View className="flex-1 bg-sheet" collapsable={false}>
855771 {Platform.OS === "android" ? (
856772 <>
857773 <NativeStackScreenOptions options={{ headerShown: false }} />
......@@ -866,11 +782,6 @@ export function NewTaskDraftScreen(props: {
866782
867783 const isAndroid = Platform.OS === "android";
868784 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;
874785 const canStart =
875786 Boolean(flow.selectedProject) &&
876787 Boolean(flow.selectedModel) &&
......@@ -882,235 +793,279 @@ export function NewTaskDraftScreen(props: {
882793 const promptEditor = (
883794 <ComposerEditor
884795 ref={promptInputRef}
885 // Native autoFocus fires becomeFirstResponder in didMoveToWindow, which
886 // forces the iOS keyboard bring-up during the formSheet present
887 // animation and stalls it. The runAfterInteractions effect above focuses
888 // the editor once the transition settles instead.
796 // The context-first screen intentionally opens with the keyboard closed.
797 // Focusing is a user action, so presenting the form sheet has one motion.
889798 autoFocus={false}
890799 editable={!isIncomingShareTransferPending}
891800 multiline
892 scrollEnabled={isExpanded}
801 scrollEnabled
893802 value={flow.prompt}
894803 skills={flow.selectedProviderSkills}
895804 onChangeText={flow.setPrompt}
896805 onFocus={() => setIsComposerFocused(true)}
897806 onBlur={() => setIsComposerFocused(false)}
898807 onPasteImages={(uris) => void handleNativePasteImages(uris)}
899 placeholder={`Describe a coding task in ${selectedProject.title}`}
900 // Same collapsed centering as ThreadComposer: native vertical gravity
901 // in a pill-height box.
902 singleLineCentered={!isExpanded}
903 contentInsetVertical={isAndroid ? 0 : undefined}
904 style={
905 isAndroid
906 ? isExpanded
907 ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 }
908 : { height: 36 }
909 : { flex: 1, minHeight: 0 }
910 }
911 textStyle={
912 isAndroid
913 ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }
914 : headlineText
915 }
808 placeholder="Ask anything…"
809 singleLineCentered={false}
810 contentInsetVertical={0}
811 style={{
812 minHeight: 72,
813 maxHeight: 160,
814 paddingHorizontal: 4,
815 paddingVertical: 4,
816 }}
817 textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }}
916818 />
917819 );
918820
919 const toolbarPills = (
920 <>
921 <ComposerToolbarButton
922 icon="plus"
923 onPress={() => void handlePickImages()}
924 showChevron={false}
925 disabled={isIncomingShareTransferPending}
926 />
927 <ComposerToolbarTrigger
928 accessibilityLabel="Thread settings"
821 const closeNewTask = () => {
822 void KeyboardController.dismiss({ animated: true });
823 const parentNavigation = navigation.getParent();
824 if (parentNavigation) {
825 parentNavigation.goBack();
826 return;
827 }
828 navigation.goBack();
829 };
830 const chooseProject = () => {
831 if (isIncomingShareTransferPending) {
832 return;
833 }
834 promptInputRef.current?.blur();
835 void KeyboardController.dismiss({ animated: true });
836 navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId }));
837 };
838 const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => {
839 if (isIncomingShareTransferPending) {
840 return;
841 }
842 promptInputRef.current?.blur();
843 void KeyboardController.dismiss({ animated: true });
844 navigation.dispatch(StackActions.push(routeName));
845 };
846
847 const hero = (
848 <View className="items-center gap-6 px-6" testID="new-task-hero">
849 <View className="w-full items-center gap-1.5">
850 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">
851 What should we build
852 </Text>
853 <View className="max-w-full flex-row items-center justify-center">
854 <Text className="text-2xl font-t3-medium tracking-tight text-foreground">in </Text>
855 <Pressable
856 accessibilityHint="Opens the project picker"
857 accessibilityLabel={`Change project from ${selectedProject.title}`}
858 accessibilityRole="button"
859 disabled={isIncomingShareTransferPending}
860 onPress={chooseProject}
861 className="min-w-0 max-w-[250px] active:opacity-65"
862 style={{
863 borderBottomColor: projectUnderlineColor,
864 borderBottomWidth: 1,
865 }}
866 >
867 <Text
868 className="text-2xl font-t3-medium tracking-tight text-foreground"
869 numberOfLines={1}
870 >
871 {selectedProject.title}
872 </Text>
873 </Pressable>
874 <Text className="text-2xl font-t3-medium tracking-tight text-foreground">?</Text>
875 </View>
876 </View>
877
878 <ComposerInlineControl
879 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
880 chevronDirection="right"
929881 disabled={isIncomingShareTransferPending}
930 iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
931 label={settingsSummaryLabel}
932 maxWidth={320}
933 onPress={settingsSheetPresentation.open}
882 icon="desktopcomputer"
883 label={`on ${selectedEnvironmentLabel}`}
884 maxWidth={260}
885 onPress={
886 flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined
887 }
888 showChevron={flow.environments.length > 1}
889 static={flow.environments.length <= 1}
934890 />
935 <ControlPillMenu
936 actions={environmentMenuActions}
937 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}
938 >
939 <ComposerToolbarTrigger
940 accessibilityLabel="Environment"
941 disabled={isIncomingShareTransferPending}
942 icon="desktopcomputer"
943 label={selectedEnvironmentLabel}
944 />
945 </ControlPillMenu>
946 <ControlPillMenu
947 actions={workspaceMenuActions}
948 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
891 </View>
892 );
893 const heroViewport = (
894 <View className="flex-1" collapsable={false}>
895 <ScrollView
896 alwaysBounceVertical={isKeyboardVisible}
897 className="flex-1"
898 contentInsetAdjustmentBehavior="never"
899 contentContainerClassName="grow items-center pb-[236px] pt-12 ios:pt-[72px]"
900 keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
901 keyboardShouldPersistTaps="handled"
902 showsVerticalScrollIndicator={false}
903 style={{ flex: 1 }}
904 testID="new-task-hero-scroll"
949905 >
950 <ComposerToolbarTrigger
951 accessibilityLabel="Workspace"
952 disabled={isIncomingShareTransferPending}
953 icon="point.topleft.down.curvedto.point.bottomright.up"
954 label={workspaceLabel}
955 />
956 </ControlPillMenu>
957 </>
906 {hero}
907 </ScrollView>
908 </View>
958909 );
959910
960 const settingsSheet = (
961 <ThreadSettingsSheet
962 visible={settingsSheetPresentation.isVisible}
963 onClose={settingsSheetPresentation.close}
964 onDismissed={settingsSheetPresentation.onDismissed}
965 providerGroups={flow.providerGroups}
966 selectedModel={flow.selectedModel}
967 onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
968 optionDescriptors={providerOptionDescriptors}
969 onUpdateOptionSelections={flow.setSelectedModelOptions}
970 runtimeMode={flow.runtimeMode}
971 onUpdateRuntimeMode={flow.setRuntimeMode}
972 />
911 const workspaceControls = (
912 <View className="flex-row items-center gap-1 px-2">
913 <ComposerInlineControl
914 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
915 accessibilityLabel={workspaceLabel}
916 disabled={isIncomingShareTransferPending}
917 iconNode={
918 <NewTaskWorkspaceIcon
919 workspaceMode={flow.workspaceMode}
920 worktreePath={flow.selectedWorktreePath}
921 />
922 }
923 label={workspaceLabel}
924 maxWidth={flow.workspaceMode === "local" ? 220 : 148}
925 onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
926 showChevron={false}
927 />
928
929 <ComposerInlineControl
930 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
931 chevronDirection="right"
932 disabled={isIncomingShareTransferPending}
933 icon="arrow.triangle.branch"
934 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
935 maxWidth={190}
936 onPress={() => openContextPicker("NewTaskBranch")}
937 />
938 </View>
973939 );
974940
975 const startButton = (
976 <ComposerToolbarButton
977 accessibilityLabel={
978 flow.submitting ? "Starting task" : environmentConnected ? "Start task" : "Queue task"
979 }
980 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
981 onPress={() => void handleStart()}
982 variant="primary"
983 showChevron={false}
984 disabled={!canStart}
985 />
941 const composerDock = (
942 <View className="bg-sheet px-4 pt-1" style={{ paddingBottom: controlsBottomPadding }}>
943 <View className="pb-1">{workspaceControls}</View>
944
945 <ComposerSurface
946 animateLayout={false}
947 isDarkMode={isDarkMode}
948 style={{
949 borderRadius: 26,
950 minHeight: 140,
951 overflow: "hidden",
952 paddingBottom: 6,
953 paddingHorizontal: 14,
954 paddingTop: 14,
955 }}
956 >
957 {flow.attachments.length > 0 ? (
958 <View className="pb-2.5">
959 <ComposerAttachmentStrip
960 attachments={flow.attachments}
961 imageBorderRadius={16}
962 imageSize={72}
963 onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment}
964 />
965 </View>
966 ) : null}
967
968 {promptEditor}
969
970 <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}>
971 <ComposerToolbarScroller
972 fadeOpaque={sheetFadeOpaque}
973 fadeTransparent={sheetFadeTransparent}
974 contentPaddingRight={8}
975 >
976 <ComposerToolbarButton
977 accessibilityLabel="Add attachment"
978 disabled={isIncomingShareTransferPending}
979 icon="plus"
980 onPress={() => void handlePickImages()}
981 showChevron={false}
982 />
983 <ComposerInlineControl
984 accessibilityLabel="Model and reasoning settings"
985 disabled={isIncomingShareTransferPending}
986 emphasized
987 iconNode={
988 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
989 }
990 label={flow.selectedModelOption?.label ?? "Choose model"}
991 maxWidth={152}
992 onPress={settingsSheetPresentation.open}
993 />
994 {flow.planModeEnabled ? (
995 <ComposerInlineControl
996 accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`}
997 accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`}
998 disabled={isIncomingShareTransferPending}
999 emphasized
1000 icon={
1001 flow.interactionMode === "plan"
1002 ? { ios: "list.bullet.clipboard", android: "auto_awesome" }
1003 : { ios: "hammer", android: "construction" }
1004 }
1005 label={flow.interactionMode === "plan" ? "Plan" : "Build"}
1006 onPress={() =>
1007 flow.setInteractionMode(flow.interactionMode === "plan" ? "default" : "plan")
1008 }
1009 showChevron={false}
1010 />
1011 ) : null}
1012 </ComposerToolbarScroller>
1013 <ComposerToolbarButton
1014 accessibilityLabel={
1015 flow.submitting ? "Starting task" : environmentConnected ? "Start task" : "Queue task"
1016 }
1017 disabled={!canStart}
1018 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
1019 onPress={() => void handleStart()}
1020 showChevron={false}
1021 variant="primary"
1022 />
1023 </ComposerToolbarRow>
1024 </ComposerSurface>
1025 </View>
9861026 );
9871027
9881028 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.
10031029 return (
1004 <View className="flex-1 bg-screen">
1030 <View className="flex-1 bg-sheet" collapsable={false}>
10051031 <NativeStackScreenOptions options={{ headerShown: false }} />
1006 <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} />
1007
1008 <View className="flex-1" />
1032 <AndroidScreenHeader title="New task" onBack={closeNewTask} />
1033 {heroViewport}
10091034
10101035 <KeyboardStickyView
1011 enabled={isKeyboardVisible}
10121036 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1013 offset={{ closed: 0, opened: 0 }}
1037 offset={{ closed: 0, opened: keyboardOpenedOffset }}
10141038 >
1015 <View
1016 className="px-4 pt-2"
1017 style={{
1018 paddingBottom: controlsBottomPadding,
1019 experimental_backgroundImage: isDarkMode
1020 ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)"
1021 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",
1022 }}
1023 >
1024 <ComposerSurface
1025 isDarkMode={isDarkMode}
1026 style={
1027 isExpanded
1028 ? {
1029 borderRadius: 20,
1030 overflow: "hidden",
1031 paddingHorizontal: 14,
1032 paddingVertical: 12,
1033 }
1034 : {
1035 borderRadius: 999,
1036 overflow: "hidden",
1037 flexDirection: "row",
1038 alignItems: "center",
1039 paddingLeft: 18,
1040 paddingRight: 5,
1041 paddingVertical: 5,
1042 }
1043 }
1044 >
1045 {isExpanded && flow.attachments.length > 0 ? (
1046 <View className="pb-2.5">
1047 <ComposerAttachmentStrip
1048 attachments={flow.attachments}
1049 onRemove={
1050 isIncomingShareTransferPending ? () => undefined : flow.removeAttachment
1051 }
1052 />
1053 </View>
1054 ) : null}
1055 <View className={isExpanded ? undefined : "min-w-0 flex-1"}>{promptEditor}</View>
1056 {!isExpanded ? (
1057 <ControlPill
1058 icon="arrow.up"
1059 variant="primary"
1060 disabled={!canStart}
1061 onPress={() => void handleStart()}
1062 />
1063 ) : null}
1064 </ComposerSurface>
1065
1066 {isExpanded ? (
1067 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
1068 <ComposerToolbarScroller
1069 fadeOpaque={isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"}
1070 fadeTransparent={isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"}
1071 >
1072 {toolbarPills}
1073 </ComposerToolbarScroller>
1074 {startButton}
1075 </ComposerToolbarRow>
1076 ) : null}
1077 </View>
1039 {composerDock}
10781040 </KeyboardStickyView>
1079 {settingsSheet}
10801041 </View>
10811042 );
10821043 }
10831044
10841045 return (
1085 <View className="flex-1 bg-sheet">
1086 <NativeStackScreenOptions options={{ title: selectedProject.title }} />
1087
1088 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">
1089 <View className="min-h-0 flex-1 px-5 pt-2">{promptEditor}</View>
1046 <View className="flex-1 bg-sheet" collapsable={false}>
1047 <NativeStackScreenOptions
1048 options={{
1049 headerBackVisible: false,
1050 headerShadowVisible: false,
1051 title: "",
1052 }}
1053 />
1054 <NativeHeaderToolbar placement="left">
1055 <NativeHeaderToolbar.Button
1056 accessibilityLabel="Cancel new task"
1057 label="Cancel"
1058 onPress={closeNewTask}
1059 />
1060 </NativeHeaderToolbar>
10901061
1091 <View className="border-t border-border" style={{ paddingBottom: controlsBottomPadding }}>
1092 {flow.attachments.length > 0 ? (
1093 <View className="px-4 pt-3">
1094 <ComposerAttachmentStrip
1095 attachments={flow.attachments}
1096 onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment}
1097 imageSize={88}
1098 imageBorderRadius={20}
1099 />
1100 </View>
1101 ) : null}
1102 <ComposerToolbarRow paddingBottom={controlsBottomPadding} paddingHorizontal={6}>
1103 <ComposerToolbarScroller
1104 fadeOpaque={sheetFadeOpaque}
1105 fadeTransparent={sheetFadeTransparent}
1106 >
1107 {toolbarPills}
1108 </ComposerToolbarScroller>
1109 {startButton}
1110 </ComposerToolbarRow>
1111 </View>
1112 </KeyboardAvoidingView>
1113 {settingsSheet}
1062 {heroViewport}
1063 <KeyboardStickyView
1064 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1065 offset={{ closed: 0, opened: keyboardOpenedOffset }}
1066 >
1067 {composerDock}
1068 </KeyboardStickyView>
11141069 </View>
11151070 );
11161071}
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+33-78
......@@ -1,8 +1,13 @@
11import { 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";
38import { SymbolView } from "../../components/AppSymbol";
49import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import { useEffect, useRef, useState } from "react";
10import { useEffect, useRef } from "react";
611import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";
712import { useSafeAreaInsets } from "react-native-safe-area-context";
813import { useThemeColor } from "../../lib/useThemeColor";
......@@ -14,10 +19,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
1419import { useProjects } from "../../state/entities";
1520import type { WorkspaceState } from "../../state/workspaceModel";
1621import { useWorkspaceState } from "../../state/workspace";
17import { scopedProjectKey } from "../../lib/scopedEntities";
1822import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1923import { useIncomingShare } from "../sharing/IncomingShareProvider";
2024import { useNewTaskFlow } from "./new-task-flow-provider";
25import { getProjectScopeSelectionTarget } from "./new-task-project-selection";
2126
2227type NewTaskRouteParams = {
2328 readonly incomingShareId?: string | string[];
......@@ -80,7 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {
8085
8186export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {
8287 const projects = useProjects();
83 const { projectScopes } = useNewTaskFlow();
88 const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow();
8489 const { state: catalogState } = useWorkspaceState();
8590 const navigation = useNavigation();
8691 const isFocused = useIsFocused();
......@@ -88,7 +93,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
8893 const insets = useSafeAreaInsets();
8994 const chevronColor = useThemeColor("--color-chevron");
9095 const accentColor = useThemeColor("--color-icon-muted");
91 const [expandedGroupKeys, setExpandedGroupKeys] = useState<ReadonlySet<string>>(() => new Set());
9296 const { getShare, releaseShareReservation } = useIncomingShare();
9397 const routeShareId = Array.isArray(route.params?.incomingShareId)
9498 ? route.params.incomingShareId[0]
......@@ -126,27 +130,22 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
126130 return;
127131 }
128132 }
129 navigation.navigate("NewTaskSheet", {
130 screen: "NewTaskDraft",
131 params: {
133 const state = navigation.getState();
134 const previousRoute = state?.routes[state.index - 1];
135 if (previousRoute?.name === "NewTaskDraft") {
136 setProject(project);
137 navigation.goBack();
138 return;
139 }
140
141 navigation.dispatch(
142 StackActions.push("NewTaskDraft", {
132143 environmentId: project.environmentId,
133144 projectId: project.id,
134145 title: project.title,
135146 incomingShareId: incomingShare?.id,
136 },
137 });
138 }
139
140 function toggleGroup(groupKey: string): void {
141 setExpandedGroupKeys((current) => {
142 const next = new Set(current);
143 if (next.has(groupKey)) {
144 next.delete(groupKey);
145 } else {
146 next.add(groupKey);
147 }
148 return next;
149 });
147 }),
148 );
150149 }
151150
152151 useEffect(() => {
......@@ -169,15 +168,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
169168 return;
170169 }
171170 resumedDestinationKeyRef.current = destinationKey;
172 navigation.navigate("NewTaskSheet", {
173 screen: "NewTaskDraft",
174 params: {
171 navigation.dispatch(
172 StackActions.push("NewTaskDraft", {
175173 environmentId: reservedDestinationProject.environmentId,
176174 projectId: reservedDestinationProject.id,
177175 title: reservedDestinationProject.title,
178176 incomingShareId: incomingShare.id,
179 },
180 });
177 }),
178 );
181179 }, [incomingShare, isFocused, navigation, reservedDestinationProject]);
182180
183181 return (
......@@ -196,7 +194,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
196194 {
197195 accessibilityLabel: "Add project",
198196 icon: "plus",
199 onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }),
197 onPress: () => navigation.dispatch(StackActions.push("AddProject")),
200198 },
201199 ]
202200 : []
......@@ -223,7 +221,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
223221 {catalogState.hasReadyEnvironment ? (
224222 <NativeHeaderToolbar.Button
225223 icon="plus"
226 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
224 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
227225 separateBackground
228226 />
229227 ) : null}
......@@ -263,7 +261,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
263261 ) : (
264262 <Pressable
265263 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"))}
267265 >
268266 <Text className="text-sm font-t3-bold text-primary-foreground">
269267 Add new project
......@@ -275,22 +273,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
275273 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
276274 {projectScopes.map((scope, scopeIndex) => {
277275 const hasMultipleProjects = scope.projects.length > 1;
278 const expanded = expandedGroupKeys.has(scope.key);
279 const singleProject = hasMultipleProjects ? null : scope.projects[0];
276 const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId);
280277 return (
281278 <View
282279 key={scope.key}
283280 className={cn(scopeIndex > 0 && "border-t border-border-subtle")}
284281 >
285282 <Pressable
286 disabled={singleProject !== null && reservedDestinationProject !== null}
287 onPress={() => {
288 if (singleProject) {
289 void selectProject(singleProject);
290 } else {
291 toggleGroup(scope.key);
292 }
293 }}
283 disabled={reservedDestinationProject !== null}
284 onPress={() => void selectProject(selectionTarget)}
294285 className="flex-row items-center gap-3 bg-card px-4 py-3.5"
295286 >
296287 <View className="h-7 w-7 items-center justify-center">
......@@ -311,52 +302,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
311302 >
312303 {hasMultipleProjects
313304 ? `${scope.projects.length} workspaces`
314 : singleProject?.workspaceRoot}
305 : selectionTarget.workspaceRoot}
315306 </Text>
316307 </View>
317308 <SymbolView
318 name={hasMultipleProjects && expanded ? "chevron.down" : "chevron.right"}
309 name="chevron.right"
319310 size={14}
320311 tintColor={chevronColor}
321312 type="monochrome"
322313 />
323314 </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}
360315 </View>
361316 );
362317 })}
apps/mobile/src/features/threads/ThreadComposer.tsx+121-151
......@@ -1,4 +1,3 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
21import type {
32 EnvironmentId,
43 MessageId,
......@@ -14,7 +13,7 @@ import {
1413 serializeComposerFileLink,
1514 type ComposerTrigger,
1615} from "@t3tools/shared/composerTrigger";
17import * as Haptics from "expo-haptics";
16import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
1817import type { ReactNode } from "react";
1918import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2019import {
......@@ -41,18 +40,19 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
4140
4241import { AppText as Text } from "../../components/AppText";
4342import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
43import { GlassSurface } from "../../components/GlassSurface";
4444import {
4545 ComposerEditor,
4646 type ComposerEditorHandle,
4747 type ComposerEditorSelection,
4848} from "../../components/ComposerEditor";
4949import {
50 ComposerInlineControl,
5051 ComposerToolbarButton,
5152 ComposerToolbarRow,
5253 ComposerToolbarScroller,
53 ComposerToolbarTrigger,
54} from "../../components/ComposerToolbarTrigger";
55import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
54} from "../../components/ComposerToolbar";
55import { ControlPill } from "../../components/ControlPill";
5656import { ProviderIcon } from "../../components/ProviderIcon";
5757import type { DraftComposerImageAttachment } from "../../lib/composerImages";
5858import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
......@@ -63,15 +63,17 @@ import {
6363 normalizeSearchQuery,
6464 scoreQueryMatch,
6565} from "@t3tools/shared/searchRanking";
66import {
67 applyProviderOptionSelection,
68 resolveProviderOptionDescriptors,
69} from "../../lib/providerOptions";
66import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
7067import { useComposerPathSearch } from "../../state/use-composer-path-search";
7168import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
72import { buildThreadSettingsMenu } from "./thread-settings-menu";
73import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
74import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";
69import {
70 type ExistingThreadSettingsRouteSession,
71 useExistingThreadSettingsRoutePresentation,
72} from "./ThreadSettingsSheet";
73import {
74 useThreadSettingsSheetPresentation,
75 type NavigationWithFinishTransitioning,
76} from "./use-thread-settings-sheet-presentation";
7577
7678/**
7779 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
......@@ -83,7 +85,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60;
8385 * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset).
8486 * Used by the parent to compute the larger feed bottom inset when the composer is focused.
8587 */
86export const COMPOSER_EXPANDED_CHROME = 174;
88export const COMPOSER_EXPANDED_CHROME = 156;
8789
8890export interface ThreadComposerProps {
8991 readonly draftMessage: string;
......@@ -123,8 +125,8 @@ export interface ThreadComposerProps {
123125}
124126
125127/**
126 * The pill / card container — renders as LiquidGlassView on supported
127 * iOS 26+ devices (progressive blur, native morph), opaque View otherwise.
128 * The pill / card container — renders with Expo's native GlassView on supported
129 * iOS 26+ devices and keeps the existing opaque fallback elsewhere.
128130 * Exported so NewTaskDraftScreen can render the same composer chrome.
129131 */
130132// One timing for every piece of the expanded↔compact morph so the surface,
......@@ -140,6 +142,8 @@ export function ComposerSurface(props: {
140142 readonly children: ReactNode;
141143 readonly style: ViewStyle;
142144 readonly isDarkMode: boolean;
145 /** Existing thread composers morph between pill and card layouts. */
146 readonly animateLayout?: boolean;
143147}) {
144148 // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself
145149 // (needed to clip content to the pill shape) would clip the shadow on iOS.
......@@ -152,35 +156,26 @@ export function ComposerSurface(props: {
152156 elevation: 10,
153157 };
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
170159 return (
171 <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}>
172 <View
173 style={[
174 props.style,
175 {
176 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",
177 borderWidth: 1,
178 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",
179 },
180 ]}
160 <Animated.View
161 layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION}
162 style={shadowStyle}
163 >
164 <GlassSurface
165 chrome="none"
166 fallbackStyle={{
167 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",
168 borderWidth: 1,
169 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",
170 }}
171 glassEffectStyle="regular"
172 // The composer is a passive material containing interactive controls.
173 // Expo GlassView defaults to non-interactive and both layouts share it.
174 tintColor="transparent"
175 style={props.style}
181176 >
182177 {props.children}
183 </View>
178 </GlassSurface>
184179 </Animated.View>
185180 );
186181}
......@@ -271,6 +266,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
271266});
272267
273268export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
269 const navigation = useNavigation();
274270 const isDarkMode = useColorScheme() === "dark";
275271 const foregroundColor = useThemeColor("--color-foreground");
276272 const bodyText = useScaledTextRole("body");
......@@ -281,14 +277,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
281277 editorRef: inputRef,
282278 isEditorFocused: isFocused,
283279 });
280 const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation();
281 const settingsRoutePresentedRef = useRef(false);
284282 const wasExpandedBeforePreviewRef = useRef(false);
285283 const inFlightThreadIdsRef = useRef(new Set<string>());
286284 const { onExpandedChange } = props;
287285
288286 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
289287 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
290 // Opening and closing count as active so the composer stays expanded while
291 // focus moves between its native editor and the settings modal.
288 // Opening and presentation count as active so the composer stays expanded
289 // while focus moves between its native editor and the settings picker.
292290 const isExpanded = isFocused || settingsSheetPresentation.isActive;
293291 const canSend = hasContent;
294292
......@@ -334,7 +332,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
334332 : "Send";
335333 const currentModelSelection = props.selectedThread.modelSelection;
336334 const currentRuntimeMode = props.selectedThread.runtimeMode;
337 const currentInteractionMode = props.selectedThread.interactionMode ?? "default";
338335 const connectionStatus = composerConnectionStatus({
339336 connectionError: props.connectionError,
340337 connectionState: props.connectionState,
......@@ -626,67 +623,71 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
626623 }),
627624 [currentModelOption?.capabilities, currentModelSelection.options],
628625 );
629 const settingsSummaryLabel = threadSettingsSummaryLabel({
630 modelLabel: currentModelOption?.label ?? currentModelSelection.model,
631 optionDescriptors: providerOptionDescriptors,
632 runtimeMode: currentRuntimeMode,
633 interactionMode: currentInteractionMode,
634 });
635
636 // iOS gets a native menu on the trigger pill: the everyday adjustments
637 // apply without resigning the keyboard, while "All Settings…" (and the
638 // Android trigger) still route through the sheet, which must dismiss it.
639 const settingsMenu = useMemo(
640 () =>
641 Platform.OS === "ios"
642 ? buildThreadSettingsMenu({
643 providerGroups: threadProviderGroups,
644 selectedModel: currentModelSelection,
645 optionDescriptors: providerOptionDescriptors,
646 runtimeMode: currentRuntimeMode,
647 })
648 : null,
649 [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode],
650 );
651
652 const onUpdateModelSelection = props.onUpdateModelSelection;
653 const onUpdateRuntimeMode = props.onUpdateRuntimeMode;
654 const handleSettingsMenuAction = useCallback(
655 (eventId: string) => {
656 const event = settingsMenu?.events.get(eventId);
657 if (!event) {
658 return;
659 }
660 switch (event.type) {
661 case "select-model":
662 void Haptics.selectionAsync();
663 onUpdateModelSelection(event.option.selection);
664 return;
665 case "set-option": {
666 const options = applyProviderOptionSelection(providerOptionDescriptors, {
667 id: event.optionId,
668 value: event.value,
669 });
670 if (options) {
671 void Haptics.selectionAsync();
672 onUpdateModelSelection({ ...currentModelSelection, options });
673 }
674 return;
675 }
676 case "set-runtime":
677 void Haptics.selectionAsync();
678 onUpdateRuntimeMode(event.mode);
679 return;
680 }
681 },
626 const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id);
627 const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>(
628 () => ({
629 ownerId: settingsOwnerId,
630 providerGroups: threadProviderGroups,
631 selectedModel: currentModelSelection,
632 onSelectModel: (option) => props.onUpdateModelSelection(option.selection),
633 optionDescriptors: providerOptionDescriptors,
634 onUpdateOptionSelections: (options) =>
635 props.onUpdateModelSelection({ ...currentModelSelection, options }),
636 runtimeMode: currentRuntimeMode,
637 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
638 }),
682639 [
683640 currentModelSelection,
684 onUpdateModelSelection,
685 onUpdateRuntimeMode,
641 currentRuntimeMode,
642 props.onUpdateModelSelection,
643 props.onUpdateRuntimeMode,
686644 providerOptionDescriptors,
687 settingsMenu,
645 settingsOwnerId,
646 threadProviderGroups,
688647 ],
689648 );
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
691692 return (
692693 <Animated.View
......@@ -739,10 +740,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
739740 style={
740741 isExpanded
741742 ? {
742 borderRadius: 20,
743 borderRadius: 26,
744 minHeight: 140,
743745 overflow: "hidden" as const,
746 paddingBottom: 6,
744747 paddingHorizontal: 14,
745 paddingVertical: 12,
748 paddingTop: 14,
746749 }
747750 : {
748751 borderRadius: 999,
......@@ -792,7 +795,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
792795 style={
793796 isExpanded
794797 ? {
795 minHeight: 80,
798 minHeight: 72,
796799 maxHeight: 160,
797800 paddingHorizontal: 4,
798801 paddingVertical: 4,
......@@ -841,15 +844,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
841844 )}
842845 </Animated.View>
843846 ) : null}
844 </ComposerSurface>
845
846 {isExpanded ? (
847 // Toolbar row — matches draft page layout (expanded only)
848 <Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)}>
849 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
847 {isExpanded ? (
848 <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}>
850849 <ComposerToolbarScroller
851850 fadeOpaque={toolbarFadeOpaque}
852851 fadeTransparent={toolbarFadeTransparent}
852 contentPaddingRight={8}
853853 >
854854 <ComposerToolbarButton
855855 accessibilityLabel="Add attachment"
......@@ -857,31 +857,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
857857 onPress={() => void props.onPickDraftImages()}
858858 showChevron={false}
859859 />
860 {settingsMenu ? (
861 <ControlPillMenu
862 actions={settingsMenu.actions}
863 onPressAction={({ nativeEvent }) => handleSettingsMenuAction(nativeEvent.event)}
864 >
865 <ComposerToolbarTrigger
866 accessibilityLabel="Thread settings"
867 iconNode={
868 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
869 }
870 label={settingsSummaryLabel}
871 maxWidth={320}
872 />
873 </ControlPillMenu>
874 ) : (
875 <ComposerToolbarTrigger
876 accessibilityLabel="Thread settings"
877 iconNode={
878 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
879 }
880 label={settingsSummaryLabel}
881 maxWidth={320}
882 onPress={settingsSheetPresentation.open}
883 />
884 )}
860 <ComposerInlineControl
861 accessibilityLabel="Model and reasoning settings"
862 emphasized
863 iconNode={
864 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
865 }
866 label={currentModelOption?.label ?? currentModelSelection.model}
867 maxWidth={152}
868 onPress={openSettings}
869 />
885870 {showStopAction ? (
886871 <ComposerToolbarButton
887872 accessibilityLabel="Stop"
......@@ -901,8 +886,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
901886 showChevron={false}
902887 />
903888 </ComposerToolbarRow>
904 </Animated.View>
905 ) : null}
889 ) : null}
890 </ComposerSurface>
906891
907892 {/* Queue count */}
908893 {props.queueCount > 0 ? (
......@@ -915,21 +900,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
915900 ) : null}
916901 </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
933903 <ImageViewing
934904 images={previewImageUri ? [{ uri: previewImageUri }] : []}
935905 imageIndex={0}
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+1024-437
......@@ -1,92 +1,113 @@
11import type {
22 ModelSelection,
3 ProviderInteractionMode,
43 ProviderOptionDescriptor,
54 ProviderOptionSelection,
65 RuntimeMode,
76} from "@t3tools/contracts";
7import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
8import { AnimatedLegendList } from "@legendapp/list/reanimated";
9import { HeaderHeightContext } from "@react-navigation/elements";
810import {
911 getProviderOptionCurrentLabel,
1012 getProviderOptionCurrentValue,
1113 getProviderOptionDescriptors,
1214} 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";
1320import * as Haptics from "expo-haptics";
14import { useCallback, useEffect, useRef, useState } from "react";
1521import {
16 Modal,
17 Platform,
18 Pressable,
19 ScrollView,
20 Switch,
21 useWindowDimensions,
22 View,
23} from "react-native";
22 createContext,
23 use,
24 useCallback,
25 useEffect,
26 useMemo,
27 useState,
28 type ReactNode,
29} from "react";
30import { Platform, Pressable, ScrollView, Switch, TextInput, View } from "react-native";
31import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
2432import { useSafeAreaInsets } from "react-native-safe-area-context";
2533
2634import { SymbolView } from "../../components/AppSymbol";
2735import { AppText as Text } from "../../components/AppText";
36import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
2837import { ProviderIcon } from "../../components/ProviderIcon";
2938import { cn } from "../../lib/cn";
3039import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
31import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
40import { applyProviderOptionSelection } from "../../lib/providerOptions";
41import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
3242import { useThemeColor } from "../../lib/useThemeColor";
33import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu";
34import { pendingModelAfterPress } from "./thread-settings-sheet-state";
35import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";
43import {
44 NativeHeaderToolbar,
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
3766/**
38 * The everyday harnesses stay expanded; every other provider (OpenRouter
39 * catalogs and friends) folds behind its header so a 300-model catalog can't
40 * bury the list.
67 * Everyday harnesses start expanded; every other provider (OpenRouter catalogs
68 * and friends) starts folded so a 300-model catalog cannot bury the list. All
69 * provider headers remain user-collapsible.
4170 */
4271const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]);
43
4472/**
45 * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill,
46 * covering model, provider options, runtime mode, and plan mode in one label.
73 * Keep measured row changes stable, but let catalog mutations use the list's
74 * native bounds so a filtered catalog that underflows returns to the top.
4775 */
48export function threadSettingsSummaryLabel(input: {
49 readonly modelLabel: string;
50 readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
51 readonly runtimeMode: RuntimeMode;
52 readonly interactionMode: ProviderInteractionMode;
53}): string {
54 const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode);
55 return [
56 input.modelLabel,
57 ...providerOptionValueLabels(input.optionDescriptors),
58 ...(runtime ? [runtime.shortLabel] : []),
59 ...(input.interactionMode === "plan" ? ["Plan"] : []),
60 ].join(" · ");
61}
62
76const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = {
77 data: false,
78 size: true,
79} as const;
80const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180);
81const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140);
82const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120);
83const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180);
84const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140);
85const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100);
86const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(
87 Platform.OS,
88 Platform.Version,
89);
6390function ModelRow(props: {
6491 readonly option: ModelOption;
6592 readonly selected: boolean;
6693 readonly onPress: () => void;
94 readonly isFirst: boolean;
95 readonly isLast: boolean;
6796}) {
68 const primaryFg = useThemeColor("--color-primary-foreground");
97 const checkmarkColor = useThemeColor("--color-icon");
6998 return (
7099 <Pressable
71 accessibilityRole="button"
72 accessibilityState={{ selected: props.selected }}
100 accessibilityLabel={props.option.label}
101 accessibilityRole="radio"
102 accessibilityState={{ checked: props.selected }}
73103 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.
78104 className={cn(
79 "mx-2.5 flex-row items-center gap-2 rounded-xl px-3 py-3.5 active:opacity-70",
80 props.selected ? "bg-primary" : "bg-transparent",
105 "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
106 props.isFirst && "rounded-t-2xl",
107 props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle",
81108 )}
82109 >
83 <Text
84 className={cn(
85 "shrink text-sm font-t3-medium",
86 props.selected ? "text-primary-foreground" : "text-foreground",
87 )}
88 numberOfLines={1}
89 >
110 <Text className="min-w-0 shrink text-base font-t3-medium text-foreground" numberOfLines={1}>
90111 {props.option.label}
91112 </Text>
92113 {props.option.isDefault ? (
......@@ -101,17 +122,19 @@ function ModelRow(props: {
101122 ) : null}
102123 <View className="flex-1" />
103124 {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 />
105132 ) : null}
106133 </Pressable>
107134 );
108135}
109136
110/**
111 * Provider section header with the harness logo. Secondary providers render
112 * as a tappable fold (count + chevron while collapsed); primary providers
113 * and the group holding the current selection are static headers.
114 */
137/** Provider catalog header with its harness logo and disclosure state. */
115138function ProviderHeader(props: {
116139 readonly driver: string | undefined;
117140 readonly label: string;
......@@ -121,24 +144,10 @@ function ProviderHeader(props: {
121144 readonly onToggle: () => void;
122145}) {
123146 const iconSubtle = useThemeColor("--color-icon-subtle");
124 return (
125 <Pressable
126 accessibilityRole={props.collapsible ? "button" : "header"}
127 accessibilityState={props.collapsible ? { expanded: !props.collapsed } : undefined}
128 accessibilityLabel={
129 props.collapsible ? `${props.label}, ${props.modelCount} models` : props.label
130 }
131 disabled={!props.collapsible}
132 onPress={props.onToggle}
133 className={cn(
134 "mx-2.5 flex-row items-center gap-2 rounded-xl px-3",
135 props.collapsible ? "py-3.5 active:opacity-70" : "pb-2 pt-4",
136 )}
137 >
147 const content = (
148 <>
138149 <ProviderIcon provider={props.driver} size={15} />
139 <Text className="text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted">
140 {props.label}
141 </Text>
150 <Text className="text-sm font-t3-medium text-foreground-muted">{props.label}</Text>
142151 {props.collapsible ? (
143152 <>
144153 <View className="flex-1" />
......@@ -149,13 +158,33 @@ function ProviderHeader(props: {
149158 ) : null}
150159 <SymbolView
151160 name={props.collapsed ? "chevron.down" : "chevron.up"}
152 size={11}
161 size={12}
153162 tintColor={iconSubtle}
154163 type="monochrome"
155164 />
156165 </>
157166 ) : 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>
159188 );
160189}
161190
......@@ -163,18 +192,17 @@ function ProviderHeader(props: {
163192function DisclosureRow(props: {
164193 readonly label: string;
165194 readonly value: string | undefined;
166 readonly disabled?: boolean;
167195 readonly onPress: () => void;
196 readonly isLast?: boolean;
168197}) {
169198 const iconSubtle = useThemeColor("--color-icon-subtle");
170199 return (
171200 <Pressable
172201 accessibilityRole="button"
173 disabled={props.disabled}
174202 onPress={props.onPress}
175203 className={cn(
176 "flex-row items-center gap-2 px-5 py-3 active:opacity-70",
177 props.disabled && "opacity-40",
204 "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
205 !props.isLast && "border-b border-border-subtle",
178206 )}
179207 >
180208 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
......@@ -192,31 +220,37 @@ function DisclosureRow(props: {
192220/** Single option inside a submenu panel. */
193221function ChoiceRow(props: {
194222 readonly label: string;
223 readonly description?: string;
195224 readonly selected: boolean;
196225 readonly onPress: () => void;
226 readonly isLast: boolean;
197227}) {
198 const primaryFg = useThemeColor("--color-primary-foreground");
228 const checkmarkColor = useThemeColor("--color-icon");
199229 return (
200230 <Pressable
201 accessibilityRole="button"
202 accessibilityState={{ selected: props.selected }}
231 accessibilityLabel={props.description ? `${props.label}. ${props.description}` : props.label}
232 accessibilityRole="radio"
233 accessibilityState={{ checked: props.selected }}
203234 onPress={props.onPress}
204235 className={cn(
205 "mx-2.5 flex-row items-center rounded-xl px-3 py-3.5 active:opacity-70",
206 props.selected ? "bg-primary" : "bg-transparent",
236 "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle",
237 !props.isLast && "border-b border-border-subtle",
207238 )}
208239 >
209 <Text
210 className={cn(
211 "shrink text-sm font-t3-medium",
212 props.selected ? "text-primary-foreground" : "text-foreground",
213 )}
214 >
215 {props.label}
216 </Text>
217 <View className="flex-1" />
240 <View className="min-w-0 flex-1 gap-0.5">
241 <Text className="text-base font-t3-medium text-foreground">{props.label}</Text>
242 {props.description ? (
243 <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text>
244 ) : null}
245 </View>
218246 {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 />
220254 ) : null}
221255 </Pressable>
222256 );
......@@ -225,60 +259,31 @@ function ChoiceRow(props: {
225259function SwitchRow(props: {
226260 readonly label: string;
227261 readonly value: boolean;
228 readonly disabled?: boolean;
229262 readonly onValueChange: (value: boolean) => void;
263 readonly isLast?: boolean;
230264}) {
231 const activeTrack = String(useThemeColor("--color-switch-active"));
232 const track = String(useThemeColor("--color-secondary-border"));
233265 return (
234266 <View
235267 className={cn(
236 "flex-row items-center justify-between px-5 py-2.5",
237 props.disabled && "opacity-40",
268 "min-h-11 flex-row items-center justify-between bg-card px-4 py-1",
269 !props.isLast && "border-b border-border-subtle",
238270 )}
239271 >
240272 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
241273 <Switch
242 disabled={props.disabled}
243 ios_backgroundColor={track}
274 accessibilityLabel={props.label}
244275 onValueChange={props.onValueChange}
245 trackColor={{ false: track, true: activeTrack }}
246276 value={props.value}
247277 />
248278 </View>
249279 );
250280}
251281
252type SubmenuPage =
282type ThreadSettingsSubmenuPage =
253283 | { readonly kind: "descriptor"; readonly id: string }
254284 | { readonly kind: "runtime" };
255285
256/**
257 * Unified thread settings: the sheet is the provider-grouped model list
258 * (primary harnesses expanded, other providers folded, legacy behind the
259 * top-right pill) with a Save button, plus compact disclosure rows whose
260 * single-choice submenus stack in a small panel over the sheet so it never
261 * changes size. Model changes stage until Save — while staged, the settings
262 * rows edit the staged model's options and Save applies everything together.
263 *
264 * Callers control which harnesses are offered via providerGroups: an
265 * existing thread must pass only its own provider's group, since a session
266 * can't switch harness mid-thread.
267 *
268 * Rendered through an RN Modal (not the root OverlayPortal) so it also
269 * presents above natively-presented form sheets like the new-task draft.
270 * Callers must dismiss the keyboard when opening — the iOS keyboard window
271 * would otherwise cover the lower half of the sheet.
272 */
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;
286type ThreadSettingsSessionProps = {
282287 readonly providerGroups: ReadonlyArray<ProviderGroup>;
283288 readonly selectedModel: ModelSelection | null;
284289 readonly onSelectModel: (option: ModelOption) => void;
......@@ -286,367 +291,949 @@ export function ThreadSettingsSheet(props: {
286291 readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void;
287292 readonly runtimeMode: RuntimeMode;
288293 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
289}) {
290 const insets = useSafeAreaInsets();
291 const { height: windowHeight } = useWindowDimensions();
294};
295
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) {
292365 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 );
294371 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,
306 // secondary providers folded. The sheet stays mounted between opens, so
307 // state would otherwise stick around.
308 useEffect(() => {
309 if (props.visible) {
310 wasPresentedRef.current = true;
311 setShowLegacyToggle(false);
312 setExpandedProviders(new Set());
313 setPendingModel(null);
314 setSubmenu(null);
315 } else if (Platform.OS === "android" && wasPresentedRef.current) {
316 // React Native only emits Modal.onDismiss on iOS. Android uses no exit
317 // animation below, so the post-commit effect is its dismissal boundary.
318 notifyDismissed();
319 }
320 }, [notifyDismissed, props.visible]);
321
322 const isApplied = (option: ModelOption) =>
323 option.selection.instanceId === props.selectedModel?.instanceId &&
324 option.selection.model === props.selectedModel.model;
373 const isApplied = useCallback(
374 (option: ModelOption) =>
375 option.selection.instanceId === props.selectedModel?.instanceId &&
376 option.selection.model === props.selectedModel.model,
377 [props.selectedModel],
378 );
325379 // The list highlights the staged pick; Save turns it into the applied one.
326 const isDisplayed = (option: ModelOption) =>
327 pendingModel ? option.key === pendingModel.key : isApplied(option);
380 const isDisplayed = useCallback(
381 (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)),
382 [isApplied, pendingModel],
383 );
328384
329385 // While a model is staged, the settings rows describe and edit the staged
330386 // model's options (kept on its pending selection); Save applies model and
331387 // options together. Otherwise they edit the applied selection directly.
332 const displayedDescriptors = pendingModel
333 ? pendingModel.capabilities
334 ? getProviderOptionDescriptors({
335 caps: pendingModel.capabilities,
336 selections: pendingModel.selection.options,
337 })
338 : []
339 : props.optionDescriptors;
340
341 const hasLegacyModels = props.providerGroups.some((group) =>
342 group.models.some((model) => model.isLegacy),
343 );
344 // Legacy stays hidden unless the pill is toggled this open; a highlighted
345 // legacy model is exempted from the filter instead of forcing the whole
346 // legacy list visible.
347 const showLegacy = showLegacyToggle;
348
349 // Stable settings rows: the union of descriptors across the primary
350 // harnesses' current models (plus whatever the displayed model advertises)
351 // always renders, with unsupported rows disabled instead of vanishing when
352 // the selection changes. Keyed by label, not id — Claude and Codex use
353 // different ids for the same "Reasoning" concept.
354 const descriptorTemplate = (() => {
355 const seen = new Map<string, { type: "select" | "boolean" }>();
356 for (const group of props.providerGroups) {
357 const driver = group.models[0]?.providerDriver;
358 if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) {
359 continue;
360 }
361 for (const model of group.models) {
362 if (model.isLegacy) {
363 continue;
364 }
365 for (const descriptor of model.capabilities?.optionDescriptors ?? []) {
366 if (!seen.has(descriptor.label)) {
367 seen.set(descriptor.label, { type: descriptor.type });
368 }
369 }
370 }
371 }
372 for (const descriptor of displayedDescriptors) {
373 if (!seen.has(descriptor.label)) {
374 seen.set(descriptor.label, { type: descriptor.type });
375 }
376 }
377 return [...seen.entries()].map(([label, entry]) => ({ label, ...entry }));
378 })();
388 const displayedDescriptors = useMemo(
389 () =>
390 pendingModel
391 ? pendingModel.capabilities
392 ? getProviderOptionDescriptors({
393 caps: pendingModel.capabilities,
394 selections: pendingModel.selection.options,
395 })
396 : []
397 : props.optionDescriptors,
398 [pendingModel, props.optionDescriptors],
399 );
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(() => {
381406 if (pendingModel) {
382407 void Haptics.selectionAsync();
383408 props.onSelectModel(pendingModel);
384409 }
385 props.onClose("save");
386 };
410 }, [pendingModel, props.onSelectModel]);
387411
388 const handleOptionChange = (id: string, value: string | boolean) => {
389 const next = applyProviderOptionSelection(displayedDescriptors, { id, value });
390 if (!next) {
391 return;
392 }
393 if (pendingModel) {
394 setPendingModel({
395 ...pendingModel,
396 selection: { ...pendingModel.selection, options: next },
397 });
398 } else {
399 props.onUpdateOptionSelections(next);
400 }
401 };
412 const applyOptionChange = useCallback(
413 (id: string, value: string | boolean) => {
414 const next = applyProviderOptionSelection(displayedDescriptors, { id, value });
415 if (!next) {
416 return;
417 }
418 if (pendingModel) {
419 setPendingModel({
420 ...pendingModel,
421 selection: { ...pendingModel.selection, options: next },
422 });
423 } else {
424 props.onUpdateOptionSelections(next);
425 }
426 },
427 [displayedDescriptors, pendingModel, props.onUpdateOptionSelections],
428 );
402429
403 const toggleProvider = (providerKey: string) => {
404 setExpandedProviders((current) => {
430 const toggleProvider = useCallback((providerKey: string) => {
431 setProviderExpansionOverrides((current) => {
405432 const next = new Set(current);
406433 if (!next.delete(providerKey)) {
407434 next.add(providerKey);
408435 }
409436 return next;
410437 });
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
413854 const activeDescriptor =
414 submenu?.kind === "descriptor"
415 ? displayedDescriptors.find(
416 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
855 descriptorId !== null
856 ? session.displayedDescriptors.find(
857 (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId,
417858 )
418859 : undefined;
419860
420861 const submenuContent =
421 submenu?.kind === "runtime"
862 props.submenu.kind === "runtime"
422863 ? {
423 title: "Runtime",
424864 rows: RUNTIME_MODE_CHOICES.map((choice) => ({
425865 id: choice.mode,
426866 label: choice.label,
427 selected: choice.mode === props.runtimeMode,
867 description: choice.description,
868 selected: choice.mode === session.runtimeMode,
428869 onPress: () => {
429870 void Haptics.selectionAsync();
430 props.onUpdateRuntimeMode(choice.mode);
431 setSubmenu(null);
871 session.onUpdateRuntimeMode(choice.mode);
872 props.onSelected();
432873 },
433874 })),
434875 }
435876 : activeDescriptor?.type === "select"
436877 ? {
437 title: activeDescriptor.label,
438878 rows: selectableChoices(activeDescriptor).map((choice) => ({
439879 id: choice.id,
440880 label: choice.label,
881 description: undefined,
441882 selected: choice.id === getProviderOptionCurrentValue(activeDescriptor),
442883 onPress: () => {
443884 void Haptics.selectionAsync();
444 handleOptionChange(activeDescriptor.id, choice.id);
445 setSubmenu(null);
885 session.applyOptionChange(activeDescriptor.id, choice.id);
886 props.onSelected();
446887 },
447888 })),
448889 }
449890 : null;
450891
892 if (!submenuContent) {
893 return <View className="flex-1 bg-sheet" />;
894 }
895
451896 return (
452 <Modal
453 transparent
454 statusBarTranslucent
455 navigationBarTranslucent
456 animationType={Platform.OS === "ios" ? "fade" : "none"}
457 visible={props.visible}
458 onDismiss={notifyDismissed}
459 onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
897 <ScrollView
898 className="flex-1 bg-sheet"
899 contentContainerStyle={{
900 paddingBottom: insets.bottom + 12,
901 paddingHorizontal: 16,
902 paddingTop: 16,
903 }}
904 contentInsetAdjustmentBehavior="automatic"
905 showsVerticalScrollIndicator={false}
460906 >
461 <View className="flex-1 justify-end">
462 <Pressable
463 accessibilityLabel="Close thread settings"
464 className="absolute inset-0 bg-backdrop"
465 onPress={() => props.onClose("dismiss")}
907 <View className="overflow-hidden rounded-2xl bg-card">
908 {submenuContent.rows.map((row, index) => (
909 <ChoiceRow
910 key={row.id}
911 description={row.description}
912 isLast={index === submenuContent.rows.length - 1}
913 label={row.label}
914 selected={row.selected}
915 onPress={row.onPress}
916 />
917 ))}
918 </View>
919 </ScrollView>
920 );
921}
922
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"
4661007 />
467 <View
468 className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet"
469 style={{ maxHeight: windowHeight * 0.85 }}
470 >
471 {/* The grabber doubles as the accessible close control: the dim
472 backdrop above a tall sheet is a sliver, and VoiceOver can't
473 reach it at all. */}
474 <Pressable
475 accessibilityLabel="Close thread settings"
476 accessibilityRole="button"
477 onPress={() => props.onClose("dismiss")}
478 className="items-center pb-1 pt-2.5"
479 >
480 <View className="h-1 w-9 rounded-full bg-subtle-strong" />
481 </Pressable>
482 {hasLegacyModels ? (
483 <View className="flex-row justify-end px-4 pb-1.5">
484 <Pressable
485 accessibilityRole="button"
486 accessibilityState={{ selected: showLegacy }}
487 hitSlop={8}
488 onPress={() => {
489 void Haptics.selectionAsync();
490 setShowLegacyToggle(!showLegacy);
491 }}
492 className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70"
493 >
494 <Text className="text-2xs font-t3-medium text-foreground-muted">
495 {showLegacy ? "Hide legacy models" : "Show legacy models"}
496 </Text>
497 </Pressable>
498 </View>
499 ) : null}
500 {/* Only the model list scrolls. Provider catalogs can run to
501 hundreds of models (OpenRouter), so the rows below stay pinned
502 and reachable instead of living at the end of that scroll. */}
503 <ScrollView
504 style={{ flexShrink: 1 }}
505 contentContainerStyle={{ paddingBottom: 8 }}
506 showsVerticalScrollIndicator={false}
1008 ) : null}
1009 <NativeStackScreenOptions
1010 optionsVersion={[
1011 session.providerFilter,
1012 session.providerGroups.map((group) => group.providerKey),
1013 session.showLegacy,
1014 ]}
1015 options={{
1016 unstable_headerToolbarItems: usesNativeMailSearchToolbar
1017 ? () => [
1018 createNativeMailSearchToolbarItem({
1019 filterButtonId: "thread-settings-model-filter",
1020 filterMenu,
1021 filterSystemImageName: hasCustomCatalogFilter
1022 ? "line.3.horizontal.decrease.circle.fill"
1023 : "line.3.horizontal.decrease",
1024 onSearchTextChange: session.setSearchQuery,
1025 placeholder: "Find a model",
1026 searchTextChangeId: "thread-settings-model-search-text",
1027 showsSearchDismissButton: true,
1028 }),
1029 ]
1030 : undefined,
1031 headerShown: Platform.OS !== "android",
1032 headerSearchBarOptions:
1033 Platform.OS === "ios" && !usesNativeMailSearchToolbar
1034 ? {
1035 autoCapitalize: "none",
1036 hideNavigationBar: false,
1037 obscureBackground: false,
1038 onCancelButtonPress: () => session.setSearchQuery(""),
1039 onChangeText: (event) => session.setSearchQuery(event.nativeEvent.text),
1040 placeholder: "Find a model",
1041 }
1042 : undefined,
1043 }}
1044 />
1045 <ThreadSettingsMainContent
1046 onOpenSubmenu={(submenu) => {
1047 const title =
1048 submenu.kind === "runtime"
1049 ? "Runtime"
1050 : (session.displayedDescriptors.find(
1051 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
1052 )?.label ?? "Option");
1053 navigation.navigate("ThreadSettingsChoice", { ...submenu, title });
1054 }}
1055 />
1056 <NativeHeaderToolbar placement="left">
1057 <NativeHeaderToolbar.Button
1058 accessibilityLabel="Cancel thread settings"
1059 label="Cancel"
1060 onPress={presentation.onClose}
1061 />
1062 </NativeHeaderToolbar>
1063 <NativeHeaderToolbar placement="right">
1064 <NativeHeaderToolbar.Button
1065 accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"}
1066 label={session.pendingModel ? "Save" : "Done"}
1067 onPress={commitAndClose}
1068 />
1069 </NativeHeaderToolbar>
1070 {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? (
1071 <NativeHeaderToolbar placement="bottom">
1072 <NativeHeaderToolbar.Menu
1073 accessibilityLabel="Filter models"
1074 icon={
1075 hasCustomCatalogFilter
1076 ? "line.3.horizontal.decrease.circle.fill"
1077 : "line.3.horizontal.decrease.circle"
1078 }
1079 separateBackground
1080 title="Model filters"
5071081 >
508 {props.providerGroups.map((group) => {
509 const driver = group.models[0]?.providerDriver;
510 const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver);
511 const visibleModels = showLegacy
512 ? group.models
513 : group.models.filter((model) => !model.isLegacy || isDisplayed(model));
514 if (visibleModels.length === 0) {
515 return null;
516 }
517 const containsSelection = group.models.some(isDisplayed);
518 const collapsible = !isPrimary && !containsSelection;
519 const collapsed = collapsible && !expandedProviders.has(group.providerKey);
520 return (
521 <View key={group.providerKey}>
522 <ProviderHeader
523 driver={driver}
524 label={group.providerLabel}
525 collapsible={collapsible}
526 collapsed={collapsed}
527 modelCount={visibleModels.length}
528 onToggle={() => toggleProvider(group.providerKey)}
529 />
530 {collapsed
531 ? null
532 : visibleModels.map((option) => (
533 <ModelRow
534 key={option.key}
535 option={option}
536 selected={isDisplayed(option)}
537 onPress={() => {
538 void Haptics.selectionAsync();
539 // Re-tapping the applied model cancels staging.
540 setPendingModel((current) =>
541 pendingModelAfterPress({
542 current,
543 pressed: option,
544 pressedIsApplied: isApplied(option),
545 }),
546 );
547 }}
548 />
549 ))}
550 </View>
551 );
552 })}
553 </ScrollView>
554
555 <View className="mx-5 h-px bg-border" />
556
557 <View style={{ paddingBottom: insets.bottom + 12 }}>
558 {descriptorTemplate.map((entry) => {
559 const live = displayedDescriptors.find(
560 (descriptor) => descriptor.label === entry.label,
561 );
562 if ((live?.type ?? entry.type) === "select") {
563 return (
564 <DisclosureRow
565 key={entry.label}
566 label={entry.label}
567 value={live ? getProviderOptionCurrentLabel(live) : undefined}
568 disabled={!live}
569 onPress={() => {
570 if (live) {
571 setSubmenu({ kind: "descriptor", id: live.id });
572 }
573 }}
574 />
575 );
576 }
577 return (
578 <SwitchRow
579 key={entry.label}
580 label={entry.label}
581 value={live?.type === "boolean" ? (live.currentValue ?? false) : false}
582 disabled={!live}
583 onValueChange={(value) => {
584 if (live) {
585 handleOptionChange(live.id, value);
586 }
587 }}
588 />
589 );
590 })}
591 <DisclosureRow
592 label="Runtime"
593 value={
594 RUNTIME_MODE_CHOICES.find((choice) => choice.mode === props.runtimeMode)?.label
595 }
596 onPress={() => setSubmenu({ kind: "runtime" })}
597 />
598 <Pressable
599 accessibilityRole="button"
600 onPress={handleSave}
601 className="mx-4 mt-2 h-12 items-center justify-center rounded-full bg-primary active:opacity-80"
602 >
603 <Text className="text-sm font-t3-bold text-primary-foreground">
604 {pendingModel ? "Save" : "Done"}
605 </Text>
606 </Pressable>
607 </View>
608 </View>
609
610 {/* Submenus stack over the sheet instead of replacing its content,
611 so the main sheet keeps its size while drilling in and out. */}
612 {submenuContent ? (
613 <View className="absolute inset-0 justify-end">
614 <Pressable
615 accessibilityLabel={`Close ${submenuContent.title}`}
616 className="absolute inset-0 bg-backdrop"
617 onPress={() => setSubmenu(null)}
618 />
619 <View className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet">
620 <Pressable
621 accessibilityLabel={`Close ${submenuContent.title}`}
622 accessibilityRole="button"
623 onPress={() => setSubmenu(null)}
624 className="items-center pb-1 pt-2.5"
1082 <NativeHeaderToolbar.Menu title="Provider">
1083 <NativeHeaderToolbar.Label>Provider</NativeHeaderToolbar.Label>
1084 <NativeHeaderToolbar.MenuAction
1085 isOn={session.providerFilter === null}
1086 onPress={() => session.setProviderFilter(null)}
6251087 >
626 <View className="h-1 w-9 rounded-full bg-subtle-strong" />
627 </Pressable>
628 <Text className="px-5 pb-1.5 text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted">
629 {submenuContent.title}
630 </Text>
631 <ScrollView
632 style={{ maxHeight: windowHeight * 0.5 }}
633 contentContainerStyle={{ paddingBottom: insets.bottom + 12 }}
634 showsVerticalScrollIndicator={false}
635 bounces={false}
1088 All providers
1089 </NativeHeaderToolbar.MenuAction>
1090 {session.providerGroups.map((group) => (
1091 <NativeHeaderToolbar.MenuAction
1092 key={group.providerKey}
1093 isOn={session.providerFilter === group.providerKey}
1094 onPress={() => session.setProviderFilter(group.providerKey)}
1095 >
1096 {group.providerLabel}
1097 </NativeHeaderToolbar.MenuAction>
1098 ))}
1099 </NativeHeaderToolbar.Menu>
1100 {session.hasLegacyModels ? (
1101 <NativeHeaderToolbar.MenuAction
1102 isOn={session.showLegacy}
1103 onPress={() => session.setShowLegacy(!session.showLegacy)}
6361104 >
637 {submenuContent.rows.map((row) => (
638 <ChoiceRow
639 key={row.id}
640 label={row.label}
641 selected={row.selected}
642 onPress={row.onPress}
643 />
644 ))}
645 </ScrollView>
646 </View>
647 </View>
648 ) : null}
649 </View>
650 </Modal>
1105 Show legacy models
1106 </NativeHeaderToolbar.MenuAction>
1107 ) : null}
1108 </NativeHeaderToolbar.Menu>
1109 </NativeHeaderToolbar>
1110 ) : null}
1111 </>
1112 );
1113}
1114
1115function ThreadSettingsChoiceScreen() {
1116 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();
1117 const route = useRoute<RouteProp<ThreadSettingsPickerStackParams, "ThreadSettingsChoice">>();
1118
1119 return (
1120 <>
1121 <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} />
1122 {Platform.OS === "android" ? (
1123 <AndroidSheetHeader title={route.params.title} onBack={() => navigation.goBack()} />
1124 ) : null}
1125 <ThreadSettingsChoiceContent submenu={route.params} onSelected={() => navigation.goBack()} />
1126 </>
1127 );
1128}
1129
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>
6511238 );
6521239}
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";
4242import {
4343 appendComposerDraftAttachments,
4444 clearComposerDraft,
45 copyComposerDraftContentIfEmpty,
4546 getComposerDraftSnapshot,
4647 isComposerDraftEmpty,
4748 removeComposerDraftAttachment,
......@@ -50,7 +51,7 @@ import {
5051 updateComposerDraftSettings,
5152 useComposerDraft,
5253} from "../../state/use-composer-drafts";
53import { useBranches } from "../../state/queries";
54import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
5455import {
5556 flattenQueuedThreadMessages,
5657 threadOutboxManager,
......@@ -74,10 +75,16 @@ import {
7475 type HomeProjectScope,
7576} from "../home/homeThreadList";
7677import { 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
7885type WorkspaceMode = "local" | "worktree";
7986
80const EMPTY_BRANCH_REFS: ReadonlyArray<VcsRef> = [];
87const BRANCH_SEARCH_DEBOUNCE_MS = 150;
8188
8289function pendingTaskDraftKey(messageId: string): string {
8390 return `pending-task:${messageId}`;
......@@ -96,14 +103,6 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null {
96103 return message?.creation !== undefined ? message : null;
97104}
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
107106export function branchBadgeLabel(input: {
108107 readonly branch: VcsRef;
109108 readonly project: EnvironmentProject | null;
......@@ -117,9 +116,6 @@ export function branchBadgeLabel(input: {
117116 if (input.branch.isDefault) {
118117 return "default";
119118 }
120 if (input.branch.isRemote) {
121 return "remote";
122 }
123119 return null;
124120}
125121
......@@ -139,9 +135,13 @@ type NewTaskFlowContextValue = {
139135 readonly submitting: boolean;
140136 readonly branchQuery: string;
141137 readonly branchesLoading: boolean;
138 readonly branchesError: string | null;
139 readonly branchesFetchingNextPage: boolean;
140 readonly hasMoreBranches: boolean;
142141 readonly availableBranches: ReadonlyArray<VcsRef>;
143142 readonly runtimeMode: RuntimeMode;
144143 readonly interactionMode: ProviderInteractionMode;
144 readonly planModeEnabled: boolean;
145145 readonly expandedProvider: string | null;
146146 readonly environments: ReadonlyArray<{
147147 readonly environmentId: EnvironmentId;
......@@ -175,7 +175,8 @@ type NewTaskFlowContextValue = {
175175 readonly clearAttachments: () => void;
176176 readonly setSubmitting: (value: boolean) => void;
177177 readonly setBranchQuery: (value: string) => void;
178 readonly loadBranches: () => Promise<void>;
178 readonly loadBranches: () => void;
179 readonly loadMoreBranches: () => void;
179180 readonly setRuntimeMode: (value: RuntimeMode) => void;
180181 readonly setInteractionMode: (value: ProviderInteractionMode) => void;
181182 readonly setSelectedModelOptions: (
......@@ -191,6 +192,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
191192 const threads = useThreadShells();
192193 const { savedConnectionsById } = useSavedRemoteConnections();
193194 const groupingSettings = useMobileProjectGroupingSettings();
195 const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState();
194196 const projectScopes = useMemo(
195197 () =>
196198 sortHomeProjectScopes({
......@@ -219,6 +221,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
219221 const [branchQuery, setBranchQuery] = useState("");
220222 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
221223 const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null);
224 const pendingLocalBranchSyncDraftKeysRef = useRef(new Set<string>());
222225 // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act
223226 // on a task whose editing session already ended this render.
224227 const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null);
......@@ -229,6 +232,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
229232 setSubmitting(false);
230233 setBranchQuery("");
231234 setExpandedProvider(null);
235 pendingLocalBranchSyncDraftKeysRef.current.clear();
232236 const editing = editingPendingTaskRef.current;
233237 editingPendingTaskRef.current = null;
234238 setEditingPendingTask(null);
......@@ -395,7 +399,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
395399 selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
396400 true;
397401 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
400406 // Stored selections only count while their provider is usable on the
401407 // server; otherwise the server's default model wins instead of silently
......@@ -521,18 +527,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
521527 }
522528 replaceComposerDraftAttachments(selectedProjectDraftKey, []);
523529 }, [selectedProjectDraftKey]);
530 const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS);
524531 const branchTarget = useMemo(
525532 () => ({
526533 environmentId: selectedProject?.environmentId ?? null,
527534 // `|| null` also skips the stand-in project's empty workspaceRoot.
528535 cwd: selectedProject?.workspaceRoot || null,
529 query: null,
536 query: debouncedBranchQuery,
530537 }),
531 [selectedProject?.environmentId, selectedProject?.workspaceRoot],
538 [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot],
532539 );
533 const branchState = useBranches(branchTarget);
534 const branchesLoading = branchState.isPending;
535 const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS;
540 const branchState = usePaginatedBranches(branchTarget);
541 const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim();
542 const branchesLoading =
543 branchSearchIsDebouncing || (branchState.isPending && branchState.data === null);
544 const branchesFetchingNextPage = branchState.isFetchingNextPage;
545 const hasMoreBranches =
546 branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined;
547 const allBranchRefs = branchState.refs;
536548 const availableBranches = useMemo(
537549 () =>
538550 pipe(
......@@ -554,11 +566,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
554566 );
555567 }, [availableBranches, branchQuery]);
556568
557 const setProject = useCallback((project: EnvironmentProject) => {
558 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
559 setSelectedEnvironmentId(project.environmentId);
560 setSelectedProjectKey(nextProjectKey);
561 }, []);
569 const setProject = useCallback(
570 (project: EnvironmentProject) => {
571 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
572 const nextDraftKey = `new-task:${nextProjectKey}`;
573 if (
574 selectedProjectDraftKey?.startsWith("new-task:") &&
575 selectedProjectDraftKey !== nextDraftKey
576 ) {
577 void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
578 }
579 setSelectedEnvironmentId(project.environmentId);
580 setSelectedProjectKey(nextProjectKey);
581 },
582 [selectedProjectDraftKey],
583 );
562584
563585 const selectEnvironment = useCallback(
564586 (environmentId: EnvironmentId) => {
......@@ -596,28 +618,86 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
596618 if (!selectedProjectDraftKey) {
597619 return;
598620 }
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 }
599633 updateComposerDraftSettings(selectedProjectDraftKey, {
600634 workspaceSelection: {
601635 mode,
602 branch: selectedBranchName,
603 worktreePath: selectedWorktreePath,
636 branch: mode === "local" ? localSelection.branch : selectedBranchName,
637 worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath,
604638 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
605639 },
606640 });
607641 },
608 [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath],
642 [
643 availableBranches,
644 draftStartFromOrigin,
645 selectedBranchName,
646 selectedProject,
647 selectedProjectDraftKey,
648 selectedWorktreePath,
649 ],
609650 );
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
611686 const selectBranch = useCallback(
612687 (branch: VcsRef) => {
613688 if (!selectedProject || !selectedProjectDraftKey) {
614689 return;
615690 }
691 pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
616692 updateComposerDraftSettings(selectedProjectDraftKey, {
617693 workspaceSelection: {
618694 mode: workspaceMode,
619695 branch: branch.name,
620 worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),
696 worktreePath: resolveNewTaskBranchWorktreePath({
697 workspaceMode,
698 projectCwd: selectedProject.workspaceRoot,
699 branchWorktreePath: branch.worktreePath,
700 }),
621701 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
622702 },
623703 });
......@@ -643,7 +723,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
643723 );
644724
645725 const refreshBranches = branchState.refresh;
646 const loadBranches = useCallback(async () => {
726 const loadMoreBranches = branchState.loadNext;
727 const loadBranches = useCallback(() => {
647728 if (!selectedProject) {
648729 return;
649730 }
......@@ -767,7 +848,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
767848 attachments: draft.attachments,
768849 modelSelection: draftModelSelection,
769850 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 }),
771857 creation: {
772858 projectId: selectedProject.id,
773859 ...(projectTitle !== undefined ? { projectTitle } : {}),
......@@ -792,6 +878,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
792878 selectedModel,
793879 selectedProject,
794880 selectedProjectDraftKey,
881 planModeEnabled,
882 planModePreferenceLoaded,
795883 startFromOrigin,
796884 workspaceMode,
797885 ],
......@@ -904,9 +992,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
904992 submitting,
905993 branchQuery,
906994 branchesLoading,
995 branchesError: branchState.error,
996 branchesFetchingNextPage,
997 hasMoreBranches,
907998 availableBranches,
908999 runtimeMode,
9091000 interactionMode,
1001 planModeEnabled,
9101002 expandedProvider,
9111003 environments,
9121004 selectedProject,
......@@ -935,6 +1027,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
9351027 setSubmitting,
9361028 setBranchQuery,
9371029 loadBranches,
1030 loadMoreBranches,
9381031 setRuntimeMode,
9391032 setInteractionMode,
9401033 setSelectedModelOptions,
......@@ -946,6 +1039,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
9461039 beginEditingPendingTask,
9471040 branchQuery,
9481041 branchesLoading,
1042 branchState.error,
1043 branchesFetchingNextPage,
9491044 buildPendingTaskMessage,
9501045 cancelEditingPendingTask,
9511046 editingPendingTask,
......@@ -954,7 +1049,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
9541049 filteredBranches,
9551050 finishEditingPendingTask,
9561051 interactionMode,
1052 planModeEnabled,
9571053 loadBranches,
1054 loadMoreBranches,
9581055 projectScopes,
9591056 modelOptions,
9601057 prompt,
......@@ -963,6 +1060,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
9631060 reset,
9641061 runtimeMode,
9651062 selectedBranchName,
1063 hasMoreBranches,
9661064 selectedEnvironmentId,
9671065 selectedModel,
9681066 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";
55import type { HomeProjectScope } from "../home/homeThreadList";
66import {
77 getOnlySelectableProject,
8 getProjectScopeSelectionTarget,
89 resolveDraftProjectSelection,
910} from "./new-task-project-selection";
1011
11function makeProject(id: string): EnvironmentProject {
12function makeProject(id: string, environmentId = "environment"): EnvironmentProject {
1213 return {
13 environmentId: EnvironmentId.make("environment"),
14 environmentId: EnvironmentId.make(environmentId),
1415 id: ProjectId.make(id),
1516 title: id,
1617 workspaceRoot: `/work/${id}`,
......@@ -41,9 +42,25 @@ describe("getOnlySelectableProject", () => {
4142 expect(getOnlySelectableProject([makeScope([project])])).toBe(project);
4243 });
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", () => {
4546 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 );
4764 });
4865});
4966
......@@ -63,10 +80,11 @@ describe("resolveDraftProjectSelection", () => {
6380 });
6481 });
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", () => {
6784 const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];
6885 expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({
69 kind: "pick",
86 kind: "select",
87 project: projects[0],
7088 });
7189 });
7290
apps/mobile/src/features/threads/new-task-project-selection.ts+13-2
......@@ -1,18 +1,29 @@
11import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import type { EnvironmentId } from "@t3tools/contracts";
23
34import { scopedProjectKey } from "../../lib/scopedEntities";
45import type { HomeProjectScope } from "../home/homeThreadList";
56
6export type DraftProjectSelectionResolution =
7type DraftProjectSelectionResolution =
78 | { readonly kind: "preserve" }
89 | { readonly kind: "select"; readonly project: EnvironmentProject }
910 | { 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
1122export function getOnlySelectableProject(
1223 projectScopes: ReadonlyArray<HomeProjectScope>,
1324): EnvironmentProject | null {
1425 const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null;
15 return onlyScope?.projects.length === 1 ? (onlyScope.projects[0] ?? null) : null;
26 return onlyScope?.representative ?? null;
1627}
1728
1829export 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";
33import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";
44
55import type { ModelOption } from "../../lib/modelOptions";
6import { pendingModelAfterPress } from "./thread-settings-sheet-state";
6import { modelMatchesCatalogQuery, pendingModelAfterPress } from "./thread-settings-sheet-state";
77
88function modelOption(
99 model: string,
......@@ -28,6 +28,26 @@ function modelOption(
2828}
2929
3030describe("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
3151 it("clears staging when the applied model is pressed", () => {
3252 expect(
3353 pendingModelAfterPress({
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+34
......@@ -1,5 +1,24 @@
11import 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
322/** Preserve staged provider options when the highlighted model is tapped again. */
423export function pendingModelAfterPress(input: {
524 readonly current: ModelOption | null;
......@@ -11,3 +30,18 @@ export function pendingModelAfterPress(input: {
1130 }
1231 return input.current?.key === input.pressed.key ? input.current : input.pressed;
1332}
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";
33
44import 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
1039/**
11 * Keeps the custom native composer and the settings modal from owning focus at
12 * the same time. Opening waits for the keyboard dismissal to finish, while
13 * focus restoration waits for the modal's dismissal callback.
40 * Keeps the custom native composer and the settings sheet from owning focus at
41 * the same time. Opening resigns the editor cleanly; a dismissal re-focuses it
42 * once the sheet has fully landed. A plain blur/focus pair costs one keyboard
43 * animation each way — keepFocus-style inputView overrides are avoided because
44 * removing them forces UIKit to reload input views, replaying the keyboard's
45 * show as a visible collapse/re-open.
1446 */
1547export function useThreadSettingsSheetPresentation(input: {
1648 readonly editorRef: RefObject<ComposerEditorHandle | null>;
......@@ -19,18 +51,36 @@ export function useThreadSettingsSheetPresentation(input: {
1951 const [phase, setPhase] = useState<PresentationPhase>("closed");
2052 const isActiveRef = useRef(false);
2153 const isMountedRef = useRef(true);
54 const isEditorFocusedRef = useRef(input.isEditorFocused);
2255 const openingIdRef = useRef(0);
23 const restoreFocusOnSaveRef = useRef(false);
24 const shouldRestoreAfterDismissRef = useRef(false);
56 const focusRestoreIdRef = useRef(0);
57 const restoreFocusAfterDismissRef = useRef(false);
58 const restorePendingRef = useRef(false);
59 const lastStackTransitionFinishedAtRef = useRef(0);
60 const dismissRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
61 const clearDismissRestoreTimer = useCallback(() => {
62 if (dismissRestoreTimerRef.current !== null) {
63 clearTimeout(dismissRestoreTimerRef.current);
64 dismissRestoreTimerRef.current = null;
65 }
66 }, []);
2567
26 useEffect(
27 () => () => {
68 useEffect(() => {
69 isEditorFocusedRef.current = input.isEditorFocused;
70 }, [input.isEditorFocused]);
71
72 useEffect(() => {
73 // React Strict Mode and Fast Refresh both run an effect cleanup/setup
74 // cycle without recreating refs. Re-arm the mounted guard on every setup.
75 isMountedRef.current = true;
76 return () => {
2877 isMountedRef.current = false;
2978 isActiveRef.current = false;
3079 openingIdRef.current += 1;
31 },
32 [],
33 );
80 focusRestoreIdRef.current += 1;
81 clearDismissRestoreTimer();
82 };
83 }, [clearDismissRestoreTimer]);
3484
3585 const open = useCallback(() => {
3686 if (isActiveRef.current) {
......@@ -38,61 +88,107 @@ export function useThreadSettingsSheetPresentation(input: {
3888 }
3989
4090 isActiveRef.current = true;
41 restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible();
42 shouldRestoreAfterDismissRef.current = false;
91 focusRestoreIdRef.current += 1;
92 clearDismissRestoreTimer();
93 restorePendingRef.current = false;
94 restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible();
4395 setPhase("opening");
4496
4597 const openingId = openingIdRef.current + 1;
4698 openingIdRef.current = openingId;
4799
48 // Keyboard.dismiss() only tracks React Native TextInputs. The composer is
49 // a custom native text view, so explicitly resign its first responder too.
100 // Start the keyboard transition before the custom native editor resigns
101 // first responder, then present the sheet on the next frame. The sheet and
102 // keyboard animate together instead of serializing two native transitions.
103 void KeyboardController.dismiss({ animated: true });
50104 input.editorRef.current?.blur();
51 void KeyboardController.dismiss().then(() => {
105
106 requestAnimationFrame(() => {
52107 if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) {
53108 return;
54109 }
55110 setPhase("visible");
56111 });
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) => {
60 if (!isActiveRef.current) {
132 attemptsRemaining -= 1;
133 input.editorRef.current?.focus();
134 setTimeout(restoreFocus, 50);
135 };
136 requestAnimationFrame(restoreFocus);
137 }, [input.editorRef]);
138
139 /** Runs the queued restore once — whichever completion signal arrives first. */
140 const runPendingDismissalRestore = useCallback(() => {
141 if (!restorePendingRef.current) {
61142 return;
62143 }
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;
65 shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current;
66 setPhase("closing");
67 }, []);
68
153 /**
154 * Marks the sheet closed and queues the keyboard's return for the moment
155 * the dismissal transition actually completes: the sheet slides away over a
156 * resting composer, then the keyboard lifts it in one continuous motion.
157 */
69158 const onDismissed = useCallback(() => {
70 const shouldRestoreFocus = shouldRestoreAfterDismissRef.current;
71 shouldRestoreAfterDismissRef.current = false;
72 restoreFocusOnSaveRef.current = false;
73159 isActiveRef.current = false;
74160 setPhase("closed");
75161
76 if (shouldRestoreFocus) {
77 input.editorRef.current?.focus();
162 if (!restoreFocusAfterDismissRef.current) {
163 return;
78164 }
79 }, [input.editorRef]);
80
81 // The new-task screen can have an autofocus queued before the sheet opens.
82 // Preserve that intent for Save without allowing it to focus under the modal.
83 const restoreFocusAfterSave = useCallback(() => {
84 if (isActiveRef.current) {
85 restoreFocusOnSaveRef.current = true;
165 restoreFocusAfterDismissRef.current = false;
166 restorePendingRef.current = true;
167 clearDismissRestoreTimer();
168 if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) {
169 // A stack transition finished just before this pop reached JS: the pop
170 // is the state echo of a gesture-driven dismissal whose animation has
171 // already completed. The sheet is gone — bring the keyboard back now.
172 runPendingDismissalRestore();
173 return;
86174 }
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
89187 return {
90188 isActive: phase !== "closed",
91 isActiveRef,
92189 isVisible: phase === "visible",
93190 open,
94 close,
95191 onDismissed,
96 restoreFocusAfterSave,
192 onStackTransitionsFinished,
97193 } as const;
98194}
apps/mobile/src/native/native-glass.ts+2-2
......@@ -1,9 +1,9 @@
1import { isLiquidGlassSupported } from "@callstack/liquid-glass";
1import { isGlassEffectAPIAvailable } from "expo-glass-effect";
22import { Platform } from "react-native";
33
44import { supportsNativeLiquidGlass } from "../lib/native-glass-capability";
55
66export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass(
77 Platform.OS,
8 isLiquidGlassSupported,
8 isGlassEffectAPIAvailable(),
99);
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 {
3535 * default flat list — see `resolveThreadListV2Enabled`.
3636 */
3737 readonly legacyThreadListEnabled?: boolean;
38 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
39 readonly planModeEnabled?: boolean;
3840}
3941
4042export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
......@@ -88,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
8890 projectGroupingMode?: SidebarProjectGroupingMode;
8991 autoSettleOnMerge?: boolean;
9092 legacyThreadListEnabled?: boolean;
93 planModeEnabled?: boolean;
9194 } = {};
9295
9396 if (typeof parsed.liveActivitiesEnabled === "boolean") {
......@@ -130,6 +133,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
130133 if (typeof parsed.legacyThreadListEnabled === "boolean") {
131134 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
132135 }
136 if (typeof parsed.planModeEnabled === "boolean") {
137 preferences.planModeEnabled = parsed.planModeEnabled;
138 }
133139 return preferences;
134140}
135141
apps/mobile/src/state/queries.ts+122-4
......@@ -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";
29import {
310 createThreadSearchResultsAtomFamily,
411 makeThreadSearchKey,
512 type EnvironmentThreadSearchMatch,
613} from "@t3tools/client-runtime/state/thread-search";
714import { useAtomValue } from "@effect/atom-react";
15import * as Cause from "effect/Cause";
816import * as Option from "effect/Option";
9import { Atom } from "effect/unstable/reactivity";
10import { useEffect, useMemo, useState } from "react";
17import { AsyncResult, Atom } from "effect/unstable/reactivity";
18import { useCallback, useEffect, useMemo, useState } from "react";
1119
20import { appAtomRegistry } from "./atom-registry";
1221import { orchestrationEnvironment } from "./orchestration";
1322import { projectEnvironment } from "./projects";
1423import { useEnvironmentQuery } from "./query";
......@@ -24,6 +33,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;
2433const COMPOSER_PATH_SEARCH_LIMIT = 20;
2534const THREAD_SEARCH_DEBOUNCE_MS = 200;
2635const VCS_REF_LIST_LIMIT = 100;
36const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
37const INITIAL_BRANCH_CURSORS = [undefined] as const;
2738const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);
2839const EMPTY_THREAD_SEARCH_ATOM = Atom.make({
2940 matches: EMPTY_THREAD_SEARCH_MATCHES,
......@@ -52,7 +63,7 @@ export interface ComposerPathSearchTarget {
5263 readonly query: string | null;
5364}
5465
55function useDebouncedValue<A>(value: A, delayMs: number): A {
66export function useDebouncedValue<A>(value: A, delayMs: number): A {
5667 const [debounced, setDebounced] = useState(value);
5768
5869 useEffect(() => {
......@@ -125,6 +136,113 @@ export function useBranches(input: {
125136 );
126137}
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
128246export function useComposerPathSearch(target: ComposerPathSearchTarget) {
129247 const normalizedTarget = useMemo(
130248 () => ({
apps/mobile/src/state/use-composer-drafts.test.ts+125
......@@ -1,10 +1,57 @@
11import { afterEach, describe, expect, it } from "@effect/vitest";
22import { 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
449import { appAtomRegistry } from "./atom-registry";
550import {
651 clearComposerDraftContentState,
752 composerDraftsAtom,
53 copyComposerDraftContentIfEmpty,
54 copyComposerDraftContentState,
855 decodePersistedComposerDrafts,
956 type ComposerDraft,
1057 getComposerDraftSnapshot,
......@@ -165,6 +212,53 @@ describe("mobile composer drafts", () => {
165212 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
166213 });
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
168262 it("merges shared content into a project draft without duplicating retries", () => {
169263 const draftKey = "new-task:environment-1:project-1";
170264 const sharedAttachment = {
......@@ -268,4 +362,35 @@ describe("mobile composer drafts", () => {
268362 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
269363 });
270364 });
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 });
271396});
apps/mobile/src/state/use-composer-drafts.ts+50-1
......@@ -253,7 +253,11 @@ export function ensureComposerDraftsLoaded(): void {
253253function updateComposerDrafts(
254254 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,
255255): 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 }
257261 appAtomRegistry.set(composerDraftsAtom, next);
258262 schedulePersistComposerDrafts(next);
259263}
......@@ -412,6 +416,51 @@ export function restoreComposerDraftSnapshotState(
412416 return next;
413417}
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
415464function mergeComposerDraftText(existing: string, incoming: string): string {
416465 if (incoming.length === 0) {
417466 return existing;
docs/user/permission-modes.md+1-2
......@@ -44,5 +44,4 @@ with prompting enabled and a restricted workspace while **Full access** disables
4444labels above describe what you get; the exact per-provider translation is internal and may
4545change.
4646
47Mobile offers the same four modes. It labels the first one **Approve actions** rather than
48**Supervised**.
47Mobile offers the same four modes with the same labels and descriptions.
patches/@react-navigation%2Fnative-stack@7.17.6.patch+22
......@@ -100,3 +100,25 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
100100-//# sourceMappingURL=useHeaderConfigProps.js.map
101101\ No newline at end of file
102102+//# 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
226226
227227 // appearance does not apply to the tvOS so we need to use lagacy customization
228228 #if TARGET_OS_TV
229@@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc
229@@ -637,10 +675,458 @@ + (void)updateViewController:(UIViewController *)vc
230230 // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).
231231 // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)
232232 navitem.title = config.title;
......@@ -391,40 +391,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
391391+ ]];
392392+ [chromeHostView bringSubviewToFront:toolbarHost];
393393+
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+
428394+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];
429395+ glassEffect.interactive = YES;
430396+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];
......@@ -438,9 +404,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
438404+ mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil;
439405+ CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0;
440406+ 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];
441411+ [NSLayoutConstraint activateConstraints:@[
442+ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset],
443+ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset],
412+ glassLeadingConstraint,
413+ glassTrailingConstraint,
444414+ [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
445415+ [glassView.heightAnchor constraintEqualToConstant:toolbarHeight],
446416+ ]];
......@@ -490,6 +460,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
490460+ UISearchBar *searchBar =
491461+ !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil;
492462+ NSString *placeholder = mailSearchToolbarConfig[@"placeholder"];
463+ UITextField *resolvedSearchTextField = nil;
493464+ if (searchBar != nil) {
494465+ if (placeholder != nil) {
495466+ searchBar.placeholder = placeholder;
......@@ -506,7 +477,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
506477+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;
507478+ searchBar.searchTextField.textColor = UIColor.labelColor;
508479+ searchBar.searchTextField.tintColor = UIColor.labelColor;
509+ configureKeyboardTracking(searchBar.searchTextField);
480+ resolvedSearchTextField = searchBar.searchTextField;
510481+ if (placeholder != nil) {
511482+ searchBar.searchTextField.attributedPlaceholder =
512483+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
......@@ -539,7 +510,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
539510+ searchField.adjustsFontForContentSizeCategory = YES;
540511+ searchField.textColor = UIColor.labelColor;
541512+ searchField.tintColor = UIColor.labelColor;
542+ configureKeyboardTracking(searchField);
513+ resolvedSearchTextField = searchField;
543514+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
544515+ [glassView.contentView addSubview:searchField];
545516+ [NSLayoutConstraint activateConstraints:@[
......@@ -550,8 +521,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
550521+ ]];
551522+ }
552523+
524+ UIButton *filterButton = nil;
553525+ if (hasFilterButton) {
554+ UIButton *filterButton = makeGlassButton(
526+ filterButton = makeGlassButton(
555527+ mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
556528+ mailSearchToolbarConfig[@"filterButtonId"],
557529+ mailSearchToolbarConfig[@"filterMenu"]);
......@@ -565,8 +537,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
565537+ ]];
566538+ }
567539+
540+ UIButton *composeButton = nil;
568541+ if (hasComposeButton) {
569+ UIButton *composeButton = makeGlassButton(
542+ composeButton = makeGlassButton(
570543+ mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil",
571544+ mailSearchToolbarConfig[@"composeButtonId"],
572545+ mailSearchToolbarConfig[@"composeMenu"]);
......@@ -579,6 +552,107 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
579552+ [composeButton.heightAnchor constraintEqualToConstant:buttonSize],
580553+ ]];
581554+ }
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];
582656+ }
583657+#endif
584658+ }
......@@ -615,7 +689,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
615689
616690 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items
617691 // (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 *
619693
620694 - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts
621695 withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems
......@@ -623,7 +697,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
623697 {
624698 if (dicts.count == 0) {
625699 return currentItems;
626@@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
700@@ -781,7 +1268,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
627701 [items addObjectsFromArray:currentItems];
628702 for (NSUInteger i = 0; i < dicts.count; i++) {
629703 NSDictionary *dict = dicts[i];
......@@ -822,7 +896,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
822896 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict
823897 action:^(NSString *buttonId) {
824898 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 *
826900 }
827901 imageLoader:_imageLoader];
828902 NSNumber *index = dict[@"index"];
......@@ -852,7 +926,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
852926 [items insertObject:item atIndex:index.integerValue];
853927 } else {
854928 [items addObject:item];
855@@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
929@@ -825,6 +1506,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
856930 return items;
857931 }
858932
......@@ -900,7 +974,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
900974 RNS_IGNORE_SUPER_CALL_BEGIN
901975 - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex
902976 {
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::
904978 }
905979
906980 _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);
......@@ -909,7 +983,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
909983 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {
910984 _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);
911985 }
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::
913987 _disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
914988 _backButtonDisplayMode =
915989 [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];
......@@ -917,7 +991,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
917991
918992 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {
919993 _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::
921995 _headerRightBarButtonItems = array;
922996 }
923997
......@@ -1313,7 +1387,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db
13131387 /**
13141388 * The tint color to apply to the item.
13151389 *
1316@@ -1145,8 +1193,37 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
1390@@ -1145,8 +1193,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
13171391 export interface HeaderBarButtonItemSpacing {
13181392 type: 'spacing';
13191393 spacing: number;
......@@ -1344,6 +1418,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db
13441418+ onSearchTextChange?: ((text: string) => void) | undefined;
13451419+ placeholder?: string | undefined;
13461420+ searchTextChangeId?: string | undefined;
1421+ showsSearchDismissButton?: boolean | undefined;
13471422+ useFallbackSearchField?: boolean | undefined;
13481423+ width?: number | undefined;
13491424 }
......@@ -1653,7 +1728,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13
16531728 /**
16541729 * The tint color to apply to the item.
16551730 *
1656@@ -1279,11 +1327,46 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
1731@@ -1279,11 +1327,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
16571732 export interface HeaderBarButtonItemSpacing {
16581733 type: 'spacing';
16591734 spacing: number;
......@@ -1687,6 +1762,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13
16871762+ onSearchTextChange?: ((text: string) => void) | undefined;
16881763+ placeholder?: string | undefined;
16891764+ searchTextChangeId?: string | undefined;
1765+ showsSearchDismissButton?: boolean | undefined;
16901766+ useFallbackSearchField?: boolean | undefined;
16911767+ width?: number | undefined;
16921768 }
pnpm-lock.yaml+54-54
......@@ -80,13 +80,13 @@ patchedDependencies:
8080 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
8181 '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045
8282 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784
83 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273
83 '@react-navigation/native-stack@7.17.6': 0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027
8484 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
8585 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f
8686 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
8787 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008
8888 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
89 react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e
89 react-native-screens@4.25.2: 59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199
9090
9191importers:
9292
......@@ -237,7 +237,7 @@ importers:
237237 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)
238238 '@react-navigation/native-stack':
239239 specifier: 7.17.6
240 version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)
240 version: 7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be)
241241 '@shikijs/core':
242242 specifier: 4.2.0
243243 version: 4.2.0
......@@ -402,7 +402,7 @@ importers:
402402 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)
403403 react-native-screens:
404404 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)
406406 react-native-shiki-engine:
407407 specifier: ^0.3.12
408408 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:
1227512275 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1227612276 zod: 3.25.76
1227712277 optionalDependencies:
12278 expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8)
12278 expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865)
1227912279 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)
1228012280 transitivePeerDependencies:
1228112281 - '@expo/dom-webview'
......@@ -12351,7 +12351,7 @@ snapshots:
1235112351 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1235212352 zod: 3.25.76
1235312353 optionalDependencies:
12354 expo-router: 56.2.11(db5c693a26481047569df6781f34db9f)
12354 expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797)
1235512355 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)
1235612356 transitivePeerDependencies:
1235712357 - '@expo/dom-webview'
......@@ -12691,7 +12691,7 @@ snapshots:
1269112691 react: 19.2.3
1269212692 optionalDependencies:
1269312693 '@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)
1269512695 react-dom: 19.2.3(react@19.2.3)
1269612696 transitivePeerDependencies:
1269712697 - supports-color
......@@ -12706,7 +12706,7 @@ snapshots:
1270612706 react: 19.2.6
1270712707 optionalDependencies:
1270812708 '@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)
1271012710 react-dom: 19.2.6(react@19.2.6)
1271112711 transitivePeerDependencies:
1271212712 - supports-color
......@@ -14291,7 +14291,7 @@ snapshots:
1429114291 optionalDependencies:
1429214292 '@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)':
1429514295 dependencies:
1429614296 '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)
1429714297 '@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:
1429914299 react: 19.2.3
1430014300 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)
1430114301 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)
1430314303 sf-symbols-typescript: 2.2.0
1430414304 warn-once: 0.1.1
1430514305 transitivePeerDependencies:
......@@ -17080,47 +17080,47 @@ snapshots:
1708017080 - supports-color
1708117081 - typescript
1708217082
17083 expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8):
17083 expo-router@56.2.11(80beea6a31a5d2003a696c1401258797):
1708417084 dependencies:
17085 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17086 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17085 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17086 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
1708717087 '@expo/schema-utils': 56.0.1
17088 '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)
17089 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3)
17090 '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
17091 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17088 '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5)
17089 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6)
17090 '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
17091 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
1709217092 '@testing-library/jest-dom': 6.9.1
1709317093 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
1709417094 client-only: 0.0.1
1709517095 color: 4.2.3
1709617096 debug: 4.4.3
1709717097 escape-string-regexp: 4.0.0
17098 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
17099 expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
17100 expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17101 expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17098 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
17099 expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))
17100 expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17101 expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
1710217102 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)
1710417104 fast-deep-equal: 3.1.3
1710517105 invariant: 2.2.4
1710617106 nanoid: 3.3.12
1710717107 query-string: 7.1.3
17108 react: 19.2.3
17108 react: 19.2.6
1710917109 react-fast-compare: 3.2.2
1711017110 react-is: 19.2.7
17111 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
17112 react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5)
17113 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17114 react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17111 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)
17112 react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)
17113 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17114 react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
1711517115 server-only: 0.0.1
1711617116 sf-symbols-typescript: 2.2.0
1711717117 shallowequal: 1.1.0
1711817118 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)
1712017120 optionalDependencies:
17121 react-dom: 19.2.3(react@19.2.3)
17122 react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17123 react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17121 react-dom: 19.2.6(react@19.2.6)
17122 react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17123 react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
1712417124 transitivePeerDependencies:
1712517125 - '@babel/core'
1712617126 - '@testing-library/dom'
......@@ -17131,47 +17131,47 @@ snapshots:
1713117131 - supports-color
1713217132 optional: true
1713317133
17134 expo-router@56.2.11(db5c693a26481047569df6781f34db9f):
17134 expo-router@56.2.11(e1497a99e5bc5be76c1cdb733671f865):
1713517135 dependencies:
17136 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17137 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17136 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17137 '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1713817138 '@expo/schema-utils': 56.0.1
17139 '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5)
17140 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6)
17141 '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
17142 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17139 '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)
17140 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3)
17141 '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
17142 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1714317143 '@testing-library/jest-dom': 6.9.1
1714417144 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
1714517145 client-only: 0.0.1
1714617146 color: 4.2.3
1714717147 debug: 4.4.3
1714817148 escape-string-regexp: 4.0.0
17149 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
17150 expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))
17151 expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17152 expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17149 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
17150 expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
17151 expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17152 expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1715317153 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)
1715517155 fast-deep-equal: 3.1.3
1715617156 invariant: 2.2.4
1715717157 nanoid: 3.3.12
1715817158 query-string: 7.1.3
17159 react: 19.2.6
17159 react: 19.2.3
1716017160 react-fast-compare: 3.2.2
1716117161 react-is: 19.2.7
17162 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)
17163 react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)
17164 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17165 react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17162 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
17163 react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5)
17164 react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17165 react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1716617166 server-only: 0.0.1
1716717167 sf-symbols-typescript: 2.2.0
1716817168 shallowequal: 1.1.0
1716917169 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)
1717117171 optionalDependencies:
17172 react-dom: 19.2.6(react@19.2.6)
17173 react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17174 react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
17172 react-dom: 19.2.3(react@19.2.3)
17173 react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
17174 react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1717517175 transitivePeerDependencies:
1717617176 - '@babel/core'
1717717177 - '@testing-library/dom'
......@@ -19977,14 +19977,14 @@ snapshots:
1997719977 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)
1997819978 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):
1998119981 dependencies:
1998219982 react: 19.2.3
1998319983 react-freeze: 1.0.4(react@19.2.3)
1998419984 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)
1998519985 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):
1998819988 dependencies:
1998919989 react: 19.2.6
1999019990 react-freeze: 1.0.4(react@19.2.6)