authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-02 22:42:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-02 22:42:14-07:00
log32d17d3db55187b48389c005a319135b0badfea2
tree5fad831862906af47bb282cd512b9f1bced4c520
parent6d35a87c5d0e6dcf16be5b1d9a236a1d5e12c8c7
signature Signed by PGP key B5690EEEBB952194

Add adaptive split-view layout for iPad/mobile workspace (#3514)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

215 files changed, 18034 insertions(+), 6008 deletions(-)

apps/mobile/.swiftlint.yml+1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1included:1included:
2 - ios/T3Code2 - ios/T3Code
3 - modules/t3-composer-editor/ios3 - modules/t3-composer-editor/ios
4 - modules/t3-native-controls/ios
4 - modules/t3-terminal/ios5 - modules/t3-terminal/ios
5 - modules/t3-review-diff/ios6 - modules/t3-review-diff/ios
67
apps/mobile/app.config.ts+14-1
...@@ -17,6 +17,7 @@ const VARIANT_CONFIG: Record<...@@ -17,6 +17,7 @@ const VARIANT_CONFIG: Record<
17 readonly iosIcon: string;17 readonly iosIcon: string;
18 readonly iosBundleIdentifier: string;18 readonly iosBundleIdentifier: string;
19 readonly androidPackage: string;19 readonly androidPackage: string;
20 readonly relyingParty?: string;
20 }21 }
21> = {22> = {
22 development: {23 development: {
...@@ -25,6 +26,7 @@ const VARIANT_CONFIG: Record<...@@ -25,6 +26,7 @@ const VARIANT_CONFIG: Record<
25 iosIcon: "./assets/icon-composer-dev.icon",26 iosIcon: "./assets/icon-composer-dev.icon",
26 iosBundleIdentifier: "com.t3tools.t3code.dev",27 iosBundleIdentifier: "com.t3tools.t3code.dev",
27 androidPackage: "com.t3tools.t3code.dev",28 androidPackage: "com.t3tools.t3code.dev",
29 relyingParty: "clerk.t3.codes",
28 },30 },
29 preview: {31 preview: {
30 appName: "T3 Code Preview",32 appName: "T3 Code Preview",
...@@ -32,6 +34,7 @@ const VARIANT_CONFIG: Record<...@@ -32,6 +34,7 @@ const VARIANT_CONFIG: Record<
32 iosIcon: "./assets/icon-composer-prod.icon",34 iosIcon: "./assets/icon-composer-prod.icon",
33 iosBundleIdentifier: "com.t3tools.t3code.preview",35 iosBundleIdentifier: "com.t3tools.t3code.preview",
34 androidPackage: "com.t3tools.t3code.preview",36 androidPackage: "com.t3tools.t3code.preview",
37 relyingParty: "clerk.t3.codes",
35 },38 },
36 production: {39 production: {
37 appName: "T3 Code",40 appName: "T3 Code",
...@@ -39,6 +42,7 @@ const VARIANT_CONFIG: Record<...@@ -39,6 +42,7 @@ const VARIANT_CONFIG: Record<
39 iosIcon: "./assets/icon-composer-prod.icon",42 iosIcon: "./assets/icon-composer-prod.icon",
40 iosBundleIdentifier: "com.t3tools.t3code",43 iosBundleIdentifier: "com.t3tools.t3code",
41 androidPackage: "com.t3tools.t3code",44 androidPackage: "com.t3tools.t3code",
45 relyingParty: "clerk.t3.codes",
42 },46 },
43};47};
4448
...@@ -77,6 +81,14 @@ const config: ExpoConfig = {...@@ -77,6 +81,14 @@ const config: ExpoConfig = {
77 icon: variant.iosIcon,81 icon: variant.iosIcon,
78 supportsTablet: true,82 supportsTablet: true,
79 bundleIdentifier: variant.iosBundleIdentifier,83 bundleIdentifier: variant.iosBundleIdentifier,
84 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
85 // does not fall back to a personal team (which cannot sign app groups,
86 // Sign in with Apple, or push notification entitlements).
87 appleTeamId: "ARK85ZXQ4Z",
88 associatedDomains: [
89 `applinks:${variant.relyingParty}`,
90 `webcredentials:${variant.relyingParty}`,
91 ],
80 infoPlist: {92 infoPlist: {
81 NSAppTransportSecurity: {93 NSAppTransportSecurity: {
82 NSAllowsArbitraryLoads: true,94 NSAllowsArbitraryLoads: true,
...@@ -101,7 +113,6 @@ const config: ExpoConfig = {...@@ -101,7 +113,6 @@ const config: ExpoConfig = {
101 favicon: "./assets/favicon.png",113 favicon: "./assets/favicon.png",
102 },114 },
103 plugins: [115 plugins: [
104 "expo-router",
105 "expo-font",116 "expo-font",
106 "expo-secure-store",117 "expo-secure-store",
107 ["@clerk/expo", { theme: "./clerk-theme.json" }],118 ["@clerk/expo", { theme: "./clerk-theme.json" }],
...@@ -139,6 +150,7 @@ const config: ExpoConfig = {...@@ -139,6 +150,7 @@ const config: ExpoConfig = {
139 },150 },
140 },151 },
141 ],152 ],
153 "./plugins/withIosCocoaPodsUuidCache.cjs",
142 [154 [
143 "expo-widgets",155 "expo-widgets",
144 {156 {
...@@ -155,6 +167,7 @@ const config: ExpoConfig = {...@@ -155,6 +167,7 @@ const config: ExpoConfig = {
155 ],167 ],
156 },168 },
157 ],169 ],
170 "./plugins/withIosSceneLifecycle.cjs",
158 "./plugins/withAndroidCleartextTraffic.cjs",171 "./plugins/withAndroidCleartextTraffic.cjs",
159 ],172 ],
160 extra: {173 extra: {
apps/mobile/global.css+18-18
...@@ -199,24 +199,24 @@...@@ -199,24 +199,24 @@
199 --font-bold: "DMSans_700Bold";199 --font-bold: "DMSans_700Bold";
200200
201 /* Keep this scale aligned with src/lib/typography.ts for native style props. */201 /* Keep this scale aligned with src/lib/typography.ts for native style props. */
202 --text-3xs: 10px;202 --text-3xs: 11px;
203 --text-3xs--line-height: 13px;203 --text-3xs--line-height: 14px;
204 --text-2xs: 11px;204 --text-2xs: 12px;
205 --text-2xs--line-height: 15px;205 --text-2xs--line-height: 16px;
206 --text-xs: 12px;206 --text-xs: 13px;
207 --text-xs--line-height: 16px;207 --text-xs--line-height: 17px;
208 --text-sm: 13px;208 --text-sm: 14px;
209 --text-sm--line-height: 18px;209 --text-sm--line-height: 19px;
210 --text-base: 15px;210 --text-base: 16px;
211 --text-base--line-height: 22px;211 --text-base--line-height: 23px;
212 --text-lg: 17px;212 --text-lg: 18px;
213 --text-lg--line-height: 22px;213 --text-lg--line-height: 23px;
214 --text-xl: 20px;214 --text-xl: 21px;
215 --text-xl--line-height: 26px;215 --text-xl--line-height: 28px;
216 --text-2xl: 24px;216 --text-2xl: 26px;
217 --text-2xl--line-height: 30px;217 --text-2xl--line-height: 32px;
218 --text-3xl: 28px;218 --text-3xl: 30px;
219 --text-3xl--line-height: 34px;219 --text-3xl--line-height: 36px;
220}220}
221221
222/* ─── Custom utilities ──────────────────────────────────────────────── */222/* ─── Custom utilities ──────────────────────────────────────────────── */
apps/mobile/index.ts+10-1
...@@ -1,2 +1,11 @@...@@ -1,2 +1,11 @@
1import { registerRootComponent } from "expo";
1import "react-native-gesture-handler";2import "react-native-gesture-handler";
2import "expo-router/entry";3import { featureFlags } from "react-native-screens";
4
5import App from "./src/App";
6
7// Required for react-native-screens' iOS FormSheet sizing fix when a nested
8// native stack is rendered inside a non-fitToContents formSheet.
9featureFlags.experiment.synchronousScreenUpdatesEnabled = true;
10
11registerRootComponent(App);
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift+1
...@@ -47,6 +47,7 @@ public class T3ComposerEditorModule: Module {...@@ -47,6 +47,7 @@ public class T3ComposerEditorModule: Module {
47 "onComposerSelectionChange",47 "onComposerSelectionChange",
48 "onComposerFocus",48 "onComposerFocus",
49 "onComposerBlur",49 "onComposerBlur",
50 "onComposerSubmit",
50 "onComposerPasteImages",51 "onComposerPasteImages",
51 "onComposerContentSizeChange"52 "onComposerContentSizeChange"
52 )53 )
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+22
...@@ -63,6 +63,24 @@ private final class ComposerTextView: UITextView {...@@ -63,6 +63,24 @@ private final class ComposerTextView: UITextView {
6363
64 var onPasteImages: (([String]) -> Void)?64 var onPasteImages: (([String]) -> Void)?
65 var onAttributedMutation: (() -> Void)?65 var onAttributedMutation: (() -> Void)?
66 var onSubmit: (() -> Void)?
67
68 override var keyCommands: [UIKeyCommand]? {
69 var commands = super.keyCommands ?? []
70 let submit = UIKeyCommand(
71 input: "\r",
72 modifierFlags: .command,
73 action: #selector(submitMessage(_:))
74 )
75 submit.discoverabilityTitle = "Send Message"
76 submit.wantsPriorityOverSystemBehavior = true
77 commands.append(submit)
78 return commands
79 }
80
81 @objc private func submitMessage(_ sender: UIKeyCommand) {
82 onSubmit?()
83 }
6684
67 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {85 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
68 if action == #selector(paste(_:)) {86 if action == #selector(paste(_:)) {
...@@ -299,6 +317,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {...@@ -299,6 +317,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {
299 let onComposerSelectionChange = EventDispatcher()317 let onComposerSelectionChange = EventDispatcher()
300 let onComposerFocus = EventDispatcher()318 let onComposerFocus = EventDispatcher()
301 let onComposerBlur = EventDispatcher()319 let onComposerBlur = EventDispatcher()
320 let onComposerSubmit = EventDispatcher()
302 let onComposerPasteImages = EventDispatcher()321 let onComposerPasteImages = EventDispatcher()
303 let onComposerContentSizeChange = EventDispatcher()322 let onComposerContentSizeChange = EventDispatcher()
304323
...@@ -320,6 +339,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {...@@ -320,6 +339,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {
320 textView.onAttributedMutation = { [weak self] in339 textView.onAttributedMutation = { [weak self] in
321 self?.emitTextChange()340 self?.emitTextChange()
322 }341 }
342 textView.onSubmit = { [weak self] in
343 self?.onComposerSubmit([:])
344 }
323 addSubview(textView)345 addSubview(textView)
324346
325 placeholderLabel.numberOfLines = 0347 placeholderLabel.numberOfLines = 0
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+14-5
...@@ -26,6 +26,15 @@ function nodeKey(node: MarkdownNode, index: number): string {...@@ -26,6 +26,15 @@ function nodeKey(node: MarkdownNode, index: number): string {
26 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;26 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
27}27}
2828
29/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */
30function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number {
31 return Math.max(10, Math.round(textStyle.fontSize * 0.8));
32}
33
34function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number {
35 return codeBlockFontSize(textStyle) + 6;
36}
37
29function nodeText(node: MarkdownNode): string {38function nodeText(node: MarkdownNode): string {
30 if (node.content !== undefined) {39 if (node.content !== undefined) {
31 return node.content;40 return node.content;
...@@ -161,8 +170,8 @@ function HighlightedCodeText(props: {...@@ -161,8 +170,8 @@ function HighlightedCodeText(props: {
161 style={{170 style={{
162 color: props.textStyle.codeColor,171 color: props.textStyle.codeColor,
163 fontFamily: "ui-monospace",172 fontFamily: "ui-monospace",
164 fontSize: 12,173 fontSize: codeBlockFontSize(props.textStyle),
165 lineHeight: 18,174 lineHeight: codeBlockLineHeight(props.textStyle),
166 }}175 }}
167 >176 >
168 {props.content}177 {props.content}
...@@ -196,8 +205,8 @@ function HighlightedCodeText(props: {...@@ -196,8 +205,8 @@ function HighlightedCodeText(props: {
196 style={{205 style={{
197 color: props.textStyle.codeColor,206 color: props.textStyle.codeColor,
198 fontFamily: "ui-monospace",207 fontFamily: "ui-monospace",
199 fontSize: 12,208 fontSize: codeBlockFontSize(props.textStyle),
200 lineHeight: 18,209 lineHeight: codeBlockLineHeight(props.textStyle),
201 }}210 }}
202 >211 >
203 {keyedLines.map((line, lineIndex) => (212 {keyedLines.map((line, lineIndex) => (
...@@ -264,7 +273,7 @@ function NativeCodeBlock(props: {...@@ -264,7 +273,7 @@ function NativeCodeBlock(props: {
264 flex: 1,273 flex: 1,
265 color: props.textStyle.mutedColor,274 color: props.textStyle.mutedColor,
266 fontFamily: "ui-monospace",275 fontFamily: "ui-monospace",
267 fontSize: 12,276 fontSize: codeBlockFontSize(props.textStyle),
268 }}277 }}
269 >278 >
270 {languageLabel}279 {languageLabel}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+21-4
...@@ -32,11 +32,25 @@ function runKeySignature(run: NativeMarkdownTextRun): string {...@@ -32,11 +32,25 @@ function runKeySignature(run: NativeMarkdownTextRun): string {
32 ].join(":");32 ].join(":");
33}33}
3434
35const DEFAULT_BODY_FONT_SIZE = 15;
36const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const;
37
38function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number {
39 const index = Math.max(0, Math.min(5, headingLevel - 1));
40 const configured = textStyle.headingFontSizes?.[index];
41 if (typeof configured === "number" && Number.isFinite(configured)) {
42 return configured;
43 }
44
45 const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE;
46 return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale));
47}
48
35function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {49function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
36 const isFile = run.fileIcon != null;50 const isFile = run.fileIcon != null;
37 const isSkill = run.skillName != null;51 const isSkill = run.skillName != null;
38 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));52 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
39 const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15;53 const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel);
40 const isHeading = run.role === "heading";54 const isHeading = run.role === "heading";
41 const isCodeBlock = run.role === "code-block" || run.role === "code-language";55 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
42 const hasParagraphStyle = run.headIndent !== undefined;56 const hasParagraphStyle = run.headIndent !== undefined;
...@@ -88,7 +102,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle...@@ -88,7 +102,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
88 : isHeading102 : isHeading
89 ? headingFontSize103 ? headingFontSize
90 : run.role === "code-language"104 : run.role === "code-language"
91 ? 11105 ? Math.max(10, Math.round(textStyle.fontSize * 0.73))
92 : run.code || isCodeBlock106 : run.code || isCodeBlock
93 ? Math.max(12, textStyle.fontSize - 2)107 ? Math.max(12, textStyle.fontSize - 2)
94 : textStyle.fontSize,108 : textStyle.fontSize,
...@@ -98,9 +112,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle...@@ -98,9 +112,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
98 : run.role === "list-break"112 : run.role === "list-break"
99 ? textStyle.lineHeight + (run.spacing ?? 0)113 ? textStyle.lineHeight + (run.spacing ?? 0)
100 : isHeading114 : isHeading
101 ? Math.max(headingFontSize + 6, 20)115 ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2)
102 : isCodeBlock116 : isCodeBlock
103 ? 18117 ? Math.max(16, textStyle.lineHeight - 2)
104 : textStyle.lineHeight,118 : textStyle.lineHeight,
105 fontStyle: run.italic ? "italic" : "normal",119 fontStyle: run.italic ? "italic" : "normal",
106 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",120 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
...@@ -148,6 +162,9 @@ export function NativeMarkdownSelectableText(props: {...@@ -148,6 +162,9 @@ export function NativeMarkdownSelectableText(props: {
148 // color-only child update can otherwise leave the previous appearance cached.162 // color-only child update can otherwise leave the previous appearance cached.
149 const appearanceKey = [163 const appearanceKey = [
150 colorScheme ?? "unspecified",164 colorScheme ?? "unspecified",
165 props.textStyle.fontSize,
166 props.textStyle.lineHeight,
167 props.textStyle.headingFontSizes?.join(","),
151 props.textStyle.color,168 props.textStyle.color,
152 props.textStyle.strongColor,169 props.textStyle.strongColor,
153 props.textStyle.mutedColor,170 props.textStyle.mutedColor,
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+1
...@@ -16,6 +16,7 @@ export interface NativeMarkdownTextStyle {...@@ -16,6 +16,7 @@ export interface NativeMarkdownTextStyle {
16 readonly fontFamily: string;16 readonly fontFamily: string;
17 readonly headingFontFamily: string;17 readonly headingFontFamily: string;
18 readonly boldFontFamily: string;18 readonly boldFontFamily: string;
19 readonly headingFontSizes?: ReadonlyArray<number>;
19}20}
2021
21export interface MarkdownHighlightedToken {22export interface MarkdownHighlightedToken {
apps/mobile/modules/t3-native-controls/expo-module.config.json created+6
...@@ -0,0 +1,6 @@
1{
2 "platforms": ["apple"],
3 "apple": {
4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]
5 }
6}
apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift created+62
...@@ -0,0 +1,62 @@
1import ExpoModulesCore
2import UIKit
3
4public final class T3HeaderButtonView: ExpoView {
5 private static let size: CGFloat = 44
6 private static let symbolSize: CGFloat = 18
7
8 private let button = UIButton(type: .system)
9 private var systemImage = "circle"
10
11 let onTriggered = EventDispatcher()
12
13 public required init(appContext: AppContext? = nil) {
14 super.init(appContext: appContext)
15
16 isAccessibilityElement = false
17 button.frame = bounds
18 button.autoresizingMask = [.flexibleWidth, .flexibleHeight]
19 button.addTarget(self, action: #selector(handlePress), for: .primaryActionTriggered)
20 addSubview(button)
21 applyConfiguration()
22 }
23
24 public override var intrinsicContentSize: CGSize {
25 CGSize(width: Self.size, height: Self.size)
26 }
27
28 public func setLabel(_ label: String) {
29 button.accessibilityLabel = label
30 }
31
32 public func setSystemImage(_ systemImage: String) {
33 guard self.systemImage != systemImage else {
34 return
35 }
36 self.systemImage = systemImage
37 applyConfiguration()
38 }
39
40 private func applyConfiguration() {
41 var configuration: UIButton.Configuration
42 if #available(iOS 26.0, *) {
43 configuration = .glass()
44 configuration.cornerStyle = .capsule
45 } else {
46 configuration = .plain()
47 }
48
49 configuration.baseForegroundColor = .label
50 configuration.contentInsets = .zero
51 configuration.image = UIImage(systemName: systemImage)
52 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
53 pointSize: Self.symbolSize,
54 weight: .regular
55 )
56 button.configuration = configuration
57 }
58
59 @objc private func handlePress() {
60 onTriggered()
61 }
62}
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift created+131
...@@ -0,0 +1,131 @@
1import ExpoModulesCore
2import UIKit
3
4public final class T3KeyboardCommandsModule: Module {
5 public func definition() -> ModuleDefinition {
6 Name("T3KeyboardCommands")
7
8 View(T3KeyboardCommandsView.self) {
9 Prop("enabledCommands") { (view: T3KeyboardCommandsView, commands: [String]) in
10 view.setEnabledCommands(commands)
11 }
12 Events("onCommand")
13 }
14 }
15}
16
17public final class T3KeyboardCommandsView: ExpoView {
18 let onCommand = EventDispatcher()
19 private var enabledCommands = Set<String>()
20
21 public override var canBecomeFirstResponder: Bool { true }
22
23 public override var keyCommands: [UIKeyCommand]? {
24 [
25 enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"),
26 enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"),
27 enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"),
28 enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"),
29 enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
30 enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),
31 enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"),
32 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
33 ].compactMap { $0 }
34 }
35
36 func setEnabledCommands(_ commands: [String]) {
37 enabledCommands = Set(commands)
38 if isFirstResponder {
39 resignFirstResponder()
40 }
41 reclaimFirstResponderIfAvailable()
42 }
43
44 private func enabledCommand(
45 _ identifier: String,
46 input: String,
47 modifiers: UIKeyModifierFlags,
48 action: Selector,
49 title: String
50 ) -> UIKeyCommand? {
51 guard enabledCommands.contains(identifier) else { return nil }
52 return command(input, modifiers: modifiers, action: action, title: title)
53 }
54
55 public required init(appContext: AppContext? = nil) {
56 super.init(appContext: appContext)
57 NotificationCenter.default.addObserver(
58 self,
59 selector: #selector(reclaimFirstResponderIfAvailable),
60 name: UITextView.textDidEndEditingNotification,
61 object: nil
62 )
63 NotificationCenter.default.addObserver(
64 self,
65 selector: #selector(reclaimFirstResponderIfAvailable),
66 name: UITextField.textDidEndEditingNotification,
67 object: nil
68 )
69 NotificationCenter.default.addObserver(
70 self,
71 selector: #selector(reclaimFirstResponderIfAvailable),
72 name: UIApplication.didBecomeActiveNotification,
73 object: nil
74 )
75 }
76
77 deinit {
78 NotificationCenter.default.removeObserver(self)
79 }
80
81 public override func didMoveToWindow() {
82 super.didMoveToWindow()
83 reclaimFirstResponderIfAvailable()
84 }
85
86 public override func layoutSubviews() {
87 super.layoutSubviews()
88 reclaimFirstResponderIfAvailable()
89 }
90
91 private func command(
92 _ input: String,
93 modifiers: UIKeyModifierFlags,
94 action: Selector,
95 title: String
96 ) -> UIKeyCommand {
97 let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: action)
98 command.discoverabilityTitle = title
99 command.wantsPriorityOverSystemBehavior = true
100 return command
101 }
102
103 @objc private func newTask() { emit("newTask") }
104 @objc private func focusSearch() { emit("focusSearch") }
105 @objc private func goBack() { emit("back") }
106 @objc private func openFiles() { emit("files") }
107 @objc private func openTerminal() { emit("terminal") }
108 @objc private func openReview() { emit("review") }
109 @objc private func handleToggleSidebar() { emit("toggleSidebar") }
110
111 private func emit(_ command: String) {
112 onCommand(["command": command])
113 }
114
115 @objc private func reclaimFirstResponderIfAvailable() {
116 DispatchQueue.main.async { [weak self] in
117 guard let self, self.window?.t3FirstResponder == nil else { return }
118 self.becomeFirstResponder()
119 }
120 }
121}
122
123private extension UIView {
124 var t3FirstResponder: UIResponder? {
125 if isFirstResponder { return self }
126 for subview in subviews {
127 if let responder = subview.t3FirstResponder { return responder }
128 }
129 return nil
130 }
131}
apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec created+19
...@@ -0,0 +1,19 @@
1Pod::Spec.new do |s|
2 s.name = 'T3NativeControls'
3 s.version = '1.0.0'
4 s.summary = 'Native UIKit controls for T3 Code mobile.'
5 s.description = 'UIKit-backed controls that match native iOS navigation chrome.'
6 s.author = 'T3 Tools'
7 s.homepage = 'https://t3tools.com'
8 s.platforms = {
9 :ios => '18.0',
10 }
11 s.source = { :path => '.' }
12 s.static_framework = true
13
14 s.dependency 'ExpoModulesCore'
15 s.pod_target_xcconfig = {
16 'DEFINES_MODULE' => 'YES',
17 }
18 s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}'
19end
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift created+18
...@@ -0,0 +1,18 @@
1import ExpoModulesCore
2
3public final class T3NativeControlsModule: Module {
4 public func definition() -> ModuleDefinition {
5 Name("T3NativeControls")
6
7 View(T3HeaderButtonView.self) {
8 Prop("label") { (view: T3HeaderButtonView, label: String) in
9 view.setLabel(label)
10 }
11 Prop("systemImage") { (view: T3HeaderButtonView, systemImage: String) in
12 view.setSystemImage(systemImage)
13 }
14
15 Events("onTriggered")
16 }
17 }
18}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+40-13
...@@ -5,22 +5,14 @@ public class T3ReviewDiffModule: Module {...@@ -5,22 +5,14 @@ public class T3ReviewDiffModule: Module {
5 Name("T3ReviewDiffSurface")5 Name("T3ReviewDiffSurface")
66
7 View(T3ReviewDiffView.self) {7 View(T3ReviewDiffView.self) {
8 Prop("rowsJson") { (view: T3ReviewDiffView, rowsJson: String) in
9 view.setRowsJson(rowsJson)
10 }
11
12 Prop("tokensJson") { (view: T3ReviewDiffView, tokensJson: String) in
13 view.setTokensJson(tokensJson)
14 }
15
16 Prop("tokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in
17 view.setTokensPatchJson(tokensPatchJson)
18 }
19
20 Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in8 Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in
21 view.setTokensResetKey(tokensResetKey)9 view.setTokensResetKey(tokensResetKey)
22 }10 }
2311
12 Prop("contentResetKey") { (view: T3ReviewDiffView, contentResetKey: String) in
13 view.setContentResetKey(contentResetKey)
14 }
15
24 Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in16 Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in
25 view.setCollapsedFileIdsJson(collapsedFileIdsJson)17 view.setCollapsedFileIdsJson(collapsedFileIdsJson)
26 }18 }
...@@ -61,7 +53,42 @@ public class T3ReviewDiffModule: Module {...@@ -61,7 +53,42 @@ public class T3ReviewDiffModule: Module {
61 view.setInitialRowIndex(initialRowIndex)53 view.setInitialRowIndex(initialRowIndex)
62 }54 }
6355
64 Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")56 Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in
57 view.setRefreshing(refreshing)
58 }
59
60 Events(
61 "onDebug",
62 "onVisibleFileChange",
63 "onToggleFile",
64 "onToggleViewedFile",
65 "onPressLine",
66 "onToggleComment",
67 "onPullToRefresh"
68 )
69
70 AsyncFunction("scrollToFile") { (view: T3ReviewDiffView, fileId: String, animated: Bool) in
71 view.scrollToFile(fileId, animated: animated)
72 }
73
74 AsyncFunction("scrollToTop") { (view: T3ReviewDiffView, animated: Bool) in
75 view.scrollToTop(animated: animated)
76 }
77
78 // Large, frequently changing JSON values cannot be regular Fabric props. Expo's
79 // prop adapter compares strings on the main thread before invoking a setter, which
80 // makes a syntax-token patch capable of blocking a frame by itself.
81 AsyncFunction("setRowsJson") { (view: T3ReviewDiffView, rowsJson: String) in
82 view.setRowsJson(rowsJson)
83 }
84
85 AsyncFunction("setTokensJson") { (view: T3ReviewDiffView, tokensJson: String) in
86 view.setTokensJson(tokensJson)
87 }
88
89 AsyncFunction("setTokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in
90 view.setTokensPatchJson(tokensPatchJson)
91 }
65 }92 }
66 }93 }
67}94}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+270-60
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import ExpoModulesCore1import ExpoModulesCore
2import UIKit2import UIKit
33
4private struct ReviewDiffNativeRow: Decodable {4private struct ReviewDiffNativeRow: Decodable, Sendable {
5 let kind: String5 let kind: String
6 let id: String6 let id: String
7 let fileId: String?7 let fileId: String?
...@@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable {...@@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable {
21 let commentSectionTitle: String?21 let commentSectionTitle: String?
22}22}
2323
24private struct ReviewDiffNativeWordDiffRange: Decodable {24private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable {
25 let start: Int25 let start: Int
26 let end: Int26 let end: Int
27}27}
2828
29private struct ReviewDiffNativeToken: Decodable {29private struct ReviewDiffNativeToken: Decodable, Sendable {
30 let content: String30 let content: String
31 let color: String?31 let color: String?
32 let fontStyle: Int?32 let fontStyle: Int?
33}33}
3434
35private struct ReviewDiffNativeTokenPatch: Decodable {35private struct ReviewDiffNativeTokenPatch: Decodable, Sendable {
36 let resetKey: String?36 let resetKey: String?
37 let chunkIndex: Int?37 let chunkIndex: Int?
38 let tokensByRowId: [String: [ReviewDiffNativeToken]]?38 let tokensByRowId: [String: [ReviewDiffNativeToken]]?
...@@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle {...@@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle {
312}312}
313313
314public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {314public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
315 private let payloadDecodeQueue = DispatchQueue(
316 label: "com.t3tools.review-diff.payload-decode",
317 qos: .userInitiated
318 )
315 private let scrollView = UIScrollView()319 private let scrollView = UIScrollView()
316 private let contentView = ReviewDiffContentView()320 private let contentView = ReviewDiffContentView()
317 private var rows: [ReviewDiffNativeRow] = []321 private var rows: [ReviewDiffNativeRow] = []
...@@ -323,14 +327,25 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -323,14 +327,25 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
323 private var lastMetricsDebugKey = ""327 private var lastMetricsDebugKey = ""
324 private var lastVisibleRangeDebugKey = ""328 private var lastVisibleRangeDebugKey = ""
325 private var tokensResetKey = ""329 private var tokensResetKey = ""
330 private var contentResetKey = ""
326 private var initialRowIndex: Int?331 private var initialRowIndex: Int?
327 private var hasAppliedInitialRowIndex = false332 private var hasAppliedInitialRowIndex = false
333 private var lastVisibleFileId: String?
334 private var pendingScrollFileId: String?
335 private var pendingScrollAnimated = false
336 private var isProgrammaticScrollActive = false
337 private var rowsDecodeGeneration = 0
338 private var tokensDecodeGeneration = 0
328339
329 let onDebug = EventDispatcher()340 let onDebug = EventDispatcher()
341 let onVisibleFileChange = EventDispatcher()
330 let onToggleFile = EventDispatcher()342 let onToggleFile = EventDispatcher()
331 let onToggleViewedFile = EventDispatcher()343 let onToggleViewedFile = EventDispatcher()
332 let onPressLine = EventDispatcher()344 let onPressLine = EventDispatcher()
333 let onToggleComment = EventDispatcher()345 let onToggleComment = EventDispatcher()
346 let onPullToRefresh = EventDispatcher()
347
348 private let pullRefreshControl = UIRefreshControl()
334349
335 public required init(appContext: AppContext? = nil) {350 public required init(appContext: AppContext? = nil) {
336 super.init(appContext: appContext)351 super.init(appContext: appContext)
...@@ -345,6 +360,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -345,6 +360,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
345 scrollView.showsVerticalScrollIndicator = true360 scrollView.showsVerticalScrollIndicator = true
346 scrollView.showsHorizontalScrollIndicator = false361 scrollView.showsHorizontalScrollIndicator = false
347 scrollView.backgroundColor = contentView.theme.background362 scrollView.backgroundColor = contentView.theme.background
363 pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged)
364 scrollView.refreshControl = pullRefreshControl
348 addSubview(scrollView)365 addSubview(scrollView)
349366
350 contentView.backgroundColor = contentView.theme.background367 contentView.backgroundColor = contentView.theme.background
...@@ -379,6 +396,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -379,6 +396,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
379 updateViewportFrame()396 updateViewportFrame()
380 }397 }
381398
399 public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
400 // A direct gesture takes ownership from any interrupted programmatic jump.
401 // Resume visible-file events immediately so the inspector follows the finger.
402 isProgrammaticScrollActive = false
403 contentView.isVerticalScrollActive = true
404 }
405
382 public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {406 public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
383 guard !decelerate else {407 guard !decelerate else {
384 return408 return
...@@ -396,77 +420,125 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -396,77 +420,125 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
396 }420 }
397421
398 func setRowsJson(_ rowsJson: String) {422 func setRowsJson(_ rowsJson: String) {
399 guard let data = rowsJson.data(using: .utf8) else {423 rowsDecodeGeneration += 1
400 return424 let generation = rowsDecodeGeneration
401 }
402425
403 do {426 payloadDecodeQueue.async { [weak self] in
404 rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)427 guard let data = rowsJson.data(using: .utf8) else {
405 contentView.rows = rows428 return
406 hasAppliedInitialRowIndex = false429 }
407 emitDebug("rows-decoded", [430
408 "rows": rows.count,431 do {
409 "firstKind": rows.first?.kind ?? "none",432 let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
410 ])433 DispatchQueue.main.async { [weak self] in
411 updateContentMetrics()434 guard let self, generation == self.rowsDecodeGeneration else {
412 } catch {435 return
413 rows = []436 }
414 contentView.rows = []437 self.rows = decodedRows
415 hasAppliedInitialRowIndex = false438 self.contentView.rows = decodedRows
416 updateContentMetrics()439 self.hasAppliedInitialRowIndex = false
417 emitDebug("rows-decode-failed", [440 self.lastVisibleFileId = nil
418 "error": error.localizedDescription,441 self.emitDebug("rows-decoded", [
419 ])442 "rows": decodedRows.count,
443 "firstKind": decodedRows.first?.kind ?? "none",
444 ])
445 self.updateContentMetrics()
446 self.applyPendingScrollIfNeeded()
447 }
448 } catch {
449 let message = error.localizedDescription
450 DispatchQueue.main.async { [weak self] in
451 guard let self, generation == self.rowsDecodeGeneration else {
452 return
453 }
454 self.rows = []
455 self.contentView.rows = []
456 self.hasAppliedInitialRowIndex = false
457 self.lastVisibleFileId = nil
458 self.pendingScrollFileId = nil
459 self.updateContentMetrics()
460 self.emitDebug("rows-decode-failed", ["error": message])
461 }
462 }
420 }463 }
421 }464 }
422465
423 func setTokensJson(_ tokensJson: String) {466 func setTokensJson(_ tokensJson: String) {
424 guard let data = tokensJson.data(using: .utf8) else {467 tokensDecodeGeneration += 1
425 return468 let generation = tokensDecodeGeneration
426 }
427469
428 do {470 payloadDecodeQueue.async { [weak self] in
429 contentView.tokensByRowId = try JSONDecoder().decode(471 guard let data = tokensJson.data(using: .utf8) else {
430 [String: [ReviewDiffNativeToken]].self,472 return
431 from: data473 }
432 )474
433 } catch {475 do {
434 contentView.tokensByRowId = [:]476 let decodedTokens = try JSONDecoder().decode(
435 emitDebug("tokens-decode-failed", [477 [String: [ReviewDiffNativeToken]].self,
436 "error": error.localizedDescription,478 from: data
437 ])479 )
480 DispatchQueue.main.async { [weak self] in
481 guard let self, generation == self.tokensDecodeGeneration else {
482 return
483 }
484 self.contentView.tokensByRowId = decodedTokens
485 }
486 } catch {
487 let message = error.localizedDescription
488 DispatchQueue.main.async { [weak self] in
489 guard let self, generation == self.tokensDecodeGeneration else {
490 return
491 }
492 self.contentView.tokensByRowId = [:]
493 self.emitDebug("tokens-decode-failed", ["error": message])
494 }
495 }
438 }496 }
439 }497 }
440498
441 func setTokensPatchJson(_ tokensPatchJson: String) {499 func setTokensPatchJson(_ tokensPatchJson: String) {
442 guard let data = tokensPatchJson.data(using: .utf8) else {500 let contentResetKey = self.contentResetKey
443 return
444 }
445501
446 do {502 payloadDecodeQueue.async { [weak self] in
447 let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)503 guard let data = tokensPatchJson.data(using: .utf8) else {
448 if let resetKey = patch.resetKey, resetKey != tokensResetKey {
449 tokensResetKey = resetKey
450 contentView.tokensByRowId = [:]
451 }
452
453 let tokensByRowId = patch.tokensByRowId ?? [:]
454 if tokensByRowId.isEmpty {
455 return504 return
456 }505 }
457506
458 contentView.mergeTokensByRowId(tokensByRowId)507 do {
459 if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {508 let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
460 emitDebug("tokens-patch-decoded", [509 DispatchQueue.main.async { [weak self] in
461 "chunkIndex": chunkIndex,510 guard let self else {
462 "rows": tokensByRowId.count,511 return
463 "totalRows": contentView.tokensByRowId.count,512 }
464 ])513 guard contentResetKey == self.contentResetKey else {
514 return
515 }
516 // A highlighter request from the previous file can finish after the view has
517 // already reset. Never let that stale patch roll the native token state back.
518 if let resetKey = patch.resetKey, resetKey != self.tokensResetKey {
519 return
520 }
521
522 let tokensByRowId = patch.tokensByRowId ?? [:]
523 if tokensByRowId.isEmpty {
524 return
525 }
526
527 self.contentView.mergeTokensByRowId(tokensByRowId)
528 if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {
529 self.emitDebug("tokens-patch-decoded", [
530 "chunkIndex": chunkIndex,
531 "rows": tokensByRowId.count,
532 "totalRows": self.contentView.tokensByRowId.count,
533 ])
534 }
535 }
536 } catch {
537 let message = error.localizedDescription
538 DispatchQueue.main.async { [weak self] in
539 self?.emitDebug("tokens-patch-decode-failed", ["error": message])
540 }
465 }541 }
466 } catch {
467 emitDebug("tokens-patch-decode-failed", [
468 "error": error.localizedDescription,
469 ])
470 }542 }
471 }543 }
472544
...@@ -482,6 +554,27 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -482,6 +554,27 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
482 ])554 ])
483 }555 }
484556
557 func setContentResetKey(_ contentResetKey: String) {
558 guard contentResetKey != self.contentResetKey else {
559 return
560 }
561
562 self.contentResetKey = contentResetKey
563 rowsDecodeGeneration += 1
564 tokensDecodeGeneration += 1
565 contentView.tokensByRowId = [:]
566 rows = []
567 contentView.rows = []
568 hasAppliedInitialRowIndex = false
569 pendingScrollFileId = nil
570 isProgrammaticScrollActive = false
571 scrollView.setContentOffset(.zero, animated: false)
572 updateContentMetrics()
573 updateViewportFrame()
574 lastVisibleFileId = nil
575 applyInitialRowIndexIfNeeded()
576 }
577
485 func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {578 func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {
486 let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)579 let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)
487 let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)580 let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)
...@@ -573,6 +666,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -573,6 +666,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
573 contentView.invalidateVisibleViewport()666 contentView.invalidateVisibleViewport()
574 contentView.setNeedsDisplay()667 contentView.setNeedsDisplay()
575 applyInitialRowIndexIfNeeded()668 applyInitialRowIndexIfNeeded()
669 applyPendingScrollIfNeeded()
670 emitVisibleFileIfNeeded()
576671
577 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"672 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
578 if debugKey != lastMetricsDebugKey {673 if debugKey != lastMetricsDebugKey {
...@@ -596,11 +691,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -596,11 +691,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
596 }691 }
597692
598 private func finishVerticalScroll() {693 private func finishVerticalScroll() {
694 isProgrammaticScrollActive = false
599 contentView.isVerticalScrollActive = false695 contentView.isVerticalScrollActive = false
600 updateViewportFrame()696 updateViewportFrame()
601 emitVisibleRange(reason: "scroll-end")697 emitVisibleRange(reason: "scroll-end")
602 }698 }
603699
700 private func emitVisibleFileIfNeeded() {
701 // Keep the explicit destination selected while UIKit animates through the files
702 // between the old and new offsets. Emitting every intermediate header forces a
703 // React render per crossing and makes the navigator visibly flash.
704 guard !isProgrammaticScrollActive else {
705 return
706 }
707
708 // The top of the combined diff is the explicit "All files" destination.
709 // Treat it as a first-class selection instead of immediately resolving the
710 // first file header and undoing the navigator's optimistic selection.
711 if scrollView.contentOffset.y <= 0.5 {
712 guard lastVisibleFileId != nil else {
713 return
714 }
715 lastVisibleFileId = nil
716 onVisibleFileChange(["fileId": NSNull()])
717 return
718 }
719
720 guard let fileId = contentView.visibleFileId(atVerticalOffset: scrollView.contentOffset.y),
721 fileId != lastVisibleFileId else {
722 return
723 }
724
725 lastVisibleFileId = fileId
726 onVisibleFileChange(["fileId": fileId])
727 }
728
604 private func emitVisibleRange(reason: String) {729 private func emitVisibleRange(reason: String) {
605 guard let range = contentView.currentVisibleRowRange() else {730 guard let range = contentView.currentVisibleRowRange() else {
606 return731 return
...@@ -670,6 +795,33 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -670,6 +795,33 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
670 applyInitialRowIndexIfNeeded()795 applyInitialRowIndexIfNeeded()
671 }796 }
672797
798 func scrollToFile(_ fileId: String, animated: Bool) {
799 pendingScrollFileId = fileId
800 pendingScrollAnimated = animated
801 applyPendingScrollIfNeeded()
802 }
803
804 func scrollToTop(animated: Bool) {
805 pendingScrollFileId = nil
806 pendingScrollAnimated = false
807 setVerticalContentOffset(0, animated: animated)
808 }
809
810 @objc private func handlePullToRefresh() {
811 onPullToRefresh([:])
812 }
813
814 /// Driven from JS: set to false once the reload completes to dismiss the spinner.
815 func setRefreshing(_ refreshing: Bool) {
816 if refreshing {
817 if !pullRefreshControl.isRefreshing {
818 pullRefreshControl.beginRefreshing()
819 }
820 } else if pullRefreshControl.isRefreshing {
821 pullRefreshControl.endRefreshing()
822 }
823 }
824
673 private func applyStyle() {825 private func applyStyle() {
674 contentView.style = ReviewDiffNativeStyle826 contentView.style = ReviewDiffNativeStyle
675 .resolve(stylePayload)827 .resolve(stylePayload)
...@@ -686,6 +838,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -686,6 +838,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
686 )838 )
687 contentView.verticalOffset = scrollView.contentOffset.y839 contentView.verticalOffset = scrollView.contentOffset.y
688 contentView.invalidateVisibleViewport()840 contentView.invalidateVisibleViewport()
841 emitVisibleFileIfNeeded()
842 }
843
844 private func applyPendingScrollIfNeeded() {
845 guard let fileId = pendingScrollFileId,
846 bounds.height > 0 else {
847 return
848 }
849
850 guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else {
851 if !rows.isEmpty {
852 pendingScrollFileId = nil
853 }
854 return
855 }
856
857 let animated = pendingScrollAnimated
858 pendingScrollFileId = nil
859 pendingScrollAnimated = false
860 setVerticalContentOffset(headerOffset, animated: animated)
861 }
862
863 private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) {
864 let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0)
865 let clampedOffset = min(max(targetOffset, 0), maxOffset)
866 let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5
867 isProgrammaticScrollActive = shouldAnimate
868 contentView.isVerticalScrollActive = shouldAnimate
869 scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate)
870 if !shouldAnimate {
871 updateViewportFrame()
872 }
689 }873 }
690874
691 private func applyInitialRowIndexIfNeeded() {875 private func applyInitialRowIndexIfNeeded() {
...@@ -1255,6 +1439,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1255,6 +1439,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
1255 return rowOffsets[rowIndex]1439 return rowOffsets[rowIndex]
1256 }1440 }
12571441
1442 func visibleFileId(atVerticalOffset verticalOffset: CGFloat) -> String? {
1443 guard let firstHeaderRowIndex = fileHeaderRowIndices.first else {
1444 return nil
1445 }
1446
1447 var lowerBound = 0
1448 var upperBound = fileHeaderRowIndices.count
1449 while lowerBound < upperBound {
1450 let midpoint = (lowerBound + upperBound) / 2
1451 let rowIndex = fileHeaderRowIndices[midpoint]
1452 if rowOffsets[rowIndex] <= verticalOffset + 0.5 {
1453 lowerBound = midpoint + 1
1454 } else {
1455 upperBound = midpoint
1456 }
1457 }
1458
1459 let rowIndex = lowerBound > 0
1460 ? fileHeaderRowIndices[lowerBound - 1]
1461 : firstHeaderRowIndex
1462 guard rows.indices.contains(rowIndex) else {
1463 return nil
1464 }
1465 return resolvedFileId(for: rows[rowIndex])
1466 }
1467
1258 private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {1468 private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {
1259 fileHeaderRowIndices.first { rowIndex in1469 fileHeaderRowIndices.first { rowIndex in
1260 rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId1470 rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt+10
...@@ -7,6 +7,12 @@ class T3TerminalModule : Module() {...@@ -7,6 +7,12 @@ class T3TerminalModule : Module() {
7 override fun definition() = ModuleDefinition {7 override fun definition() = ModuleDefinition {
8 Name("T3TerminalSurface")8 Name("T3TerminalSurface")
99
10 // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug
11 // logs so a stale native binary is distinguishable from a broken key pipeline.
12 Constants(
13 "hardwareKeyRevision" to 2,
14 )
15
10 View(T3TerminalView::class) {16 View(T3TerminalView::class) {
11 Prop("terminalKey") { view: T3TerminalView, terminalKey: String ->17 Prop("terminalKey") { view: T3TerminalView, terminalKey: String ->
12 view.terminalKey = terminalKey18 view.terminalKey = terminalKey
...@@ -20,6 +26,10 @@ class T3TerminalModule : Module() {...@@ -20,6 +26,10 @@ class T3TerminalModule : Module() {
20 view.fontSize = fontSize.toFloat()26 view.fontSize = fontSize.toFloat()
21 }27 }
2228
29 Prop("focusRequest") { view: T3TerminalView, focusRequest: Double ->
30 view.focusRequest = focusRequest
31 }
32
23 Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String ->33 Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String ->
24 view.appearanceScheme = appearanceScheme34 view.appearanceScheme = appearanceScheme
25 }35 }
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt+19-2
...@@ -62,6 +62,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex...@@ -62,6 +62,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
6262
63 var themeConfig: String = ""63 var themeConfig: String = ""
6464
65 var focusRequest: Double = 0.0
66 set(value) {
67 val previous = field
68 field = value
69 if (value != previous && value > 0) {
70 requestKeyboardFocus()
71 }
72 }
73
65 var backgroundColorHex: String = "#24292E"74 var backgroundColorHex: String = "#24292E"
66 set(value) {75 set(value) {
67 field = value76 field = value
...@@ -113,11 +122,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex...@@ -113,11 +122,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
113 }122 }
114 inputView.setOnKeyListener { _, keyCode, event ->123 inputView.setOnKeyListener { _, keyCode, event ->
115 if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false124 if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false
116 when (keyCode) {125 when {
117 android.view.KeyEvent.KEYCODE_DEL -> {126 keyCode == android.view.KeyEvent.KEYCODE_DEL -> {
118 onInput(mapOf("data" to "\u007F"))127 onInput(mapOf("data" to "\u007F"))
119 true128 true
120 }129 }
130 // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...).
131 event.isCtrlPressed &&
132 keyCode in android.view.KeyEvent.KEYCODE_A..android.view.KeyEvent.KEYCODE_Z -> {
133 onInput(
134 mapOf("data" to (keyCode - android.view.KeyEvent.KEYCODE_A + 1).toChar().toString()),
135 )
136 true
137 }
121 else -> false138 else -> false
122 }139 }
123 }140 }
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+10
...@@ -4,6 +4,12 @@ public class T3TerminalModule: Module {...@@ -4,6 +4,12 @@ public class T3TerminalModule: Module {
4 public func definition() -> ModuleDefinition {4 public func definition() -> ModuleDefinition {
5 Name("T3TerminalSurface")5 Name("T3TerminalSurface")
66
7 // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug
8 // logs so a stale native binary is distinguishable from a broken key pipeline.
9 Constants([
10 "hardwareKeyRevision": 2,
11 ])
12
7 View(T3TerminalView.self) {13 View(T3TerminalView.self) {
8 Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in14 Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in
9 view.terminalKey = terminalKey15 view.terminalKey = terminalKey
...@@ -17,6 +23,10 @@ public class T3TerminalModule: Module {...@@ -17,6 +23,10 @@ public class T3TerminalModule: Module {
17 view.fontSize = CGFloat(fontSize)23 view.fontSize = CGFloat(fontSize)
18 }24 }
1925
26 Prop("focusRequest") { (view: T3TerminalView, focusRequest: Double) in
27 view.focusRequest = focusRequest
28 }
29
20 Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in30 Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in
21 view.appearanceScheme = appearanceScheme31 view.appearanceScheme = appearanceScheme
22 }32 }
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+128
...@@ -22,13 +22,129 @@ private enum GhosttyRuntime {...@@ -22,13 +22,129 @@ private enum GhosttyRuntime {
22 }22 }
23}23}
2424
25/// Encodes hardware-keyboard combos that UITextField never surfaces through its
26/// text-editing delegate (control combos, Escape, Tab, arrow keys) into the byte
27/// sequences a terminal expects.
28///
29/// Capture uses UIKeyCommand with `wantsPriorityOverSystemBehavior` rather than
30/// `pressesBegan`: while a text field is first responder, iPadOS routes hardware key
31/// events through the text-input system, which can consume presses before they reach
32/// responder press callbacks. Registered key commands are matched deterministically
33/// before that happens.
34private enum TerminalHardwareKeyEncoder {
35 /// Characters that produce a control byte when combined with Ctrl.
36 private static let controlInputs = "abcdefghijklmnopqrstuvwxyz@[\\]^_-? "
37
38 static func makeKeyCommands(action: Selector) -> [UIKeyCommand] {
39 var commands: [UIKeyCommand] = []
40
41 let specialInputs = [
42 UIKeyCommand.inputEscape,
43 UIKeyCommand.inputUpArrow,
44 UIKeyCommand.inputDownArrow,
45 UIKeyCommand.inputLeftArrow,
46 UIKeyCommand.inputRightArrow,
47 "\t",
48 ]
49 for input in specialInputs {
50 commands.append(makeCommand(input: input, modifierFlags: [], action: action))
51 }
52 commands.append(makeCommand(input: "\t", modifierFlags: .shift, action: action))
53
54 for character in controlInputs {
55 commands.append(makeCommand(input: String(character), modifierFlags: .control, action: action))
56 commands.append(
57 makeCommand(input: String(character), modifierFlags: [.control, .shift], action: action)
58 )
59 }
60
61 return commands
62 }
63
64 private static func makeCommand(
65 input: String,
66 modifierFlags: UIKeyModifierFlags,
67 action: Selector
68 ) -> UIKeyCommand {
69 let command = UIKeyCommand(input: input, modifierFlags: modifierFlags, action: action)
70 command.wantsPriorityOverSystemBehavior = true
71 return command
72 }
73
74 static func sequence(input: String, modifiers: UIKeyModifierFlags) -> String? {
75 switch input {
76 case UIKeyCommand.inputEscape:
77 return "\u{1B}"
78 case UIKeyCommand.inputUpArrow:
79 return "\u{1B}[A"
80 case UIKeyCommand.inputDownArrow:
81 return "\u{1B}[B"
82 case UIKeyCommand.inputRightArrow:
83 return "\u{1B}[C"
84 case UIKeyCommand.inputLeftArrow:
85 return "\u{1B}[D"
86 case "\t":
87 return modifiers.contains(.shift) ? "\u{1B}[Z" : "\t"
88 default:
89 break
90 }
91
92 guard modifiers.contains(.control) else { return nil }
93 guard let scalar = input.lowercased().unicodeScalars.first else { return nil }
94 return controlSequence(for: scalar)
95 }
96
97 private static func controlSequence(for scalar: Unicode.Scalar) -> String? {
98 switch scalar {
99 case "a"..."z":
100 // Ctrl+A..Z -> 0x01..0x1A (Ctrl+C = ETX, Ctrl+Z = SUB, ...).
101 return UnicodeScalar(scalar.value - 96).map(String.init)
102 case " ", "@":
103 return "\u{00}"
104 case "[":
105 return "\u{1B}"
106 case "\\":
107 return "\u{1C}"
108 case "]":
109 return "\u{1D}"
110 case "^":
111 return "\u{1E}"
112 case "_", "-":
113 return "\u{1F}"
114 case "?":
115 return "\u{7F}"
116 default:
117 return nil
118 }
119 }
120}
121
25private final class TerminalInputField: UITextField {122private final class TerminalInputField: UITextField {
26 var onDeleteBackward: (() -> Void)?123 var onDeleteBackward: (() -> Void)?
124 var onInsert: ((String) -> Void)?
125
126 private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands(
127 action: #selector(handleHardwareKeyCommand(_:))
128 )
129
130 override var keyCommands: [UIKeyCommand]? {
131 Self.hardwareKeyCommands
132 }
27133
28 override func deleteBackward() {134 override func deleteBackward() {
29 onDeleteBackward?()135 onDeleteBackward?()
30 super.deleteBackward()136 super.deleteBackward()
31 }137 }
138
139 @objc
140 private func handleHardwareKeyCommand(_ command: UIKeyCommand) {
141 guard let input = command.input else { return }
142 guard let sequence = TerminalHardwareKeyEncoder.sequence(
143 input: input,
144 modifiers: command.modifierFlags
145 ) else { return }
146 onInsert?(sequence)
147 }
32}148}
33149
34private enum TerminalAppearanceScheme: String {150private enum TerminalAppearanceScheme: String {
...@@ -108,6 +224,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {...@@ -108,6 +224,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
108 }224 }
109 }225 }
110226
227 var focusRequest: Double = 0 {
228 didSet {
229 guard oldValue != focusRequest else { return }
230 DispatchQueue.main.async { [weak self] in
231 self?.requestKeyboardFocus()
232 }
233 }
234 }
235
111 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {236 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
112 didSet {237 didSet {
113 guard oldValue != appearanceScheme else { return }238 guard oldValue != appearanceScheme else { return }
...@@ -167,6 +292,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {...@@ -167,6 +292,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
167 inputField.onDeleteBackward = { [weak self] in292 inputField.onDeleteBackward = { [weak self] in
168 self?.emitInput("\u{7F}")293 self?.emitInput("\u{7F}")
169 }294 }
295 inputField.onInsert = { [weak self] data in
296 self?.emitInput(data)
297 }
170298
171 focusTapGesture.addTarget(self, action: #selector(handleViewportTap))299 focusTapGesture.addTarget(self, action: #selector(handleViewportTap))
172 terminalViewport.addGestureRecognizer(focusTapGesture)300 terminalViewport.addGestureRecognizer(focusTapGesture)
apps/mobile/package.json+23-20
...@@ -5,7 +5,7 @@...@@ -5,7 +5,7 @@
5 "main": "index.ts",5 "main": "index.ts",
6 "scripts": {6 "scripts": {
7 "dev": "expo start --clear",7 "dev": "expo start --clear",
8 "dev:client": "APP_VARIANT=development expo start --dev-client --clear",8 "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear",
9 "start": "expo start",9 "start": "expo start",
10 "start:dev": "APP_VARIANT=development expo start",10 "start:dev": "APP_VARIANT=development expo start",
11 "start:preview": "APP_VARIANT=preview expo start",11 "start:preview": "APP_VARIANT=preview expo start",
...@@ -43,12 +43,16 @@...@@ -43,12 +43,16 @@
43 "@clerk/expo": "catalog:",43 "@clerk/expo": "catalog:",
44 "@effect/atom-react": "catalog:",44 "@effect/atom-react": "catalog:",
45 "@expo-google-fonts/dm-sans": "^0.4.2",45 "@expo-google-fonts/dm-sans": "^0.4.2",
46 "@expo/ui": "~56.0.8",46 "@expo/metro-runtime": "~56.0.15",
47 "@expo/ui": "~56.0.18",
47 "@legendapp/list": "3.2.0",48 "@legendapp/list": "3.2.0",
48 "@noble/curves": "catalog:",49 "@noble/curves": "catalog:",
49 "@noble/hashes": "catalog:",50 "@noble/hashes": "catalog:",
50 "@pierre/diffs": "catalog:",51 "@pierre/diffs": "catalog:",
51 "@react-native-menu/menu": "^2.0.0",52 "@react-native-menu/menu": "^2.0.0",
53 "@react-navigation/elements": "2.9.26",
54 "@react-navigation/native": "7.3.4",
55 "@react-navigation/native-stack": "7.17.6",
52 "@shikijs/core": "4.2.0",56 "@shikijs/core": "4.2.0",
53 "@shikijs/engine-javascript": "4.2.0",57 "@shikijs/engine-javascript": "4.2.0",
54 "@shikijs/langs": "4.2.0",58 "@shikijs/langs": "4.2.0",
...@@ -62,38 +66,37 @@...@@ -62,38 +66,37 @@
62 "clsx": "^2.1.1",66 "clsx": "^2.1.1",
63 "diff": "8.0.3",67 "diff": "8.0.3",
64 "effect": "catalog:",68 "effect": "catalog:",
65 "expo": "^56.0.0",69 "expo": "~56.0.12",
66 "expo-asset": "~56.0.15",70 "expo-asset": "~56.0.17",
67 "expo-auth-session": "~56.0.12",71 "expo-auth-session": "~56.0.14",
68 "expo-build-properties": "~56.0.15",72 "expo-build-properties": "~56.0.19",
69 "expo-camera": "~56.0.7",73 "expo-camera": "~56.0.8",
70 "expo-clipboard": "~56.0.3",74 "expo-clipboard": "~56.0.4",
71 "expo-constants": "~56.0.16",75 "expo-constants": "~56.0.18",
72 "expo-crypto": "~56.0.4",76 "expo-crypto": "~56.0.4",
73 "expo-dev-client": "~56.0.16",77 "expo-dev-client": "~56.0.20",
74 "expo-file-system": "~56.0.7",78 "expo-file-system": "~56.0.8",
75 "expo-font": "~56.0.5",79 "expo-font": "~56.0.7",
76 "expo-glass-effect": "~56.0.4",80 "expo-glass-effect": "~56.0.4",
77 "expo-haptics": "~56.0.3",81 "expo-haptics": "~56.0.3",
78 "expo-image-picker": "~56.0.14",82 "expo-image-picker": "~56.0.18",
79 "expo-linking": "~56.0.12",83 "expo-linking": "~56.0.14",
80 "expo-network": "~56.0.5",84 "expo-network": "~56.0.5",
81 "expo-notifications": "~56.0.14",85 "expo-notifications": "~56.0.18",
82 "expo-paste-input": "^0.1.15",86 "expo-paste-input": "^0.1.15",
83 "expo-router": "~56.2.7",
84 "expo-secure-store": "~56.0.4",87 "expo-secure-store": "~56.0.4",
85 "expo-splash-screen": "~56.0.10",88 "expo-splash-screen": "~56.0.10",
86 "expo-symbols": "~56.0.5",89 "expo-symbols": "~56.0.6",
87 "expo-updates": "~56.0.17",90 "expo-updates": "~56.0.19",
88 "expo-web-browser": "~56.0.5",91 "expo-web-browser": "~56.0.5",
89 "expo-widgets": "~56.0.15",92 "expo-widgets": "~56.0.19",
90 "punycode": "^2.3.1",93 "punycode": "^2.3.1",
91 "react": "19.2.3",94 "react": "19.2.3",
92 "react-dom": "19.2.3",95 "react-dom": "19.2.3",
93 "react-native": "0.85.3",96 "react-native": "0.85.3",
94 "react-native-gesture-handler": "~2.31.1",97 "react-native-gesture-handler": "~2.31.1",
95 "react-native-image-viewing": "^0.2.2",98 "react-native-image-viewing": "^0.2.2",
96 "react-native-keyboard-controller": "1.21.7",99 "react-native-keyboard-controller": "1.21.6",
97 "react-native-nitro-markdown": "^0.5.0",100 "react-native-nitro-markdown": "^0.5.0",
98 "react-native-nitro-modules": "0.35.9",101 "react-native-nitro-modules": "0.35.9",
99 "react-native-reanimated": "4.3.1",102 "react-native-reanimated": "4.3.1",
apps/mobile/plugins/withIosCocoaPodsUuidCache.cjs created+52
...@@ -0,0 +1,52 @@
1const fs = require("node:fs");
2const path = require("node:path");
3
4const { withDangerousMod } = require("expo/config-plugins");
5
6const MARKER = "# t3code: repair cached CocoaPods UUID allocation before SPM integration";
7const UUID_REPAIR = `${MARKER}
8 pods_project = installer.pods_project
9 existing_uuids = pods_project.objects.map(&:uuid)
10 uuid_prefix = pods_project.instance_variable_get(:@uuid_prefix)[0, 6]
11 sequential_uuid = /\\A#{Regexp.escape(uuid_prefix)}([0-9A-F]{7})0\\z/
12 highest_index = existing_uuids.filter_map do |uuid|
13 match = sequential_uuid.match(uuid)
14 match && match[1].to_i(16)
15 end.max || -1
16
17 # Pod::Project generates sequential UUIDs without collision checks because CocoaPods
18 # normally creates the project in this process. EAS can restore Pods.xcodeproj from
19 # cache, leaving the allocator behind the loaded objects. React Native's SPM support
20 # then reuses an existing UUID and silently corrupts the project graph.
21 next_index = highest_index + 1
22 pods_project.instance_variable_set(:@generated_uuids, Array.new(next_index))
23 pods_project.instance_variable_set(:@available_uuids, [])
24 pods_project.generate_available_uuid_list(1_000)
25 Pod::UI.puts "T3Code: reset CocoaPods UUID allocator at #{next_index} (#{existing_uuids.length} existing objects)"
26`;
27
28module.exports = function withIosCocoaPodsUuidCache(config) {
29 return withDangerousMod(config, [
30 "ios",
31 (nextConfig) => {
32 const podfilePath = path.join(nextConfig.modRequest.platformProjectRoot, "Podfile");
33 const podfile = fs.readFileSync(podfilePath, "utf8");
34
35 if (podfile.includes(MARKER)) {
36 return nextConfig;
37 }
38
39 const postInstallStart = "post_install do |installer|\n";
40 if (!podfile.includes(postInstallStart)) {
41 throw new Error("Unable to repair CocoaPods UUID allocation: post_install is missing.");
42 }
43
44 fs.writeFileSync(
45 podfilePath,
46 podfile.replace(postInstallStart, `${postInstallStart}${UUID_REPAIR}`),
47 "utf8",
48 );
49 return nextConfig;
50 },
51 ]);
52};
apps/mobile/plugins/withIosSceneLifecycle.cjs created+99
...@@ -0,0 +1,99 @@
1const { withAppDelegate, withInfoPlist } = require("expo/config-plugins");
2
3const SCENE_DELEGATE = `
4
5class SceneDelegate: UIResponder, UIWindowSceneDelegate {
6 var window: UIWindow?
7
8 func scene(
9 _ scene: UIScene,
10 willConnectTo session: UISceneSession,
11 options connectionOptions: UIScene.ConnectionOptions
12 ) {
13 guard
14 let windowScene = scene as? UIWindowScene,
15 let appDelegate = UIApplication.shared.delegate as? AppDelegate
16 else {
17 return
18 }
19
20 let appWindow: UIWindow
21 if let existingWindow = appDelegate.window {
22 appWindow = existingWindow
23 } else {
24 appWindow = UIWindow(windowScene: windowScene)
25 appDelegate.window = appWindow
26 appDelegate.reactNativeFactory?.startReactNative(
27 withModuleName: "main",
28 in: appWindow,
29 launchOptions: nil)
30 }
31
32 window = appWindow
33 appWindow.windowScene = windowScene
34 appWindow.makeKeyAndVisible()
35
36 if let url = connectionOptions.urlContexts.first?.url {
37 _ = appDelegate.application(UIApplication.shared, open: url, options: [:])
38 }
39
40 if let userActivity = connectionOptions.userActivities.first {
41 _ = appDelegate.application(
42 UIApplication.shared,
43 continue: userActivity,
44 restorationHandler: { _ in })
45 }
46 }
47
48 func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
49 guard
50 let url = URLContexts.first?.url,
51 let appDelegate = UIApplication.shared.delegate as? AppDelegate
52 else {
53 return
54 }
55
56 _ = appDelegate.application(UIApplication.shared, open: url, options: [:])
57 }
58
59 func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
60 guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
61 return
62 }
63
64 _ = appDelegate.application(
65 UIApplication.shared,
66 continue: userActivity,
67 restorationHandler: { _ in })
68 }
69}`;
70
71module.exports = function withIosSceneLifecycle(config) {
72 config = withInfoPlist(config, (nextConfig) => {
73 nextConfig.modResults.UIApplicationSceneManifest = {
74 UIApplicationSupportsMultipleScenes: false,
75 UISceneConfigurations: {
76 UIWindowSceneSessionRoleApplication: [
77 {
78 UISceneConfigurationName: "Default Configuration",
79 UISceneDelegateClassName: "$(PRODUCT_MODULE_NAME).SceneDelegate",
80 },
81 ],
82 },
83 };
84
85 return nextConfig;
86 });
87
88 return withAppDelegate(config, (nextConfig) => {
89 if (nextConfig.modResults.language !== "swift") {
90 throw new Error("The iOS scene lifecycle plugin requires a Swift AppDelegate.");
91 }
92
93 if (!nextConfig.modResults.contents.includes("class SceneDelegate:")) {
94 nextConfig.modResults.contents += SCENE_DELEGATE;
95 }
96
97 return nextConfig;
98 });
99};
apps/mobile/src/App.tsx created+72
...@@ -0,0 +1,72 @@
1import {
2 DMSans_400Regular,
3 DMSans_500Medium,
4 DMSans_700Bold,
5 useFonts,
6} from "@expo-google-fonts/dm-sans";
7import * as Linking from "expo-linking";
8import * as SplashScreen from "expo-splash-screen";
9import { StatusBar, useColorScheme } from "react-native";
10import { GestureHandlerRootView } from "react-native-gesture-handler";
11import { KeyboardProvider } from "react-native-keyboard-controller";
12import { SafeAreaProvider } from "react-native-safe-area-context";
13import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native";
14
15import { RegistryContext } from "@effect/atom-react";
16import { useEffect } from "react";
17import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
18import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider";
19import { RootStack } from "./Stack";
20import { appAtomRegistry } from "./state/atom-registry";
21import { useThemeColor } from "./lib/useThemeColor";
22
23import "../global.css";
24
25const appLinking = {
26 prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],
27};
28
29const Navigation = createStaticNavigation(RootStack);
30
31export default function App() {
32 const [fontsLoaded] = useFonts({
33 DMSans_400Regular,
34 DMSans_500Medium,
35 DMSans_700Bold,
36 });
37 const colorScheme = useColorScheme();
38 const statusBarBg = useThemeColor("--color-status-bar");
39
40 useEffect(() => {
41 if (fontsLoaded) SplashScreen.hide();
42 }, [fontsLoaded]);
43
44 return (
45 <RegistryContext.Provider value={appAtomRegistry}>
46 <CloudAuthProvider>
47 <AppearancePreferencesProvider>
48 <GestureHandlerRootView style={{ flex: 1 }}>
49 <KeyboardProvider statusBarTranslucent>
50 <SafeAreaProvider>
51 <StatusBar
52 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
53 backgroundColor={statusBarBg}
54 translucent
55 />
56 {/* The navigation theme drives the NATIVE header appearance: native-stack
57 forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
58 this, React Navigation defaults to its light theme and every native
59 header (glass buttons, title, materials) is forced light even when
60 the system is in dark mode. */}
61 <Navigation
62 linking={appLinking}
63 theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
64 />
65 </SafeAreaProvider>
66 </KeyboardProvider>
67 </GestureHandlerRootView>
68 </AppearancePreferencesProvider>
69 </CloudAuthProvider>
70 </RegistryContext.Provider>
71 );
72}
apps/mobile/src/Stack.tsx created+463
...@@ -0,0 +1,463 @@
1import {
2 createPathConfigForStaticNavigation,
3 getPathFromState,
4 NavigationState,
5 StackActions,
6 useNavigation,
7} from "@react-navigation/native";
8import {
9 createNativeStackNavigator,
10 createNativeStackScreen,
11 type NativeStackNavigationOptions,
12} from "@react-navigation/native-stack";
13import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native";
14import { useResolveClassNames } from "uniwind";
15
16import { AppText as Text } from "./components/AppText";
17import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
18import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
19import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
20import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
21import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
22import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
23import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
24import { ReviewSheet } from "./features/review/ReviewSheet";
25import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
26import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
27import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
28import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
29import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
30import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
31import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
32import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
33import { HomeRouteScreen } from "./features/home/HomeRouteScreen";
34import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute";
35import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
36import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
37import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";
38import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen";
39import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
40import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
41import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
42import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
43import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
44import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
45import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen";
46import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
47import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
48
49const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
50
51// Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header
52// background stay STATIC config while still adapting to appearance changes.
53const SHEET_BACKGROUND_COLOR =
54 Platform.OS === "ios"
55 ? DynamicColorIOS({ light: "rgba(242, 242, 247, 0.98)", dark: "rgba(14, 14, 14, 0.98)" })
56 : undefined;
57
58type AppScreenOptions = NativeStackNavigationOptions & {
59 readonly unstable_navigationItemStyle?: "editor";
60};
61
62// Shared header presets. Screens only override genuinely dynamic values (titles,
63// subtitles, toolbar items, search callbacks) via NativeStackScreenOptions.
64//
65// GLASS: transparent header over the screen's primary scroll view, with the iOS 26
66// scroll-edge blur sampling the content (Home, Thread, Files tree, settings sheet).
67const GLASS_HEADER_OPTIONS: AppScreenOptions = {
68 headerBackButtonDisplayMode: "minimal",
69 headerBackTitle: "",
70 headerLargeTitle: false,
71 headerShadowVisible: false,
72 headerShown: true,
73 headerStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined,
74 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
75 headerTransparent: Platform.OS === "ios",
76 scrollEdgeEffects: Platform.OS === "ios" ? HEADER_SCROLL_EDGE_EFFECTS : undefined,
77 unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
78};
79
80// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally
81// (file viewer, terminal, review) — there is nothing for glass to sample there.
82const SOLID_HEADER_OPTIONS: AppScreenOptions = {
83 headerBackButtonDisplayMode: "minimal",
84 headerBackTitle: "",
85 headerLargeTitle: false,
86 headerShadowVisible: false,
87 headerShown: true,
88 headerStyle:
89 SHEET_BACKGROUND_COLOR !== undefined
90 ? // native-stack types this as `string`, but the native side accepts any
91 // ColorValue including DynamicColorIOS.
92 { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string }
93 : undefined,
94 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
95 headerTransparent: false,
96 unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
97};
98
99// Solid header variant for screens inside sheets (centered title, no editor style).
100const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = {
101 ...SOLID_HEADER_OPTIONS,
102 unstable_navigationItemStyle: undefined,
103};
104
105const SettingsSheetStack = createNativeStackNavigator({
106 initialRouteName: "Settings",
107 screenOptions: {
108 ...GLASS_HEADER_OPTIONS,
109 // Sheets read better with the iOS-default centered title (no editor style).
110 unstable_navigationItemStyle: undefined,
111 },
112 screens: {
113 Settings: createNativeStackScreen({
114 screen: SettingsRouteScreen,
115 linking: "",
116 options: {
117 title: "Settings",
118 },
119 }),
120 SettingsEnvironments: createNativeStackScreen({
121 screen: SettingsEnvironmentsRouteScreen,
122 linking: "environments",
123 options: {
124 title: "Environments",
125 },
126 }),
127 SettingsEnvironmentNew: createNativeStackScreen({
128 screen: ConnectionsNewRouteScreen,
129 linking: "environment-new",
130 options: {
131 title: "Add Environment",
132 },
133 }),
134 SettingsArchive: createNativeStackScreen({
135 screen: ArchivedThreadsRouteScreen,
136 linking: "archive",
137 options: {
138 title: "Archived Threads",
139 },
140 }),
141 SettingsAppearance: createNativeStackScreen({
142 screen: SettingsAppearanceRouteScreen,
143 linking: "appearance",
144 options: {
145 title: "Appearance",
146 },
147 }),
148 SettingsAuth: createNativeStackScreen({
149 screen: SettingsAuthRouteScreen,
150 linking: "auth",
151 options: {
152 title: "Sign in",
153 },
154 }),
155 SettingsWaitlist: createNativeStackScreen({
156 screen: SettingsWaitlistRouteScreen,
157 linking: "waitlist",
158 options: {
159 title: "Join the waitlist",
160 },
161 }),
162 },
163});
164
165// Thread routes live FLAT in the root stack (not in a nested navigator). A nested
166// stack means a second UINavigationController with its own UINavigationBar, which
167// breaks iOS 26's shared-header morphing between Home and Thread (each pair inside
168// one bar morphs; across two bars the whole screen slides). Flat linking paths keep
169// the same deep-link URLs the nested config produced.
170const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";
171
172// New-task / add-project flow: nested navigator inside the formSheet (Settings-sheet
173// pattern — a plain formSheet screen cannot render a stack header; the header and
174// in-sheet pushes come from this nested stack).
175const NewTaskSheetStack = createNativeStackNavigator({
176 initialRouteName: "NewTask",
177 screenOptions: {
178 ...GLASS_HEADER_OPTIONS,
179 // Sheets read better with the iOS-default centered title (no editor style).
180 unstable_navigationItemStyle: undefined,
181 },
182 screens: {
183 NewTask: createNativeStackScreen({
184 screen: NewTaskRouteScreen,
185 linking: "",
186 options: {
187 title: "Choose project",
188 },
189 }),
190 NewTaskDraft: createNativeStackScreen({
191 screen: NewTaskDraftRouteScreen,
192 linking: "draft",
193 // The draft composer has no scroll view for glass to sample; a solid
194 // header also lays the content out below the bar (no manual inset).
195 options: SHEET_SOLID_HEADER_OPTIONS,
196 }),
197 AddProject: createNativeStackScreen({
198 screen: AddProjectSourceRoute,
199 linking: "add-project",
200 options: {
201 title: "Add Project",
202 },
203 }),
204 AddProjectRepository: createNativeStackScreen({
205 screen: AddProjectRepositoryRoute,
206 linking: "add-project/repository",
207 }),
208 AddProjectDestination: createNativeStackScreen({
209 screen: AddProjectDestinationRoute,
210 linking: "add-project/destination",
211 }),
212 AddProjectLocal: createNativeStackScreen({
213 screen: AddProjectLocalRoute,
214 linking: "add-project/local",
215 }),
216 },
217});
218
219// Routes presented as sheets/overlays ON TOP of the workspace. They must not
220// influence the adaptive workspace layout: opening Settings over Home should
221// not flip the sidebar in or change the active thread.
222const WORKSPACE_OVERLAY_ROUTES = new Set([
223 "Connections",
224 "ConnectionsNew",
225 "GitBranches",
226 "GitCommit",
227 "GitConfirm",
228 "GitOverview",
229 "NewTaskSheet",
230 "SettingsSheet",
231 "ThreadReviewComment",
232]);
233
234/**
235 * Pathname of the topmost NON-overlay route — the screen the workspace is
236 * actually "on", regardless of any sheets floating above it.
237 */
238function workspacePathFromState(state: NavigationState): string {
239 const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name));
240 const effectiveState =
241 routes.length > 0 && routes.length !== state.routes.length
242 ? ({ ...state, routes, index: routes.length - 1 } as NavigationState)
243 : state;
244 const path = getPathFromState(effectiveState, navigationPathConfig);
245 return path.startsWith("/") ? path : `/${path}`;
246}
247
248function RootStackLayout(props: {
249 readonly children: React.ReactNode;
250 readonly state: NavigationState;
251}) {
252 useAgentNotificationNavigation();
253 useThreadOutboxDrain();
254 // Full pathname (sheets included) for keyboard-command scoping; the
255 // workspace layout only reacts to the underlying non-overlay route.
256 const path = getPathFromState(props.state, navigationPathConfig);
257 const pathname = path.startsWith("/") ? path : `/${path}`;
258 const workspacePathname = workspacePathFromState(props.state);
259
260 return (
261 <HardwareKeyboardCommandProvider pathname={pathname}>
262 <ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
263 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
264 {props.children}
265 </AdaptiveWorkspaceLayout>
266 </ClerkSettingsSheetDetentProvider>
267 </HardwareKeyboardCommandProvider>
268 );
269}
270
271function NotFoundScreen() {
272 const navigation = useNavigation();
273 const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
274 const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
275 const returnHomeButtonStyle = StyleSheet.flatten([
276 {
277 borderRadius: 999,
278 paddingHorizontal: 20,
279 paddingVertical: 14,
280 },
281 primaryBgStyle,
282 ]);
283
284 return (
285 <ScrollView
286 contentInsetAdjustmentBehavior="automatic"
287 contentContainerStyle={{
288 flexGrow: 1,
289 alignItems: "center",
290 justifyContent: "center",
291 gap: 16,
292 paddingHorizontal: 24,
293 paddingVertical: 32,
294 }}
295 style={[{ flex: 1 }, screenBgStyle]}
296 >
297 <Text className="text-3xl font-t3-bold text-foreground" selectable>
298 Route not found
299 </Text>
300 <Pressable
301 style={returnHomeButtonStyle}
302 onPress={() => navigation.dispatch(StackActions.replace("Home"))}
303 >
304 <Text className="text-base font-t3-bold text-primary-foreground">Return home</Text>
305 </Pressable>
306 </ScrollView>
307 );
308}
309
310export const RootStack = createNativeStackNavigator({
311 initialRouteName: "Home",
312 layout: RootStackLayout,
313 screenOptions: {
314 headerShown: false,
315 },
316 screens: {
317 Home: createNativeStackScreen({
318 screen: HomeRouteScreen,
319 linking: "",
320 options: {
321 ...GLASS_HEADER_OPTIONS,
322 contentStyle: { backgroundColor: "transparent" },
323 headerBackVisible: false,
324 title: "Threads",
325 },
326 }),
327 Thread: createNativeStackScreen({
328 screen: ThreadRouteScreen,
329 linking: THREAD_LINKING_PREFIX,
330 options: GLASS_HEADER_OPTIONS,
331 }),
332 ThreadTerminal: createNativeStackScreen({
333 screen: ThreadTerminalRouteScreen,
334 linking: `${THREAD_LINKING_PREFIX}/terminal`,
335 options: SOLID_HEADER_OPTIONS,
336 }),
337 ThreadReview: createNativeStackScreen({
338 screen: ReviewSheet,
339 linking: `${THREAD_LINKING_PREFIX}/review`,
340 options: SOLID_HEADER_OPTIONS,
341 }),
342 ThreadReviewComment: createNativeStackScreen({
343 screen: ReviewCommentComposerSheet,
344 linking: `${THREAD_LINKING_PREFIX}/review-comment`,
345 options: {
346 presentation: "formSheet",
347 sheetAllowedDetents: [0.55, 0.92],
348 sheetGrabberVisible: true,
349 },
350 }),
351 ThreadFiles: createNativeStackScreen({
352 screen: ThreadFilesTreeScreen,
353 linking: `${THREAD_LINKING_PREFIX}/files`,
354 options: {
355 ...GLASS_HEADER_OPTIONS,
356 contentStyle:
357 SHEET_BACKGROUND_COLOR !== undefined
358 ? { backgroundColor: SHEET_BACKGROUND_COLOR }
359 : undefined,
360 title: "Files",
361 },
362 }),
363 ThreadFile: createNativeStackScreen({
364 screen: ThreadFileScreen,
365 linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
366 options: SOLID_HEADER_OPTIONS,
367 }),
368 GitOverview: createNativeStackScreen({
369 screen: GitOverviewSheet,
370 linking: `${THREAD_LINKING_PREFIX}/git`,
371 options: {
372 presentation: "formSheet",
373 sheetAllowedDetents: [0.55, 0.92],
374 sheetGrabberVisible: true,
375 },
376 }),
377 GitCommit: createNativeStackScreen({
378 screen: GitCommitSheet,
379 linking: `${THREAD_LINKING_PREFIX}/git/commit`,
380 options: {
381 presentation: "formSheet",
382 sheetAllowedDetents: [0.55, 0.92],
383 sheetGrabberVisible: true,
384 },
385 }),
386 GitBranches: createNativeStackScreen({
387 screen: GitBranchesSheet,
388 linking: `${THREAD_LINKING_PREFIX}/git/branches`,
389 options: {
390 presentation: "formSheet",
391 sheetAllowedDetents: [0.55, 0.92],
392 sheetGrabberVisible: true,
393 },
394 }),
395 GitConfirm: createNativeStackScreen({
396 screen: GitConfirmSheet,
397 linking: `${THREAD_LINKING_PREFIX}/git-confirm`,
398 options: {
399 presentation: "formSheet",
400 sheetAllowedDetents: [0.45, 0.7],
401 sheetGrabberVisible: true,
402 },
403 }),
404 SettingsSheet: createNativeStackScreen({
405 screen: SettingsSheetStack,
406 linking: "settings",
407 options: {
408 gestureEnabled: true,
409 headerShown: false,
410 presentation: "formSheet",
411 sheetAllowedDetents: [0.7, 0.92],
412 sheetGrabberVisible: true,
413 },
414 }),
415 Connections: createNativeStackScreen({
416 screen: ConnectionsRouteScreen,
417 linking: "connections",
418 options: {
419 title: "Environments",
420 presentation: "formSheet",
421 sheetAllowedDetents: [0.55, 0.7],
422 sheetGrabberVisible: true,
423 },
424 }),
425 ConnectionsNew: createNativeStackScreen({
426 screen: ConnectionsNewRouteScreen,
427 linking: "connections/new",
428 options: {
429 presentation: "formSheet",
430 sheetAllowedDetents: [0.55, 0.7],
431 sheetGrabberVisible: true,
432 },
433 }),
434 NewTaskSheet: createNativeStackScreen({
435 screen: NewTaskSheetStack,
436 linking: "new",
437 // The whole new-task flow (choose project → draft → add project) shares
438 // draft state via NewTaskFlowProvider. The expo-router era mounted it in
439 // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
440 layout: ({ children }) => <NewTaskFlowProvider>{children}</NewTaskFlowProvider>,
441 options: {
442 gestureEnabled: true,
443 headerShown: false,
444 presentation: "formSheet",
445 sheetAllowedDetents: [0.92],
446 sheetGrabberVisible: true,
447 },
448 }),
449 NotFound: createNativeStackScreen({
450 screen: NotFoundScreen,
451 linking: "*",
452 }),
453 },
454});
455type RootStackType = typeof RootStack;
456
457const navigationPathConfig = {
458 screens: createPathConfigForStaticNavigation(RootStack) ?? {},
459};
460
461declare module "@react-navigation/native" {
462 interface RootNavigator extends RootStackType {}
463}
apps/mobile/src/app/+not-found.tsx deleted-43
...@@ -1,43 +0,0 @@
1import { Link } from "expo-router";
2import { Pressable, ScrollView, StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5import { AppText as Text } from "../components/AppText";
6
7export default function NotFoundRoute() {
8 const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
9 const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
10
11 return (
12 <ScrollView
13 contentInsetAdjustmentBehavior="automatic"
14 contentContainerStyle={{
15 flexGrow: 1,
16 alignItems: "center",
17 justifyContent: "center",
18 gap: 16,
19 paddingHorizontal: 24,
20 paddingVertical: 32,
21 }}
22 style={[{ flex: 1 }, screenBgStyle]}
23 >
24 <Text className="text-3xl font-t3-bold text-foreground" selectable>
25 Route not found
26 </Text>
27 <Link href="/" asChild>
28 <Pressable
29 style={[
30 {
31 borderRadius: 999,
32 paddingHorizontal: 20,
33 paddingVertical: 14,
34 },
35 primaryBgStyle,
36 ]}
37 >
38 <Text className="text-base font-t3-bold text-primary-foreground">Return home</Text>
39 </Pressable>
40 </Link>
41 </ScrollView>
42 );
43}
apps/mobile/src/app/_layout.tsx deleted-149
...@@ -1,149 +0,0 @@
1import "../../global.css";
2import {
3 DMSans_400Regular,
4 DMSans_500Medium,
5 DMSans_700Bold,
6 useFonts,
7} from "@expo-google-fonts/dm-sans";
8import { usePathname } from "expo-router";
9import Stack from "expo-router/stack";
10import { useCallback } from "react";
11import { StatusBar, useColorScheme } from "react-native";
12import { GestureHandlerRootView } from "react-native-gesture-handler";
13import { KeyboardProvider } from "react-native-keyboard-controller";
14import { SafeAreaProvider } from "react-native-safe-area-context";
15import { useResolveClassNames } from "uniwind";
16
17import { LoadingScreen } from "../components/LoadingScreen";
18
19import { useWorkspaceState } from "../state/workspace";
20import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain";
21import { RegistryContext } from "@effect/atom-react";
22import { appAtomRegistry } from "../state/atom-registry";
23import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
24import {
25 ClerkSettingsSheetDetentProvider,
26 useClerkSettingsSheetDetent,
27} from "../features/cloud/ClerkSettingsSheetDetent";
28import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";
29import { useThemeColor } from "../lib/useThemeColor";
30
31function AppNavigator() {
32 const pathname = usePathname();
33 const expandedSettingsRouteIsActive =
34 pathname === "/settings/archive" || pathname === "/settings/auth";
35
36 return (
37 <ClerkSettingsSheetDetentProvider initiallyExpanded={expandedSettingsRouteIsActive}>
38 <AppNavigatorContent />
39 </ClerkSettingsSheetDetentProvider>
40 );
41}
42
43function AppNavigatorContent() {
44 const { state } = useWorkspaceState();
45 const { collapse, isExpanded } = useClerkSettingsSheetDetent();
46 const colorScheme = useColorScheme();
47 const statusBarBg = useThemeColor("--color-status-bar");
48 const sheetStyle = useResolveClassNames("bg-sheet");
49 useAgentNotificationNavigation();
50 useThreadOutboxDrain();
51
52 const handleSettingsTransitionEnd = useCallback(
53 (event: { data: { closing: boolean } }) => {
54 if (event.data.closing) {
55 collapse();
56 }
57 },
58 [collapse],
59 );
60
61 const newTaskScreenOptions = {
62 contentStyle: sheetStyle,
63 gestureEnabled: true,
64 headerShown: false,
65 presentation: "formSheet" as const,
66 sheetAllowedDetents: [0.92],
67 sheetGrabberVisible: true,
68 };
69
70 const connectionSheetScreenOptions = {
71 contentStyle: sheetStyle,
72 gestureEnabled: true,
73 headerShown: false,
74 presentation: "formSheet" as const,
75 sheetAllowedDetents: [0.55, 0.7],
76 sheetGrabberVisible: true,
77 };
78
79 const settingsSheetScreenOptions = {
80 ...connectionSheetScreenOptions,
81 sheetAllowedDetents: isExpanded ? [0.92] : [0.7],
82 };
83
84 if (state.isLoadingConnections) {
85 return <LoadingScreen message="Loading remote workspace…" />;
86 }
87
88 return (
89 <>
90 <StatusBar
91 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
92 backgroundColor={String(statusBarBg)}
93 translucent
94 />
95 <Stack screenOptions={{ headerShown: false }}>
96 <Stack.Screen
97 name="index"
98 options={{
99 contentStyle: { backgroundColor: "transparent" },
100 headerShown: true,
101 headerTransparent: true,
102 headerShadowVisible: false,
103 }}
104 />
105 <Stack.Screen
106 name="settings"
107 listeners={{ transitionEnd: handleSettingsTransitionEnd }}
108 options={settingsSheetScreenOptions}
109 />
110 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
111 <Stack.Screen name="new" options={newTaskScreenOptions} />
112 <Stack.Screen
113 name="threads/[environmentId]/[threadId]"
114 options={{
115 animation: "slide_from_right",
116 contentStyle: { backgroundColor: "transparent" },
117 gestureEnabled: true,
118 headerShown: false,
119 }}
120 />
121 </Stack>
122 </>
123 );
124}
125
126export default function RootLayout() {
127 const [fontsLoaded] = useFonts({
128 DMSans_400Regular,
129 DMSans_500Medium,
130 DMSans_700Bold,
131 });
132 return (
133 <RegistryContext.Provider value={appAtomRegistry}>
134 <CloudAuthProvider>
135 <GestureHandlerRootView style={{ flex: 1 }}>
136 <KeyboardProvider statusBarTranslucent>
137 <SafeAreaProvider>
138 {fontsLoaded ? (
139 <AppNavigator />
140 ) : (
141 <LoadingScreen message="Loading remote workspace…" />
142 )}
143 </SafeAreaProvider>
144 </KeyboardProvider>
145 </GestureHandlerRootView>
146 </CloudAuthProvider>
147 </RegistryContext.Provider>
148 );
149}
apps/mobile/src/app/connections/_layout.tsx deleted-29
...@@ -1,29 +0,0 @@
1import Stack from "expo-router/stack";
2import { useResolveClassNames } from "uniwind";
3import { useThemeColor } from "../../lib/useThemeColor";
4
5export const unstable_settings = {
6 anchor: "index",
7};
8
9export default function ConnectionsLayout() {
10 const contentStyle = useResolveClassNames("bg-sheet");
11 const connSheetBg = useThemeColor("--color-sheet");
12 const headerTint = useThemeColor("--color-foreground");
13
14 return (
15 <Stack
16 screenOptions={{
17 contentStyle,
18 headerStyle: { backgroundColor: connSheetBg },
19 headerTintColor: headerTint,
20 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
21 headerShadowVisible: false,
22 headerBackButtonDisplayMode: "minimal",
23 }}
24 >
25 <Stack.Screen name="index" options={{ animation: "none" }} />
26 <Stack.Screen name="new" options={{ animation: "slide_from_right" }} />
27 </Stack>
28 );
29}
apps/mobile/src/app/connections/index.tsx deleted-97
...@@ -1,97 +0,0 @@
1import { Stack, useRouter } from "expo-router";
2import { SymbolView } from "expo-symbols";
3import type { EnvironmentId } from "@t3tools/contracts";
4import { useCallback, useState } from "react";
5import { ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
8
9import { AppText as Text } from "../../components/AppText";
10import { cn } from "../../lib/cn";
11import { useRemoteConnections } from "../../state/use-remote-environment-registry";
12import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
13
14export default function ConnectionsRouteScreen() {
15 const {
16 connectedEnvironments,
17 onReconnectEnvironment,
18 onRemoveEnvironmentPress,
19 onUpdateEnvironment,
20 } = useRemoteConnections();
21 const router = useRouter();
22 const insets = useSafeAreaInsets();
23 const hasEnvironments = connectedEnvironments.length > 0;
24 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
25
26 const accentColor = useThemeColor("--color-icon-muted");
27
28 const handleToggle = useCallback((environmentId: EnvironmentId) => {
29 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
30 }, []);
31
32 return (
33 <View collapsable={false} className="flex-1 bg-sheet">
34 <Stack.Screen
35 options={{
36 title: "Environments",
37 }}
38 />
39 <Stack.Toolbar placement="right">
40 <Stack.Toolbar.Button
41 icon="plus"
42 onPress={() => router.push("/connections/new")}
43 separateBackground
44 />
45 </Stack.Toolbar>
46 <ScrollView
47 contentInsetAdjustmentBehavior="automatic"
48 showsVerticalScrollIndicator={false}
49 style={{ flex: 1 }}
50 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
51 contentContainerStyle={{
52 paddingHorizontal: 20,
53 paddingTop: 16,
54 }}
55 >
56 {hasEnvironments ? (
57 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
58 {connectedEnvironments.map((environment, index) => (
59 <View
60 key={environment.environmentId}
61 collapsable={false}
62 style={{
63 borderTopWidth: index === 0 ? 0 : 1,
64 }}
65 className={cn(index !== 0 && "border-border")}
66 >
67 <ConnectionEnvironmentRow
68 environment={environment}
69 expanded={expandedId === environment.environmentId}
70 onToggle={() => handleToggle(environment.environmentId)}
71 onReconnect={onReconnectEnvironment}
72 onRemove={onRemoveEnvironmentPress}
73 onUpdate={onUpdateEnvironment}
74 />
75 </View>
76 ))}
77 </View>
78 ) : (
79 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
80 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
81 <SymbolView
82 name="point.3.connected.trianglepath.dotted"
83 size={20}
84 tintColor={accentColor}
85 type="monochrome"
86 />
87 </View>
88 <Text className="text-center text-sm leading-[20px] text-foreground-muted">
89 No environments connected yet.{"\n"}Tap{" "}
90 <Text className="font-t3-bold text-foreground">+</Text> to add one.
91 </Text>
92 </View>
93 )}
94 </ScrollView>
95 </View>
96 );
97}
apps/mobile/src/app/connections/new.tsx deleted-244
...@@ -1,244 +0,0 @@
1import { CameraView, useCameraPermissions } from "expo-camera";
2import { Stack, useLocalSearchParams, useRouter } from "expo-router";
3import { AsyncResult } from "effect/unstable/reactivity";
4import { useCallback, useEffect, useState } from "react";
5import { Alert, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
8
9import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
10import { ErrorBanner } from "../../components/ErrorBanner";
11import { dismissRoute } from "../../lib/routes";
12import { ConnectionSheetButton } from "../../features/connection/ConnectionSheetButton";
13import { extractPairingUrlFromQrPayload } from "../../features/connection/pairing";
14import { useRemoteConnections } from "../../state/use-remote-environment-registry";
15import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing";
16
17export default function ConnectionsNewRouteScreen() {
18 const {
19 connectionPairingUrl,
20 onChangeConnectionPairingUrl,
21 onConnectPress,
22 pairingConnectionError,
23 } = useRemoteConnections();
24 const router = useRouter();
25 const params = useLocalSearchParams<{ mode?: string }>();
26 const insets = useSafeAreaInsets();
27 const [hostInput, setHostInput] = useState("");
28 const [codeInput, setCodeInput] = useState("");
29 const [isSubmitting, setIsSubmitting] = useState(false);
30 const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
31 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
32 const [scannerLocked, setScannerLocked] = useState(false);
33
34 const placeholderColor = useThemeColor("--color-placeholder");
35
36 const connectDisabled = isSubmitting || hostInput.trim().length === 0;
37
38 useEffect(() => {
39 const { host, code } = parsePairingUrl(connectionPairingUrl);
40 setHostInput(host);
41 setCodeInput(code);
42 }, [connectionPairingUrl]);
43
44 useEffect(() => {
45 if (pairingConnectionError) {
46 setIsSubmitting(false);
47 }
48 }, [pairingConnectionError]);
49
50 const handleHostChange = useCallback((value: string) => {
51 setHostInput(value);
52 }, []);
53
54 const handleCodeChange = useCallback((value: string) => {
55 setCodeInput(value);
56 }, []);
57
58 const openScanner = useCallback(async () => {
59 if (cameraPermission?.granted) {
60 setScannerLocked(false);
61 setShowScanner(true);
62 return;
63 }
64
65 const permission = await requestCameraPermission();
66 if (permission.granted) {
67 setScannerLocked(false);
68 setShowScanner(true);
69 return;
70 }
71
72 Alert.alert(
73 "Camera access needed",
74 "Allow camera access to scan an environment pairing QR code.",
75 );
76 }, [cameraPermission?.granted, requestCameraPermission]);
77
78 const closeScanner = useCallback(() => {
79 setShowScanner(false);
80 setScannerLocked(false);
81 }, []);
82
83 const handleQrScan = useCallback(
84 ({ data }: { readonly data: string }) => {
85 if (scannerLocked) {
86 return;
87 }
88
89 setScannerLocked(true);
90
91 try {
92 const pairingUrl = extractPairingUrlFromQrPayload(data);
93 const { host, code } = parsePairingUrl(pairingUrl);
94 setHostInput(host);
95 setCodeInput(code);
96 onChangeConnectionPairingUrl(pairingUrl);
97 setShowScanner(false);
98 } catch (error) {
99 Alert.alert(
100 "Invalid QR code",
101 error instanceof Error ? error.message : "Scanned QR code was not recognized.",
102 );
103 } finally {
104 setTimeout(() => {
105 setScannerLocked(false);
106 }, 600);
107 }
108 },
109 [onChangeConnectionPairingUrl, scannerLocked],
110 );
111
112 const handleSubmit = useCallback(async () => {
113 setIsSubmitting(true);
114
115 const pairingUrl = buildPairingUrl(hostInput, codeInput);
116 onChangeConnectionPairingUrl(pairingUrl);
117 const result = await onConnectPress(pairingUrl);
118 if (AsyncResult.isSuccess(result)) {
119 dismissRoute(router);
120 } else {
121 setIsSubmitting(false);
122 }
123 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);
124
125 return (
126 <View collapsable={false} className="flex-1 bg-sheet">
127 <Stack.Screen
128 options={{
129 title: showScanner ? "Scan QR Code" : "Add Environment",
130 }}
131 />
132 <Stack.Toolbar placement="right">
133 <Stack.Toolbar.Button
134 icon={showScanner ? "xmark" : "qrcode.viewfinder"}
135 onPress={() => {
136 if (showScanner) {
137 closeScanner();
138 } else {
139 void openScanner();
140 }
141 }}
142 separateBackground
143 />
144 </Stack.Toolbar>
145
146 <ScrollView
147 contentInsetAdjustmentBehavior="automatic"
148 showsVerticalScrollIndicator={false}
149 style={{ flex: 1 }}
150 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
151 contentContainerStyle={{
152 paddingHorizontal: 20,
153 paddingTop: 16,
154 }}
155 >
156 <View collapsable={false} className="gap-5">
157 {showScanner ? (
158 cameraPermission?.granted ? (
159 <View
160 className="overflow-hidden rounded-[24px]"
161 style={{ borderCurve: "continuous" }}
162 >
163 <CameraView
164 barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
165 onBarcodeScanned={handleQrScan}
166 style={{ aspectRatio: 1, width: "100%" }}
167 />
168 </View>
169 ) : (
170 <View
171 className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"
172 style={{ borderCurve: "continuous" }}
173 >
174 <Text className="text-center text-sm leading-[20px] text-foreground-muted">
175 Camera permission is required to scan a QR code.
176 </Text>
177 <ConnectionSheetButton
178 compact
179 icon="camera"
180 label="Allow camera"
181 tone="secondary"
182 onPress={() => {
183 void openScanner();
184 }}
185 />
186 </View>
187 )
188 ) : (
189 <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4">
190 <View collapsable={false} className="gap-1.5">
191 <Text
192 className="text-2xs font-t3-bold uppercase text-foreground-muted"
193 style={{ letterSpacing: 0.8 }}
194 >
195 Host
196 </Text>
197 <TextInput
198 autoCapitalize="none"
199 autoCorrect={false}
200 keyboardType="url"
201 placeholder="192.168.1.100:8080"
202 placeholderTextColor={placeholderColor}
203 value={hostInput}
204 onChangeText={handleHostChange}
205 className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground"
206 />
207 </View>
208
209 <View collapsable={false} className="gap-1.5">
210 <Text
211 className="text-2xs font-t3-bold uppercase text-foreground-muted"
212 style={{ letterSpacing: 0.8 }}
213 >
214 Pairing code
215 </Text>
216 <TextInput
217 autoCapitalize="none"
218 autoCorrect={false}
219 placeholder="abc-123-xyz"
220 placeholderTextColor={placeholderColor}
221 value={codeInput}
222 onChangeText={handleCodeChange}
223 className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground"
224 />
225 </View>
226
227 {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null}
228
229 <ConnectionSheetButton
230 icon="plus"
231 label={isSubmitting ? "Pairing..." : "Add environment"}
232 disabled={connectDisabled}
233 tone="primary"
234 onPress={() => {
235 void handleSubmit();
236 }}
237 />
238 </View>
239 )}
240 </View>
241 </ScrollView>
242 </View>
243 );
244}
apps/mobile/src/app/index.tsx deleted-120
...@@ -1,120 +0,0 @@
1import type {
2 EnvironmentId,
3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";
6import {
7 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10} from "@t3tools/contracts";
11import * as Arr from "effect/Array";
12import * as Order from "effect/Order";
13import { useRouter } from "expo-router";
14import { useCallback, useMemo, useState } from "react";
15
16import { useProjects, useThreadShells } from "../state/entities";
17import { useWorkspaceState } from "../state/workspace";
18import { buildThreadRoutePath } from "../lib/routes";
19import { useSavedRemoteConnections } from "../state/use-remote-environment-registry";
20import { HomeScreen } from "../features/home/HomeScreen";
21import { HomeHeader } from "../features/home/HomeHeader";
22import type { HomeProjectSortOrder } from "../features/home/homeThreadList";
23import { useThreadListActions } from "../features/home/useThreadListActions";
24
25interface HomeListOptions {
26 readonly selectedEnvironmentId: EnvironmentId | null;
27 readonly projectSortOrder: HomeProjectSortOrder;
28 readonly threadSortOrder: SidebarThreadSortOrder;
29 readonly projectGroupingMode: SidebarProjectGroupingMode;
30}
31
32/* ─── Route screen ───────────────────────────────────────────────────── */
33
34export default function HomeRouteScreen() {
35 const projects = useProjects();
36 const threads = useThreadShells();
37 const { state: catalogState } = useWorkspaceState();
38 const { savedConnectionsById } = useSavedRemoteConnections();
39 const router = useRouter();
40 const [searchQuery, setSearchQuery] = useState("");
41 const [listOptions, setListOptions] = useState<HomeListOptions>({
42 selectedEnvironmentId: null,
43 projectSortOrder:
44 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
45 ? "updated_at"
46 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
47 threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
48 projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
49 });
50 const { archiveThread, confirmDeleteThread } = useThreadListActions();
51 const environments = useMemo(
52 () =>
53 Arr.sort(
54 Object.values(savedConnectionsById).map((connection) => ({
55 environmentId: connection.environmentId,
56 label: connection.environmentLabel,
57 })),
58 Order.mapInput(
59 Order.String,
60 (environment: { readonly label: string }) => environment.label,
61 ),
62 ),
63 [savedConnectionsById],
64 );
65 const selectedEnvironmentId = environments.some(
66 (environment) => environment.environmentId === listOptions.selectedEnvironmentId,
67 )
68 ? listOptions.selectedEnvironmentId
69 : null;
70 const setSelectedEnvironmentId = useCallback((environmentId: EnvironmentId | null) => {
71 setListOptions((current) => ({ ...current, selectedEnvironmentId: environmentId }));
72 }, []);
73 const setProjectSortOrder = useCallback((projectSortOrder: HomeProjectSortOrder) => {
74 setListOptions((current) => ({ ...current, projectSortOrder }));
75 }, []);
76 const setThreadSortOrder = useCallback((threadSortOrder: SidebarThreadSortOrder) => {
77 setListOptions((current) => ({ ...current, threadSortOrder }));
78 }, []);
79 const setProjectGroupingMode = useCallback((projectGroupingMode: SidebarProjectGroupingMode) => {
80 setListOptions((current) => ({ ...current, projectGroupingMode }));
81 }, []);
82
83 return (
84 <>
85 <HomeHeader
86 environments={environments}
87 selectedEnvironmentId={selectedEnvironmentId}
88 projectSortOrder={listOptions.projectSortOrder}
89 threadSortOrder={listOptions.threadSortOrder}
90 projectGroupingMode={listOptions.projectGroupingMode}
91 onEnvironmentChange={setSelectedEnvironmentId}
92 onOpenSettings={() => router.push("/settings")}
93 onProjectGroupingModeChange={setProjectGroupingMode}
94 onProjectSortOrderChange={setProjectSortOrder}
95 onSearchQueryChange={setSearchQuery}
96 onStartNewTask={() => router.push("/new")}
97 onThreadSortOrderChange={setThreadSortOrder}
98 />
99
100 <HomeScreen
101 catalogState={catalogState}
102 onAddConnection={() => router.push("/connections/new")}
103 onArchiveThread={archiveThread}
104 onDeleteThread={confirmDeleteThread}
105 onOpenEnvironments={() => router.push("/settings/environments")}
106 onSelectThread={(thread) => {
107 router.push(buildThreadRoutePath(thread));
108 }}
109 projectGroupingMode={listOptions.projectGroupingMode}
110 projects={projects}
111 projectSortOrder={listOptions.projectSortOrder}
112 savedConnectionsById={savedConnectionsById}
113 searchQuery={searchQuery}
114 selectedEnvironmentId={selectedEnvironmentId}
115 threads={threads}
116 threadSortOrder={listOptions.threadSortOrder}
117 />
118 </>
119 );
120}
apps/mobile/src/app/new/_layout.tsx deleted-50
...@@ -1,50 +0,0 @@
1import Stack from "expo-router/stack";
2import { useResolveClassNames } from "uniwind";
3
4import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
5import { useThemeColor } from "../../lib/useThemeColor";
6
7export const unstable_settings = {
8 anchor: "index",
9};
10
11export default function NewTaskLayout() {
12 const sheetStyle = useResolveClassNames("bg-sheet");
13 const sheetBg = useThemeColor("--color-sheet");
14 const headerTint = useThemeColor("--color-foreground");
15
16 return (
17 <NewTaskFlowProvider>
18 <Stack
19 screenOptions={{
20 contentStyle: sheetStyle,
21 headerBackButtonDisplayMode: "minimal",
22 headerLargeTitle: false,
23 headerShadowVisible: false,
24 headerStyle: { backgroundColor: sheetBg },
25 headerTintColor: headerTint,
26 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
27 }}
28 >
29 <Stack.Screen name="index" options={{ animation: "none", title: "Choose project" }} />
30 <Stack.Screen
31 name="add-project/index"
32 options={{ animation: "slide_from_right", title: "New project" }}
33 />
34 <Stack.Screen
35 name="add-project/repository"
36 options={{ animation: "slide_from_right", title: "Repository" }}
37 />
38 <Stack.Screen
39 name="add-project/destination"
40 options={{ animation: "slide_from_right", title: "Clone destination" }}
41 />
42 <Stack.Screen
43 name="add-project/local"
44 options={{ animation: "slide_from_right", title: "Local folder" }}
45 />
46 <Stack.Screen name="draft" options={{ animation: "slide_from_right", title: "New task" }} />
47 </Stack>
48 </NewTaskFlowProvider>
49 );
50}
apps/mobile/src/app/new/add-project/destination.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen";
2
3export default function AddProjectDestinationRoute() {
4 return <AddProjectDestinationScreen />;
5}
apps/mobile/src/app/new/add-project/index.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen";
2
3export default function AddProjectRoute() {
4 return <AddProjectSourceScreen />;
5}
apps/mobile/src/app/new/add-project/local.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen";
2
3export default function AddProjectLocalRoute() {
4 return <AddProjectLocalFolderScreen />;
5}
apps/mobile/src/app/new/add-project/repository.tsx deleted-23
...@@ -1,23 +0,0 @@
1import { Stack, useLocalSearchParams } from "expo-router";
2import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
3
4import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";
5
6export default function AddProjectRepositoryRoute() {
7 const params = useLocalSearchParams<{ source?: string | string[] }>();
8 const source = Array.isArray(params.source) ? params.source[0] : params.source;
9 const title =
10 source === "github" ||
11 source === "gitlab" ||
12 source === "bitbucket" ||
13 source === "azure-devops"
14 ? addProjectRemoteSourceLabel(source)
15 : "Git URL";
16
17 return (
18 <>
19 <Stack.Screen options={{ title }} />
20 <AddProjectRepositoryScreen />
21 </>
22 );
23}
apps/mobile/src/app/new/draft.tsx deleted-29
...@@ -1,29 +0,0 @@
1import { Stack, useLocalSearchParams } from "expo-router";
2
3import { NewTaskDraftScreen } from "../../features/threads/NewTaskDraftScreen";
4
5export default function NewTaskDraftRoute() {
6 const params = useLocalSearchParams<{
7 environmentId?: string | string[];
8 projectId?: string | string[];
9 title?: string | string[];
10 }>();
11
12 return (
13 <>
14 <Stack.Screen
15 options={{
16 title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"),
17 }}
18 />
19 <NewTaskDraftScreen
20 initialProjectRef={{
21 environmentId: Array.isArray(params.environmentId)
22 ? params.environmentId[0]
23 : params.environmentId,
24 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
25 }}
26 />
27 </>
28 );
29}
apps/mobile/src/app/new/index.tsx deleted-218
...@@ -1,218 +0,0 @@
1import { Link, Stack, useRouter } from "expo-router";
2import { SymbolView } from "expo-symbols";
3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
4import { useMemo } from "react";
5import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
8
9import { AppText as Text } from "../../components/AppText";
10import { ProjectFavicon } from "../../components/ProjectFavicon";
11import { useProjects, useThreadShells } from "../../state/entities";
12import type { WorkspaceState } from "../../state/workspaceModel";
13import { useWorkspaceState } from "../../state/workspace";
14import { groupProjectsByRepository } from "../../lib/repositoryGroups";
15
16function deriveProjectEmptyState(catalogState: WorkspaceState): {
17 readonly title: string;
18 readonly detail: string;
19 readonly loading: boolean;
20} {
21 if (catalogState.isLoadingConnections) {
22 return {
23 title: "Loading environments",
24 detail: "Checking saved environments on this device.",
25 loading: true,
26 };
27 }
28
29 if (!catalogState.hasConnections) {
30 return {
31 title: "No environments connected",
32 detail: "Add an environment before creating a task.",
33 loading: false,
34 };
35 }
36
37 if (
38 (catalogState.connectionState === "available" ||
39 catalogState.connectionState === "offline" ||
40 catalogState.connectionState === "error") &&
41 !catalogState.hasLoadedShellSnapshot
42 ) {
43 return {
44 title: "Environment unavailable",
45 detail:
46 catalogState.connectionError ??
47 "The saved environment is offline. Check the URL or start the environment, then retry.",
48 loading: false,
49 };
50 }
51
52 if (
53 catalogState.hasConnectingEnvironment &&
54 !catalogState.hasLoadedShellSnapshot &&
55 catalogState.connectionError === null
56 ) {
57 return {
58 title: "Connecting to environment",
59 detail: "Loading projects from the saved environment.",
60 loading: true,
61 };
62 }
63
64 return {
65 title: "No projects found",
66 detail: "The connected environment did not report any projects.",
67 loading: false,
68 };
69}
70
71export default function NewTaskRoute() {
72 const projects = useProjects();
73 const threads = useThreadShells();
74 const { state: catalogState } = useWorkspaceState();
75 const router = useRouter();
76 const insets = useSafeAreaInsets();
77 const chevronColor = useThemeColor("--color-chevron");
78 const accentColor = useThemeColor("--color-icon-muted");
79 const borderSubtleColor = useThemeColor("--color-border-subtle");
80 const repositoryGroups = useMemo(
81 () => groupProjectsByRepository({ projects, threads }),
82 [projects, threads],
83 );
84 const items = useMemo(() => {
85 const nextItems: Array<{
86 readonly environmentId: EnvironmentId;
87 readonly id: ProjectId;
88 readonly key: string;
89 readonly title: string;
90 readonly workspaceRoot: string;
91 }> = [];
92 for (const group of repositoryGroups) {
93 const project = group.projects[0]?.project;
94 if (!project) {
95 continue;
96 }
97 nextItems.push({
98 environmentId: project.environmentId,
99 id: project.id,
100 key: group.key,
101 title: project.title,
102 workspaceRoot: project.workspaceRoot,
103 });
104 }
105 return nextItems;
106 }, [repositoryGroups]);
107 const projectEmptyState = deriveProjectEmptyState(catalogState);
108
109 return (
110 <View collapsable={false} className="flex-1 bg-sheet">
111 <Stack.Screen options={{ title: "Choose project" }} />
112 <Stack.Toolbar placement="right">
113 <Stack.Toolbar.Button
114 icon="plus"
115 onPress={() => router.push("/new/add-project")}
116 separateBackground
117 />
118 </Stack.Toolbar>
119
120 <ScrollView
121 showsVerticalScrollIndicator={false}
122 style={{ flex: 1 }}
123 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
124 contentContainerStyle={{
125 paddingHorizontal: 20,
126 paddingTop: 8,
127 }}
128 >
129 {items.length === 0 ? (
130 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
131 {projectEmptyState.loading ? <ActivityIndicator color={accentColor} /> : null}
132 <Text className="text-center text-lg font-t3-bold text-foreground">
133 {projectEmptyState.title}
134 </Text>
135 <Text className="text-center text-sm leading-[20px] text-foreground-muted">
136 {projectEmptyState.detail}
137 </Text>
138 {!catalogState.hasReadyEnvironment ? (
139 <Pressable
140 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
141 onPress={() => router.push("/connections/new")}
142 >
143 <Text className="text-sm font-t3-bold text-primary-foreground">
144 Add environment
145 </Text>
146 </Pressable>
147 ) : (
148 <Pressable
149 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
150 onPress={() => router.push("/new/add-project")}
151 >
152 <Text className="text-sm font-t3-bold text-primary-foreground">
153 Add new project
154 </Text>
155 </Pressable>
156 )}
157 </View>
158 ) : (
159 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
160 {items.map((item, index) => {
161 const isFirst = index === 0;
162 const isLast = index === items.length - 1;
163
164 return (
165 <Link
166 key={item.key}
167 href={{
168 pathname: "/new/draft",
169 params: {
170 environmentId: item.environmentId,
171 projectId: item.id,
172 title: item.title,
173 },
174 }}
175 asChild
176 >
177 <Pressable
178 className="bg-card"
179 style={{
180 paddingHorizontal: 16,
181 paddingVertical: 14,
182 borderTopWidth: isFirst ? 0 : 1,
183 borderTopColor: borderSubtleColor,
184 borderTopLeftRadius: isFirst ? 24 : 0,
185 borderTopRightRadius: isFirst ? 24 : 0,
186 borderBottomLeftRadius: isLast ? 24 : 0,
187 borderBottomRightRadius: isLast ? 24 : 0,
188 }}
189 >
190 <View className="flex-row items-center justify-between gap-3">
191 <View className="h-7 w-7 items-center justify-center">
192 <ProjectFavicon
193 environmentId={item.environmentId}
194 size={20}
195 projectTitle={item.title}
196 workspaceRoot={item.workspaceRoot}
197 />
198 </View>
199 <View className="flex-1">
200 <Text className="text-base leading-[21px] font-t3-bold">{item.title}</Text>
201 </View>
202 <SymbolView
203 name="chevron.right"
204 size={14}
205 tintColor={chevronColor}
206 type="monochrome"
207 />
208 </View>
209 </Pressable>
210 </Link>
211 );
212 })}
213 </View>
214 )}
215 </ScrollView>
216 </View>
217 );
218}
apps/mobile/src/app/settings/_layout.tsx deleted-62
...@@ -1,62 +0,0 @@
1import Stack from "expo-router/stack";
2import { useCallback } from "react";
3import { useResolveClassNames } from "uniwind";
4
5import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
6import { useThemeColor } from "../../lib/useThemeColor";
7
8export const unstable_settings = {
9 anchor: "index",
10};
11
12export default function SettingsLayout() {
13 const { collapse } = useClerkSettingsSheetDetent();
14 const contentStyle = useResolveClassNames("bg-sheet");
15 const sheetBg = useThemeColor("--color-sheet");
16 const headerTint = useThemeColor("--color-foreground");
17 const handleExpandedRouteTransitionEnd = useCallback(
18 (event: { data: { closing: boolean } }) => {
19 if (event.data.closing) {
20 collapse();
21 }
22 },
23 [collapse],
24 );
25
26 return (
27 <Stack
28 screenOptions={{
29 contentStyle,
30 headerBackButtonDisplayMode: "minimal",
31 headerShadowVisible: false,
32 headerStyle: { backgroundColor: sheetBg },
33 headerTintColor: headerTint,
34 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
35 }}
36 >
37 <Stack.Screen name="index" options={{ animation: "none", title: "Settings" }} />
38 <Stack.Screen
39 name="environments"
40 options={{ animation: "slide_from_right", title: "Environments" }}
41 />
42 <Stack.Screen
43 name="environment-new"
44 options={{ animation: "slide_from_right", title: "Add Environment" }}
45 />
46 <Stack.Screen
47 name="waitlist"
48 options={{ animation: "slide_from_right", title: "Join the waitlist" }}
49 />
50 <Stack.Screen
51 name="archive"
52 listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
53 options={{ animation: "slide_from_right", title: "Archived Threads" }}
54 />
55 <Stack.Screen
56 name="auth"
57 listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
58 options={{ animation: "slide_from_right", title: "Sign in" }}
59 />
60 </Stack>
61 );
62}
apps/mobile/src/app/settings/archive.tsx deleted-3
...@@ -1,3 +0,0 @@
1import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen";
2
3export default ArchivedThreadsRouteScreen;
apps/mobile/src/app/settings/auth.tsx deleted-33
...@@ -1,33 +0,0 @@
1import { useAuth } from "@clerk/expo";
2import { AuthView, UserProfileView } from "@clerk/expo/native";
3import { Redirect, Stack } from "expo-router";
4import { View } from "react-native";
5
6import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
7
8export default function SettingsAuthRouteScreen() {
9 return hasCloudPublicConfig() ? (
10 <ConfiguredSettingsAuthRouteScreen />
11 ) : (
12 <Redirect href="/settings" />
13 );
14}
15
16function ConfiguredSettingsAuthRouteScreen() {
17 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
18
19 return (
20 <>
21 <Stack.Screen options={{ title: isSignedIn ? "Account" : "Sign in" }} />
22 <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
23 {isLoaded ? (
24 isSignedIn ? (
25 <UserProfileView isDismissible={false} />
26 ) : (
27 <AuthView isDismissible={false} />
28 )
29 ) : null}
30 </View>
31 </>
32 );
33}
apps/mobile/src/app/settings/environment-new.tsx deleted-1
...@@ -1 +0,0 @@
1export { default } from "../connections/new";
apps/mobile/src/app/settings/environments.tsx deleted-452
...@@ -1,452 +0,0 @@
1import { useAuth } from "@clerk/expo";
2import { Stack, useRouter } from "expo-router";
3import { SymbolView } from "expo-symbols";
4import {
5 connectionStatusText,
6 type EnvironmentConnectionPhase,
7} from "@t3tools/client-runtime/connection";
8import type { EnvironmentId } from "@t3tools/contracts";
9import { useCallback, useState } from "react";
10import {
11 ActivityIndicator,
12 Pressable,
13 ScrollView,
14 Switch,
15 type NativeSyntheticEvent,
16 type TextLayoutEventData,
17 View,
18} from "react-native";
19import { useSafeAreaInsets } from "react-native-safe-area-context";
20
21import { AppText as Text } from "../../components/AppText";
22import {
23 type RelayEnvironmentView,
24 useConnectionController,
25} from "../../features/connection/useConnectionController";
26import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
27import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation";
28import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
29import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot";
30import { splitEnvironmentSections } from "../../features/connection/environmentSections";
31import { cn } from "../../lib/cn";
32import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
33import { useThemeColor } from "../../lib/useThemeColor";
34import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
35import { useRemoteConnections } from "../../state/use-remote-environment-registry";
36
37export default function SettingsEnvironmentsRouteScreen() {
38 const {
39 connectedEnvironments,
40 onReconnectEnvironment,
41 onRemoveEnvironmentPress,
42 onUpdateEnvironment,
43 } = useRemoteConnections();
44 const router = useRouter();
45 const insets = useSafeAreaInsets();
46 const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({
47 connectedEnvironments,
48 cloudEnvironments: null,
49 });
50 const hasLocalEnvironments = localEnvironments.length > 0;
51 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
52 const accentColor = useThemeColor("--color-icon-muted");
53
54 const handleToggle = useCallback((environmentId: EnvironmentId) => {
55 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
56 }, []);
57
58 return (
59 <View collapsable={false} className="flex-1 bg-sheet">
60 <Stack.Screen
61 options={{
62 title: "Environments",
63 }}
64 />
65 <Stack.Toolbar placement="right">
66 <Stack.Toolbar.Button
67 icon="plus"
68 onPress={() => router.push("/settings/environment-new")}
69 separateBackground
70 />
71 </Stack.Toolbar>
72 <ScrollView
73 contentInsetAdjustmentBehavior="automatic"
74 showsVerticalScrollIndicator={false}
75 style={{ flex: 1 }}
76 contentContainerStyle={{
77 paddingBottom: Math.max(insets.bottom, 18) + 18,
78 paddingHorizontal: 20,
79 paddingTop: 16,
80 }}
81 >
82 {hasLocalEnvironments ? (
83 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
84 {localEnvironments.map((environment, index) => (
85 <View
86 key={environment.environmentId}
87 collapsable={false}
88 style={{
89 borderTopWidth: index === 0 ? 0 : 1,
90 }}
91 className={cn(index !== 0 && "border-border")}
92 >
93 <ConnectionEnvironmentRow
94 environment={environment}
95 expanded={expandedId === environment.environmentId}
96 onToggle={() => handleToggle(environment.environmentId)}
97 onReconnect={onReconnectEnvironment}
98 onRemove={onRemoveEnvironmentPress}
99 onUpdate={onUpdateEnvironment}
100 />
101 </View>
102 ))}
103 </View>
104 ) : (
105 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
106 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
107 <SymbolView
108 name="point.3.connected.trianglepath.dotted"
109 size={20}
110 tintColor={accentColor}
111 type="monochrome"
112 />
113 </View>
114 <Text className="text-center text-sm leading-[20px] text-foreground-muted">
115 No environments connected yet.{"\n"}Tap{" "}
116 <Text className="font-t3-bold text-foreground">+</Text> to add one.
117 </Text>
118 </View>
119 )}
120
121 {hasCloudPublicConfig() ? (
122 <ConfiguredCloudEnvironmentRows
123 connectedCloudEnvironments={connectedCloudEnvironments}
124 onReconnectEnvironment={onReconnectEnvironment}
125 />
126 ) : null}
127 </ScrollView>
128 </View>
129 );
130}
131
132function ConfiguredCloudEnvironmentRows(props: {
133 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
134 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
135}) {
136 const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
137 const controller = useConnectionController();
138 const iconColor = useThemeColor("--color-icon");
139 const availableCloudEnvironments = controller.availableRelayEnvironments;
140 const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
141 const hasCloudRows =
142 props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0;
143
144 const handleConnectCloudEnvironment = useCallback(
145 (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment),
146 [controller],
147 );
148
149 const handleDisconnectCloudEnvironment = useCallback(
150 (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId),
151 [controller],
152 );
153
154 const handleToggleCloudError = useCallback((environmentId: string) => {
155 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));
156 }, []);
157
158 if (!isSignedIn) return null;
159
160 return (
161 <View collapsable={false} className="mt-5 gap-3">
162 <View className="flex-row items-center justify-between px-1">
163 <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Cloud</Text>
164 <Pressable
165 accessibilityRole="button"
166 disabled={controller.relayDiscovery.isRefreshing}
167 onPress={() => {
168 void controller.refreshRelayEnvironments();
169 }}
170 className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
171 >
172 {controller.relayDiscovery.isRefreshing ? (
173 <ActivityIndicator color={iconColor} size="small" />
174 ) : (
175 <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" />
176 )}
177 </Pressable>
178 </View>
179
180 {hasCloudRows ? (
181 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
182 {props.connectedCloudEnvironments.map((environment, index) => (
183 <ConnectedCloudEnvironmentRow
184 key={environment.environmentId}
185 environment={environment}
186 borderTop={index !== 0}
187 onConnect={() => props.onReconnectEnvironment(environment.environmentId)}
188 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}
189 errorExpanded={expandedErrorId === environment.environmentId}
190 onToggleError={() => handleToggleCloudError(environment.environmentId)}
191 />
192 ))}
193 {availableCloudEnvironments.map((environment, index) => (
194 <CloudEnvironmentRow
195 key={environment.environment.environmentId}
196 environment={environment}
197 borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0}
198 onConnect={() => handleConnectCloudEnvironment(environment)}
199 errorExpanded={expandedErrorId === environment.environment.environmentId}
200 onToggleError={() => handleToggleCloudError(environment.environment.environmentId)}
201 />
202 ))}
203 </View>
204 ) : controller.relayDiscovery.isRefreshing ? (
205 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">
206 <ActivityIndicator color={iconColor} />
207 <Text className="text-center text-sm leading-[20px] text-foreground-muted">
208 Loading linked cloud environments.
209 </Text>
210 </View>
211 ) : controller.relayDiscovery.error ? (
212 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">
213 <Text className="text-base font-t3-bold text-foreground">
214 Could not load T3 Cloud environments
215 </Text>
216 <Text className="text-sm leading-[18px] text-foreground-muted">
217 {controller.relayDiscovery.error}
218 </Text>
219 {controller.relayDiscovery.errorTraceId ? (
220 <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} />
221 ) : null}
222 </View>
223 ) : (
224 <View collapsable={false} className="rounded-[24px] bg-card p-5">
225 <Text className="text-sm leading-[20px] text-foreground-muted">
226 No additional linked cloud environments.
227 </Text>
228 </View>
229 )}
230 </View>
231 );
232}
233
234function ConnectedCloudEnvironmentRow(props: {
235 readonly environment: ConnectedEnvironmentSummary;
236 readonly borderTop: boolean;
237 readonly errorExpanded: boolean;
238 readonly onConnect: () => void;
239 readonly onDisconnect: () => void;
240 readonly onToggleError: () => void;
241}) {
242 return (
243 <CloudEnvironmentRowShell
244 borderTop={props.borderTop}
245 connectionError={props.environment.connectionError}
246 connectionErrorTraceId={props.environment.connectionErrorTraceId}
247 connectionState={props.environment.connectionState}
248 errorExpanded={props.errorExpanded}
249 label={props.environment.environmentLabel}
250 onValueChange={(enabled) => {
251 if (enabled) {
252 props.onConnect();
253 return;
254 }
255 props.onDisconnect();
256 }}
257 onToggleError={props.onToggleError}
258 value={props.environment.connectionState !== "available"}
259 />
260 );
261}
262
263function CloudEnvironmentRow(props: {
264 readonly environment: RelayEnvironmentView;
265 readonly borderTop: boolean;
266 readonly errorExpanded: boolean;
267 readonly onConnect: () => void;
268 readonly onToggleError: () => void;
269}) {
270 const presentation = availableCloudEnvironmentPresentation({
271 isStatusPending: props.environment.availability === "checking",
272 status: props.environment.status,
273 statusError: props.environment.error,
274 statusErrorTraceId: props.environment.traceId,
275 });
276
277 return (
278 <CloudEnvironmentRowShell
279 borderTop={props.borderTop}
280 connectionError={presentation.connectionError}
281 connectionErrorTraceId={presentation.connectionErrorTraceId}
282 connectionState={presentation.connectionState}
283 errorExpanded={props.errorExpanded}
284 label={props.environment.environment.label}
285 onValueChange={(enabled) => {
286 if (enabled) {
287 props.onConnect();
288 }
289 }}
290 onToggleError={props.onToggleError}
291 statusText={presentation.statusText}
292 value={false}
293 />
294 );
295}
296
297function CloudEnvironmentRowShell(props: {
298 readonly borderTop: boolean;
299 readonly connectionError: string | null;
300 readonly connectionErrorTraceId: string | null;
301 readonly connectionState: EnvironmentConnectionPhase;
302 readonly disabled?: boolean;
303 readonly errorExpanded: boolean;
304 readonly label: string;
305 readonly onToggleError: () => void;
306 readonly onValueChange: (enabled: boolean) => void;
307 readonly statusText?: string;
308 readonly value: boolean;
309}) {
310 const activeTrack = String(useThemeColor("--color-switch-active"));
311 const track = String(useThemeColor("--color-secondary-border"));
312 const chevron = useThemeColor("--color-chevron");
313 const isRetrying =
314 props.connectionState === "connecting" || props.connectionState === "reconnecting";
315 const shouldPulse = isRetrying;
316 const statusText =
317 props.statusText ??
318 connectionStatusText({
319 phase: props.connectionState,
320 error: props.connectionError,
321 traceId: props.connectionErrorTraceId,
322 });
323 const statusClassName = props.connectionError
324 ? "text-rose-500 dark:text-rose-400"
325 : "text-foreground-muted";
326 const [errorMeasurement, setErrorMeasurement] = useState<{
327 readonly text: string;
328 readonly lineCount: number;
329 } | null>(null);
330 const errorTraceId = props.connectionErrorTraceId;
331 const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText;
332 const errorLineCount =
333 errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0;
334 const errorCanExpand = props.connectionError !== null && errorLineCount > 1;
335 const isErrorExpanded = errorCanExpand && props.errorExpanded;
336 const StatusContainer = errorCanExpand ? Pressable : View;
337 const onMeasuredErrorTextLayout = useCallback(
338 (event: NativeSyntheticEvent<TextLayoutEventData>) => {
339 if (!props.connectionError) {
340 return;
341 }
342 const nextLineCount = event.nativeEvent.lines.length;
343 setErrorMeasurement((currentMeasurement) =>
344 currentMeasurement?.text === measuredErrorText &&
345 currentMeasurement.lineCount === nextLineCount
346 ? currentMeasurement
347 : { text: measuredErrorText, lineCount: nextLineCount },
348 );
349 },
350 [measuredErrorText, props.connectionError],
351 );
352 return (
353 <View
354 collapsable={false}
355 className={cn(
356 "flex-row items-center gap-3 bg-card px-4 py-3.5",
357 props.borderTop && "border-t border-border",
358 )}
359 >
360 <View className="min-w-0 flex-1 gap-0.5">
361 <View className="min-w-0 flex-row items-center gap-2">
362 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
363 <Text
364 className="min-w-0 flex-shrink text-base font-t3-bold leading-[21px] text-foreground"
365 numberOfLines={1}
366 >
367 {props.label}
368 </Text>
369 </View>
370 {props.connectionError ? (
371 <Text
372 aria-hidden
373 className={cn("absolute left-0 right-0 text-xs leading-[16px]", statusClassName)}
374 onTextLayout={onMeasuredErrorTextLayout}
375 style={{ opacity: 0, zIndex: -1 }}
376 >
377 {measuredErrorText}
378 </Text>
379 ) : null}
380 <StatusContainer
381 {...(errorCanExpand
382 ? { accessibilityRole: "button" as const, onPress: props.onToggleError }
383 : {})}
384 className="min-w-0 flex-row items-start gap-1"
385 >
386 <Text
387 className={cn("min-w-0 flex-1 text-xs leading-[16px]", statusClassName)}
388 numberOfLines={isErrorExpanded ? undefined : 1}
389 >
390 {statusText}
391 {errorTraceId ? (
392 <>
393 {" Trace ID: "}
394 <Text
395 accessibilityHint="Copies the trace ID"
396 accessibilityRole="button"
397 className={cn("text-xs leading-[16px] underline", statusClassName)}
398 onLongPress={(event) => {
399 event.stopPropagation();
400 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });
401 }}
402 onPress={(event) => {
403 event.stopPropagation();
404 }}
405 style={{ textDecorationStyle: "dotted" }}
406 >
407 {errorTraceId}
408 </Text>
409 </>
410 ) : null}
411 </Text>
412 {errorCanExpand ? (
413 <SymbolView
414 name="chevron.down"
415 size={10}
416 tintColor={chevron}
417 type="monochrome"
418 style={{
419 marginTop: 3,
420 transform: [{ rotate: isErrorExpanded ? "180deg" : "0deg" }],
421 }}
422 />
423 ) : null}
424 </StatusContainer>
425 </View>
426 <Switch
427 disabled={props.disabled}
428 ios_backgroundColor={track}
429 onValueChange={props.onValueChange}
430 trackColor={{ false: track, true: activeTrack }}
431 value={props.value}
432 />
433 </View>
434 );
435}
436
437function CopyTraceIdButton(props: { readonly traceId: string }) {
438 const iconColor = useThemeColor("--color-icon");
439
440 return (
441 <Pressable
442 accessibilityRole="button"
443 onPress={() => {
444 copyTextWithHaptic(props.traceId, { target: "connection-trace-id" });
445 }}
446 className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70"
447 >
448 <SymbolView name="doc.on.doc" size={12} tintColor={iconColor} type="monochrome" />
449 <Text className="text-xs font-t3-bold text-foreground">Copy trace ID</Text>
450 </Pressable>
451 );
452}
apps/mobile/src/app/settings/index.tsx deleted-515
...@@ -1,515 +0,0 @@
1import { useAuth, useUser } from "@clerk/expo";
2import * as Notifications from "expo-notifications";
3import { Link, Stack, useRouter } from "expo-router";
4import { SymbolView } from "expo-symbols";
5import * as Effect from "effect/Effect";
6import { useCallback, useEffect, useMemo, useState } from "react";
7import type { ComponentProps, ReactNode } from "react";
8import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import {
12 isAtomCommandInterrupted,
13 reportAtomCommandResult,
14 settleAsyncResult,
15 settlePromise,
16 squashAtomCommandFailure,
17} from "@t3tools/client-runtime/state/runtime";
18import { AppText as Text } from "../../components/AppText";
19import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences";
20import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";
21import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";
22import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState";
23import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
24import {
25 hasCloudPublicConfig,
26 resolveRelayClerkTokenOptions,
27} from "../../features/cloud/publicConfig";
28import { runtime } from "../../lib/runtime";
29import { loadPreferences } from "../../lib/storage";
30import { useThemeColor } from "../../lib/useThemeColor";
31import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
32
33type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
34type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
35
36export default function SettingsRouteScreen() {
37 return hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />;
38}
39
40function LocalSettingsRouteScreen() {
41 const insets = useSafeAreaInsets();
42 const { savedConnectionsById } = useSavedRemoteConnections();
43 const environmentCount = Object.keys(savedConnectionsById).length;
44
45 return (
46 <View collapsable={false} className="flex-1 bg-sheet">
47 <Stack.Screen options={{ title: "Settings" }} />
48 <ScrollView
49 contentInsetAdjustmentBehavior="automatic"
50 showsVerticalScrollIndicator={false}
51 style={{ flex: 1 }}
52 contentContainerStyle={{
53 gap: 24,
54 paddingBottom: Math.max(insets.bottom, 18) + 18,
55 paddingHorizontal: 20,
56 paddingTop: 16,
57 }}
58 >
59 <SettingsSection title="Configuration">
60 <SettingsRow
61 icon="desktopcomputer"
62 label="Environments"
63 value={`${environmentCount}`}
64 href="/settings/environments"
65 />
66 </SettingsSection>
67
68 <ArchivedThreadsSettingsSection />
69
70 <AppSettingsSection />
71 </ScrollView>
72 </View>
73 );
74}
75
76function ConfiguredSettingsRouteScreen() {
77 const insets = useSafeAreaInsets();
78 const { push } = useRouter();
79 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
80 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
81 const { user } = useUser();
82 const { savedConnectionsById } = useSavedRemoteConnections();
83 const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking");
84 const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking");
85
86 const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]);
87 const environmentCount = connections.length;
88 const accountLabel = useMemo(() => {
89 if (!isLoaded) return "Checking";
90 if (!isSignedIn) return "Request access";
91 return user?.primaryEmailAddress?.emailAddress ?? "Signed in";
92 }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]);
93
94 const refreshNotifications = useCallback(async () => {
95 if (process.env.EXPO_OS !== "ios") {
96 setNotificationStatus("unsupported");
97 return;
98 }
99 const result = await settlePromise(() => Notifications.getPermissionsAsync());
100 if (result._tag === "Failure") {
101 reportAtomCommandResult(result, { label: "notification permission refresh" });
102 setNotificationStatus("disabled");
103 return;
104 }
105 setNotificationStatus(result.value.granted ? "enabled" : "disabled");
106 }, []);
107
108 useEffect(() => {
109 void refreshNotifications();
110 }, [refreshNotifications]);
111
112 useEffect(() => {
113 if (!isLoaded) {
114 setLiveActivityStatus("checking");
115 return;
116 }
117 if (!isSignedIn) {
118 setLiveActivityStatus("signed-out");
119 return;
120 }
121 void (async () => {
122 const result = await settlePromise(() => loadPreferences());
123 if (result._tag === "Failure") {
124 reportAtomCommandResult(result, { label: "live activity preference load" });
125 setLiveActivityStatus("enabled");
126 return;
127 }
128 setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled");
129 })();
130 }, [isLoaded, isSignedIn]);
131
132 const requestNotifications = useCallback(async () => {
133 const result = await settleAsyncResult(() =>
134 runtime.runPromiseExit(
135 requestAgentNotificationPermission.pipe(
136 Effect.tap((permission) =>
137 permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void,
138 ),
139 ),
140 ),
141 );
142 if (result._tag === "Failure") {
143 if (!isAtomCommandInterrupted(result)) {
144 const error = squashAtomCommandFailure(result);
145 Alert.alert(
146 "Notifications unavailable",
147 error instanceof Error ? error.message : "Could not request notification permission.",
148 );
149 }
150 return;
151 }
152 if (result.value.type === "granted") {
153 setNotificationStatus("enabled");
154 Alert.alert(
155 "Notifications enabled",
156 "Live Activity notifications are enabled for this device.",
157 );
158 return;
159 }
160 if (result.value.type === "unsupported") {
161 setNotificationStatus("unsupported");
162 Alert.alert(
163 "Notifications unavailable",
164 "Live Activity notifications are only available on iOS.",
165 );
166 return;
167 }
168 setNotificationStatus("disabled");
169 if (result.value.canAskAgain) {
170 Alert.alert("Notifications disabled", "Notifications were not enabled.");
171 return;
172 }
173 Alert.alert(
174 "Notifications disabled",
175 "Notifications were denied for this app. Open Settings to enable them.",
176 [
177 { text: "Cancel", style: "cancel" },
178 { text: "Open Settings", onPress: () => void Linking.openSettings() },
179 ],
180 );
181 }, []);
182
183 const promptSignIn = useCallback(() => {
184 Alert.alert(
185 "Request T3 Cloud access",
186 "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",
187 [
188 { text: "Cancel", style: "cancel" },
189 { text: "Continue", onPress: () => push("/settings/waitlist") },
190 ],
191 );
192 }, [push]);
193
194 const linkEnvironments = useCallback(async () => {
195 if (!isSignedIn) {
196 promptSignIn();
197 return;
198 }
199
200 setLiveActivityStatus("linking");
201 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
202 if (tokenResult._tag === "Failure") {
203 setLiveActivityStatus("disabled");
204 const error = squashAtomCommandFailure(tokenResult);
205 Alert.alert(
206 "Live Activities unavailable",
207 error instanceof Error ? error.message : "Could not enable Live Activity updates.",
208 );
209 return;
210 }
211 if (!tokenResult.value) {
212 promptSignIn();
213 setLiveActivityStatus("signed-out");
214 return;
215 }
216
217 const updateResult = await settleAsyncResult(() =>
218 runtime.runPromiseExit(
219 setLiveActivityUpdatesEnabled({
220 enabled: true,
221 clerkToken: tokenResult.value,
222 connections,
223 }),
224 ),
225 );
226 if (updateResult._tag === "Failure") {
227 setLiveActivityStatus("disabled");
228 if (!isAtomCommandInterrupted(updateResult)) {
229 const error = squashAtomCommandFailure(updateResult);
230 Alert.alert(
231 "Live Activities unavailable",
232 error instanceof Error ? error.message : "Could not enable Live Activity updates.",
233 );
234 }
235 return;
236 }
237
238 refreshManagedRelayEnvironments();
239 setLiveActivityStatus("enabled");
240 Alert.alert(
241 "Live Activities enabled",
242 environmentCount > 0
243 ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.`
244 : "Live Activity updates are enabled. Add an environment to start receiving updates.",
245 );
246 }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]);
247
248 const handleDeviceNotificationsChange = useCallback(
249 (enabled: boolean) => {
250 if (enabled) {
251 void requestNotifications();
252 return;
253 }
254
255 Alert.alert(
256 "Disable notifications",
257 "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.",
258 [
259 { text: "Cancel", style: "cancel" },
260 { text: "Open Settings", onPress: () => void Linking.openSettings() },
261 ],
262 );
263 },
264 [requestNotifications],
265 );
266
267 const handleLiveActivitiesChange = useCallback(
268 (enabled: boolean) => {
269 if (!enabled) {
270 setLiveActivityStatus("disabled");
271 void (async () => {
272 let token: string | null = null;
273 if (isSignedIn) {
274 const tokenResult = await settlePromise(() =>
275 getToken(resolveRelayClerkTokenOptions()),
276 );
277 if (tokenResult._tag === "Failure") {
278 reportAtomCommandResult(tokenResult, {
279 label: "live activity disable token lookup",
280 });
281 return;
282 }
283 token = tokenResult.value;
284 }
285
286 const updateResult = await settleAsyncResult(() =>
287 runtime.runPromiseExit(
288 setLiveActivityUpdatesEnabled({
289 enabled: false,
290 clerkToken: token,
291 connections,
292 }),
293 ),
294 );
295 if (updateResult._tag === "Failure") {
296 reportAtomCommandResult(updateResult, {
297 label: "live activity disable",
298 });
299 return;
300 }
301 refreshManagedRelayEnvironments();
302 })();
303 return;
304 }
305
306 if (!isSignedIn) {
307 promptSignIn();
308 return;
309 }
310
311 void linkEnvironments();
312 },
313 [connections, getToken, isSignedIn, linkEnvironments, promptSignIn],
314 );
315
316 const openAccount = useCallback(() => {
317 if (!isLoaded) return;
318 if (!isSignedIn) {
319 push("/settings/waitlist");
320 return;
321 }
322 expandClerkSheet();
323 push("/settings/auth");
324 }, [expandClerkSheet, isLoaded, isSignedIn, push]);
325
326 return (
327 <View collapsable={false} className="flex-1 bg-sheet">
328 <Stack.Screen options={{ title: "Settings" }} />
329 <ScrollView
330 contentInsetAdjustmentBehavior="automatic"
331 showsVerticalScrollIndicator={false}
332 style={{ flex: 1 }}
333 contentContainerStyle={{
334 gap: 24,
335 paddingBottom: Math.max(insets.bottom, 18) + 18,
336 paddingHorizontal: 20,
337 paddingTop: 16,
338 }}
339 >
340 <View className="gap-3">
341 <SettingsSection title="Account">
342 <SettingsRow
343 icon="person.crop.circle"
344 label="T3 Account"
345 value={accountLabel}
346 onPress={openAccount}
347 />
348 </SettingsSection>
349 <Text className="px-2 text-sm leading-[18px] text-foreground-muted">
350 T3 Code works locally without signing in. Cloud features are optional.
351 </Text>
352 </View>
353
354 <SettingsSection title="Configuration">
355 <SettingsRow
356 icon="desktopcomputer"
357 label="Environments"
358 value={`${environmentCount}`}
359 href="/settings/environments"
360 />
361 <SettingsSwitchRow
362 icon="bell.badge"
363 label="Device Notifications"
364 disabled={notificationStatus === "checking" || notificationStatus === "unsupported"}
365 value={notificationStatus === "enabled"}
366 onValueChange={handleDeviceNotificationsChange}
367 />
368 <SettingsSwitchRow
369 disabled={
370 !isLoaded || liveActivityStatus === "checking" || liveActivityStatus === "linking"
371 }
372 icon="bolt.circle"
373 label="Live Activity Updates"
374 value={liveActivityStatus === "enabled" || liveActivityStatus === "linking"}
375 onValueChange={handleLiveActivitiesChange}
376 />
377 </SettingsSection>
378
379 <ArchivedThreadsSettingsSection />
380
381 <AppSettingsSection />
382 </ScrollView>
383 </View>
384 );
385}
386
387type SymbolName = ComponentProps<typeof SymbolView>["name"];
388
389function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {
390 return (
391 <View className="gap-2">
392 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>
393 <View
394 className="overflow-hidden rounded-[28px] bg-card"
395 style={{ borderCurve: "continuous" }}
396 >
397 {props.children}
398 </View>
399 </View>
400 );
401}
402
403function AppSettingsSection() {
404 const icon = useThemeColor("--color-icon");
405
406 return (
407 <SettingsSection title="App">
408 <View className="flex-row items-center gap-4 p-4">
409 <SymbolView
410 name="info.circle"
411 size={22}
412 tintColor={icon}
413 type="monochrome"
414 weight="regular"
415 />
416 <Text className="flex-1 text-lg text-foreground">Version</Text>
417 <Text className="text-lg text-foreground-muted">Alpha</Text>
418 </View>
419 </SettingsSection>
420 );
421}
422
423function ArchivedThreadsSettingsSection() {
424 return (
425 <SettingsSection title="Threads">
426 <SettingsRow icon="archivebox" label="Archived Threads" href="/settings/archive" />
427 </SettingsSection>
428 );
429}
430
431function SettingsRow(props: {
432 readonly disabled?: boolean;
433 readonly icon: SymbolName;
434 readonly label: string;
435 readonly value?: string;
436 readonly href?: "/settings/archive" | "/settings/environments";
437 readonly onPress?: () => void;
438}) {
439 const icon = useThemeColor("--color-icon");
440 const chevron = useThemeColor("--color-chevron");
441 const content = (
442 <View
443 className="flex-row items-center gap-4 p-4"
444 style={{ opacity: props.disabled ? 0.45 : 1 }}
445 >
446 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
447 <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
448 {props.label}
449 </Text>
450 <View className="min-w-0 flex-1 items-end">
451 {props.value ? (
452 <Text
453 className="max-w-[180px] text-right text-base text-foreground-muted"
454 ellipsizeMode="middle"
455 numberOfLines={1}
456 >
457 {props.value}
458 </Text>
459 ) : null}
460 </View>
461 <SymbolView
462 name="chevron.right"
463 size={16}
464 tintColor={chevron}
465 type="monochrome"
466 weight="semibold"
467 />
468 </View>
469 );
470
471 if (props.href) {
472 return (
473 <Link href={props.href} asChild>
474 <Pressable accessibilityLabel={props.label} accessibilityRole="button">
475 {content}
476 </Pressable>
477 </Link>
478 );
479 }
480
481 return (
482 <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}>
483 {content}
484 </Pressable>
485 );
486}
487
488function SettingsSwitchRow(props: {
489 readonly disabled?: boolean;
490 readonly icon: SymbolName;
491 readonly label: string;
492 readonly value: boolean;
493 readonly onValueChange: (value: boolean) => void;
494}) {
495 const icon = useThemeColor("--color-icon");
496 const activeTrack = String(useThemeColor("--color-switch-active"));
497 const track = String(useThemeColor("--color-secondary-border"));
498
499 return (
500 <View
501 className="flex-row items-center gap-4 p-4"
502 style={{ opacity: props.disabled ? 0.45 : 1 }}
503 >
504 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
505 <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
506 <Switch
507 disabled={props.disabled}
508 ios_backgroundColor={track}
509 onValueChange={props.onValueChange}
510 trackColor={{ false: track, true: activeTrack }}
511 value={props.value}
512 />
513 </View>
514 );
515}
apps/mobile/src/app/settings/waitlist.tsx deleted-55
...@@ -1,55 +0,0 @@
1import { useAuth } from "@clerk/expo";
2import { Redirect, Stack, useFocusEffect, useRouter } from "expo-router";
3import { useCallback } from "react";
4import { ScrollView } from "react-native";
5
6import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment";
7import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
8import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
9
10export default function SettingsWaitlistRouteScreen() {
11 return hasCloudPublicConfig() ? (
12 <ConfiguredSettingsWaitlistRouteScreen />
13 ) : (
14 <Redirect href="/settings" />
15 );
16}
17
18function ConfiguredSettingsWaitlistRouteScreen() {
19 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
20 const { expand } = useClerkSettingsSheetDetent();
21 const router = useRouter();
22
23 useFocusEffect(
24 useCallback(() => {
25 if (isLoaded && isSignedIn) {
26 router.replace("/settings");
27 }
28 }, [isLoaded, isSignedIn, router]),
29 );
30
31 return (
32 <>
33 <Stack.Screen options={{ title: "Join the waitlist" }} />
34 <ScrollView
35 automaticallyAdjustKeyboardInsets
36 contentContainerStyle={{
37 paddingBottom: 32,
38 paddingHorizontal: 20,
39 paddingTop: 12,
40 }}
41 contentInsetAdjustmentBehavior="automatic"
42 keyboardDismissMode="interactive"
43 keyboardShouldPersistTaps="handled"
44 showsVerticalScrollIndicator={false}
45 >
46 <CloudWaitlistEnrollment
47 onSignIn={() => {
48 expand();
49 router.push("/settings/auth");
50 }}
51 />
52 </ScrollView>
53 </>
54 );
55}
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx deleted-107
...@@ -1,107 +0,0 @@
1import Stack from "expo-router/stack";
2import { StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5export default function ThreadLayout() {
6 const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
7 const headerBg = {
8 backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
9 };
10
11 return (
12 <Stack screenOptions={{ headerShown: false }}>
13 <Stack.Screen
14 name="index"
15 options={{
16 contentStyle: { backgroundColor: "transparent" },
17 headerShown: true,
18 headerTransparent: true,
19 headerShadowVisible: false,
20 headerTitle: "",
21 }}
22 />
23 <Stack.Screen
24 name="git"
25 options={{
26 contentStyle: sheetStyle,
27 gestureEnabled: true,
28 headerShown: false,
29 presentation: "formSheet" as const,
30 sheetAllowedDetents: [0.85],
31 sheetGrabberVisible: true,
32 }}
33 />
34 <Stack.Screen
35 name="git-confirm"
36 options={{
37 contentStyle: sheetStyle,
38 gestureEnabled: true,
39 headerShown: false,
40 presentation: "formSheet" as const,
41 sheetAllowedDetents: [0.4],
42 sheetGrabberVisible: true,
43 }}
44 />
45 <Stack.Screen
46 name="review"
47 options={{
48 animation: "slide_from_right",
49 contentStyle: sheetStyle,
50 headerBackButtonDisplayMode: "minimal",
51 headerShown: true,
52 headerTitle: "Files changed",
53 headerBackTitle: "",
54 headerShadowVisible: false,
55 headerStyle: headerBg,
56 }}
57 />
58 <Stack.Screen
59 name="files/index"
60 options={{
61 animation: "slide_from_right",
62 contentStyle: sheetStyle,
63 headerBackButtonDisplayMode: "minimal",
64 headerShown: true,
65 headerTitle: "Files",
66 headerBackTitle: "",
67 headerShadowVisible: false,
68 headerStyle: headerBg,
69 }}
70 />
71 <Stack.Screen
72 name="files/[...path]"
73 options={{
74 animation: "slide_from_right",
75 contentStyle: sheetStyle,
76 headerBackButtonDisplayMode: "minimal",
77 headerShown: true,
78 headerTitle: "File",
79 headerBackTitle: "",
80 headerShadowVisible: false,
81 headerStyle: headerBg,
82 }}
83 />
84 <Stack.Screen
85 name="review-comment"
86 options={{
87 contentStyle: sheetStyle,
88 gestureEnabled: true,
89 headerShown: false,
90 presentation: "formSheet" as const,
91 sheetAllowedDetents: [0.72, 0.92],
92 sheetGrabberVisible: true,
93 }}
94 />
95 <Stack.Screen
96 name="terminal"
97 options={{
98 animation: "slide_from_right",
99 contentStyle: { backgroundColor: "#050505" },
100 headerBackButtonDisplayMode: "minimal",
101 headerShown: true,
102 headerShadowVisible: false,
103 }}
104 />
105 </Stack>
106 );
107}
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx deleted-5
...@@ -1,5 +0,0 @@
1import { ThreadFileScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2
3export default function ThreadFileRoute() {
4 return <ThreadFileScreen />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2
3export default function ThreadFilesIndexRoute() {
4 return <ThreadFilesTreeScreen />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet";
2
3export default function GitConfirmRoute() {
4 return <GitConfirmSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx deleted-58
...@@ -1,58 +0,0 @@
1import Stack from "expo-router/stack";
2import { StyleSheet } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5export const unstable_settings = {
6 anchor: "index",
7};
8
9export default function GitSheetLayout() {
10 const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
11 const headerBg = {
12 backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
13 };
14
15 return (
16 <Stack
17 screenOptions={{
18 contentStyle: sheetStyle,
19 headerShown: false,
20 }}
21 >
22 <Stack.Screen name="index" options={{ animation: "none" }} />
23 <Stack.Screen
24 name="commit"
25 options={{
26 animation: "slide_from_right",
27 headerShown: true,
28 headerTitle: "Commit changes",
29 headerBackTitle: "",
30 headerShadowVisible: false,
31 headerStyle: headerBg,
32 }}
33 />
34 <Stack.Screen
35 name="branches"
36 options={{
37 animation: "slide_from_right",
38 headerShown: true,
39 headerTitle: "Branches & worktrees",
40 headerBackTitle: "",
41 headerShadowVisible: false,
42 headerStyle: headerBg,
43 }}
44 />
45 <Stack.Screen
46 name="review"
47 options={{
48 animation: "slide_from_right",
49 headerShown: true,
50 headerTitle: "Review changes",
51 headerBackTitle: "",
52 headerShadowVisible: false,
53 headerStyle: headerBg,
54 }}
55 />
56 </Stack>
57 );
58}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet";
2
3export default function GitBranchesRoute() {
4 return <GitBranchesSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet";
2
3export default function GitCommitRoute() {
4 return <GitCommitSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet";
2
3export default function GitRoute() {
4 return <GitOverviewSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx deleted-18
...@@ -1,18 +0,0 @@
1import { Redirect, useLocalSearchParams } from "expo-router";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3
4export default function ReviewRoute() {
5 const { environmentId, threadId } = useLocalSearchParams<{
6 environmentId: EnvironmentId;
7 threadId: ThreadId;
8 }>();
9
10 return (
11 <Redirect
12 href={{
13 pathname: "/threads/[environmentId]/[threadId]/review",
14 params: { environmentId, threadId },
15 }}
16 />
17 );
18}
apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen";
2
3export default function ThreadRoute() {
4 return <ThreadRouteScreen />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { ReviewCommentComposerSheet } from "../../../../features/review/ReviewCommentComposerSheet";
2
3export default function ReviewCommentRoute() {
4 return <ReviewCommentComposerSheet />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx deleted-10
...@@ -1,10 +0,0 @@
1import { ReviewHighlighterProvider } from "../../../../features/review/ReviewHighlighterProvider";
2import { ReviewSheet } from "../../../../features/review/ReviewSheet";
3
4export default function ReviewRoute() {
5 return (
6 <ReviewHighlighterProvider>
7 <ReviewSheet />
8 </ReviewHighlighterProvider>
9 );
10}
apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx deleted-5
...@@ -1,5 +0,0 @@
1import { ThreadTerminalRouteScreen } from "../../../../features/terminal/ThreadTerminalRouteScreen";
2
3export default function ThreadTerminalRoute() {
4 return <ThreadTerminalRouteScreen />;
5}
apps/mobile/src/components/AppText.tsx+2-3
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import type { Ref } from "react";
2import {1import {
3 Text as RNText,2 Text as RNText,
4 TextInput as RNTextInput,3 TextInput as RNTextInput,
...@@ -21,7 +20,7 @@ export function AppText({ className, ...props }: AppTextProps) {...@@ -21,7 +20,7 @@ export function AppText({ className, ...props }: AppTextProps) {
2120
22export type AppTextInputProps = RNTextInputProps & {21export type AppTextInputProps = RNTextInputProps & {
23 readonly className?: string;22 readonly className?: string;
24 readonly ref?: Ref<RNTextInput>;23 readonly ref?: React.Ref<RNTextInput>;
25};24};
2625
27/**26/**
...@@ -40,7 +39,7 @@ export function AppTextInput({...@@ -40,7 +39,7 @@ export function AppTextInput({
40 <RNTextInput39 <RNTextInput
41 ref={ref}40 ref={ref}
42 className={cn(41 className={cn(
43 "min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground",42 "min-h-13.5 rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground",
44 className,43 className,
45 )}44 )}
46 placeholderTextColor={placeholderTextColor ?? placeholderColor}45 placeholderTextColor={placeholderTextColor ?? placeholderColor}
apps/mobile/src/components/ControlPill.tsx+1-1
...@@ -75,7 +75,7 @@ export function ControlPill(props: {...@@ -75,7 +75,7 @@ export function ControlPill(props: {
75}75}
7676
77export function ControlPillMenu(77export function ControlPillMenu(
78 props: Omit<ComponentProps<typeof MenuView>, "children" | "style" | "themeVariant"> & {78 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & {
79 readonly children: ReactNode;79 readonly children: ReactNode;
80 },80 },
81) {81) {
apps/mobile/src/components/EmptyState.tsx+23-1
...@@ -7,11 +7,33 @@ export function EmptyState(props: {...@@ -7,11 +7,33 @@ export function EmptyState(props: {
7 readonly detail: string;7 readonly detail: string;
8 readonly actionLabel?: string;8 readonly actionLabel?: string;
9 readonly onAction?: () => void;9 readonly onAction?: () => void;
10 readonly variant?: "card" | "plain";
10}) {11}) {
12 if (props.variant === "plain") {
13 return (
14 <View className="items-center px-8 py-8">
15 <Text className="text-center text-xl font-t3-bold text-foreground">{props.title}</Text>
16 <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted">
17 {props.detail}
18 </Text>
19 {props.actionLabel && props.onAction ? (
20 <Pressable
21 className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70"
22 onPress={props.onAction}
23 >
24 <Text className="text-sm font-t3-bold text-primary-foreground">
25 {props.actionLabel}
26 </Text>
27 </Pressable>
28 ) : null}
29 </View>
30 );
31 }
32
11 return (33 return (
12 <View className="rounded-[22px] border border-border bg-card p-5">34 <View className="rounded-[22px] border border-border bg-card p-5">
13 <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text>35 <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text>
14 <Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted">36 <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted">
15 {props.detail}37 {props.detail}
16 </Text>38 </Text>
17 {props.actionLabel && props.onAction ? (39 {props.actionLabel && props.onAction ? (
apps/mobile/src/components/ErrorBanner.tsx+1-1
...@@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText";...@@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText";
4export function ErrorBanner(props: { readonly message: string }) {4export function ErrorBanner(props: { readonly message: string }) {
5 return (5 return (
6 <View className="rounded-2xl border border-rose-300/70 bg-rose-100/80 px-3.5 py-3 dark:border-rose-400/28 dark:bg-rose-500/12">6 <View className="rounded-2xl border border-rose-300/70 bg-rose-100/80 px-3.5 py-3 dark:border-rose-400/28 dark:bg-rose-500/12">
7 <Text className="font-t3-medium text-sm leading-[18px] text-rose-700 dark:text-rose-300">7 <Text className="font-t3-medium text-sm text-rose-700 dark:text-rose-300">
8 {props.message}8 {props.message}
9 </Text>9 </Text>
10 </View>10 </View>
apps/mobile/src/features/agent-awareness/notificationNavigation.ts+4-4
...@@ -1,12 +1,12 @@...@@ -1,12 +1,12 @@
1import { useEffect, useRef } from "react";1import { useEffect, useRef } from "react";
2import * as Notifications from "expo-notifications";2import * as Notifications from "expo-notifications";
3import { useRouter } from "expo-router";3import { useLinkTo } from "@react-navigation/native";
44
5import { routeAgentNotificationResponseOnce } from "./notificationPayload";5import { routeAgentNotificationResponseOnce } from "./notificationPayload";
6import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";6import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
77
8export function useAgentNotificationNavigation(): void {8export function useAgentNotificationNavigation(): void {
9 const router = useRouter();9 const linkTo = useLinkTo();
10 const handledResponseIds = useRef(new Set<string>());10 const handledResponseIds = useRef(new Set<string>());
1111
12 useEffect(() => {12 useEffect(() => {
...@@ -14,7 +14,7 @@ export function useAgentNotificationNavigation(): void {...@@ -14,7 +14,7 @@ export function useAgentNotificationNavigation(): void {
14 routeAgentNotificationResponseOnce({14 routeAgentNotificationResponseOnce({
15 handledResponseIds: handledResponseIds.current,15 handledResponseIds: handledResponseIds.current,
16 response,16 response,
17 navigate: (deepLink) => router.push(deepLink as never),17 navigate: linkTo,
18 });18 });
19 };19 };
2020
...@@ -28,5 +28,5 @@ export function useAgentNotificationNavigation(): void {...@@ -28,5 +28,5 @@ export function useAgentNotificationNavigation(): void {
28 return () => {28 return () => {
29 subscription.remove();29 subscription.remove();
30 };30 };
31 }, [router]);31 }, [linkTo]);
32}32}
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
3import * as Order from "effect/Order";3import * as Order from "effect/Order";
4import { useFocusEffect } from "expo-router";4import { useFocusEffect } from "@react-navigation/native";
5import { useCallback, useMemo, useState } from "react";5import { useCallback, useMemo, useState } from "react";
66
7import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";7import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+361-245
...@@ -2,23 +2,22 @@ import type {...@@ -2,23 +2,22 @@ import type {
2 EnvironmentProject,2 EnvironmentProject,
3 EnvironmentThreadShell,3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import { LegendList } from "@legendapp/list/react-native";
5import type { EnvironmentId } from "@t3tools/contracts";6import type { EnvironmentId } from "@t3tools/contracts";
6import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
7import * as Haptics from "expo-haptics";8import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
8import { Stack } from "expo-router";
9import { SymbolView } from "expo-symbols";9import { SymbolView } from "expo-symbols";
10import { useCallback, useRef } from "react";10import { useCallback, useMemo, useRef, type ComponentProps } from "react";
11import {11import {
12 ActivityIndicator,12 ActivityIndicator,
13 Platform,
13 Pressable,14 Pressable,
14 RefreshControl,15 RefreshControl,
15 ScrollView,
16 useWindowDimensions,16 useWindowDimensions,
17 View,17 View,
18} from "react-native";18} from "react-native";
19import ReanimatedSwipeable, {19import { Gesture, GestureDetector } from "react-native-gesture-handler";
20 type SwipeableMethods,20import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
21} from "react-native-gesture-handler/ReanimatedSwipeable";
2221
23import { AppText as Text } from "../../components/AppText";22import { AppText as Text } from "../../components/AppText";
24import { ControlPillMenu } from "../../components/ControlPill";23import { ControlPillMenu } from "../../components/ControlPill";
...@@ -26,11 +25,8 @@ import { EmptyState } from "../../components/EmptyState";...@@ -26,11 +25,8 @@ import { EmptyState } from "../../components/EmptyState";
26import { ProjectFavicon } from "../../components/ProjectFavicon";25import { ProjectFavicon } from "../../components/ProjectFavicon";
27import { relativeTime } from "../../lib/time";26import { relativeTime } from "../../lib/time";
28import { useThemeColor } from "../../lib/useThemeColor";27import { useThemeColor } from "../../lib/useThemeColor";
29import {28import { ThreadSwipeable } from "../home/thread-swipe-actions";
30 THREAD_SWIPE_ACTIONS_WIDTH,29import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
31 THREAD_SWIPE_SPRING,
32 ThreadSwipeActions,
33} from "../home/thread-swipe-actions";
34import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";30import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
3531
36export interface ArchivedThreadsHeaderEnvironment {32export interface ArchivedThreadsHeaderEnvironment {
...@@ -52,84 +48,185 @@ const THREAD_ACTIONS: MenuAction[] = [...@@ -52,84 +48,185 @@ const THREAD_ACTIONS: MenuAction[] = [
52 },48 },
53];49];
5450
51type ArchivedThreadListItem =
52 | {
53 readonly kind: "project";
54 readonly key: string;
55 readonly environmentLabel: string | null;
56 readonly project: EnvironmentProject;
57 }
58 | {
59 readonly kind: "thread";
60 readonly key: string;
61 readonly environmentLabel: string | null;
62 readonly isFirst: boolean;
63 readonly isLast: boolean;
64 readonly thread: EnvironmentThreadShell;
65 };
66
55function ArchivedThreadsHeader(props: {67function ArchivedThreadsHeader(props: {
56 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;68 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
57 readonly selectedEnvironmentId: EnvironmentId | null;69 readonly selectedEnvironmentId: EnvironmentId | null;
58 readonly sortOrder: ArchivedThreadSortOrder;70 readonly sortOrder: ArchivedThreadSortOrder;
59 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;71 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
72 readonly onRefresh: () => void;
60 readonly onSearchQueryChange: (query: string) => void;73 readonly onSearchQueryChange: (query: string) => void;
61 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;74 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
62}) {75}) {
76 const { width } = useWindowDimensions();
63 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";77 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";
78 const usesNativeChrome = Platform.OS === "ios";
79 const usesCompactMailToolbar = Platform.OS === "ios" && width < 700;
80 const archiveFilterMenu = {
81 title: "Archived thread options",
82 items: [
83 {
84 type: "submenu" as const,
85 title: "Environment",
86 items: [
87 {
88 type: "action" as const,
89 title: "All environments",
90 state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const),
91 onPress: () => props.onEnvironmentChange(null),
92 },
93 ...props.environments.map((environment) => ({
94 type: "action" as const,
95 title: environment.label,
96 state:
97 props.selectedEnvironmentId === environment.environmentId
98 ? ("on" as const)
99 : ("off" as const),
100 onPress: () => props.onEnvironmentChange(environment.environmentId),
101 })),
102 ],
103 },
104 {
105 type: "submenu" as const,
106 title: "Sort by archived date",
107 items: [
108 {
109 type: "action" as const,
110 title: "Newest first",
111 state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const),
112 onPress: () => props.onSortOrderChange("newest"),
113 },
114 {
115 type: "action" as const,
116 title: "Oldest first",
117 state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const),
118 onPress: () => props.onSortOrderChange("oldest"),
119 },
120 ],
121 },
122 ],
123 };
64124
65 return (125 return (
66 <>126 <>
67 <Stack.Screen127 {/* Static header config (glass preset + title) lives in Stack.tsx; only
128 dynamic toolbar/search wiring is set here. */}
129 <NativeStackScreenOptions
68 options={{130 options={{
69 title: "Archived Threads",131 unstable_headerToolbarItems: usesCompactMailToolbar
70 headerSearchBarOptions: {132 ? () => [
71 autoCapitalize: "none",133 createNativeMailSearchToolbarItem({
72 hideNavigationBar: false,134 composeButtonId: "archived-refresh",
73 obscureBackground: false,135 composeSystemImageName: "arrow.clockwise",
74 placeholder: "Search archived threads",136 filterMenu: archiveFilterMenu,
75 placement: "stacked",137 filterButtonId: "archived-filter",
76 onChangeText: (event) => {138 filterSystemImageName: hasCustomFilter
77 props.onSearchQueryChange(event.nativeEvent.text);139 ? "line.3.horizontal.decrease.circle.fill"
78 },140 : "line.3.horizontal.decrease",
79 onCancelButtonPress: () => {141 onComposePress: props.onRefresh,
80 props.onSearchQueryChange("");142 onSearchTextChange: props.onSearchQueryChange,
81 },143 placeholder: "Search",
82 },144 searchTextChangeId: "archived-search-text",
145 }),
146 ]
147 : undefined,
148 headerSearchBarOptions: usesCompactMailToolbar
149 ? undefined
150 : {
151 ...(usesNativeChrome
152 ? {
153 allowToolbarIntegration: true,
154 placement: "integratedButton" as const,
155 }
156 : {
157 placement: "stacked" as const,
158 }),
159 autoCapitalize: "none",
160 hideNavigationBar: false,
161 obscureBackground: false,
162 placeholder: "Search archived threads",
163 onChangeText: (event) => {
164 props.onSearchQueryChange(event.nativeEvent.text);
165 },
166 onCancelButtonPress: () => {
167 props.onSearchQueryChange("");
168 },
169 },
83 }}170 }}
84 />171 />
85172
86 <Stack.Toolbar placement="right">173 {usesCompactMailToolbar ? null : (
87 <Stack.Toolbar.Menu174 <NativeHeaderToolbar placement="right">
88 accessibilityLabel="Filter and sort archived threads"175 {usesNativeChrome ? (
89 icon={176 <NativeHeaderToolbar.Button
90 hasCustomFilter177 accessibilityLabel="Refresh archived threads"
91 ? "line.3.horizontal.decrease.circle.fill"178 icon="arrow.clockwise"
92 : "line.3.horizontal.decrease.circle"179 onPress={props.onRefresh}
93 }180 separateBackground
94 separateBackground181 />
95 title="Archived thread options"182 ) : null}
96 >183 <NativeHeaderToolbar.Menu
97 <Stack.Toolbar.Menu title="Environment">184 accessibilityLabel="Filter and sort archived threads"
98 <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>185 icon={
99 <Stack.Toolbar.MenuAction186 hasCustomFilter
100 isOn={props.selectedEnvironmentId === null}187 ? "line.3.horizontal.decrease.circle.fill"
101 onPress={() => props.onEnvironmentChange(null)}188 : "line.3.horizontal.decrease.circle"
102 >189 }
103 <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>190 separateBackground
104 </Stack.Toolbar.MenuAction>191 title="Archived thread options"
105 {props.environments.map((environment) => (192 >
106 <Stack.Toolbar.MenuAction193 <NativeHeaderToolbar.Menu title="Environment">
107 key={environment.environmentId}194 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
108 isOn={props.selectedEnvironmentId === environment.environmentId}195 <NativeHeaderToolbar.MenuAction
109 onPress={() => props.onEnvironmentChange(environment.environmentId)}196 isOn={props.selectedEnvironmentId === null}
197 onPress={() => props.onEnvironmentChange(null)}
110 >198 >
111 <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>199 <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>
112 </Stack.Toolbar.MenuAction>200 </NativeHeaderToolbar.MenuAction>
113 ))}201 {props.environments.map((environment) => (
114 </Stack.Toolbar.Menu>202 <NativeHeaderToolbar.MenuAction
203 key={environment.environmentId}
204 isOn={props.selectedEnvironmentId === environment.environmentId}
205 onPress={() => props.onEnvironmentChange(environment.environmentId)}
206 >
207 <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label>
208 </NativeHeaderToolbar.MenuAction>
209 ))}
210 </NativeHeaderToolbar.Menu>
115211
116 <Stack.Toolbar.Menu title="Sort by archived date">212 <NativeHeaderToolbar.Menu title="Sort by archived date">
117 <Stack.Toolbar.Label>Sort by archived date</Stack.Toolbar.Label>213 <NativeHeaderToolbar.Label>Sort by archived date</NativeHeaderToolbar.Label>
118 <Stack.Toolbar.MenuAction214 <NativeHeaderToolbar.MenuAction
119 isOn={props.sortOrder === "newest"}215 isOn={props.sortOrder === "newest"}
120 onPress={() => props.onSortOrderChange("newest")}216 onPress={() => props.onSortOrderChange("newest")}
121 >217 >
122 <Stack.Toolbar.Label>Newest first</Stack.Toolbar.Label>218 <NativeHeaderToolbar.Label>Newest first</NativeHeaderToolbar.Label>
123 </Stack.Toolbar.MenuAction>219 </NativeHeaderToolbar.MenuAction>
124 <Stack.Toolbar.MenuAction220 <NativeHeaderToolbar.MenuAction
125 isOn={props.sortOrder === "oldest"}221 isOn={props.sortOrder === "oldest"}
126 onPress={() => props.onSortOrderChange("oldest")}222 onPress={() => props.onSortOrderChange("oldest")}
127 >223 >
128 <Stack.Toolbar.Label>Oldest first</Stack.Toolbar.Label>224 <NativeHeaderToolbar.Label>Oldest first</NativeHeaderToolbar.Label>
129 </Stack.Toolbar.MenuAction>225 </NativeHeaderToolbar.MenuAction>
130 </Stack.Toolbar.Menu>226 </NativeHeaderToolbar.Menu>
131 </Stack.Toolbar.Menu>227 </NativeHeaderToolbar.Menu>
132 </Stack.Toolbar>228 </NativeHeaderToolbar>
229 )}
133 </>230 </>
134 );231 );
135}232}
...@@ -164,30 +261,25 @@ function ProjectGroupLabel(props: {...@@ -164,30 +261,25 @@ function ProjectGroupLabel(props: {
164261
165function ArchivedThreadRow(props: {262function ArchivedThreadRow(props: {
166 readonly environmentLabel: string | null;263 readonly environmentLabel: string | null;
264 readonly isFirst: boolean;
167 readonly isLast: boolean;265 readonly isLast: boolean;
168 readonly onDelete: () => void;266 readonly onDelete: () => void;
169 readonly onSwipeableClose: (methods: SwipeableMethods) => void;267 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
170 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;268 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
269 readonly simultaneousSwipeGesture?: ComponentProps<
270 typeof ThreadSwipeable
271 >["simultaneousWithExternalGesture"];
171 readonly onUnarchive: () => void;272 readonly onUnarchive: () => void;
172 readonly thread: EnvironmentThreadShell;273 readonly thread: EnvironmentThreadShell;
173}) {274}) {
174 const swipeableRef = useRef<SwipeableMethods | null>(null);
175 const fullSwipeArmedRef = useRef(false);
176 const { width: windowWidth } = useWindowDimensions();275 const { width: windowWidth } = useWindowDimensions();
177 const cardColor = useThemeColor("--color-card");276 const cardColor = useThemeColor("--color-card");
178 const iconColor = useThemeColor("--color-icon-subtle");277 const iconColor = useThemeColor("--color-icon-subtle");
179 const separatorColor = useThemeColor("--color-separator");278 const separatorColor = useThemeColor("--color-separator");
180 const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58);
181 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);279 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
182 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>280 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
183 Boolean(part),281 Boolean(part),
184 );282 );
185 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
186 if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
187 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
188 }
189 fullSwipeArmedRef.current = armed;
190 }, []);
191 const handleMenuAction = useCallback(283 const handleMenuAction = useCallback(
192 (event: { nativeEvent: { event: string } }) => {284 (event: { nativeEvent: { event: string } }) => {
193 if (event.nativeEvent.event === "unarchive") {285 if (event.nativeEvent.event === "unarchive") {
...@@ -200,114 +292,84 @@ function ArchivedThreadRow(props: {...@@ -200,114 +292,84 @@ function ArchivedThreadRow(props: {
200 );292 );
201293
202 return (294 return (
203 <ReanimatedSwipeable295 <ThreadSwipeable
204 ref={swipeableRef}296 backgroundColor={cardColor}
205 animationOptions={THREAD_SWIPE_SPRING}297 fullSwipeWidth={windowWidth - 32}
206 childrenContainerStyle={{ backgroundColor: cardColor }}298 onDelete={props.onDelete}
207 containerStyle={{ backgroundColor: cardColor }}299 onSwipeableClose={props.onSwipeableClose}
208 dragOffsetFromRightEdge={8}300 onSwipeableWillOpen={props.onSwipeableWillOpen}
209 enableTrackpadTwoFingerGesture301 primaryAction={{
210 friction={1}302 accessibilityLabel: `Unarchive ${props.thread.title}`,
211 onSwipeableClose={() => {303 icon: "arrow.uturn.backward",
212 fullSwipeArmedRef.current = false;304 label: "Unarchive",
213 if (swipeableRef.current) {305 onPress: props.onUnarchive,
214 props.onSwipeableClose(swipeableRef.current);
215 }
216 }}
217 onSwipeableOpenStartDrag={() => {
218 if (swipeableRef.current) {
219 props.onSwipeableWillOpen(swipeableRef.current);
220 }
221 }}306 }}
222 onSwipeableWillOpen={() => {307 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
223 const methods = swipeableRef.current;308 threadTitle={props.thread.title}
224 if (!methods) return;
225
226 props.onSwipeableWillOpen(methods);
227 if (fullSwipeArmedRef.current) {
228 fullSwipeArmedRef.current = false;
229 methods.close();
230 props.onDelete();
231 }
232 }}
233 overshootFriction={1}
234 overshootRight
235 renderRightActions={(_progress, translation, methods) => (
236 <ThreadSwipeActions
237 backgroundColor={cardColor}
238 fullSwipeThreshold={fullSwipeThreshold}
239 onDelete={props.onDelete}
240 onFullSwipeArmedChange={handleFullSwipeArmedChange}
241 primaryAction={{
242 accessibilityLabel: `Unarchive ${props.thread.title}`,
243 icon: "arrow.uturn.backward",
244 label: "Unarchive",
245 onPress: props.onUnarchive,
246 }}
247 swipeableMethods={methods}
248 threadTitle={props.thread.title}
249 translation={translation}
250 />
251 )}
252 rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
253 >309 >
254 <View310 {() => (
255 className="flex-row items-center gap-3 bg-card px-4 py-3"311 <View
256 style={{312 className="flex-row items-center gap-3 bg-card px-4 py-3"
257 borderBottomColor: separatorColor,313 style={{
258 borderBottomWidth: props.isLast ? 0 : 1,314 borderBottomColor: separatorColor,
259 }}315 borderBottomWidth: props.isLast ? 0 : 1,
260 >316 borderTopLeftRadius: props.isFirst ? 20 : 0,
261 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">317 borderTopRightRadius: props.isFirst ? 20 : 0,
262 <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />318 borderBottomLeftRadius: props.isLast ? 20 : 0,
263 </View>319 borderBottomRightRadius: props.isLast ? 20 : 0,
264320 }}
265 <View className="min-w-0 flex-1 gap-1">321 >
266 <View className="flex-row items-center gap-2">322 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
267 <Text323 <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />
268 className="min-w-0 flex-1 text-base font-t3-bold leading-[20px] text-foreground"
269 numberOfLines={1}
270 >
271 {props.thread.title}
272 </Text>
273 <Text
274 className="text-xs text-foreground-tertiary"
275 style={{ fontVariant: ["tabular-nums"] }}
276 >
277 {timestamp}
278 </Text>
279 </View>324 </View>
280 {subtitle.length > 0 ? (325
281 <View className="flex-row items-center gap-1.5">326 <View className="min-w-0 flex-1 gap-1">
282 <SymbolView327 <View className="flex-row items-center gap-2">
283 name="arrow.triangle.branch"
284 size={10}
285 tintColor={iconColor}
286 type="monochrome"
287 />
288 <Text328 <Text
289 className="min-w-0 flex-1 text-2xs text-foreground-tertiary"329 className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground"
290 numberOfLines={1}330 numberOfLines={1}
291 style={{ fontFamily: "monospace" }}
292 >331 >
293 {subtitle.join(" · ")}332 {props.thread.title}
333 </Text>
334 <Text
335 className="text-xs text-foreground-tertiary"
336 style={{ fontVariant: ["tabular-nums"] }}
337 >
338 {timestamp}
294 </Text>339 </Text>
295 </View>340 </View>
296 ) : null}341 {subtitle.length > 0 ? (
297 </View>342 <View className="flex-row items-center gap-1.5">
343 <SymbolView
344 name="arrow.triangle.branch"
345 size={10}
346 tintColor={iconColor}
347 type="monochrome"
348 />
349 <Text
350 className="min-w-0 flex-1 text-2xs text-foreground-tertiary"
351 numberOfLines={1}
352 style={{ fontFamily: "monospace" }}
353 >
354 {subtitle.join(" · ")}
355 </Text>
356 </View>
357 ) : null}
358 </View>
298359
299 <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>360 <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>
300 <Pressable361 <Pressable
301 accessibilityLabel={`Actions for ${props.thread.title}`}362 accessibilityLabel={`Actions for ${props.thread.title}`}
302 accessibilityRole="button"363 accessibilityRole="button"
303 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"364 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
304 hitSlop={6}365 hitSlop={6}
305 >366 >
306 <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />367 <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />
307 </Pressable>368 </Pressable>
308 </ControlPillMenu>369 </ControlPillMenu>
309 </View>370 </View>
310 </ReanimatedSwipeable>371 )}
372 </ThreadSwipeable>
311 );373 );
312}374}
313375
...@@ -317,7 +379,7 @@ function ArchiveError(props: { readonly message: string; readonly onRetry: () =>...@@ -317,7 +379,7 @@ function ArchiveError(props: { readonly message: string; readonly onRetry: () =>
317 <Text className="text-base font-t3-bold text-danger-foreground">379 <Text className="text-base font-t3-bold text-danger-foreground">
318 Could not load every archive380 Could not load every archive
319 </Text>381 </Text>
320 <Text className="mt-1 text-sm leading-[18px] text-foreground-muted">{props.message}</Text>382 <Text className="mt-1 text-sm text-foreground-muted">{props.message}</Text>
321 <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}>383 <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}>
322 <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text>384 <Text className="text-sm font-t3-bold text-danger-foreground">Try again</Text>
323 </Pressable>385 </Pressable>
...@@ -340,8 +402,41 @@ export function ArchivedThreadsScreen(props: {...@@ -340,8 +402,41 @@ export function ArchivedThreadsScreen(props: {
340 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;402 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
341 readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;403 readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;
342}) {404}) {
405 const { onDeleteThread, onUnarchiveThread } = props;
343 const openSwipeableRef = useRef<SwipeableMethods | null>(null);406 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
407 const archiveScrollGesture = useMemo(() => Gesture.Native(), []);
344 const refreshTint = useThemeColor("--color-icon");408 const refreshTint = useThemeColor("--color-icon");
409 const environmentLabelsById = useMemo(
410 () =>
411 new Map(
412 props.environments.map((environment) => [environment.environmentId, environment.label]),
413 ),
414 [props.environments],
415 );
416 const listItems = useMemo<ReadonlyArray<ArchivedThreadListItem>>(() => {
417 const items: ArchivedThreadListItem[] = [];
418 for (const group of props.groups) {
419 const environmentLabel = environmentLabelsById.get(group.project.environmentId) ?? null;
420 items.push({
421 kind: "project",
422 key: `${group.key}:project`,
423 environmentLabel,
424 project: group.project,
425 });
426
427 group.threads.forEach((thread, index) => {
428 items.push({
429 kind: "thread",
430 key: `${thread.environmentId}:${thread.id}`,
431 environmentLabel,
432 isFirst: index === 0,
433 isLast: index === group.threads.length - 1,
434 thread,
435 });
436 });
437 }
438 return items;
439 }, [environmentLabelsById, props.groups]);
345 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {440 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
346 if (openSwipeableRef.current && openSwipeableRef.current !== methods) {441 if (openSwipeableRef.current && openSwipeableRef.current !== methods) {
347 openSwipeableRef.current.close();442 openSwipeableRef.current.close();
...@@ -355,82 +450,103 @@ export function ArchivedThreadsScreen(props: {...@@ -355,82 +450,103 @@ export function ArchivedThreadsScreen(props: {
355 }, []);450 }, []);
356 const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null;451 const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null;
357 const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null;452 const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null;
453 const renderListItem = useCallback(
454 ({ item }: { item: ArchivedThreadListItem }) => {
455 if (item.kind === "project") {
456 return (
457 <View className="pt-4">
458 <ProjectGroupLabel environmentLabel={item.environmentLabel} project={item.project} />
459 </View>
460 );
461 }
462
463 return (
464 <ArchivedThreadRow
465 environmentLabel={item.environmentLabel}
466 isFirst={item.isFirst}
467 isLast={item.isLast}
468 onDelete={() => onDeleteThread(item.thread)}
469 onSwipeableClose={handleSwipeableClose}
470 onSwipeableWillOpen={handleSwipeableWillOpen}
471 onUnarchive={() => onUnarchiveThread(item.thread)}
472 simultaneousSwipeGesture={archiveScrollGesture}
473 thread={item.thread}
474 />
475 );
476 },
477 [
478 archiveScrollGesture,
479 handleSwipeableClose,
480 handleSwipeableWillOpen,
481 onDeleteThread,
482 onUnarchiveThread,
483 ],
484 );
485 const listEmptyComponent = useMemo(() => {
486 if (isInitialLoad) {
487 return (
488 <View className="items-center py-16">
489 <ActivityIndicator color={refreshTint} />
490 <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text>
491 </View>
492 );
493 }
494
495 return (
496 <EmptyState
497 detail={
498 isFiltered
499 ? "Try another search or environment."
500 : "Threads you archive will appear here."
501 }
502 title={isFiltered ? "No matching threads" : "No archived threads"}
503 />
504 );
505 }, [isFiltered, isInitialLoad, refreshTint]);
358506
359 return (507 return (
360 <View className="flex-1 bg-sheet">508 <View className="flex-1 bg-sheet">
361 <ArchivedThreadsHeader509 <ArchivedThreadsHeader
362 environments={props.environments}510 environments={props.environments}
363 onEnvironmentChange={props.onEnvironmentChange}511 onEnvironmentChange={props.onEnvironmentChange}
512 onRefresh={props.onRefresh}
364 onSearchQueryChange={props.onSearchQueryChange}513 onSearchQueryChange={props.onSearchQueryChange}
365 onSortOrderChange={props.onSortOrderChange}514 onSortOrderChange={props.onSortOrderChange}
366 selectedEnvironmentId={props.selectedEnvironmentId}515 selectedEnvironmentId={props.selectedEnvironmentId}
367 sortOrder={props.sortOrder}516 sortOrder={props.sortOrder}
368 />517 />
369518
370 <ScrollView519 <GestureDetector gesture={archiveScrollGesture}>
371 className="flex-1"520 <LegendList
372 contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 16, paddingTop: 8 }}521 className="flex-1"
373 contentInsetAdjustmentBehavior="automatic"522 contentContainerStyle={{
374 keyboardDismissMode="on-drag"523 paddingBottom: 32,
375 keyboardShouldPersistTaps="handled"524 paddingHorizontal: 16,
376 onScrollBeginDrag={() => openSwipeableRef.current?.close()}525 paddingTop: 4,
377 refreshControl={526 }}
378 <RefreshControl527 contentInsetAdjustmentBehavior="automatic"
379 onRefresh={props.onRefresh}528 data={listItems}
380 refreshing={props.isLoading && !isInitialLoad}529 estimatedItemSize={62}
381 tintColor={String(refreshTint)}530 getItemType={(item) => item.kind}
382 />531 keyboardDismissMode="on-drag"
383 }532 keyboardShouldPersistTaps="handled"
384 showsVerticalScrollIndicator={false}533 keyExtractor={(item) => item.key}
385 >534 ListEmptyComponent={listEmptyComponent}
386 {props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null}535 ListHeaderComponent={
387536 props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null
388 {isInitialLoad ? (537 }
389 <View className="items-center py-16">538 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
390 <ActivityIndicator color={refreshTint} />539 refreshControl={
391 <Text className="mt-3 text-sm text-foreground-muted">Loading archive…</Text>540 <RefreshControl
392 </View>541 onRefresh={props.onRefresh}
393 ) : props.groups.length === 0 ? (542 refreshing={props.isLoading && !isInitialLoad}
394 <EmptyState543 tintColor={String(refreshTint)}
395 detail={544 />
396 isFiltered545 }
397 ? "Try another search or environment."546 renderItem={renderListItem}
398 : "Threads you archive will appear here."547 showsVerticalScrollIndicator={false}
399 }548 />
400 title={isFiltered ? "No matching threads" : "No archived threads"}549 </GestureDetector>
401 />
402 ) : (
403 props.groups.map((group) => {
404 const environmentLabel =
405 props.environments.find(
406 (environment) => environment.environmentId === group.project.environmentId,
407 )?.label ?? null;
408
409 return (
410 <View key={group.key} collapsable={false}>
411 <ProjectGroupLabel environmentLabel={environmentLabel} project={group.project} />
412 <View
413 className="overflow-hidden rounded-[20px] bg-card"
414 style={{ borderCurve: "continuous" }}
415 >
416 {group.threads.map((thread, index) => (
417 <ArchivedThreadRow
418 key={`${thread.environmentId}:${thread.id}`}
419 environmentLabel={environmentLabel}
420 isLast={index === group.threads.length - 1}
421 onDelete={() => props.onDeleteThread(thread)}
422 onSwipeableClose={handleSwipeableClose}
423 onSwipeableWillOpen={handleSwipeableWillOpen}
424 onUnarchive={() => props.onUnarchiveThread(thread)}
425 thread={thread}
426 />
427 ))}
428 </View>
429 </View>
430 );
431 })
432 )}
433 </ScrollView>
434 </View>550 </View>
435 );551 );
436}552}
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+11-43
...@@ -2,7 +2,6 @@ import { useWaitlist } from "@clerk/expo";...@@ -2,7 +2,6 @@ import { useWaitlist } from "@clerk/expo";
2import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";2import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
3import { useState } from "react";3import { useState } from "react";
44
5import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
6import { useThemeColor } from "../../lib/useThemeColor";5import { useThemeColor } from "../../lib/useThemeColor";
7import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin";6import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin";
87
...@@ -36,8 +35,10 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }...@@ -36,8 +35,10 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
36 if (waitlist.id) {35 if (waitlist.id) {
37 return (36 return (
38 <View style={styles.content}>37 <View style={styles.content}>
39 <Text style={[styles.title, { color: colors.foreground }]}>You are on the waitlist</Text>38 <Text className="text-center font-t3-bold text-xl text-foreground">
40 <Text style={[styles.body, styles.confirmationBody, { color: colors.secondaryForeground }]}>39 You are on the waitlist
40 </Text>
41 <Text className="text-center font-sans text-base text-foreground-secondary">
41 We will email you when your T3 Cloud access is ready.42 We will email you when your T3 Cloud access is ready.
42 </Text>43 </Text>
43 <SignInAction onPress={props.onSignIn} />44 <SignInAction onPress={props.onSignIn} />
...@@ -47,17 +48,18 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }...@@ -47,17 +48,18 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
4748
48 return (49 return (
49 <View style={styles.content}>50 <View style={styles.content}>
50 <Text style={[styles.body, { color: colors.secondaryForeground }]}>51 <Text className="font-sans text-base text-foreground-secondary">
51 Enter your email and we will let you know when access is ready.52 Enter your email and we will let you know when access is ready.
52 </Text>53 </Text>
5354
54 <View style={styles.field}>55 <View style={styles.field}>
55 <Text style={[styles.label, { color: colors.secondaryForeground }]}>Email address</Text>56 <Text className="font-t3-bold text-sm text-foreground-secondary">Email address</Text>
56 <TextInput57 <TextInput
57 accessibilityLabel="Email address"58 accessibilityLabel="Email address"
58 autoCapitalize="none"59 autoCapitalize="none"
59 autoComplete="email"60 autoComplete="email"
60 autoCorrect={false}61 autoCorrect={false}
62 className="font-sans text-lg text-foreground"
61 keyboardType="email-address"63 keyboardType="email-address"
62 onChangeText={(value) => {64 onChangeText={(value) => {
63 setEmailAddress(value);65 setEmailAddress(value);
...@@ -73,7 +75,6 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }...@@ -73,7 +75,6 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
73 backgroundColor: colors.input,75 backgroundColor: colors.input,
74 borderColor:76 borderColor:
75 fieldError || requestError ? colors.dangerForeground : colors.inputBorder,77 fieldError || requestError ? colors.dangerForeground : colors.inputBorder,
76 color: colors.foreground,
77 },78 },
78 ]}79 ]}
79 textContentType="emailAddress"80 textContentType="emailAddress"
...@@ -82,7 +83,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }...@@ -82,7 +83,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
82 {fieldError || requestError ? (83 {fieldError || requestError ? (
83 <Text84 <Text
84 accessibilityLiveRegion="polite"85 accessibilityLiveRegion="polite"
85 style={[styles.error, { color: colors.dangerForeground }]}86 className="font-sans text-sm text-danger-foreground"
86 selectable87 selectable
87 >88 >
88 {fieldError ?? requestError}89 {fieldError ?? requestError}
...@@ -107,7 +108,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }...@@ -107,7 +108,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
107 ]}108 ]}
108 >109 >
109 {isSubmitting ? <ActivityIndicator color={colors.primaryForeground} size="small" /> : null}110 {isSubmitting ? <ActivityIndicator color={colors.primaryForeground} size="small" /> : null}
110 <Text style={[styles.buttonText, { color: colors.primaryForeground }]}>111 <Text className="font-t3-bold text-base text-primary-foreground">
111 {isSubmitting ? "Joining" : "Join the waitlist"}112 {isSubmitting ? "Joining" : "Join the waitlist"}
112 </Text>113 </Text>
113 </Pressable>114 </Pressable>
...@@ -118,12 +119,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }...@@ -118,12 +119,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
118}119}
119120
120function SignInAction(props: { readonly onPress: () => void }) {121function SignInAction(props: { readonly onPress: () => void }) {
121 const colors = useCloudWaitlistColors();
122 return (122 return (
123 <View style={styles.signInRow}>123 <View style={styles.signInRow}>
124 <Text style={[styles.body, { color: colors.secondaryForeground }]}>Already have access?</Text>124 <Text className="font-sans text-base text-foreground-secondary">Already have access?</Text>
125 <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}>125 <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}>
126 <Text style={[styles.signInText, { color: colors.foreground }]}>Sign in</Text>126 <Text className="font-t3-bold text-base text-foreground">Sign in</Text>
127 </Pressable>127 </Pressable>
128 </View>128 </View>
129 );129 );
...@@ -132,35 +132,18 @@ function SignInAction(props: { readonly onPress: () => void }) {...@@ -132,35 +132,18 @@ function SignInAction(props: { readonly onPress: () => void }) {
132function useCloudWaitlistColors() {132function useCloudWaitlistColors() {
133 return {133 return {
134 dangerForeground: String(useThemeColor("--color-danger-foreground")),134 dangerForeground: String(useThemeColor("--color-danger-foreground")),
135 foreground: String(useThemeColor("--color-foreground")),
136 input: String(useThemeColor("--color-input")),135 input: String(useThemeColor("--color-input")),
137 inputBorder: String(useThemeColor("--color-input-border")),136 inputBorder: String(useThemeColor("--color-input-border")),
138 placeholder: String(useThemeColor("--color-placeholder")),137 placeholder: String(useThemeColor("--color-placeholder")),
139 primary: String(useThemeColor("--color-primary")),138 primary: String(useThemeColor("--color-primary")),
140 primaryForeground: String(useThemeColor("--color-primary-foreground")),139 primaryForeground: String(useThemeColor("--color-primary-foreground")),
141 secondaryForeground: String(useThemeColor("--color-foreground-secondary")),
142 };140 };
143}141}
144142
145const styles = StyleSheet.create({143const styles = StyleSheet.create({
146 body: {
147 fontFamily: "DMSans_400Regular",
148 ...MOBILE_TYPOGRAPHY.body,
149 },
150 buttonText: {
151 fontFamily: "DMSans_700Bold",
152 fontSize: MOBILE_TYPOGRAPHY.body.fontSize,
153 },
154 content: {144 content: {
155 gap: 18,145 gap: 18,
156 },146 },
157 confirmationBody: {
158 textAlign: "center",
159 },
160 error: {
161 fontFamily: "DMSans_400Regular",
162 ...MOBILE_TYPOGRAPHY.footnote,
163 },
164 field: {147 field: {
165 gap: 8,148 gap: 8,
166 },149 },
...@@ -168,16 +151,10 @@ const styles = StyleSheet.create({...@@ -168,16 +151,10 @@ const styles = StyleSheet.create({
168 borderCurve: "continuous",151 borderCurve: "continuous",
169 borderRadius: 16,152 borderRadius: 16,
170 borderWidth: 1,153 borderWidth: 1,
171 fontFamily: "DMSans_400Regular",
172 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
173 minHeight: 54,154 minHeight: 54,
174 paddingHorizontal: 16,155 paddingHorizontal: 16,
175 paddingVertical: 14,156 paddingVertical: 14,
176 },157 },
177 label: {
178 fontFamily: "DMSans_700Bold",
179 ...MOBILE_TYPOGRAPHY.footnote,
180 },
181 primaryButton: {158 primaryButton: {
182 alignItems: "center",159 alignItems: "center",
183 borderRadius: 999,160 borderRadius: 999,
...@@ -195,13 +172,4 @@ const styles = StyleSheet.create({...@@ -195,13 +172,4 @@ const styles = StyleSheet.create({
195 justifyContent: "center",172 justifyContent: "center",
196 paddingTop: 4,173 paddingTop: 4,
197 },174 },
198 signInText: {
199 fontFamily: "DMSans_700Bold",
200 ...MOBILE_TYPOGRAPHY.body,
201 },
202 title: {
203 fontFamily: "DMSans_700Bold",
204 ...MOBILE_TYPOGRAPHY.title,
205 textAlign: "center",
206 },
207});175});
apps/mobile/src/features/cloud/dpop.ts+41-9
...@@ -8,12 +8,7 @@ import * as Schema from "effect/Schema";...@@ -8,12 +8,7 @@ import * as Schema from "effect/Schema";
8import * as ExpoCrypto from "expo-crypto";8import * as ExpoCrypto from "expo-crypto";
9import * as SecureStore from "expo-secure-store";9import * as SecureStore from "expo-secure-store";
10import { p256 } from "@noble/curves/nist";10import { p256 } from "@noble/curves/nist";
11import {11import { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon";
12 computeDpopAccessTokenHash,
13 computeDpopJwkThumbprint,
14 DpopPublicJwk,
15 normalizeDpopHtu,
16} from "@t3tools/shared/dpop";
17import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
1813
19export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{14export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
...@@ -107,6 +102,40 @@ function sha256Digest(...@@ -107,6 +102,40 @@ function sha256Digest(
107 );102 );
108}103}
109104
105function base64UrlSha256(
106 data: Uint8Array,
107 message: string,
108): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
109 return sha256Digest(data, message).pipe(Effect.map(Encoding.encodeBase64Url));
110}
111
112function dpopThumbprintInput(jwk: DpopPublicJwk): string {
113 return JSON.stringify({
114 crv: jwk.crv,
115 kty: jwk.kty,
116 x: jwk.x,
117 y: jwk.y,
118 });
119}
120
121function computeDpopJwkThumbprintEffect(
122 jwk: DpopPublicJwk,
123): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
124 return base64UrlSha256(
125 new TextEncoder().encode(dpopThumbprintInput(jwk)),
126 "Could not hash the DPoP public key thumbprint.",
127 );
128}
129
130function computeDpopAccessTokenHashEffect(
131 accessToken: string,
132): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
133 return base64UrlSha256(
134 new TextEncoder().encode(accessToken),
135 "Could not hash the DPoP access token.",
136 );
137}
138
110function secureRandomBytes(139function secureRandomBytes(
111 byteCount: number,140 byteCount: number,
112 message: string,141 message: string,
...@@ -153,7 +182,7 @@ export function generateDpopProofKeyPair(): Effect.Effect<...@@ -153,7 +182,7 @@ export function generateDpopProofKeyPair(): Effect.Effect<
153 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),182 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),
154 catch: cloudDpopError("Generated DPoP public key is invalid."),183 catch: cloudDpopError("Generated DPoP public key is invalid."),
155 });184 });
156 const thumbprint = computeDpopJwkThumbprint(publicJwk);185 const thumbprint = yield* computeDpopJwkThumbprintEffect(publicJwk);
157 return {186 return {
158 privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk),187 privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk),
159 publicJwk,188 publicJwk,
...@@ -189,9 +218,10 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect<...@@ -189,9 +218,10 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect<
189 },218 },
190 catch: cloudDpopError("Stored DPoP proof key is invalid."),219 catch: cloudDpopError("Stored DPoP proof key is invalid."),
191 });220 });
221 const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk);
192 return {222 return {
193 ...restored,223 ...restored,
194 thumbprint: computeDpopJwkThumbprint(restored.publicJwk),224 thumbprint,
195 };225 };
196 }226 }
197 const generated = yield* generateDpopProofKeyPair();227 const generated = yield* generateDpopProofKeyPair();
...@@ -243,7 +273,9 @@ export function createDpopProof(input: {...@@ -243,7 +273,9 @@ export function createDpopProof(input: {
243 Effect.map(Encoding.encodeBase64Url),273 Effect.map(Encoding.encodeBase64Url),
244 Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),274 Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),
245 );275 );
246 const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null;276 const ath = input.accessToken
277 ? yield* computeDpopAccessTokenHashEffect(input.accessToken)
278 : null;
247 const payload = yield* encodeDpopJwtPayloadJson({279 const payload = yield* encodeDpopJwtPayloadJson({
248 htm: input.method.toUpperCase(),280 htm: input.method.toUpperCase(),
249 htu,281 htu,
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+4-4
...@@ -76,16 +76,16 @@ export function ConnectionEnvironmentRow(props: {...@@ -76,16 +76,16 @@ export function ConnectionEnvironmentRow(props: {
76 />76 />
7777
78 <View className="flex-1 gap-0.5">78 <View className="flex-1 gap-0.5">
79 <Text className="text-base font-t3-bold leading-[21px] text-foreground" numberOfLines={1}>79 <Text className="text-base font-t3-bold leading-snug text-foreground" numberOfLines={1}>
80 {props.environment.environmentLabel}80 {props.environment.environmentLabel}
81 </Text>81 </Text>
82 <Text className="text-xs leading-[16px] text-foreground-muted" numberOfLines={1}>82 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
83 {props.environment.displayUrl}83 {props.environment.displayUrl}
84 </Text>84 </Text>
85 {statusLabel ? (85 {statusLabel ? (
86 <Text86 <Text
87 className={cn(87 className={cn(
88 "text-xs leading-[16px]",88 "text-xs",
89 hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted",89 hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted",
90 )}90 )}
91 numberOfLines={props.expanded ? undefined : 1}91 numberOfLines={props.expanded ? undefined : 1}
...@@ -134,7 +134,7 @@ export function ConnectionEnvironmentRow(props: {...@@ -134,7 +134,7 @@ export function ConnectionEnvironmentRow(props: {
134 className="gap-3 px-4 pb-4"134 className="gap-3 px-4 pb-4"
135 >135 >
136 {props.environment.isRelayManaged ? (136 {props.environment.isRelayManaged ? (
137 <Text className="text-sm leading-[18px] text-foreground-muted">137 <Text className="text-sm text-foreground-muted">
138 Managed by T3 Cloud. Tunnel details update automatically.138 Managed by T3 Cloud. Tunnel details update automatically.
139 </Text>139 </Text>
140 ) : (140 ) : (
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx created+256
...@@ -0,0 +1,256 @@
1import { CameraView, useCameraPermissions } from "expo-camera";
2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
3import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
4import { AsyncResult } from "effect/unstable/reactivity";
5import { useCallback, useEffect, useState } from "react";
6import { Alert, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useThemeColor } from "../../lib/useThemeColor";
9
10import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
11import { ErrorBanner } from "../../components/ErrorBanner";
12import { ConnectionSheetButton } from "./ConnectionSheetButton";
13import { extractPairingUrlFromQrPayload } from "./pairing";
14import { useRemoteConnections } from "../../state/use-remote-environment-registry";
15import { buildPairingUrl, parsePairingUrl } from "./pairing";
16
17type ConnectionsNewRouteParams = {
18 readonly mode?: string;
19};
20
21export function ConnectionsNewRouteScreen({
22 route,
23}: StaticScreenProps<ConnectionsNewRouteParams | undefined>) {
24 const {
25 connectionPairingUrl,
26 onChangeConnectionPairingUrl,
27 onConnectPress,
28 pairingConnectionError,
29 } = useRemoteConnections();
30 const navigation = useNavigation();
31 const params = route.params ?? {};
32 const insets = useSafeAreaInsets();
33 const [hostInput, setHostInput] = useState("");
34 const [codeInput, setCodeInput] = useState("");
35 const [isSubmitting, setIsSubmitting] = useState(false);
36 const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
37 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
38 const [scannerLocked, setScannerLocked] = useState(false);
39
40 const headerIconColor = useThemeColor("--color-icon");
41 const placeholderColor = useThemeColor("--color-placeholder");
42
43 const connectDisabled = isSubmitting || hostInput.trim().length === 0;
44
45 useEffect(() => {
46 const { host, code } = parsePairingUrl(connectionPairingUrl);
47 setHostInput(host);
48 setCodeInput(code);
49 }, [connectionPairingUrl]);
50
51 useEffect(() => {
52 if (pairingConnectionError) {
53 setIsSubmitting(false);
54 }
55 }, [pairingConnectionError]);
56
57 const handleHostChange = useCallback((value: string) => {
58 setHostInput(value);
59 }, []);
60
61 const handleCodeChange = useCallback((value: string) => {
62 setCodeInput(value);
63 }, []);
64
65 const openScanner = useCallback(async () => {
66 if (cameraPermission?.granted) {
67 setScannerLocked(false);
68 setShowScanner(true);
69 return;
70 }
71
72 const permission = await requestCameraPermission();
73 if (permission.granted) {
74 setScannerLocked(false);
75 setShowScanner(true);
76 return;
77 }
78
79 Alert.alert(
80 "Camera access needed",
81 "Allow camera access to scan an environment pairing QR code.",
82 );
83 }, [cameraPermission?.granted, requestCameraPermission]);
84
85 const closeScanner = useCallback(() => {
86 setShowScanner(false);
87 setScannerLocked(false);
88 }, []);
89
90 const handleQrScan = useCallback(
91 ({ data }: { readonly data: string }) => {
92 if (scannerLocked) {
93 return;
94 }
95
96 setScannerLocked(true);
97
98 try {
99 const pairingUrl = extractPairingUrlFromQrPayload(data);
100 const { host, code } = parsePairingUrl(pairingUrl);
101 setHostInput(host);
102 setCodeInput(code);
103 onChangeConnectionPairingUrl(pairingUrl);
104 setShowScanner(false);
105 } catch (error) {
106 Alert.alert(
107 "Invalid QR code",
108 error instanceof Error ? error.message : "Scanned QR code was not recognized.",
109 );
110 } finally {
111 setTimeout(() => {
112 setScannerLocked(false);
113 }, 600);
114 }
115 },
116 [onChangeConnectionPairingUrl, scannerLocked],
117 );
118
119 const handleSubmit = useCallback(async () => {
120 setIsSubmitting(true);
121
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);
133 }
134 }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]);
135
136 return (
137 <View collapsable={false} className="flex-1 bg-sheet">
138 <NativeStackScreenOptions
139 options={{
140 title: showScanner ? "Scan QR Code" : "Add Environment",
141 }}
142 />
143 <NativeHeaderToolbar placement="right">
144 <NativeHeaderToolbar.Button
145 icon={showScanner ? "xmark" : "qrcode.viewfinder"}
146 onPress={() => {
147 if (showScanner) {
148 closeScanner();
149 } else {
150 void openScanner();
151 }
152 }}
153 separateBackground
154 tintColor={headerIconColor}
155 />
156 </NativeHeaderToolbar>
157
158 <ScrollView
159 contentInsetAdjustmentBehavior="automatic"
160 showsVerticalScrollIndicator={false}
161 style={{ flex: 1 }}
162 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
163 contentContainerStyle={{
164 paddingHorizontal: 20,
165 paddingTop: 16,
166 }}
167 >
168 <View collapsable={false} className="gap-5">
169 {showScanner ? (
170 cameraPermission?.granted ? (
171 <View
172 className="overflow-hidden rounded-[24px]"
173 style={{ borderCurve: "continuous" }}
174 >
175 <CameraView
176 barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
177 onBarcodeScanned={handleQrScan}
178 style={{ aspectRatio: 1, width: "100%" }}
179 />
180 </View>
181 ) : (
182 <View
183 className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"
184 style={{ borderCurve: "continuous" }}
185 >
186 <Text className="text-center text-sm leading-normal text-foreground-muted">
187 Camera permission is required to scan a QR code.
188 </Text>
189 <ConnectionSheetButton
190 compact
191 icon="camera"
192 label="Allow camera"
193 tone="secondary"
194 onPress={() => {
195 void openScanner();
196 }}
197 />
198 </View>
199 )
200 ) : (
201 <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4">
202 <View collapsable={false} className="gap-1.5">
203 <Text
204 className="text-2xs font-t3-bold uppercase text-foreground-muted"
205 style={{ letterSpacing: 0.8 }}
206 >
207 Host
208 </Text>
209 <TextInput
210 autoCapitalize="none"
211 autoCorrect={false}
212 keyboardType="url"
213 placeholder="192.168.1.100:8080"
214 placeholderTextColor={placeholderColor}
215 value={hostInput}
216 onChangeText={handleHostChange}
217 className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground"
218 />
219 </View>
220
221 <View collapsable={false} className="gap-1.5">
222 <Text
223 className="text-2xs font-t3-bold uppercase text-foreground-muted"
224 style={{ letterSpacing: 0.8 }}
225 >
226 Pairing code
227 </Text>
228 <TextInput
229 autoCapitalize="none"
230 autoCorrect={false}
231 placeholder="abc-123-xyz"
232 placeholderTextColor={placeholderColor}
233 value={codeInput}
234 onChangeText={handleCodeChange}
235 className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground"
236 />
237 </View>
238
239 {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null}
240
241 <ConnectionSheetButton
242 icon="plus"
243 label={isSubmitting ? "Pairing..." : "Add environment"}
244 disabled={connectDisabled}
245 tone="primary"
246 onPress={() => {
247 void handleSubmit();
248 }}
249 />
250 </View>
251 )}
252 </View>
253 </ScrollView>
254 </View>
255 );
256}
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx created+93
...@@ -0,0 +1,93 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";
3import { SymbolView } from "expo-symbols";
4import type { EnvironmentId } from "@t3tools/contracts";
5import { useCallback, useState } from "react";
6import { ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useThemeColor } from "../../lib/useThemeColor";
9
10import { AppText as Text } from "../../components/AppText";
11import { cn } from "../../lib/cn";
12import { useRemoteConnections } from "../../state/use-remote-environment-registry";
13import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";
14
15export function ConnectionsRouteScreen() {
16 const {
17 connectedEnvironments,
18 onReconnectEnvironment,
19 onRemoveEnvironmentPress,
20 onUpdateEnvironment,
21 } = useRemoteConnections();
22 const navigation = useNavigation();
23 const insets = useSafeAreaInsets();
24 const hasEnvironments = connectedEnvironments.length > 0;
25 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
26
27 const accentColor = useThemeColor("--color-icon-muted");
28
29 const handleToggle = useCallback((environmentId: EnvironmentId) => {
30 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
31 }, []);
32
33 return (
34 <View collapsable={false} className="flex-1 bg-sheet">
35 <NativeHeaderToolbar placement="right">
36 <NativeHeaderToolbar.Button
37 icon="plus"
38 onPress={() => navigation.navigate("ConnectionsNew")}
39 separateBackground
40 />
41 </NativeHeaderToolbar>
42 <ScrollView
43 contentInsetAdjustmentBehavior="automatic"
44 showsVerticalScrollIndicator={false}
45 style={{ flex: 1 }}
46 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
47 contentContainerStyle={{
48 paddingHorizontal: 20,
49 paddingTop: 16,
50 }}
51 >
52 {hasEnvironments ? (
53 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
54 {connectedEnvironments.map((environment, index) => (
55 <View
56 key={environment.environmentId}
57 collapsable={false}
58 style={{
59 borderTopWidth: index === 0 ? 0 : 1,
60 }}
61 className={cn(index !== 0 && "border-border")}
62 >
63 <ConnectionEnvironmentRow
64 environment={environment}
65 expanded={expandedId === environment.environmentId}
66 onToggle={() => handleToggle(environment.environmentId)}
67 onReconnect={onReconnectEnvironment}
68 onRemove={onRemoveEnvironmentPress}
69 onUpdate={onUpdateEnvironment}
70 />
71 </View>
72 ))}
73 </View>
74 ) : (
75 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
76 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
77 <SymbolView
78 name="point.3.connected.trianglepath.dotted"
79 size={20}
80 tintColor={accentColor}
81 type="monochrome"
82 />
83 </View>
84 <Text className="text-center text-sm leading-normal text-foreground-muted">
85 No environments connected yet.{"\n"}Tap{" "}
86 <Text className="font-t3-bold text-foreground">+</Text> to add one.
87 </Text>
88 </View>
89 )}
90 </ScrollView>
91 </View>
92 );
93}
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+2-2
...@@ -73,10 +73,10 @@ export function EnvironmentConnectionNotice(props: {...@@ -73,10 +73,10 @@ export function EnvironmentConnectionNotice(props: {
73 />73 />
74 )}74 )}
7575
76 <Text className="text-center text-lg font-t3-bold leading-[22px] text-foreground">76 <Text className="text-center text-lg font-t3-bold text-foreground">
77 {noticeTitle(props.connection.phase, props.environmentLabel)}77 {noticeTitle(props.connection.phase, props.environmentLabel)}
78 </Text>78 </Text>
79 <Text className="text-center text-sm leading-[20px] text-foreground-muted">79 <Text className="text-center text-sm leading-normal text-foreground-muted">
80 {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)}80 {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)}
81 {props.connection.traceId ? (81 {props.connection.traceId ? (
82 <>82 <>
apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts+22-2
...@@ -33,11 +33,14 @@ describe("resolveNativeReviewDiffView", () => {...@@ -33,11 +33,14 @@ describe("resolveNativeReviewDiffView", () => {
33 expect(expoMocks.requireNativeView).not.toHaveBeenCalled();33 expect(expoMocks.requireNativeView).not.toHaveBeenCalled();
34 });34 });
3535
36 it("returns the native review diff view when the view config is installed", async () => {36 it("returns the payload bridge when the native review diff view is installed", async () => {
37 setExpoViewConfigAvailable();37 setExpoViewConfigAvailable();
38 expoMocks.requireNativeView.mockReturnValue(nativeView);38 expoMocks.requireNativeView.mockReturnValue(nativeView);
39 const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");39 const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
40 expect(resolveNativeReviewDiffView()).toBe(nativeView);40 const resolvedView = resolveNativeReviewDiffView();
41 expect(resolvedView).not.toBeNull();
42 expect(resolvedView).not.toBe(nativeView);
43 expect(resolveNativeReviewDiffView()).toBe(resolvedView);
41 expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface");44 expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface");
42 });45 });
4346
...@@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => {...@@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => {
78 expect(consoleError).toHaveBeenCalledTimes(1);81 expect(consoleError).toHaveBeenCalledTimes(1);
79 });82 });
80});83});
84
85describe("isPendingNativeViewRegistration", () => {
86 it("recognizes registration races for the installed native view name", async () => {
87 const { isPendingNativeViewRegistration } = await import("./nativeReviewDiffSurface");
88
89 expect(
90 isPendingNativeViewRegistration(
91 new Error("Unable to find the 'T3ReviewDiffSurface' view for this native tag"),
92 ),
93 ).toBe(true);
94 expect(
95 isPendingNativeViewRegistration(
96 new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"),
97 ),
98 ).toBe(false);
99 });
100});
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+133-6
...@@ -1,10 +1,18 @@...@@ -1,10 +1,18 @@
1import type { ComponentType } from "react";1import {
2 createElement,
3 useEffect,
4 useImperativeHandle,
5 useRef,
6 type ComponentType,
7 type Ref,
8} from "react";
2import type { NativeSyntheticEvent, ViewProps } from "react-native";9import type { NativeSyntheticEvent, ViewProps } from "react-native";
3import { requireNativeView } from "expo";10import { requireNativeView } from "expo";
411
5import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";12import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
613
7const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";14const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";
15const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60;
816
9interface ExpoGlobalWithViewConfig {17interface ExpoGlobalWithViewConfig {
10 readonly expo?: {18 readonly expo?: {
...@@ -103,6 +111,7 @@ export interface NativeReviewDiffViewProps extends ViewProps {...@@ -103,6 +111,7 @@ export interface NativeReviewDiffViewProps extends ViewProps {
103 readonly tokensJson?: string;111 readonly tokensJson?: string;
104 readonly tokensPatchJson?: string;112 readonly tokensPatchJson?: string;
105 readonly tokensResetKey?: string;113 readonly tokensResetKey?: string;
114 readonly contentResetKey?: string;
106 readonly collapsedFileIdsJson?: string;115 readonly collapsedFileIdsJson?: string;
107 readonly viewedFileIdsJson?: string;116 readonly viewedFileIdsJson?: string;
108 readonly selectedRowIdsJson?: string;117 readonly selectedRowIdsJson?: string;
...@@ -113,7 +122,13 @@ export interface NativeReviewDiffViewProps extends ViewProps {...@@ -113,7 +122,13 @@ export interface NativeReviewDiffViewProps extends ViewProps {
113 readonly rowHeight: number;122 readonly rowHeight: number;
114 readonly contentWidth: number;123 readonly contentWidth: number;
115 readonly initialRowIndex?: number;124 readonly initialRowIndex?: number;
125 readonly refreshing?: boolean;
126 readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>;
127 readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void;
116 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;128 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
129 readonly onVisibleFileChange?: (
130 event: NativeSyntheticEvent<{ readonly fileId?: string | null }>,
131 ) => void;
117 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;132 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
118 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;133 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
119 readonly onPressLine?: (134 readonly onPressLine?: (
...@@ -129,18 +144,130 @@ export interface NativeReviewDiffViewProps extends ViewProps {...@@ -129,18 +144,130 @@ export interface NativeReviewDiffViewProps extends ViewProps {
129 readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;144 readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;
130}145}
131146
132let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined;147export interface NativeReviewDiffViewHandle {
148 readonly scrollToFile: (fileId: string, animated?: boolean) => Promise<void>;
149 readonly scrollToTop: (animated?: boolean) => Promise<void>;
150}
151
152interface NativeReviewDiffViewRef {
153 readonly setRowsJson: (rowsJson: string) => Promise<void>;
154 readonly setTokensJson: (tokensJson: string) => Promise<void>;
155 readonly setTokensPatchJson: (tokensPatchJson: string) => Promise<void>;
156 readonly scrollToFile: (fileId: string, animated: boolean) => Promise<void>;
157 readonly scrollToTop: (animated: boolean) => Promise<void>;
158}
159
160type NativeReviewDiffRawViewProps = Omit<
161 NativeReviewDiffViewProps,
162 "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson"
163> & {
164 readonly ref?: Ref<NativeReviewDiffViewRef>;
165};
166
167let cachedNativeReviewDiffRawView: ComponentType<NativeReviewDiffRawViewProps> | undefined;
133let nativeReviewDiffViewResolutionFailed = false;168let nativeReviewDiffViewResolutionFailed = false;
134169
170type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson";
171
172export function isPendingNativeViewRegistration(error: unknown): boolean {
173 return (
174 error instanceof Error &&
175 error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`)
176 );
177}
178
179function useNativeReviewDiffPayload(
180 nativeRef: React.RefObject<NativeReviewDiffViewRef | null>,
181 method: NativeReviewDiffPayloadMethod,
182 payload: string | undefined,
183) {
184 useEffect(() => {
185 if (payload === undefined) {
186 return;
187 }
188
189 let cancelled = false;
190 let frame: number | null = null;
191 let attempts = 0;
192
193 const dispatch = () => {
194 if (cancelled) {
195 return;
196 }
197
198 const view = nativeRef.current;
199 const command = view?.[method];
200 if (!view || !command) {
201 if (attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES) {
202 attempts += 1;
203 frame = requestAnimationFrame(dispatch);
204 }
205 return;
206 }
207
208 void command.call(view, payload).catch((error: unknown) => {
209 if (
210 !cancelled &&
211 attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES &&
212 isPendingNativeViewRegistration(error)
213 ) {
214 attempts += 1;
215 frame = requestAnimationFrame(dispatch);
216 return;
217 }
218 console.error(`[native-review-diff] ${method} failed`, error);
219 });
220 };
221
222 // Fabric attaches the React ref before Expo registers the native tag used by
223 // view functions. Starting on the next frame avoids racing that registration.
224 frame = requestAnimationFrame(dispatch);
225
226 return () => {
227 cancelled = true;
228 if (frame !== null) {
229 cancelAnimationFrame(frame);
230 }
231 };
232 }, [method, nativeRef, payload]);
233}
234
135function getExpoViewConfig(moduleName: string) {235function getExpoViewConfig(moduleName: string) {
136 return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(236 return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(
137 moduleName,237 moduleName,
138 );238 );
139}239}
140240
241function NativeReviewDiffView(props: NativeReviewDiffViewProps) {
242 const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props;
243 const nativeRef = useRef<NativeReviewDiffViewRef>(null);
244 useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson);
245 useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson);
246 useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson);
247 useImperativeHandle(
248 nativeViewRef,
249 () => ({
250 scrollToFile: async (fileId, animated = true) => {
251 await nativeRef.current?.scrollToFile(fileId, animated);
252 },
253 scrollToTop: async (animated = true) => {
254 await nativeRef.current?.scrollToTop(animated);
255 },
256 }),
257 [],
258 );
259
260 const RawNativeView = cachedNativeReviewDiffRawView;
261 if (!RawNativeView) {
262 return null;
263 }
264
265 return createElement(RawNativeView, { ...nativeProps, ref: nativeRef });
266}
267
141export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {268export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {
142 if (cachedNativeReviewDiffView) {269 if (cachedNativeReviewDiffRawView) {
143 return cachedNativeReviewDiffView;270 return NativeReviewDiffView;
144 }271 }
145272
146 if (nativeReviewDiffViewResolutionFailed) {273 if (nativeReviewDiffViewResolutionFailed) {
...@@ -152,7 +279,7 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie...@@ -152,7 +279,7 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie
152 }279 }
153280
154 try {281 try {
155 cachedNativeReviewDiffView = requireNativeView<NativeReviewDiffViewProps>(282 cachedNativeReviewDiffRawView = requireNativeView<NativeReviewDiffRawViewProps>(
156 NATIVE_REVIEW_DIFF_MODULE_NAME,283 NATIVE_REVIEW_DIFF_MODULE_NAME,
157 );284 );
158 } catch (cause) {285 } catch (cause) {
...@@ -166,5 +293,5 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie...@@ -166,5 +293,5 @@ export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffVie
166 return null;293 return null;
167 }294 }
168295
169 return cachedNativeReviewDiffView ?? null;296 return cachedNativeReviewDiffRawView ? NativeReviewDiffView : null;
170}297}
apps/mobile/src/features/files/FileMarkdownPreview.tsx+51-7
...@@ -1,15 +1,19 @@...@@ -1,15 +1,19 @@
1import { useCallback, useMemo } from "react";1import { useCallback, useMemo, useState } from "react";
2import {2import {
3 Markdown,3 Markdown,
4 type CustomRenderers,4 type CustomRenderers,
5 type NodeStyleOverrides,5 type NodeStyleOverrides,
6 type PartialMarkdownTheme,6 type PartialMarkdownTheme,
7} from "react-native-nitro-markdown";7} from "react-native-nitro-markdown";
8import { ScrollView, Text as NativeText, View } from "react-native";8import { RefreshControl, ScrollView, Text as NativeText, View } from "react-native";
99
10import { tryOpenExternalUrl } from "../../lib/openExternalUrl";10import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
11import { MOBILE_TYPOGRAPHY } from "../../lib/typography";11import {
12 resolveMarkdownFontSizes,
13 resolveNativeMarkdownTypography,
14} from "../../lib/appearancePreferences";
12import { useThemeColor } from "../../lib/useThemeColor";15import { useThemeColor } from "../../lib/useThemeColor";
16import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
13import {17import {
14 hasNativeSelectableMarkdownText,18 hasNativeSelectableMarkdownText,
15 SelectableMarkdownText,19 SelectableMarkdownText,
...@@ -24,6 +28,15 @@ interface MarkdownPreviewStyles {...@@ -24,6 +28,15 @@ interface MarkdownPreviewStyles {
24}28}
2529
26function useMarkdownPreviewStyles(): MarkdownPreviewStyles {30function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
31 const { appearance } = useAppearancePreferences();
32 const markdownFontSizes = useMemo(
33 () => resolveMarkdownFontSizes(appearance.baseFontSize),
34 [appearance.baseFontSize],
35 );
36 const nativeMarkdownTypography = useMemo(
37 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
38 [appearance.baseFontSize],
39 );
27 const body = String(useThemeColor("--color-md-body"));40 const body = String(useThemeColor("--color-md-body"));
28 const strong = String(useThemeColor("--color-md-strong"));41 const strong = String(useThemeColor("--color-md-strong"));
29 const link = String(useThemeColor("--color-md-link"));42 const link = String(useThemeColor("--color-md-link"));
...@@ -74,7 +87,8 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {...@@ -74,7 +87,8 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
74 text: {87 text: {
75 color: body,88 color: body,
76 fontFamily: "DMSans_400Regular",89 fontFamily: "DMSans_400Regular",
77 ...MOBILE_TYPOGRAPHY.body,90 fontSize: markdownFontSizes.m,
91 lineHeight: markdownFontSizes.bodyLineHeight,
78 },92 },
79 heading: {93 heading: {
80 color: strong,94 color: strong,
...@@ -124,7 +138,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {...@@ -124,7 +138,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
124 skillTextColor: codeText,138 skillTextColor: codeText,
125 quoteMarkerColor: blockquoteBorder,139 quoteMarkerColor: blockquoteBorder,
126 dividerColor: horizontalRule,140 dividerColor: horizontalRule,
127 ...MOBILE_TYPOGRAPHY.body,141 fontSize: nativeMarkdownTypography.fontSize,
142 lineHeight: nativeMarkdownTypography.lineHeight,
143 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
128 fontFamily: "DMSans_400Regular",144 fontFamily: "DMSans_400Regular",
129 headingFontFamily: "DMSans_700Bold",145 headingFontFamily: "DMSans_700Bold",
130 boldFontFamily: "DMSans_700Bold",146 boldFontFamily: "DMSans_700Bold",
...@@ -138,18 +154,46 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {...@@ -138,18 +154,46 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
138 codeText,154 codeText,
139 horizontalRule,155 horizontalRule,
140 link,156 link,
157 markdownFontSizes,
158 nativeMarkdownTypography,
141 strong,159 strong,
142 ]);160 ]);
143}161}
144162
145export function FileMarkdownPreview(props: { readonly markdown: string }) {163export function FileMarkdownPreview(props: {
164 readonly markdown: string;
165 readonly onRefresh?: () => Promise<void> | void;
166}) {
167 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
168 const handlePullToRefresh = useCallback(async () => {
169 if (!props.onRefresh) {
170 return;
171 }
172 setIsPullRefreshing(true);
173 try {
174 await props.onRefresh();
175 } finally {
176 setIsPullRefreshing(false);
177 }
178 }, [props.onRefresh]);
146 const styles = useMarkdownPreviewStyles();179 const styles = useMarkdownPreviewStyles();
147 const onLinkPress = useCallback((href: string) => {180 const onLinkPress = useCallback((href: string) => {
148 void tryOpenExternalUrl(href, "markdown-link");181 void tryOpenExternalUrl(href, "markdown-link");
149 }, []);182 }, []);
150183
151 return (184 return (
152 <ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}>185 <ScrollView
186 className="flex-1 bg-sheet"
187 contentContainerStyle={{ padding: 18 }}
188 refreshControl={
189 props.onRefresh ? (
190 <RefreshControl
191 refreshing={isPullRefreshing}
192 onRefresh={() => void handlePullToRefresh()}
193 />
194 ) : undefined
195 }
196 >
153 <View className="mx-auto w-full max-w-[760px]">197 <View className="mx-auto w-full max-w-[760px]">
154 {hasNativeSelectableMarkdownText() ? (198 {hasNativeSelectableMarkdownText() ? (
155 <SelectableMarkdownText199 <SelectableMarkdownText
apps/mobile/src/features/files/FileTreeBrowser.tsx+149-55
...@@ -1,7 +1,15 @@...@@ -1,7 +1,15 @@
1import type { ProjectEntry } from "@t3tools/contracts";1import type { ProjectEntry } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { memo, useCallback, useEffect, useMemo, useState } from "react";3import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native";4import {
5 ActivityIndicator,
6 FlatList,
7 Platform,
8 Pressable,
9 RefreshControl,
10 View,
11} from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
513
6import { AppText as Text } from "../../components/AppText";14import { AppText as Text } from "../../components/AppText";
7import { PierreEntryIcon } from "../../components/PierreEntryIcon";15import { PierreEntryIcon } from "../../components/PierreEntryIcon";
...@@ -11,9 +19,25 @@ import {...@@ -11,9 +19,25 @@ import {
11 buildFileTree,19 buildFileTree,
12 defaultExpandedTreePaths,20 defaultExpandedTreePaths,
13 flattenFileTree,21 flattenFileTree,
22 type FileTreeNode,
14 type VisibleFileTreeNode,23 type VisibleFileTreeNode,
15} from "./fileTree";24} from "./fileTree";
1625
26const fileTreeCache = new WeakMap<ReadonlyArray<ProjectEntry>, ReadonlyArray<FileTreeNode>>();
27const FILE_TREE_INITIAL_RENDER_COUNT = 20;
28const FILE_TREE_RENDER_BATCH_SIZE = 12;
29const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000;
30
31function cachedFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> {
32 const cached = fileTreeCache.get(entries);
33 if (cached !== undefined) {
34 return cached;
35 }
36 const tree = buildFileTree(entries);
37 fileTreeCache.set(entries, tree);
38 return tree;
39}
40
17function ancestorPaths(path: string): ReadonlyArray<string> {41function ancestorPaths(path: string): ReadonlyArray<string> {
18 const parts = path.split("/").filter(Boolean);42 const parts = path.split("/").filter(Boolean);
19 const ancestors: string[] = [];43 const ancestors: string[] = [];
...@@ -25,19 +49,24 @@ function ancestorPaths(path: string): ReadonlyArray<string> {...@@ -25,19 +49,24 @@ function ancestorPaths(path: string): ReadonlyArray<string> {
2549
26const FileTreeRow = memo(function FileTreeRow(props: {50const FileTreeRow = memo(function FileTreeRow(props: {
27 readonly item: VisibleFileTreeNode;51 readonly item: VisibleFileTreeNode;
28 readonly selectedPath: string | null;52 readonly selected: boolean;
29 readonly expanded: boolean;53 readonly expanded: boolean;
30 readonly iconColor: string;54 readonly iconColor: string;
31 readonly onPressDirectory: (path: string) => void;55 readonly onPressDirectory: (path: string) => void;
56 readonly onPreviewFile?: (path: string) => void;
32 readonly onPressFile: (path: string) => void;57 readonly onPressFile: (path: string) => void;
33}) {58}) {
34 const { node, depth } = props.item;59 const { node, depth } = props.item;
35 const selected = node.kind === "file" && node.path === props.selectedPath;
3660
37 return (61 return (
38 <Pressable62 <Pressable
39 accessibilityRole="button"63 accessibilityRole="button"
40 accessibilityLabel={node.path}64 accessibilityLabel={node.path}
65 onPressIn={() => {
66 if (node.kind === "file") {
67 props.onPreviewFile?.(node.path);
68 }
69 }}
41 onPress={() => {70 onPress={() => {
42 if (node.kind === "directory") {71 if (node.kind === "directory") {
43 props.onPressDirectory(node.path);72 props.onPressDirectory(node.path);
...@@ -47,7 +76,7 @@ const FileTreeRow = memo(function FileTreeRow(props: {...@@ -47,7 +76,7 @@ const FileTreeRow = memo(function FileTreeRow(props: {
47 }}76 }}
48 className={cn(77 className={cn(
49 "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle",78 "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle",
50 selected && "bg-subtle-strong",79 props.selected && "bg-subtle-strong",
51 )}80 )}
52 style={{ paddingLeft: 8 + depth * 18 }}81 style={{ paddingLeft: 8 + depth * 18 }}
53 >82 >
...@@ -64,8 +93,10 @@ const FileTreeRow = memo(function FileTreeRow(props: {...@@ -64,8 +93,10 @@ const FileTreeRow = memo(function FileTreeRow(props: {
64 <PierreEntryIcon path={node.path} kind={node.kind} size={17} />93 <PierreEntryIcon path={node.path} kind={node.kind} size={17} />
65 <Text94 <Text
66 className={cn(95 className={cn(
67 "min-w-0 flex-1 text-sm leading-[19px]",96 "min-w-0 flex-1 text-sm leading-normal",
68 selected ? "font-t3-bold text-foreground" : "font-t3-medium text-foreground-secondary",97 props.selected
98 ? "font-t3-bold text-foreground"
99 : "font-t3-medium text-foreground-secondary",
69 )}100 )}
70 numberOfLines={1}101 numberOfLines={1}
71 >102 >
...@@ -86,13 +117,30 @@ export function FileTreeBrowser(props: {...@@ -86,13 +117,30 @@ export function FileTreeBrowser(props: {
86 readonly isPending: boolean;117 readonly isPending: boolean;
87 readonly searchQuery: string;118 readonly searchQuery: string;
88 readonly selectedPath: string | null;119 readonly selectedPath: string | null;
120 readonly onPreviewFile?: (path: string) => void;
89 readonly onRefresh: () => void;121 readonly onRefresh: () => void;
90 readonly onSelectFile: (path: string) => void;122 readonly onSelectFile: (path: string) => void;
91}) {123}) {
92 const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set());124 const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set());
125 const [pendingSelection, setPendingSelection] = useState<{
126 readonly path: string;
127 readonly selectedPathAtPress: string | null;
128 } | null>(null);
129 const insets = useSafeAreaInsets();
130 // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the
131 // observed adjustedContentInset bottom (~102) seen in the native trace.
132 const headerInset = Platform.OS === "ios" ? insets.top + 44 : 0;
93 const iconColor = String(useThemeColor("--color-icon-muted"));133 const iconColor = String(useThemeColor("--color-icon-muted"));
134 const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props;
135 const controlledSelectedPathRef = useRef(controlledSelectedPath);
136 const pendingSelectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
137 controlledSelectedPathRef.current = controlledSelectedPath;
94138
95 const tree = useMemo(() => buildFileTree(props.entries), [props.entries]);139 const selectedPath =
140 pendingSelection?.selectedPathAtPress === controlledSelectedPath
141 ? pendingSelection.path
142 : controlledSelectedPath;
143 const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]);
96 const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]);144 const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]);
97 const visibleNodes = useMemo(145 const visibleNodes = useMemo(
98 () =>146 () =>
...@@ -114,17 +162,30 @@ export function FileTreeBrowser(props: {...@@ -114,17 +162,30 @@ export function FileTreeBrowser(props: {
114 }, [defaultExpanded]);162 }, [defaultExpanded]);
115163
116 useEffect(() => {164 useEffect(() => {
117 if (!props.selectedPath) {165 if (!controlledSelectedPath) {
118 return;166 return;
119 }167 }
120 setExpandedPaths((current) => {168 setExpandedPaths((current) => {
169 const ancestors = ancestorPaths(controlledSelectedPath);
170 if (ancestors.every((ancestor) => current.has(ancestor))) {
171 return current;
172 }
121 const next = new Set(current);173 const next = new Set(current);
122 for (const ancestor of ancestorPaths(props.selectedPath ?? "")) {174 for (const ancestor of ancestors) {
123 next.add(ancestor);175 next.add(ancestor);
124 }176 }
125 return next;177 return next;
126 });178 });
127 }, [props.selectedPath]);179 }, [controlledSelectedPath]);
180
181 useEffect(
182 () => () => {
183 if (pendingSelectionTimeoutRef.current !== null) {
184 clearTimeout(pendingSelectionTimeoutRef.current);
185 }
186 },
187 [],
188 );
128189
129 const toggleDirectory = useCallback((path: string) => {190 const toggleDirectory = useCallback((path: string) => {
130 setExpandedPaths((current) => {191 setExpandedPaths((current) => {
...@@ -137,53 +198,86 @@ export function FileTreeBrowser(props: {...@@ -137,53 +198,86 @@ export function FileTreeBrowser(props: {
137 return next;198 return next;
138 });199 });
139 }, []);200 }, []);
201 const handleSelectFile = useCallback(
202 (path: string) => {
203 if (pendingSelectionTimeoutRef.current !== null) {
204 clearTimeout(pendingSelectionTimeoutRef.current);
205 }
206 setPendingSelection({
207 path,
208 selectedPathAtPress: controlledSelectedPathRef.current,
209 });
210 pendingSelectionTimeoutRef.current = setTimeout(() => {
211 pendingSelectionTimeoutRef.current = null;
212 setPendingSelection((current) => (current?.path === path ? null : current));
213 }, OPTIMISTIC_SELECTION_TIMEOUT_MS);
214 onSelectFile(path);
215 },
216 [onSelectFile],
217 );
218 const renderItem = useCallback(
219 ({ item }: { readonly item: VisibleFileTreeNode }) => (
220 <FileTreeRow
221 item={item}
222 selected={item.node.kind === "file" && item.node.path === selectedPath}
223 expanded={expandedPaths.has(item.node.path)}
224 iconColor={iconColor}
225 onPressDirectory={toggleDirectory}
226 onPreviewFile={onPreviewFile}
227 onPressFile={handleSelectFile}
228 />
229 ),
230 [expandedPaths, handleSelectFile, iconColor, onPreviewFile, selectedPath, toggleDirectory],
231 );
140232
233 if (props.error && props.entries.length === 0) {
234 return (
235 <View className="flex-1 bg-sheet px-4 py-5">
236 <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>
237 <Text className="mt-1 text-xs leading-normal text-foreground-muted">{props.error}</Text>
238 </View>
239 );
240 }
241
242 // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and
243 // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"`
244 // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge
245 // blur to a scroll view in the screen's primary position; a scroll view buried in
246 // flex-1 Views is ignored, which is why the tree rendered under the header with no blur.
141 return (247 return (
142 <View className="flex-1 bg-sheet">248 <FlatList
143 {props.error && props.entries.length === 0 ? (249 style={{ flex: 1 }}
250 data={visibleNodes}
251 keyExtractor={(item) => item.node.path}
252 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
253 scrollIndicatorInsets={
254 Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined
255 }
256 keyboardDismissMode="on-drag"
257 keyboardShouldPersistTaps="handled"
258 initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT}
259 maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE}
260 updateCellsBatchingPeriod={16}
261 windowSize={5}
262 contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }}
263 refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />}
264 renderItem={renderItem}
265 ListEmptyComponent={
144 <View className="px-4 py-5">266 <View className="px-4 py-5">
145 <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>267 {props.isPending ? (
146 <Text className="mt-1 text-xs leading-[18px] text-foreground-muted">{props.error}</Text>268 <ActivityIndicator size="small" />
147 </View>269 ) : (
148 ) : (270 <>
149 <FlatList271 <Text className="text-sm font-t3-bold text-foreground">No files found</Text>
150 data={visibleNodes}272 <Text className="mt-1 text-xs leading-normal text-foreground-muted">
151 keyExtractor={(item) => item.node.path}273 {props.searchQuery.trim().length > 0
152 contentInsetAdjustmentBehavior="automatic"274 ? "Try a different search."
153 keyboardDismissMode="on-drag"275 : "The workspace file index is empty."}
154 keyboardShouldPersistTaps="handled"276 </Text>
155 contentContainerStyle={{ paddingVertical: 8 }}277 </>
156 refreshControl={
157 <RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />
158 }
159 renderItem={({ item }) => (
160 <FileTreeRow
161 item={item}
162 selectedPath={props.selectedPath}
163 expanded={expandedPaths.has(item.node.path)}
164 iconColor={iconColor}
165 onPressDirectory={toggleDirectory}
166 onPressFile={props.onSelectFile}
167 />
168 )}278 )}
169 ListEmptyComponent={279 </View>
170 <View className="px-4 py-5">280 }
171 {props.isPending ? (281 />
172 <ActivityIndicator size="small" />
173 ) : (
174 <>
175 <Text className="text-sm font-t3-bold text-foreground">No files found</Text>
176 <Text className="mt-1 text-xs leading-[18px] text-foreground-muted">
177 {props.searchQuery.trim().length > 0
178 ? "Try a different search."
179 : "The workspace file index is empty."}
180 </Text>
181 </>
182 )}
183 </View>
184 }
185 />
186 )}
187 </View>
188 );282 );
189}283}
apps/mobile/src/features/files/SourceFileSurface.tsx+98-65
...@@ -1,8 +1,15 @@...@@ -1,8 +1,15 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";2import { AsyncResult } from "effect/unstable/reactivity";
3import type { ComponentType } from "react";3import type { ComponentType } from "react";
4import { memo, useCallback, useEffect, useMemo, useRef } from "react";4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
5import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native";5import {
6 FlatList,
7 ScrollView,
8 Text as NativeText,
9 useColorScheme,
10 useWindowDimensions,
11 View,
12} from "react-native";
613
7import { AppText as Text } from "../../components/AppText";14import { AppText as Text } from "../../components/AppText";
8import { LoadingStrip } from "../../components/LoadingStrip";15import { LoadingStrip } from "../../components/LoadingStrip";
...@@ -11,71 +18,62 @@ import {...@@ -11,71 +18,62 @@ import {
11 resolveNativeReviewDiffView,18 resolveNativeReviewDiffView,
12} from "../diffs/nativeReviewDiffSurface";19} from "../diffs/nativeReviewDiffSurface";
13import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";20import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";
14import {21import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering";
15 REVIEW_DIFF_LINE_HEIGHT,
16 REVIEW_MONO_FONT_FAMILY,
17 renderVisibleWhitespace,
18} from "../review/reviewDiffRendering";
19import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";22import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
20import { cn } from "../../lib/cn";23import { cn } from "../../lib/cn";
21import { MOBILE_CODE_SURFACE } from "../../lib/typography";24import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
25import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
22import {26import {
23 buildNativeSourceRows,
24 buildNativeSourceTokens,27 buildNativeSourceTokens,
25 NATIVE_SOURCE_CONTENT_WIDTH,28 NATIVE_SOURCE_CONTENT_WIDTH,
26 NATIVE_SOURCE_ROW_HEIGHT,
27 NATIVE_SOURCE_STYLE,
28 nativeSourceRowId,29 nativeSourceRowId,
29} from "./nativeSourceFileAdapter";30} from "./nativeSourceFileAdapter";
31import { prepareSourceFileDocument } from "./source-file-document";
30import { sourceHighlightAtom } from "./sourceHighlightingState";32import { sourceHighlightAtom } from "./sourceHighlightingState";
3133
32const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
33const SOURCE_LINE_NUMBER_WIDTH = MOBILE_CODE_SURFACE.gutterWidth;
34const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE);
35
36interface SourceFileSurfaceProps {34interface SourceFileSurfaceProps {
37 readonly contents: string;35 readonly contents: string;
38 readonly path: string;36 readonly path: string;
39 readonly initialLine?: number | null;37 readonly initialLine?: number | null;
38 /** Enables native pull-to-refresh on the source surface. */
39 readonly onRefresh?: () => Promise<void> | void;
40}40}
4141
42type SourceHighlightStatus = "highlighting" | "ready" | "error";42type SourceHighlightStatus = "highlighting" | "ready" | "error";
4343
44function splitSourceLines(contents: string): ReadonlyArray<string> {
45 return contents.replace(/\r\n?/g, "\n").split("\n");
46}
47
48const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {44const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
45 readonly codeSurface: ResolvedMobileCodeSurface;
49 readonly index: number;46 readonly index: number;
50 readonly line: string;47 readonly line: string;
51 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;48 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
52 readonly highlighted: boolean;49 readonly highlighted: boolean;
50 readonly wordBreak: boolean;
53}) {51}) {
54 return (52 return (
55 <View53 <View
56 className={cn("flex-row", props.highlighted && "bg-primary/10")}54 className={cn("flex-row", props.highlighted && "bg-primary/10")}
57 style={{ minHeight: SOURCE_LINE_HEIGHT }}55 style={{ minHeight: props.codeSurface.rowHeight }}
58 >56 >
59 <NativeText57 <NativeText
60 className="select-none pr-3 text-right text-foreground-tertiary"58 className="select-none pr-3 text-right text-foreground-tertiary"
61 style={{59 style={{
62 width: SOURCE_LINE_NUMBER_WIDTH,60 width: props.codeSurface.gutterWidth,
63 fontFamily: REVIEW_MONO_FONT_FAMILY,61 fontFamily: REVIEW_MONO_FONT_FAMILY,
64 fontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,62 fontSize: props.codeSurface.lineNumberFontSize,
65 lineHeight: MOBILE_CODE_SURFACE.rowHeight,63 lineHeight: props.codeSurface.rowHeight,
66 }}64 }}
67 >65 >
68 {props.index + 1}66 {props.index + 1}
69 </NativeText>67 </NativeText>
70 <NativeText68 <NativeText
71 selectable69 selectable
72 numberOfLines={1}70 numberOfLines={props.wordBreak ? undefined : 1}
73 className="font-normal text-foreground"71 className="flex-1 font-normal text-foreground"
74 style={{72 style={{
75 fontFamily: REVIEW_MONO_FONT_FAMILY,73 fontFamily: REVIEW_MONO_FONT_FAMILY,
76 fontSize: MOBILE_CODE_SURFACE.fontSize,74 fontSize: props.codeSurface.fontSize,
77 lineHeight: MOBILE_CODE_SURFACE.rowHeight,75 lineHeight: props.codeSurface.rowHeight,
78 minWidth: 320,76 minWidth: props.wordBreak ? undefined : 320,
79 }}77 }}
80 >78 >
81 {props.tokens && props.tokens.length > 079 {props.tokens && props.tokens.length > 0
...@@ -119,11 +117,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {...@@ -119,11 +117,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
119function useSourceFileModel(props: SourceFileSurfaceProps) {117function useSourceFileModel(props: SourceFileSurfaceProps) {
120 const colorScheme = useColorScheme();118 const colorScheme = useColorScheme();
121 const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";119 const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";
122 const normalizedContents = useMemo(120 const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]);
123 () => props.contents.replace(/\r\n?/g, "\n"),121 const { contents: normalizedContents, lines, rowsJson } = document;
124 [props.contents],
125 );
126 const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]);
127 const targetIndex =122 const targetIndex =
128 props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0123 props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0
129 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1))124 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1))
...@@ -140,7 +135,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) {...@@ -140,7 +135,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) {
140 ? "ready"135 ? "ready"
141 : "highlighting";136 : "highlighting";
142137
143 return { lines, status, targetIndex, theme, tokens };138 return { lines, rowsJson, status, targetIndex, theme, tokens };
144}139}
145140
146function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {141function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {
...@@ -162,39 +157,63 @@ function NativeSourceFileSurface(...@@ -162,39 +157,63 @@ function NativeSourceFileSurface(
162 readonly NativeView: ComponentType<NativeReviewDiffViewProps>;157 readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
163 },158 },
164) {159) {
165 const { NativeView } = props;160 const { NativeView, onRefresh } = props;
166 const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props);161 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
167 const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]);162 const { width: viewportWidth } = useWindowDimensions();
163 const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props);
164 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
165 const handlePullToRefresh = useCallback(async () => {
166 if (!onRefresh) {
167 return;
168 }
169 setIsPullRefreshing(true);
170 try {
171 await onRefresh();
172 } finally {
173 setIsPullRefreshing(false);
174 }
175 }, [onRefresh]);
168 const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);176 const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);
169 const selectedRowIdsJson = useMemo(177 const selectedRowIdsJson = useMemo(
170 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),178 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
171 [targetIndex],179 [targetIndex],
172 );180 );
173 const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);181 const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);
182 const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]);
183 const contentWidth = codeWordBreak
184 ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24)
185 : NATIVE_SOURCE_CONTENT_WIDTH;
174186
175 return (187 return (
176 <View className="relative flex-1 bg-card">188 <View className="relative flex-1 bg-sheet">
177 <SourceHighlightStatusView status={status} />189 <SourceHighlightStatusView status={status} />
178 <NativeView190 <NativeView
179 key={props.path}
180 collapsable={false}191 collapsable={false}
181 testID="source-native-code-view"192 testID="source-native-code-view"
182 style={{ flex: 1 }}193 style={{ flex: 1 }}
183 appearanceScheme={theme}194 appearanceScheme={theme}
184 contentWidth={NATIVE_SOURCE_CONTENT_WIDTH}195 contentResetKey={props.path}
196 contentWidth={contentWidth}
185 initialRowIndex={targetIndex ?? -1}197 initialRowIndex={targetIndex ?? -1}
186 rowHeight={NATIVE_SOURCE_ROW_HEIGHT}198 rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight}
187 rowsJson={rowsJson}199 rowsJson={rowsJson}
188 selectedRowIdsJson={selectedRowIdsJson}200 selectedRowIdsJson={selectedRowIdsJson}
189 styleJson={NATIVE_SOURCE_STYLE_JSON}201 styleJson={styleJson}
190 themeJson={themeJson}202 themeJson={themeJson}
191 tokensJson={tokensJson}203 tokensJson={tokensJson}
204 {...(onRefresh
205 ? {
206 refreshing: isPullRefreshing,
207 onPullToRefresh: () => void handlePullToRefresh(),
208 }
209 : {})}
192 />210 />
193 </View>211 </View>
194 );212 );
195}213}
196214
197function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {215function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
216 const { codeSurface, codeWordBreak } = useAppearanceCodeSurface();
198 const { lines, status, targetIndex, tokens } = useSourceFileModel(props);217 const { lines, status, targetIndex, tokens } = useSourceFileModel(props);
199 const listRef = useRef<FlatList<string>>(null);218 const listRef = useRef<FlatList<string>>(null);
200219
...@@ -211,39 +230,53 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {...@@ -211,39 +230,53 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
211 const renderLine = useCallback(230 const renderLine = useCallback(
212 ({ item, index }: { item: string; index: number }) => (231 ({ item, index }: { item: string; index: number }) => (
213 <HighlightedSourceLine232 <HighlightedSourceLine
233 codeSurface={codeSurface}
214 index={index}234 index={index}
215 line={item}235 line={item}
216 tokens={tokens?.[index] ?? null}236 tokens={tokens?.[index] ?? null}
217 highlighted={index === targetIndex}237 highlighted={index === targetIndex}
238 wordBreak={codeWordBreak}
218 />239 />
219 ),240 ),
220 [targetIndex, tokens],241 [codeSurface, codeWordBreak, targetIndex, tokens],
242 );
243
244 const list = (
245 <FlatList
246 ref={listRef}
247 data={lines}
248 keyExtractor={(_line, index) => String(index)}
249 initialNumToRender={80}
250 maxToRenderPerBatch={80}
251 windowSize={12}
252 {...(codeWordBreak
253 ? {}
254 : {
255 getItemLayout: (_data, index) => ({
256 length: codeSurface.rowHeight,
257 offset: codeSurface.rowHeight * index,
258 index,
259 }),
260 })}
261 contentContainerStyle={{
262 minWidth: codeWordBreak ? undefined : "100%",
263 paddingBottom: codeSurface.rowHeight,
264 paddingTop: 8,
265 }}
266 renderItem={renderLine}
267 />
221 );268 );
222269
223 return (270 return (
224 <View className="relative flex-1 bg-card">271 <View className="relative flex-1 bg-sheet">
225 <SourceHighlightStatusView status={status} />272 <SourceHighlightStatusView status={status} />
226 <ScrollView horizontal bounces={false} className="flex-1">273 {codeWordBreak ? (
227 <FlatList274 list
228 ref={listRef}275 ) : (
229 data={lines}276 <ScrollView horizontal bounces={false} className="flex-1">
230 keyExtractor={(_line, index) => String(index)}277 {list}
231 initialNumToRender={80}278 </ScrollView>
232 maxToRenderPerBatch={80}279 )}
233 windowSize={12}
234 getItemLayout={(_data, index) => ({
235 length: SOURCE_LINE_HEIGHT,
236 offset: SOURCE_LINE_HEIGHT * index,
237 index,
238 })}
239 contentContainerStyle={{
240 minWidth: "100%",
241 paddingBottom: REVIEW_DIFF_LINE_HEIGHT,
242 paddingTop: 8,
243 }}
244 renderItem={renderLine}
245 />
246 </ScrollView>
247 </View>280 </View>
248 );281 );
249}282}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+327-308
...@@ -1,8 +1,15 @@...@@ -1,8 +1,15 @@
1import Stack from "expo-router/stack";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { SymbolView } from "expo-symbols";2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useLocalSearchParams, useRouter } from "expo-router";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { useCallback, useMemo, useRef, useState } from "react";4import {
5import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native";5 ActivityIndicator,
6 Platform,
7 Pressable,
8 ScrollView,
9 useColorScheme,
10 View,
11} from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
6import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";13import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
7import {14import {
8 EnvironmentId,15 EnvironmentId,
...@@ -12,22 +19,31 @@ import {...@@ -12,22 +19,31 @@ import {
12} from "@t3tools/contracts";19} from "@t3tools/contracts";
1320
14import { AppText as Text } from "../../components/AppText";21import { AppText as Text } from "../../components/AppText";
15import { CopyTextButton } from "../../components/CopyTextButton";
16import { EmptyState } from "../../components/EmptyState";22import { EmptyState } from "../../components/EmptyState";
17import { LoadingScreen } from "../../components/LoadingScreen";23import { LoadingScreen } from "../../components/LoadingScreen";
18import { cn } from "../../lib/cn";24import { cn } from "../../lib/cn";
25import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
26import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
19import { tryOpenExternalUrl } from "../../lib/openExternalUrl";27import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
20import { buildThreadFilesNavigation } from "../../lib/routes";
21import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
22import { useThemeColor } from "../../lib/useThemeColor";28import { useThemeColor } from "../../lib/useThemeColor";
23import { useThreadSelection } from "../../state/use-thread-selection";29import { useThreadSelection } from "../../state/use-thread-selection";
24import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";30import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
25import { useEnvironmentQuery } from "../../state/query";31import { useEnvironmentQuery } from "../../state/query";
26import { projectEnvironment } from "../../state/projects";32import { projectEnvironment } from "../../state/projects";
33import {
34 useAdaptiveWorkspaceLayout,
35 useAdaptiveWorkspacePaneRole,
36 useRegisterWorkspaceInspector,
37} from "../layout/AdaptiveWorkspaceLayout";
38import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
39import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
27import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";40import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
41import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
28import { FileMarkdownPreview } from "./FileMarkdownPreview";42import { FileMarkdownPreview } from "./FileMarkdownPreview";
29import { FileTreeBrowser } from "./FileTreeBrowser";43import { FileTreeBrowser } from "./FileTreeBrowser";
44import { preloadWorkspaceFileContents } from "./preload-workspace-file";
30import { SourceFileSurface } from "./SourceFileSurface";45import { SourceFileSurface } from "./SourceFileSurface";
46import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
31import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";47import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
32import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";48import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
33import {49import {
...@@ -72,196 +88,6 @@ function defaultViewMode(path: string | null): FileViewMode {...@@ -72,196 +88,6 @@ function defaultViewMode(path: string | null): FileViewMode {
72 : "source";88 : "source";
73}89}
7490
75function ModeButton(props: {
76 readonly active: boolean;
77 readonly icon: "doc.text" | "eye";
78 readonly label: string;
79 readonly onPress: () => void;
80}) {
81 const iconColor = String(
82 useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"),
83 );
84
85 return (
86 <Pressable
87 accessibilityRole="button"
88 accessibilityState={{ selected: props.active }}
89 className={cn(
90 "h-8 flex-row items-center justify-center gap-1.5 rounded-full px-3 active:opacity-70",
91 props.active ? "bg-primary" : "bg-subtle",
92 )}
93 onPress={props.onPress}
94 >
95 <SymbolView name={props.icon} size={13} tintColor={iconColor} type="monochrome" />
96 <Text
97 className={cn(
98 "text-xs font-t3-bold",
99 props.active ? "text-primary-foreground" : "text-foreground-muted",
100 )}
101 >
102 {props.label}
103 </Text>
104 </Pressable>
105 );
106}
107
108function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) {
109 const gradientId = `file-breadcrumb-${props.side}-fade`;
110 const isLeft = props.side === "left";
111
112 return (
113 <View
114 pointerEvents="none"
115 className={cn("absolute inset-y-0 w-7", isLeft ? "left-0" : "right-0")}
116 >
117 <Svg width="100%" height="100%">
118 <Defs>
119 <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
120 <Stop offset="0%" stopColor={props.color} stopOpacity={isLeft ? 1 : 0} />
121 <Stop offset="100%" stopColor={props.color} stopOpacity={isLeft ? 0 : 1} />
122 </LinearGradient>
123 </Defs>
124 <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
125 </Svg>
126 </View>
127 );
128}
129
130function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) {
131 const iconColor = String(useThemeColor("--color-icon-muted"));
132 const cardColor = String(useThemeColor("--color-card"));
133 const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 });
134 const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false });
135 const breadcrumbs = useMemo(
136 () => fileBreadcrumbs(props.projectName, props.relativePath),
137 [props.projectName, props.relativePath],
138 );
139 const updateFadeVisibility = useCallback(
140 (metrics: Partial<(typeof scrollMetrics)["current"]>) => {
141 Object.assign(scrollMetrics.current, metrics);
142 const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current;
143 const maxOffset = Math.max(0, contentWidth - viewportWidth);
144 const next = {
145 left: maxOffset > 1 && offsetX > 1,
146 right: maxOffset > 1 && offsetX < maxOffset - 1,
147 };
148
149 setFadeVisibility((current) =>
150 current.left === next.left && current.right === next.right ? current : next,
151 );
152 },
153 [],
154 );
155
156 return (
157 <View className="min-w-0 flex-1">
158 <ScrollView
159 horizontal
160 showsHorizontalScrollIndicator={false}
161 onContentSizeChange={(contentWidth) => {
162 updateFadeVisibility({ contentWidth });
163 }}
164 onLayout={(event) => {
165 updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width });
166 }}
167 onScroll={(event) => {
168 updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x });
169 }}
170 scrollEventThrottle={16}
171 >
172 <View className="h-8 flex-row items-center">
173 {breadcrumbs.map((crumb, index) => (
174 <View key={crumb.path || "project"} className="flex-row items-center">
175 {index > 0 ? (
176 <SymbolView
177 name="chevron.right"
178 size={10}
179 tintColor={iconColor}
180 type="monochrome"
181 />
182 ) : null}
183 <Text
184 className={cn(
185 "max-w-[180px] px-1 text-xs",
186 crumb.kind === "file"
187 ? "font-t3-bold text-foreground"
188 : "font-t3-medium text-foreground-muted",
189 )}
190 numberOfLines={1}
191 >
192 {crumb.label}
193 </Text>
194 </View>
195 ))}
196 </View>
197 </ScrollView>
198 {fadeVisibility.left ? <BreadcrumbFade color={cardColor} side="left" /> : null}
199 {fadeVisibility.right ? <BreadcrumbFade color={cardColor} side="right" /> : null}
200 </View>
201 );
202}
203
204function FilePreviewHeader(props: {
205 readonly activeMode: FileViewMode;
206 readonly showModeSelector: boolean;
207 readonly externalPreviewUri?: string | null;
208 readonly projectName: string;
209 readonly relativePath: string;
210 readonly onSetMode: (mode: FileViewMode) => void;
211}) {
212 const iconColor = String(useThemeColor("--color-icon-muted"));
213
214 return (
215 <View className="border-b border-border bg-card px-3 py-2">
216 <View className="flex-row items-center gap-2">
217 <FileBreadcrumbs projectName={props.projectName} relativePath={props.relativePath} />
218 <CopyTextButton
219 accessibilityLabel="Copy file path"
220 text={props.relativePath}
221 tintColor={iconColor}
222 buttonSize={32}
223 iconSize={13}
224 />
225 </View>
226 {props.showModeSelector ? (
227 <View className="mt-2 flex-row items-center gap-2">
228 <ModeButton
229 active={props.activeMode === "preview"}
230 icon="eye"
231 label="Preview"
232 onPress={() => props.onSetMode("preview")}
233 />
234 <ModeButton
235 active={props.activeMode === "source"}
236 icon="doc.text"
237 label="Source"
238 onPress={() => props.onSetMode("source")}
239 />
240 {props.externalPreviewUri !== undefined ? (
241 <Pressable
242 accessibilityRole="button"
243 accessibilityLabel="Open preview in Safari"
244 disabled={props.externalPreviewUri === null}
245 hitSlop={8}
246 className={cn(
247 "ml-auto h-8 w-8 items-center justify-center rounded-full bg-subtle active:opacity-70",
248 props.externalPreviewUri === null && "opacity-40",
249 )}
250 onPress={() => {
251 if (typeof props.externalPreviewUri === "string") {
252 void tryOpenExternalUrl(props.externalPreviewUri, "file-preview");
253 }
254 }}
255 >
256 <SymbolView name="safari" size={15} tintColor={iconColor} type="monochrome" />
257 </Pressable>
258 ) : null}
259 </View>
260 ) : null}
261 </View>
262 );
263}
264
265function FileContent(props: {91function FileContent(props: {
266 readonly activeMode: FileViewMode;92 readonly activeMode: FileViewMode;
267 readonly previewUri: string | null;93 readonly previewUri: string | null;
...@@ -270,6 +96,7 @@ function FileContent(props: {...@@ -270,6 +96,7 @@ function FileContent(props: {
270 readonly relativePath: string;96 readonly relativePath: string;
271 readonly initialLine: number | null;97 readonly initialLine: number | null;
272 readonly truncated: boolean;98 readonly truncated: boolean;
99 readonly onRefresh?: () => Promise<void> | void;
273}) {100}) {
274 const isMarkdown = isMarkdownPreviewFile(props.relativePath);101 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
275 const isBrowserFile = isBrowserPreviewFile(props.relativePath);102 const isBrowserFile = isBrowserPreviewFile(props.relativePath);
...@@ -293,7 +120,7 @@ function FileContent(props: {...@@ -293,7 +120,7 @@ function FileContent(props: {
293120
294 if (props.fileError && props.fileContents === null) {121 if (props.fileError && props.fileContents === null) {
295 return (122 return (
296 <View className="flex-1 items-center justify-center bg-card px-6">123 <View className="flex-1 items-center justify-center bg-sheet px-6">
297 <EmptyState title="File unavailable" detail={props.fileError} />124 <EmptyState title="File unavailable" detail={props.fileError} />
298 </View>125 </View>
299 );126 );
...@@ -301,7 +128,7 @@ function FileContent(props: {...@@ -301,7 +128,7 @@ function FileContent(props: {
301128
302 if (props.fileContents === null) {129 if (props.fileContents === null) {
303 return (130 return (
304 <View className="flex-1 items-center justify-center gap-3 bg-card px-6">131 <View className="flex-1 items-center justify-center gap-3 bg-sheet px-6">
305 <ActivityIndicator />132 <ActivityIndicator />
306 <Text className="text-center text-sm text-foreground-muted">Loading file...</Text>133 <Text className="text-center text-sm text-foreground-muted">Loading file...</Text>
307 </View>134 </View>
...@@ -309,35 +136,47 @@ function FileContent(props: {...@@ -309,35 +136,47 @@ function FileContent(props: {
309 }136 }
310137
311 return (138 return (
312 <View className="flex-1 bg-card">139 <View className="flex-1 bg-sheet">
313 {props.truncated ? (140 {props.truncated ? (
314 <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40">141 <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40">
315 <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">142 <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">
316 Partial file143 Partial file
317 </Text>144 </Text>
318 <Text className="text-xs leading-[17px] text-amber-800 dark:text-amber-200">145 <Text className="text-xs leading-snug text-amber-800 dark:text-amber-200">
319 Preview limited to the first 1 MB of a truncated file.146 Preview limited to the first 1 MB of a truncated file.
320 </Text>147 </Text>
321 </View>148 </View>
322 ) : null}149 ) : null}
323 {props.activeMode === "preview" && isMarkdown ? (150 {props.activeMode === "preview" && isMarkdown ? (
324 <FileMarkdownPreview markdown={props.fileContents} />151 <FileMarkdownPreview markdown={props.fileContents} onRefresh={props.onRefresh} />
325 ) : (152 ) : (
326 <SourceFileSurface153 <SourceFileSurface
327 contents={props.fileContents}154 contents={props.fileContents}
328 path={props.relativePath}155 path={props.relativePath}
329 initialLine={props.initialLine}156 initialLine={props.initialLine}
157 onRefresh={props.onRefresh}
330 />158 />
331 )}159 )}
332 </View>160 </View>
333 );161 );
334}162}
335163
336function useThreadFilesWorkspace() {164type ThreadFilesRouteScreenProps = StaticScreenProps<{
337 const params = useLocalSearchParams<{165 readonly environmentId: string;
338 environmentId?: string | string[];166 readonly threadId: string;
339 threadId?: string | string[];167}>;
340 }>();168
169type ThreadFileRouteScreenProps = StaticScreenProps<{
170 readonly environmentId: string;
171 readonly threadId: string;
172 readonly path: string[];
173 readonly line?: string;
174}>;
175
176function useThreadFilesWorkspace(params: {
177 readonly environmentId?: string | string[];
178 readonly threadId?: string | string[];
179}) {
341 const routeEnvironmentId = firstRouteParam(params.environmentId);180 const routeEnvironmentId = firstRouteParam(params.environmentId);
342 const routeThreadId = firstRouteParam(params.threadId);181 const routeThreadId = firstRouteParam(params.threadId);
343 const { selectedThread, selectedThreadProject } = useThreadSelection();182 const { selectedThread, selectedThreadProject } = useThreadSelection();
...@@ -364,7 +203,7 @@ function useThreadFilesWorkspace() {...@@ -364,7 +203,7 @@ function useThreadFilesWorkspace() {
364function FilesUnavailable() {203function FilesUnavailable() {
365 return (204 return (
366 <View className="flex-1 items-center justify-center bg-sheet px-6">205 <View className="flex-1 items-center justify-center bg-sheet px-6">
367 <Stack.Screen options={{ title: "Files" }} />206 <NativeStackScreenOptions options={{ title: "Files" }} />
368 <EmptyState207 <EmptyState
369 title="Files unavailable"208 title="Files unavailable"
370 detail="This thread does not have an active workspace path."209 detail="This thread does not have an active workspace path."
...@@ -373,40 +212,6 @@ function FilesUnavailable() {...@@ -373,40 +212,6 @@ function FilesUnavailable() {
373 );212 );
374}213}
375214
376function FilesHeaderTitle(props: { readonly projectName: string }) {
377 const foregroundColor = String(useThemeColor("--color-foreground"));
378 const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary"));
379
380 return (
381 <View style={{ alignItems: "center", maxWidth: 220 }}>
382 <RNText
383 numberOfLines={1}
384 style={{
385 color: foregroundColor,
386 fontFamily: "DMSans_700Bold",
387 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
388 fontWeight: "900",
389 letterSpacing: -0.4,
390 }}
391 >
392 Files
393 </RNText>
394 <RNText
395 numberOfLines={1}
396 style={{
397 color: secondaryForegroundColor,
398 fontFamily: "DMSans_500Medium",
399 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
400 fontWeight: "500",
401 letterSpacing: 0.2,
402 }}
403 >
404 {props.projectName}
405 </RNText>
406 </View>
407 );
408}
409
410function FilesToolbarBottomFade() {215function FilesToolbarBottomFade() {
411 const sheetColor = String(useThemeColor("--color-sheet"));216 const sheetColor = String(useThemeColor("--color-sheet"));
412217
...@@ -442,12 +247,20 @@ function FilesToolbarBottomFade() {...@@ -442,12 +247,20 @@ function FilesToolbarBottomFade() {
442 );247 );
443}248}
444249
445export function ThreadFilesTreeScreen() {250export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
446 const router = useRouter();251 useAdaptiveWorkspacePaneRole("inspector");
252 const navigation = useNavigation();
253 const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
254 useAdaptiveWorkspaceLayout();
447 const [searchQuery, setSearchQuery] = useState("");255 const [searchQuery, setSearchQuery] = useState("");
448 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();256 const colorScheme = useColorScheme();
257 const highlightTheme = colorScheme === "dark" ? "dark" : "light";
258 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
259 props.route.params,
260 );
261 const revealedInspectorRef = useRef(false);
449 const entriesQuery = useEnvironmentQuery(262 const entriesQuery = useEnvironmentQuery(
450 environmentId !== null && cwd !== null263 environmentId !== null && cwd !== null && !fileInspector.supported
451 ? projectEnvironment.listEntries({264 ? projectEnvironment.listEntries({
452 environmentId,265 environmentId,
453 input: { cwd },266 input: { cwd },
...@@ -455,18 +268,87 @@ export function ThreadFilesTreeScreen() {...@@ -455,18 +268,87 @@ export function ThreadFilesTreeScreen() {
455 : null,268 : null,
456 );269 );
457 const entriesData = entriesQuery.data as ProjectListEntriesResult | null;270 const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
271 const handleReturnToThread = useCallback(() => {
272 if (navigation.canGoBack()) {
273 navigation.goBack();
274 return;
275 }
276 if (environmentId !== null && threadId !== null) {
277 navigation.dispatch(
278 StackActions.replace("Thread", {
279 environmentId: String(environmentId),
280 threadId: String(threadId),
281 }),
282 );
283 }
284 }, [environmentId, navigation, threadId]);
458285
459 const handleSelectFile = useCallback(286 const handleSelectFile = useCallback(
460 (path: string) => {287 (path: string) => {
461 if (environmentId === null || threadId === null) {288 if (environmentId === null || threadId === null) {
462 return;289 return;
463 }290 }
464 router.push(buildThreadFilesNavigation({ environmentId, threadId }, path));291 const params = {
292 environmentId: String(environmentId),
293 threadId: String(threadId),
294 path: path.split("/").filter((segment) => segment.length > 0),
295 };
296 const navigationAction = resolveFileSelectionNavigationAction({
297 hasPersistentFileInspector: fileInspector.supported,
298 });
299 if (navigationAction === "replace") {
300 navigation.dispatch(StackActions.replace("ThreadFile", params));
301 return;
302 }
303 navigation.navigate("ThreadFile", params);
465 },304 },
466 [environmentId, router, threadId],305 [environmentId, fileInspector.supported, navigation, threadId],
467 );306 );
307 const renderInspector = useCallback(
308 (headerInset: number) =>
309 environmentId !== null && cwd !== null ? (
310 <ThreadFileNavigatorPane
311 cwd={cwd}
312 environmentId={environmentId}
313 headerInset={headerInset}
314 projectName={projectName}
315 selectedPath={null}
316 onSelectFile={handleSelectFile}
317 />
318 ) : null,
319 [cwd, environmentId, handleSelectFile, projectName],
320 );
321 const handlePreviewFile = useCallback(
322 (relativePath: string) => {
323 if (environmentId === null || cwd === null) {
324 return;
325 }
326 preloadWorkspaceFileContents({
327 cwd,
328 environmentId,
329 relativePath,
330 theme: highlightTheme,
331 });
332 },
333 [cwd, environmentId, highlightTheme],
334 );
335 useEffect(() => {
336 if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) {
337 revealedInspectorRef.current = true;
338 showAuxiliaryPane("inspector");
339 }
340 }, [cwd, fileInspector.supported, showAuxiliaryPane]);
468341
469 if (selectedThread === null || environmentId === null || threadId === null) {342 if (selectedThread === null || environmentId === null || threadId === null) {
343 if (fileInspector.supported) {
344 return (
345 <ThreadRouteScreen
346 onReturnToThread={handleReturnToThread}
347 renderInspector={renderInspector}
348 route={props.route}
349 />
350 );
351 }
470 return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;352 return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;
471 }353 }
472354
...@@ -474,62 +356,95 @@ export function ThreadFilesTreeScreen() {...@@ -474,62 +356,95 @@ export function ThreadFilesTreeScreen() {
474 return <FilesUnavailable />;356 return <FilesUnavailable />;
475 }357 }
476358
359 if (fileInspector.supported) {
360 return (
361 <ThreadRouteScreen
362 onReturnToThread={handleReturnToThread}
363 renderInspector={renderInspector}
364 route={props.route}
365 />
366 );
367 }
368
369 const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView;
370
477 return (371 return (
478 <View className="flex-1 bg-sheet">372 <>
479 <Stack.Screen373 {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx.
374 Only genuinely dynamic options are set here. */}
375 <NativeStackScreenOptions
480 options={{376 options={{
481 title: "Files",377 unstable_headerSubtitle:
482 headerShown: true,378 Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,
483 headerTransparent: true,379 // No refresh button: the list already supports pull-to-refresh.
484 headerStyle: { backgroundColor: "transparent" },380 unstable_headerToolbarItems: usesCompactMailToolbar
485 headerShadowVisible: false,381 ? () => [
486 headerTitle: () => <FilesHeaderTitle projectName={projectName} />,382 createNativeMailSearchToolbarItem({
487 headerSearchBarOptions: {383 onSearchTextChange: setSearchQuery,
488 allowToolbarIntegration: true,384 placeholder: "Search files",
489 autoCapitalize: "none",385 searchTextChangeId: "files-search-text",
490 hideNavigationBar: false,386 }),
491 placeholder: "Search files",387 ]
492 onChangeText: (event) => {388 : undefined,
493 setSearchQuery(event.nativeEvent.text);389 headerSearchBarOptions: usesCompactMailToolbar
494 },390 ? undefined
495 onCancelButtonPress: () => {391 : {
496 setSearchQuery("");392 allowToolbarIntegration: true,
497 },393 autoCapitalize: "none",
498 },394 hideNavigationBar: false,
395 placeholder: "Search files",
396 onChangeText: (event) => {
397 setSearchQuery(event.nativeEvent.text);
398 },
399 onCancelButtonPress: () => {
400 setSearchQuery("");
401 },
402 },
499 }}403 }}
500 />404 />
501 <Stack.Toolbar placement="right">405 {layout.usesSplitView ? (
502 <Stack.Toolbar.Button406 <NativeHeaderToolbar placement="left">
503 accessibilityLabel="Refresh files"407 <NativeHeaderToolbar.Button
504 icon="arrow.clockwise"408 accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"}
505 onPress={entriesQuery.refresh}409 icon={
506 />410 panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
507 </Stack.Toolbar>411 }
508 <Stack.Toolbar placement="bottom">412 onPress={togglePrimarySidebar}
509 <Stack.Toolbar.SearchBarSlot />413 separateBackground
510 </Stack.Toolbar>414 />
415 </NativeHeaderToolbar>
416 ) : null}
417 {usesCompactMailToolbar ? null : (
418 <NativeHeaderToolbar placement="bottom">
419 <NativeHeaderToolbar.SearchBarSlot />
420 </NativeHeaderToolbar>
421 )}
511 <FileTreeBrowser422 <FileTreeBrowser
512 entries={entriesData?.entries ?? []}423 entries={entriesData?.entries ?? []}
513 error={entriesQuery.error}424 error={entriesQuery.error}
514 isPending={entriesQuery.isPending}425 isPending={entriesQuery.isPending}
515 searchQuery={searchQuery}426 searchQuery={searchQuery}
516 selectedPath={null}427 selectedPath={null}
428 onPreviewFile={handlePreviewFile}
517 onRefresh={entriesQuery.refresh}429 onRefresh={entriesQuery.refresh}
518 onSelectFile={handleSelectFile}430 onSelectFile={handleSelectFile}
519 />431 />
520 <FilesToolbarBottomFade />432 <FilesToolbarBottomFade />
521 </View>433 </>
522 );434 );
523}435}
524436
525export function ThreadFileScreen() {437export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
526 const params = useLocalSearchParams<{438 useAdaptiveWorkspacePaneRole("inspector");
527 line?: string | string[];439 const navigation = useNavigation();
528 path?: string | string[];440 const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
529 }>();441 const iconColor = useThemeColor("--color-icon");
442 const params = props.route.params;
530 const relativePath = normalizeRoutePath(params.path);443 const relativePath = normalizeRoutePath(params.path);
531 const targetLine = normalizeRouteLine(firstRouteParam(params.line));444 const targetLine = normalizeRouteLine(firstRouteParam(params.line));
532 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();445 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
446 props.route.params,
447 );
533 const [modeOverride, setModeOverride] = useState<{448 const [modeOverride, setModeOverride] = useState<{
534 readonly path: string;449 readonly path: string;
535 readonly mode: FileViewMode;450 readonly mode: FileViewMode;
...@@ -568,6 +483,43 @@ export function ThreadFileScreen() {...@@ -568,6 +483,43 @@ export function ThreadFileScreen() {
568 );483 );
569 const fileData = fileQuery.data as ProjectReadFileResult | null;484 const fileData = fileQuery.data as ProjectReadFileResult | null;
570485
486 const handleSelectFile = useCallback(
487 (path: string) => {
488 navigation.navigate("ThreadFile", {
489 environmentId: String(environmentId),
490 threadId: String(threadId),
491 path: path.split("/").filter(Boolean),
492 });
493 },
494 [environmentId, navigation, threadId],
495 );
496 const renderInspector = useCallback(
497 (headerInset: number) =>
498 fileInspector.supported && environmentId !== null && cwd !== null ? (
499 <ThreadFileNavigatorPane
500 cwd={cwd}
501 environmentId={environmentId}
502 headerInset={headerInset}
503 projectName={projectName}
504 selectedPath={relativePath}
505 onSelectFile={handleSelectFile}
506 />
507 ) : undefined,
508 [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath],
509 );
510 // The workspace inspector column spans the full window height. On iOS the
511 // pane brings its own nested native header; elsewhere it pads itself below
512 // the top inset.
513 const safeAreaInsets = useSafeAreaInsets();
514 const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top;
515 // Hand the file navigator to the workspace so it renders beside the
516 // navigator, outside this screen's native header.
517 const renderWorkspaceInspector = useCallback(
518 () => renderInspector(inspectorHeaderInset),
519 [inspectorHeaderInset, renderInspector],
520 );
521 useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined);
522
571 if (selectedThread === null || environmentId === null || threadId === null) {523 if (selectedThread === null || environmentId === null || threadId === null) {
572 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;524 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
573 }525 }
...@@ -579,38 +531,104 @@ export function ThreadFileScreen() {...@@ -579,38 +531,104 @@ export function ThreadFileScreen() {
579 if (relativePath === null) {531 if (relativePath === null) {
580 return (532 return (
581 <View className="flex-1 items-center justify-center bg-sheet px-6">533 <View className="flex-1 items-center justify-center bg-sheet px-6">
582 <Stack.Screen options={{ title: "Files" }} />534 <NativeStackScreenOptions options={{ title: "Files" }} />
583 <EmptyState title="File unavailable" detail="This file path is invalid." />535 <EmptyState title="File unavailable" detail="This file path is invalid." />
584 </View>536 </View>
585 );537 );
586 }538 }
587539
540 const parentDir = relativePath.split("/").slice(0, -1).join("/");
541 const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · ");
542
588 return (543 return (
589 <ReviewHighlighterProvider>544 <ReviewHighlighterProvider>
590 <View className="flex-1 bg-sheet">545 <View className="flex-1 bg-sheet">
591 <Stack.Screen options={{ title: basename(relativePath) }} />546 <NativeStackScreenOptions
592 <Stack.Toolbar placement="right">547 options={{
593 <Stack.Toolbar.Button548 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
594 icon="arrow.clockwise"549 // sheet-colored header — this route's content scrolls internally, so
595 onPress={() => {550 // there is nothing for glass to sample). Only dynamic values here.
596 if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) {551 headerTintColor: iconColor,
597 setPreviewRevision((current) => current + 1);552 headerTitle: basename(relativePath),
598 return;553 title: basename(relativePath),
599 }554 unstable_headerSubtitle:
600 fileQuery.refresh();555 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
601 }}
602 />
603 </Stack.Toolbar>
604 <FilePreviewHeader
605 activeMode={resolvedActiveMode}
606 showModeSelector={canPreview && !isImageFile}
607 externalPreviewUri={isBrowserFile ? assetPreviewUri : undefined}
608 projectName={projectName}
609 relativePath={relativePath}
610 onSetMode={(mode) => {
611 setModeOverride({ path: relativePath, mode });
612 }}556 }}
613 />557 />
558 <WorkspaceSidebarToolbar>
559 {fileInspector.supported ? (
560 <NativeHeaderToolbar.Button
561 accessibilityLabel="Return to chat"
562 icon="chevron.left"
563 onPress={() => {
564 navigation.dispatch(
565 StackActions.replace("Thread", {
566 environmentId: String(environmentId),
567 threadId: String(threadId),
568 }),
569 );
570 }}
571 />
572 ) : null}
573 </WorkspaceSidebarToolbar>
574 <NativeHeaderToolbar placement="right">
575 {fileInspector.supported ? (
576 <NativeHeaderToolbar.Button
577 accessibilityLabel={
578 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
579 }
580 icon="sidebar.right"
581 onPress={toggleAuxiliaryPane}
582 separateBackground
583 />
584 ) : null}
585 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
586 {canPreview && !isImageFile ? (
587 <NativeHeaderToolbar.Menu inline>
588 <NativeHeaderToolbar.MenuAction
589 icon="eye"
590 isOn={resolvedActiveMode === "preview"}
591 onPress={() => setModeOverride({ path: relativePath, mode: "preview" })}
592 >
593 Preview
594 </NativeHeaderToolbar.MenuAction>
595 <NativeHeaderToolbar.MenuAction
596 icon="doc.text"
597 isOn={resolvedActiveMode === "source"}
598 onPress={() => setModeOverride({ path: relativePath, mode: "source" })}
599 >
600 Source
601 </NativeHeaderToolbar.MenuAction>
602 </NativeHeaderToolbar.Menu>
603 ) : null}
604 <NativeHeaderToolbar.MenuAction
605 icon="doc.on.doc"
606 onPress={() => copyTextWithHaptic(relativePath)}
607 >
608 Copy path
609 </NativeHeaderToolbar.MenuAction>
610 {isBrowserFile && typeof assetPreviewUri === "string" ? (
611 <NativeHeaderToolbar.MenuAction
612 icon="safari"
613 onPress={() => {
614 void tryOpenExternalUrl(assetPreviewUri, "file-preview");
615 }}
616 >
617 Open in Safari
618 </NativeHeaderToolbar.MenuAction>
619 ) : null}
620 {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? (
621 <NativeHeaderToolbar.MenuAction
622 icon="arrow.clockwise"
623 onPress={() => {
624 setPreviewRevision((current) => current + 1);
625 }}
626 >
627 Refresh
628 </NativeHeaderToolbar.MenuAction>
629 ) : null}
630 </NativeHeaderToolbar.Menu>
631 </NativeHeaderToolbar>
614 <FileContent632 <FileContent
615 activeMode={resolvedActiveMode}633 activeMode={resolvedActiveMode}
616 previewUri={previewUri}634 previewUri={previewUri}
...@@ -619,6 +637,7 @@ export function ThreadFileScreen() {...@@ -619,6 +637,7 @@ export function ThreadFileScreen() {
619 initialLine={targetLine}637 initialLine={targetLine}
620 relativePath={relativePath}638 relativePath={relativePath}
621 truncated={fileData?.truncated ?? false}639 truncated={fileData?.truncated ?? false}
640 onRefresh={() => fileQuery.refresh()}
622 />641 />
623 </View>642 </View>
624 </ReviewHighlighterProvider>643 </ReviewHighlighterProvider>
apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx+1-1
...@@ -24,7 +24,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null })...@@ -24,7 +24,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null })
24 {loadError ? (24 {loadError ? (
25 <View className="border-b border-border bg-card px-4 py-2">25 <View className="border-b border-border bg-card px-4 py-2">
26 <Text className="text-xs font-t3-bold text-foreground">Preview failed</Text>26 <Text className="text-xs font-t3-bold text-foreground">Preview failed</Text>
27 <Text className="mt-0.5 text-xs leading-[17px] text-foreground-muted">{loadError}</Text>27 <Text className="mt-0.5 text-xs leading-snug text-foreground-muted">{loadError}</Text>
28 </View>28 </View>
29 ) : null}29 ) : null}
30 <WebView30 <WebView
apps/mobile/src/features/files/nativeSourceFileAdapter.ts+26-14
...@@ -3,26 +3,38 @@ import type {...@@ -3,26 +3,38 @@ import type {
3 NativeReviewDiffStyle,3 NativeReviewDiffStyle,
4 NativeReviewDiffToken,4 NativeReviewDiffToken,
5} from "../diffs/nativeReviewDiffSurface";5} from "../diffs/nativeReviewDiffSurface";
6import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
7import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
6import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";8import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";
7import type { SourceHighlightTokens } from "./sourceHighlightingState";9import type { SourceHighlightTokens } from "./sourceHighlightingState";
810
9export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;11export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
10export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000;12export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000;
1113
12export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = {14export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle(
13 rowHeight: NATIVE_SOURCE_ROW_HEIGHT,15 resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
14 contentWidth: NATIVE_SOURCE_CONTENT_WIDTH,16);
15 changeBarWidth: 0,17
16 gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,18export function createNativeSourceStyle(
17 codePadding: MOBILE_CODE_SURFACE.codePadding,19 codeSurface: ResolvedMobileCodeSurface,
18 textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,20): NativeReviewDiffStyle {
19 codeFontSize: MOBILE_CODE_SURFACE.fontSize,21 return {
20 codeFontWeight: "regular",22 rowHeight: codeSurface.rowHeight,
21 lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,23 contentWidth: NATIVE_SOURCE_CONTENT_WIDTH,
22 lineNumberFontWeight: "regular",24 changeBarWidth: 0,
23 emptyStateFontSize: MOBILE_TYPOGRAPHY.label.fontSize,25 gutterWidth: codeSurface.gutterWidth,
24 emptyStateFontWeight: "medium",26 codePadding: codeSurface.codePadding,
25};27 textVerticalInset: codeSurface.textVerticalInset,
28 codeFontSize: codeSurface.fontSize,
29 codeFontWeight: "regular",
30 lineNumberFontSize: codeSurface.lineNumberFontSize,
31 lineNumberFontWeight: "regular",
32 emptyStateFontSize: Math.round(
33 MOBILE_TYPOGRAPHY.label.fontSize * (codeSurface.fontSize / MOBILE_CODE_SURFACE.fontSize),
34 ),
35 emptyStateFontWeight: "medium",
36 };
37}
2638
27const SOURCE_FILE_ID = "source-file";39const SOURCE_FILE_ID = "source-file";
2840
apps/mobile/src/features/files/preload-workspace-file.ts created+74
...@@ -0,0 +1,74 @@
1import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId } from "@t3tools/contracts";
3
4import { appAtomRegistry } from "../../state/atom-registry";
5import { projectEnvironment } from "../../state/projects";
6import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath";
7import { prepareSourceFileDocument } from "./source-file-document";
8import { sourceHighlightAtom } from "./sourceHighlightingState";
9import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";
10
11const inFlightPreloads = new Map<string, Promise<void>>();
12const MAX_HIGHLIGHT_PRELOAD_CHARACTERS = 256 * 1024;
13
14function preloadKey(input: {
15 readonly cwd: string;
16 readonly environmentId: EnvironmentId;
17 readonly relativePath: string;
18}): string {
19 return JSON.stringify([input.environmentId, input.cwd, input.relativePath]);
20}
21
22export function preloadWorkspaceFileContents(input: {
23 readonly cwd: string;
24 readonly environmentId: EnvironmentId;
25 readonly relativePath: string;
26 readonly theme: ReviewDiffTheme;
27}): void {
28 if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) {
29 return;
30 }
31
32 const key = preloadKey(input);
33 if (inFlightPreloads.has(key)) {
34 return;
35 }
36
37 const preload = executeAtomQuery(
38 appAtomRegistry,
39 projectEnvironment.readFile({
40 environmentId: input.environmentId,
41 input: { cwd: input.cwd, relativePath: input.relativePath },
42 }),
43 {
44 label: "workspace file preload",
45 reportDefect: false,
46 reportFailure: false,
47 },
48 )
49 .then(async (result) => {
50 if (result._tag === "Success") {
51 const document = prepareSourceFileDocument(result.value.contents);
52 if (document.contents.length <= MAX_HIGHLIGHT_PRELOAD_CHARACTERS) {
53 await executeAtomQuery(
54 appAtomRegistry,
55 sourceHighlightAtom({
56 path: input.relativePath,
57 contents: document.contents,
58 theme: input.theme,
59 }),
60 {
61 label: "workspace source highlight preload",
62 reportDefect: false,
63 reportFailure: false,
64 },
65 );
66 }
67 }
68 })
69 .finally(() => {
70 inFlightPreloads.delete(key);
71 });
72
73 inFlightPreloads.set(key, preload);
74}
apps/mobile/src/features/files/source-file-document.test.ts created+16
...@@ -0,0 +1,16 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { prepareSourceFileDocument } from "./source-file-document";
4
5describe("prepareSourceFileDocument", () => {
6 it("normalizes and serializes source rows once for repeated consumers", () => {
7 const first = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r");
8 const second = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r");
9 const rows = JSON.parse(first.rowsJson) as ReadonlyArray<{ readonly content: string }>;
10
11 expect(first.contents).toBe("const value = 1;\n\tvalue;\n");
12 expect(first.lines).toEqual(["const value = 1;", "\tvalue;", ""]);
13 expect(rows.map((row) => row.content)).toEqual(["const value = 1;", " value;", ""]);
14 expect(second).toBe(first);
15 });
16});
apps/mobile/src/features/files/source-file-document.ts created+54
...@@ -0,0 +1,54 @@
1import { buildNativeSourceRows } from "./nativeSourceFileAdapter";
2
3const MAX_CACHED_DOCUMENTS = 8;
4const MAX_CACHED_CHARACTERS = 4 * 1024 * 1024;
5
6export interface SourceFileDocument {
7 readonly contents: string;
8 readonly lines: ReadonlyArray<string>;
9 readonly rowsJson: string;
10}
11
12const documentCache = new Map<string, SourceFileDocument>();
13let cachedCharacterCount = 0;
14
15function removeOldestCachedDocument(): void {
16 const oldestKey = documentCache.keys().next().value;
17 if (typeof oldestKey !== "string") {
18 return;
19 }
20 const document = documentCache.get(oldestKey);
21 documentCache.delete(oldestKey);
22 cachedCharacterCount -= (document?.contents.length ?? 0) + (document?.rowsJson.length ?? 0);
23}
24
25export function prepareSourceFileDocument(contents: string): SourceFileDocument {
26 const cached = documentCache.get(contents);
27 if (cached !== undefined) {
28 documentCache.delete(contents);
29 documentCache.set(contents, cached);
30 return cached;
31 }
32
33 const normalizedContents = contents.replace(/\r\n?/g, "\n");
34 const lines = normalizedContents.split("\n");
35 const document = {
36 contents: normalizedContents,
37 lines,
38 rowsJson: JSON.stringify(buildNativeSourceRows(lines)),
39 } satisfies SourceFileDocument;
40 const characterCount = document.contents.length + document.rowsJson.length;
41
42 if (characterCount <= MAX_CACHED_CHARACTERS) {
43 while (
44 documentCache.size >= MAX_CACHED_DOCUMENTS ||
45 cachedCharacterCount + characterCount > MAX_CACHED_CHARACTERS
46 ) {
47 removeOldestCachedDocument();
48 }
49 documentCache.set(contents, document);
50 cachedCharacterCount += characterCount;
51 }
52
53 return document;
54}
apps/mobile/src/features/files/sourceHighlightingState.test.ts+1-1
...@@ -88,7 +88,7 @@ describe("sourceHighlightingState", () => {...@@ -88,7 +88,7 @@ describe("sourceHighlightingState", () => {
88 expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);88 expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);
89 });89 });
90 firstUnmount();90 firstUnmount();
91 await new Promise((resolve) => setTimeout(resolve, 25));91 await new Promise((resolve) => setTimeout(resolve, 100));
9292
93 const secondUnmount = registry.mount(atom);93 const secondUnmount = registry.mount(atom);
94 await vi.waitFor(() => {94 await vi.waitFor(() => {
apps/mobile/src/features/files/thread-file-navigator-pane.tsx created+175
...@@ -0,0 +1,175 @@
1import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";
3import { useCallback, useMemo, useState, type ComponentProps } from "react";
4import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native";
5import {
6 Screen,
7 ScreenStack,
8 ScreenStackHeaderConfig,
9 ScreenStackHeaderSearchBarView,
10 SearchBar,
11} from "react-native-screens";
12
13import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
14import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
15import { useThemeColor } from "../../lib/useThemeColor";
16import { projectEnvironment } from "../../state/projects";
17import { useEnvironmentQuery } from "../../state/query";
18import { FileTreeBrowser } from "./FileTreeBrowser";
19import { preloadWorkspaceFileContents } from "./preload-workspace-file";
20
21export function ThreadFileNavigatorPane(props: {
22 readonly cwd: string;
23 readonly environmentId: EnvironmentId;
24 readonly headerInset: number;
25 readonly projectName: string;
26 readonly selectedPath: string | null;
27 readonly onSelectFile: (path: string) => void;
28}) {
29 const [searchQuery, setSearchQuery] = useState("");
30 const colorScheme = useColorScheme();
31 const highlightTheme = colorScheme === "dark" ? "dark" : "light";
32 const iconColor = String(useThemeColor("--color-icon-muted"));
33 const foregroundColor = String(useThemeColor("--color-foreground"));
34 const sheetColor = String(useThemeColor("--color-sheet"));
35 const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
36 const entriesQuery = useEnvironmentQuery(
37 projectEnvironment.listEntries({
38 environmentId: props.environmentId,
39 input: { cwd: props.cwd },
40 }),
41 );
42 const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
43 const handlePreviewFile = useCallback(
44 (relativePath: string) => {
45 preloadWorkspaceFileContents({
46 cwd: props.cwd,
47 environmentId: props.environmentId,
48 relativePath,
49 theme: highlightTheme,
50 });
51 },
52 [highlightTheme, props.cwd, props.environmentId],
53 );
54 const nativeHeaderRightBarButtonItems = useMemo(
55 () =>
56 [
57 {
58 accessibilityLabel: "Refresh files",
59 icon: { name: "arrow.clockwise", type: "sfSymbol" as const },
60 identifier: "thread-file-navigator-refresh",
61 onPress: entriesQuery.refresh,
62 sharesBackground: false,
63 tintColor: foregroundColor,
64 type: "button" as const,
65 width: 44,
66 },
67 ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"],
68 [entriesQuery.refresh, foregroundColor],
69 );
70
71 const fileTree = (
72 <FileTreeBrowser
73 entries={entriesData?.entries ?? []}
74 error={entriesQuery.error}
75 isPending={entriesQuery.isPending}
76 searchQuery={searchQuery}
77 selectedPath={props.selectedPath}
78 onPreviewFile={handlePreviewFile}
79 onRefresh={entriesQuery.refresh}
80 onSelectFile={props.onSelectFile}
81 />
82 );
83
84 if (Platform.OS === "ios") {
85 return (
86 <View className="flex-1 border-l border-border bg-sheet">
87 <ScreenStack style={{ flex: 1 }}>
88 <Screen
89 activityState={2}
90 enabled
91 isNativeStack
92 screenId="thread-file-navigator-native"
93 scrollEdgeEffects={headerScrollEdgeEffects}
94 style={{ backgroundColor: sheetColor, flex: 1 }}
95 >
96 {fileTree}
97 <ScreenStackHeaderConfig
98 backgroundColor="rgba(0,0,0,0)"
99 color={foregroundColor}
100 headerRightBarButtonItems={nativeHeaderRightBarButtonItems}
101 hideBackButton
102 hideShadow={false}
103 navigationItemStyle="editor"
104 subtitle={props.projectName}
105 title="Files"
106 titleColor={foregroundColor}
107 titleFontSize={17}
108 titleFontWeight="700"
109 translucent
110 >
111 <ScreenStackHeaderSearchBarView>
112 <SearchBar
113 allowToolbarIntegration
114 autoCapitalize="none"
115 barTintColor={sheetColor}
116 hideNavigationBar={false}
117 hideWhenScrolling={false}
118 obscureBackground={false}
119 onCancelButtonPress={() => {
120 setSearchQuery("");
121 }}
122 onChangeText={(event: NativeSyntheticEvent<{ readonly text?: string }>) => {
123 setSearchQuery(event.nativeEvent.text ?? "");
124 }}
125 placement="integratedButton"
126 placeholder="Search files"
127 textColor={foregroundColor}
128 tintColor={foregroundColor}
129 />
130 </ScreenStackHeaderSearchBarView>
131 </ScreenStackHeaderConfig>
132 </Screen>
133 </ScreenStack>
134 </View>
135 );
136 }
137
138 return (
139 <View className="flex-1 border-l border-border bg-sheet">
140 <View className="border-b border-border" style={{ paddingTop: props.headerInset }}>
141 <View className="h-12 flex-row items-center gap-2 px-3">
142 <View className="min-w-0 flex-1">
143 <Text className="text-sm font-t3-bold text-foreground">Files</Text>
144 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
145 {props.projectName}
146 </Text>
147 </View>
148 <Pressable
149 accessibilityRole="button"
150 accessibilityLabel="Refresh files"
151 hitSlop={8}
152 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
153 onPress={entriesQuery.refresh}
154 >
155 <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" />
156 </Pressable>
157 </View>
158 <View className="flex-row items-center gap-2 border-t border-border px-3 py-2">
159 <SymbolView name="magnifyingglass" size={15} tintColor={iconColor} type="monochrome" />
160 <TextInput
161 accessibilityLabel="Search files"
162 autoCapitalize="none"
163 autoCorrect={false}
164 clearButtonMode="while-editing"
165 className="min-h-10 flex-1 rounded-xl py-2 text-sm"
166 placeholder="Search files"
167 value={searchQuery}
168 onChangeText={setSearchQuery}
169 />
170 </View>
171 </View>
172 {fileTree}
173 </View>
174 );
175}
apps/mobile/src/features/home/HomeHeader.tsx+169-203
...@@ -3,60 +3,30 @@ import type {...@@ -3,60 +3,30 @@ import type {
3 SidebarProjectGroupingMode,3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import {6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,7import { useCallback, useRef } from "react";
8 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,8import { Platform } from "react-native";
9 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,9import type { SearchBarCommands } from "react-native-screens";
10} from "@t3tools/contracts";
11import { Stack } from "expo-router";
12import { Text as RNText, View } from "react-native";
1310
11import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
14import { useThemeColor } from "../../lib/useThemeColor";12import { useThemeColor } from "../../lib/useThemeColor";
15import { MOBILE_TYPOGRAPHY } from "../../lib/typography";13import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
14import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
15import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
16import type { HomeProjectSortOrder } from "./homeThreadList";16import type { HomeProjectSortOrder } from "./homeThreadList";
17import {
18 buildHomeListFilterMenu,
19 type HomeListFilterMenuEnvironment,
20} from "./home-list-filter-menu";
21import {
22 hasCustomHomeListOptions,
23 PROJECT_GROUPING_OPTIONS,
24 PROJECT_SORT_OPTIONS,
25 THREAD_SORT_OPTIONS,
26} from "./home-list-options";
1727
18export interface HomeHeaderEnvironment {28export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
19 readonly environmentId: EnvironmentId;29const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
20 readonly label: string;
21}
22
23const PROJECT_SORT_OPTIONS: ReadonlyArray<{
24 readonly value: HomeProjectSortOrder;
25 readonly label: string;
26}> = [
27 { value: "updated_at", label: "Last user message" },
28 { value: "created_at", label: "Created at" },
29];
30
31const THREAD_SORT_OPTIONS: ReadonlyArray<{
32 readonly value: SidebarThreadSortOrder;
33 readonly label: string;
34}> = [
35 { value: "updated_at", label: "Last user message" },
36 { value: "created_at", label: "Created at" },
37];
38
39const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{
40 readonly value: SidebarProjectGroupingMode;
41 readonly label: string;
42 readonly subtitle: string;
43}> = [
44 {
45 value: "repository",
46 label: "Group by repository",
47 subtitle: "Combine matching repositories across environments",
48 },
49 {
50 value: "repository_path",
51 label: "Group by repository path",
52 subtitle: "Combine only matching paths within a repository",
53 },
54 {
55 value: "separate",
56 label: "Keep separate",
57 subtitle: "Show every project path separately",
58 },
59];
6030
61export function HomeHeader(props: {31export function HomeHeader(props: {
62 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;32 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
...@@ -72,174 +42,170 @@ export function HomeHeader(props: {...@@ -72,174 +42,170 @@ export function HomeHeader(props: {
72 readonly onOpenSettings: () => void;42 readonly onOpenSettings: () => void;
73 readonly onStartNewTask: () => void;43 readonly onStartNewTask: () => void;
74}) {44}) {
45 const searchBarRef = useRef<SearchBarCommands>(null);
75 const iconColor = useThemeColor("--color-icon");46 const iconColor = useThemeColor("--color-icon");
76 const mutedColor = useThemeColor("--color-foreground-muted");47 const hasCustomListOptions = hasCustomHomeListOptions(props);
77 const subtleColor = useThemeColor("--color-subtle");48 const focusSearch = useCallback(() => {
78 const hasCustomListOptions =49 searchBarRef.current?.focus();
79 props.selectedEnvironmentId !== null ||50 return searchBarRef.current !== null;
80 props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER ||51 }, []);
81 props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||52 useHardwareKeyboardCommand("focusSearch", focusSearch);
82 props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE;53 const filterMenu = buildHomeListFilterMenu(props);
8354
84 return (55 return (
85 <>56 <>
86 <Stack.Screen57 <NativeStackScreenOptions
87 options={{58 options={{
88 headerShown: true,59 // Static header config (glass, title, fonts) lives in Stack.tsx
89 headerTransparent: true,60 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
90 headerStyle: { backgroundColor: "transparent" },
91 headerShadowVisible: false,
92 headerTintColor: iconColor,61 headerTintColor: iconColor,
93 headerTitle: "",62 unstable_headerRightItems:
94 headerSearchBarOptions: {63 Platform.OS === "ios"
95 placeholder: "Search threads",64 ? () => [
96 hideNavigationBar: false,65 withNativeGlassHeaderItem({
97 onChangeText: (event) => {66 accessibilityLabel: "Open settings",
98 props.onSearchQueryChange(event.nativeEvent.text);67 icon: { name: "ellipsis", type: "sfSymbol" } as const,
99 },68 identifier: "home-settings",
100 onCancelButtonPress: () => {69 label: "",
101 props.onSearchQueryChange("");70 onPress: props.onOpenSettings,
102 },71 type: "button",
103 allowToolbarIntegration: true,72 }),
104 },73 ]
74 : undefined,
75 unstable_headerToolbarItems:
76 Platform.OS === "ios"
77 ? () => [
78 createNativeMailSearchToolbarItem({
79 composeButtonId: "home-new-task",
80 composeSystemImageName: "square.and.pencil",
81 filterMenu,
82 filterButtonId: "home-filter",
83 filterSystemImageName: hasCustomListOptions
84 ? "line.3.horizontal.decrease.circle.fill"
85 : "line.3.horizontal.decrease",
86 onComposePress: props.onStartNewTask,
87 onSearchTextChange: props.onSearchQueryChange,
88 placeholder: "Search",
89 searchTextChangeId: "home-search-text",
90 }),
91 ]
92 : undefined,
93 headerSearchBarOptions:
94 Platform.OS === "ios"
95 ? undefined
96 : {
97 ref: searchBarRef,
98 allowToolbarIntegration: true,
99 hideNavigationBar: false,
100 placeholder: "Search",
101 onCancelButtonPress: () => {
102 props.onSearchQueryChange("");
103 },
104 onChangeText: (event) => {
105 props.onSearchQueryChange(event.nativeEvent.text);
106 },
107 },
105 }}108 }}
106 />109 />
107110
108 <Stack.Toolbar placement="left">111 {Platform.OS === "ios" ? null : (
109 <Stack.Toolbar.View hidesSharedBackground>112 <NativeHeaderToolbar placement="right">
110 <View113 <NativeHeaderToolbar.Button
111 style={{114 accessibilityLabel="Open settings"
112 width: 128,115 icon="gearshape"
113 height: 32,116 onPress={props.onOpenSettings}
114 flexDirection: "row",117 separateBackground
115 alignItems: "center",118 />
116 gap: 8,119 </NativeHeaderToolbar>
117 }}120 )}
118 >
119 <RNText
120 style={{
121 fontFamily: "DMSans_700Bold",
122 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
123 color: iconColor,
124 letterSpacing: -0.4,
125 }}
126 >
127 T3 Code
128 </RNText>
129 <View
130 style={{
131 backgroundColor: subtleColor,
132 borderRadius: 99,
133 paddingHorizontal: 8,
134 paddingVertical: 3,
135 }}
136 >
137 <RNText
138 style={{
139 fontFamily: "DMSans_700Bold",
140 fontSize: MOBILE_TYPOGRAPHY.micro.fontSize,
141 color: mutedColor,
142 letterSpacing: 1.1,
143 textTransform: "uppercase",
144 }}
145 >
146 Alpha
147 </RNText>
148 </View>
149 </View>
150 </Stack.Toolbar.View>
151 </Stack.Toolbar>
152
153 <Stack.Toolbar placement="right">
154 <Stack.Toolbar.Menu
155 accessibilityLabel="Filter and sort threads"
156 icon={
157 hasCustomListOptions
158 ? "line.3.horizontal.decrease.circle.fill"
159 : "line.3.horizontal.decrease.circle"
160 }
161 separateBackground
162 title="Thread list options"
163 >
164 <Stack.Toolbar.Menu title="Environment">
165 <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>
166 <Stack.Toolbar.MenuAction
167 isOn={props.selectedEnvironmentId === null}
168 onPress={() => props.onEnvironmentChange(null)}
169 subtitle="Show threads from every environment"
170 >
171 <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>
172 </Stack.Toolbar.MenuAction>
173 {props.environments.map((environment) => (
174 <Stack.Toolbar.MenuAction
175 key={environment.environmentId}
176 isOn={props.selectedEnvironmentId === environment.environmentId}
177 onPress={() => props.onEnvironmentChange(environment.environmentId)}
178 >
179 <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>
180 </Stack.Toolbar.MenuAction>
181 ))}
182 </Stack.Toolbar.Menu>
183121
184 <Stack.Toolbar.Menu title="Sort projects">122 {Platform.OS === "ios" ? null : (
185 <Stack.Toolbar.Label>Sort projects</Stack.Toolbar.Label>123 <NativeHeaderToolbar placement="bottom">
186 {PROJECT_SORT_OPTIONS.map((option) => (124 <NativeHeaderToolbar.Menu
187 <Stack.Toolbar.MenuAction125 accessibilityLabel="Filter and sort threads"
188 key={option.value}126 icon={
189 isOn={props.projectSortOrder === option.value}127 hasCustomListOptions
190 onPress={() => props.onProjectSortOrderChange(option.value)}128 ? "line.3.horizontal.decrease.circle.fill"
191 >129 : "line.3.horizontal.decrease.circle"
192 <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>130 }
193 </Stack.Toolbar.MenuAction>131 title="Thread list options"
194 ))}132 separateBackground
195 </Stack.Toolbar.Menu>133 >
134 <NativeHeaderToolbar.MenuAction onPress={props.onOpenSettings}>
135 <NativeHeaderToolbar.Label>Settings</NativeHeaderToolbar.Label>
136 </NativeHeaderToolbar.MenuAction>
196137
197 <Stack.Toolbar.Menu title="Sort threads">138 <NativeHeaderToolbar.Menu title="Environment">
198 <Stack.Toolbar.Label>Sort threads</Stack.Toolbar.Label>139 <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label>
199 {THREAD_SORT_OPTIONS.map((option) => (140 <NativeHeaderToolbar.MenuAction
200 <Stack.Toolbar.MenuAction141 isOn={props.selectedEnvironmentId === null}
201 key={option.value}142 onPress={() => props.onEnvironmentChange(null)}
202 isOn={props.threadSortOrder === option.value}143 subtitle="Show threads from every environment"
203 onPress={() => props.onThreadSortOrderChange(option.value)}
204 >144 >
205 <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>145 <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label>
206 </Stack.Toolbar.MenuAction>146 </NativeHeaderToolbar.MenuAction>
207 ))}147 {props.environments.map((environment) => (
208 </Stack.Toolbar.Menu>148 <NativeHeaderToolbar.MenuAction
149 key={environment.environmentId}
150 isOn={props.selectedEnvironmentId === environment.environmentId}
151 onPress={() => props.onEnvironmentChange(environment.environmentId)}
152 >
153 <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label>
154 </NativeHeaderToolbar.MenuAction>
155 ))}
156 </NativeHeaderToolbar.Menu>
209157
210 <Stack.Toolbar.Menu title="Group projects">158 <NativeHeaderToolbar.Menu title="Sort projects">
211 <Stack.Toolbar.Label>Group projects</Stack.Toolbar.Label>159 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
212 {PROJECT_GROUPING_OPTIONS.map((option) => (160 {PROJECT_SORT_OPTIONS.map((option) => (
213 <Stack.Toolbar.MenuAction161 <NativeHeaderToolbar.MenuAction
214 key={option.value}162 key={option.value}
215 isOn={props.projectGroupingMode === option.value}163 isOn={props.projectSortOrder === option.value}
216 onPress={() => props.onProjectGroupingModeChange(option.value)}164 onPress={() => props.onProjectSortOrderChange(option.value)}
217 subtitle={option.subtitle}165 >
218 >166 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
219 <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>167 </NativeHeaderToolbar.MenuAction>
220 </Stack.Toolbar.MenuAction>168 ))}
221 ))}169 </NativeHeaderToolbar.Menu>
222 </Stack.Toolbar.Menu>
223 </Stack.Toolbar.Menu>
224170
225 <Stack.Toolbar.Button171 <NativeHeaderToolbar.Menu title="Sort threads">
226 accessibilityLabel="Open settings"172 <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>
227 icon="gearshape"173 {THREAD_SORT_OPTIONS.map((option) => (
228 onPress={props.onOpenSettings}174 <NativeHeaderToolbar.MenuAction
229 separateBackground175 key={option.value}
230 />176 isOn={props.threadSortOrder === option.value}
231 </Stack.Toolbar>177 onPress={() => props.onThreadSortOrderChange(option.value)}
178 >
179 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
180 </NativeHeaderToolbar.MenuAction>
181 ))}
182 </NativeHeaderToolbar.Menu>
232183
233 <Stack.Toolbar placement="bottom">184 <NativeHeaderToolbar.Menu title="Group projects">
234 <Stack.Toolbar.SearchBarSlot />185 <NativeHeaderToolbar.Label>Group projects</NativeHeaderToolbar.Label>
235 <Stack.Toolbar.Spacer width={8} sharesBackground={false} />186 {PROJECT_GROUPING_OPTIONS.map((option) => (
236 <Stack.Toolbar.Button187 <NativeHeaderToolbar.MenuAction
237 accessibilityLabel="New task"188 key={option.value}
238 icon="square.and.pencil"189 isOn={props.projectGroupingMode === option.value}
239 onPress={props.onStartNewTask}190 onPress={() => props.onProjectGroupingModeChange(option.value)}
240 separateBackground191 subtitle={option.subtitle}
241 />192 >
242 </Stack.Toolbar>193 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
194 </NativeHeaderToolbar.MenuAction>
195 ))}
196 </NativeHeaderToolbar.Menu>
197 </NativeHeaderToolbar.Menu>
198 <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />
199 <NativeHeaderToolbar.SearchBarSlot />
200 <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />
201 <NativeHeaderToolbar.Button
202 accessibilityLabel="New task"
203 icon="square.and.pencil"
204 onPress={props.onStartNewTask}
205 separateBackground
206 />
207 </NativeHeaderToolbar>
208 )}
243 </>209 </>
244 );210 );
245}211}
apps/mobile/src/features/home/HomeRouteScreen.tsx created+132
...@@ -0,0 +1,132 @@
1import * as Arr from "effect/Array";
2import * as Order from "effect/Order";
3import { useNavigation } from "@react-navigation/native";
4import { useMemo, useState } from "react";
5
6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { useProjects, useThreadShells } from "../../state/entities";
8import { useWorkspaceState } from "../../state/workspace";
9import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
10import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
11import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
12import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
13import { HomeScreen } from "./HomeScreen";
14import { HomeHeader } from "./HomeHeader";
15import { useHomeListOptions } from "./home-list-options";
16import { useThreadListActions } from "./useThreadListActions";
17
18/* ─── Route screen ───────────────────────────────────────────────────── */
19
20export function HomeRouteScreen() {
21 const { layout } = useAdaptiveWorkspaceLayout();
22 const projects = useProjects();
23 const threads = useThreadShells();
24 const { state: catalogState } = useWorkspaceState();
25 const { savedConnectionsById } = useSavedRemoteConnections();
26 const navigation = useNavigation();
27 const [searchQuery, setSearchQuery] = useState("");
28 const { archiveThread, confirmDeleteThread } = useThreadListActions();
29 const environments = useMemo(
30 () =>
31 Arr.sort(
32 Object.values(savedConnectionsById).map((connection) => ({
33 environmentId: connection.environmentId,
34 label: connection.environmentLabel,
35 })),
36 Order.mapInput(
37 Order.String,
38 (environment: { readonly label: string }) => environment.label,
39 ),
40 ),
41 [savedConnectionsById],
42 );
43 const availableEnvironmentIds = useMemo(
44 () => new Set(environments.map((environment) => environment.environmentId)),
45 [environments],
46 );
47 const {
48 options: listOptions,
49 setSelectedEnvironmentId,
50 setProjectGroupingMode,
51 setProjectSortOrder,
52 setThreadSortOrder,
53 } = useHomeListOptions(availableEnvironmentIds);
54 const selectedEnvironmentId = listOptions.selectedEnvironmentId;
55
56 // In split layouts the persistent sidebar IS the thread list — Home becomes
57 // an empty detail pane so selecting a thread never transitions layouts.
58 if (layout.usesSplitView) {
59 return (
60 <>
61 <NativeStackScreenOptions options={{ title: "", headerTitle: "" }} />
62 <WorkspaceSidebarToolbar
63 afterSidebarButton={
64 <NativeHeaderToolbar.Button
65 accessibilityLabel="New task"
66 icon="square.and.pencil"
67 onPress={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
68 />
69 }
70 />
71 <WorkspaceEmptyDetail
72 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
73 />
74 </>
75 );
76 }
77
78 return (
79 <>
80 {/* Restore the compact title in case the split branch blanked it. */}
81 <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />
82 <HomeHeader
83 environments={environments}
84 selectedEnvironmentId={selectedEnvironmentId}
85 projectSortOrder={listOptions.projectSortOrder}
86 threadSortOrder={listOptions.threadSortOrder}
87 projectGroupingMode={listOptions.projectGroupingMode}
88 onEnvironmentChange={setSelectedEnvironmentId}
89 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
90 onProjectGroupingModeChange={setProjectGroupingMode}
91 onProjectSortOrderChange={setProjectSortOrder}
92 onSearchQueryChange={setSearchQuery}
93 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
94 onThreadSortOrderChange={setThreadSortOrder}
95 />
96
97 <HomeScreen
98 catalogState={catalogState}
99 environments={environments}
100 onAddConnection={() =>
101 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })
102 }
103 onArchiveThread={archiveThread}
104 onDeleteThread={confirmDeleteThread}
105 onEnvironmentChange={setSelectedEnvironmentId}
106 onOpenEnvironments={() =>
107 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
108 }
109 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
110 onProjectGroupingModeChange={setProjectGroupingMode}
111 onProjectSortOrderChange={setProjectSortOrder}
112 onSearchQueryChange={setSearchQuery}
113 onSelectThread={(thread) => {
114 navigation.navigate("Thread", {
115 environmentId: thread.environmentId,
116 threadId: thread.id,
117 });
118 }}
119 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
120 onThreadSortOrderChange={setThreadSortOrder}
121 projectGroupingMode={listOptions.projectGroupingMode}
122 projects={projects}
123 projectSortOrder={listOptions.projectSortOrder}
124 savedConnectionsById={savedConnectionsById}
125 searchQuery={searchQuery}
126 selectedEnvironmentId={selectedEnvironmentId}
127 threads={threads}
128 threadSortOrder={listOptions.threadSortOrder}
129 />
130 </>
131 );
132}
apps/mobile/src/features/home/HomeScreen.tsx+248-446
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1import {
2 LegendList,
3 type LegendListRef,
4 type LegendListRenderItemProps,
5} from "@legendapp/list/react-native";
1import {6import {
2 type EnvironmentProject,7 type EnvironmentProject,
3 type EnvironmentThreadShell,8 type EnvironmentThreadShell,
...@@ -7,36 +12,35 @@ import type {...@@ -7,36 +12,35 @@ import type {
7 SidebarProjectGroupingMode,12 SidebarProjectGroupingMode,
8 SidebarThreadSortOrder,13 SidebarThreadSortOrder,
9} from "@t3tools/contracts";14} from "@t3tools/contracts";
10import * as Haptics from "expo-haptics";
11import { SymbolView } from "expo-symbols";
12import { useCallback, useMemo, useRef, useState } from "react";15import { useCallback, useMemo, useRef, useState } from "react";
13import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native";16import { ActivityIndicator, Platform, View } from "react-native";
14import ReanimatedSwipeable, {17import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
15 type SwipeableMethods,
16} from "react-native-gesture-handler/ReanimatedSwipeable";
17import Animated, {
18 Easing,
19 LinearTransition,
20 type ExitAnimationsValues,
21 withDelay,
22 withTiming,
23} from "react-native-reanimated";
24import { useSafeAreaInsets } from "react-native-safe-area-context";18import { useSafeAreaInsets } from "react-native-safe-area-context";
25import { useThemeColor } from "../../lib/useThemeColor";19import { useThemeColor } from "../../lib/useThemeColor";
2620
27import { AppText as Text } from "../../components/AppText";
28import { EmptyState } from "../../components/EmptyState";21import { EmptyState } from "../../components/EmptyState";
29import { ProjectFavicon } from "../../components/ProjectFavicon";
30import type { WorkspaceState } from "../../state/workspaceModel";22import type { WorkspaceState } from "../../state/workspaceModel";
31import type { SavedRemoteConnection } from "../../lib/connection";23import type { SavedRemoteConnection } from "../../lib/connection";
32import { relativeTime } from "../../lib/time";24import { scopedProjectKey } from "../../lib/scopedEntities";
33import { threadStatusTone } from "../threads/threadPresentation";
34import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
35import {25import {
36 THREAD_SWIPE_ACTIONS_WIDTH,26 ThreadListGroupHeader,
37 THREAD_SWIPE_SPRING,27 ThreadListRow,
38 ThreadSwipeActions,28 ThreadListShowMoreRow,
39} from "./thread-swipe-actions";29} from "../threads/thread-list-items";
30import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
31import {
32 buildHomeListLayout,
33 DEFAULT_GROUP_DISPLAY_STATE,
34 homeListItemsAreEqual,
35 nextGroupDisplayState,
36 type HomeGroupDisplayAction,
37 type HomeGroupDisplayState,
38 type HomeListItem,
39} from "./homeListItems";
40import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
41import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
42import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
43import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
4044
41/* ─── Types ──────────────────────────────────────────────────────────── */45/* ─── Types ──────────────────────────────────────────────────────────── */
4246
...@@ -45,63 +49,31 @@ interface HomeScreenProps {...@@ -45,63 +49,31 @@ interface HomeScreenProps {
45 readonly threads: ReadonlyArray<EnvironmentThreadShell>;49 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
46 readonly catalogState: WorkspaceState;50 readonly catalogState: WorkspaceState;
47 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;51 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
52 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
48 readonly searchQuery: string;53 readonly searchQuery: string;
49 readonly selectedEnvironmentId: EnvironmentId | null;54 readonly selectedEnvironmentId: EnvironmentId | null;
50 readonly projectSortOrder: HomeProjectSortOrder;55 readonly projectSortOrder: HomeProjectSortOrder;
51 readonly threadSortOrder: SidebarThreadSortOrder;56 readonly threadSortOrder: SidebarThreadSortOrder;
52 readonly projectGroupingMode: SidebarProjectGroupingMode;57 readonly projectGroupingMode: SidebarProjectGroupingMode;
58 readonly onSearchQueryChange: (query: string) => void;
59 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
60 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
61 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
62 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
53 readonly onAddConnection: () => void;63 readonly onAddConnection: () => void;
54 readonly onOpenEnvironments: () => void;64 readonly onOpenEnvironments: () => void;
65 readonly onOpenSettings: () => void;
66 readonly onStartNewTask: () => void;
55 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;67 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
56 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;68 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
57 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;69 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
58}70}
5971
60/* ─── Status indicator colors ────────────────────────────────────────── */72/* ─── Layout constants ───────────────────────────────────────────────── */
61
62function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } {
63 switch (thread.session?.status) {
64 case "running":
65 return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" };
66 case "ready":
67 return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" };
68 case "starting":
69 return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" };
70 case "error":
71 return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" };
72 default:
73 return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" };
74 }
75}
7673
77const COLLAPSED_THREAD_LIMIT = 6;74const ESTIMATED_THREAD_ROW_HEIGHT = 72;
78const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic));75/** Height of the floating custom header on non-iOS platforms. */
7976const CUSTOM_HEADER_HEIGHT = 78;
80function threadRowExit(values: ExitAnimationsValues) {
81 "worklet";
82
83 return {
84 initialValues: {
85 height: values.currentHeight,
86 opacity: 1,
87 originX: values.currentOriginX,
88 },
89 animations: {
90 height: withDelay(
91 90,
92 withTiming(0, {
93 duration: 170,
94 easing: Easing.inOut(Easing.cubic),
95 }),
96 ),
97 opacity: withDelay(80, withTiming(0, { duration: 100 })),
98 originX: withTiming(values.currentOriginX - values.windowWidth, {
99 duration: 190,
100 easing: Easing.out(Easing.cubic),
101 }),
102 },
103 };
104}
10577
106function deriveEmptyState(props: {78function deriveEmptyState(props: {
107 readonly catalogState: WorkspaceState;79 readonly catalogState: WorkspaceState;
...@@ -166,287 +138,28 @@ function deriveEmptyState(props: {...@@ -166,287 +138,28 @@ function deriveEmptyState(props: {
166 };138 };
167}139}
168140
169/* ─── Project group header ───────────────────────────────────────────── */141function HomeTopContentSpacer(props: { readonly topInset: number }) {
170142 return <View style={{ height: props.topInset + CUSTOM_HEADER_HEIGHT }} />;
171function ProjectGroupLabel(props: {
172 readonly project: EnvironmentProject;
173 readonly title: string;
174 readonly totalThreadCount: number;
175 readonly isExpanded: boolean;
176 readonly onToggleExpand: () => void;
177}) {
178 const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT;
179
180 return (
181 <View className="flex-row items-center gap-2.5 px-1 pb-2">
182 <ProjectFavicon
183 environmentId={props.project.environmentId}
184 size={18}
185 projectTitle={props.project.title}
186 workspaceRoot={props.project.workspaceRoot}
187 />
188 <Text
189 className="flex-1 text-xs font-t3-medium uppercase text-foreground-muted"
190 style={{ letterSpacing: 0.5 }}
191 numberOfLines={1}
192 >
193 {props.title}
194 </Text>
195
196 {hiddenCount > 0 ? (
197 <Pressable onPress={props.onToggleExpand} hitSlop={8}>
198 <Text
199 className="text-xs font-t3-medium text-foreground-muted"
200 style={{ letterSpacing: 0.4 }}
201 >
202 {props.isExpanded ? "Show less" : `${hiddenCount} more`}
203 </Text>
204 </Pressable>
205 ) : null}
206 </View>
207 );
208}
209
210/* ─── Thread row ─────────────────────────────────────────────────────── */
211
212function ThreadRow(props: {
213 readonly thread: EnvironmentThreadShell;
214 readonly environmentLabel: string | null;
215 readonly onPress: () => void;
216 readonly onArchive: () => void;
217 readonly onDelete: () => void;
218 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
219 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
220 readonly isLast: boolean;
221}) {
222 const swipeableRef = useRef<SwipeableMethods | null>(null);
223 const fullSwipeArmedRef = useRef(false);
224 const { width: windowWidth } = useWindowDimensions();
225 const separatorColor = useThemeColor("--color-separator");
226 const iconSubtleColor = useThemeColor("--color-icon-subtle");
227 const cardColor = useThemeColor("--color-card");
228 const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58);
229 const { bg, fg } = statusColors(props.thread);
230 const tone = threadStatusTone(props.thread);
231 const timestamp = relativeTime(
232 props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt,
233 );
234 const branch = props.thread.branch;
235 const subtitleParts = [props.environmentLabel, branch].filter((part): part is string =>
236 Boolean(part),
237 );
238 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
239 if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
240 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
241 }
242 fullSwipeArmedRef.current = armed;
243 }, []);
244
245 return (
246 <ReanimatedSwipeable
247 ref={swipeableRef}
248 animationOptions={THREAD_SWIPE_SPRING}
249 childrenContainerStyle={{ backgroundColor: cardColor }}
250 containerStyle={{ backgroundColor: cardColor }}
251 dragOffsetFromRightEdge={8}
252 enableTrackpadTwoFingerGesture
253 friction={1}
254 onSwipeableClose={() => {
255 fullSwipeArmedRef.current = false;
256 if (swipeableRef.current) {
257 props.onSwipeableClose(swipeableRef.current);
258 }
259 }}
260 onSwipeableOpenStartDrag={() => {
261 if (swipeableRef.current) {
262 props.onSwipeableWillOpen(swipeableRef.current);
263 }
264 }}
265 onSwipeableWillOpen={() => {
266 const methods = swipeableRef.current;
267 if (!methods) {
268 return;
269 }
270
271 props.onSwipeableWillOpen(methods);
272 if (fullSwipeArmedRef.current) {
273 fullSwipeArmedRef.current = false;
274 methods.close();
275 props.onDelete();
276 }
277 }}
278 overshootFriction={1}
279 overshootRight
280 renderRightActions={(_progress, translation, methods) => (
281 <ThreadSwipeActions
282 backgroundColor={cardColor}
283 fullSwipeThreshold={fullSwipeThreshold}
284 onDelete={props.onDelete}
285 onFullSwipeArmedChange={handleFullSwipeArmedChange}
286 primaryAction={{
287 accessibilityLabel: `Archive ${props.thread.title}`,
288 icon: "archivebox",
289 label: "Archive",
290 onPress: props.onArchive,
291 }}
292 swipeableMethods={methods}
293 threadTitle={props.thread.title}
294 translation={translation}
295 />
296 )}
297 rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
298 >
299 <Pressable
300 accessibilityHint="Swipe left for archive and delete actions"
301 accessibilityLabel={props.thread.title}
302 accessibilityRole="button"
303 className="bg-card"
304 onPress={() => {
305 swipeableRef.current?.close();
306 props.onPress();
307 }}
308 style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
309 >
310 <View
311 style={{
312 flexDirection: "row",
313 paddingLeft: 16,
314 paddingRight: 16,
315 paddingVertical: 10,
316 gap: 12,
317 borderBottomWidth: props.isLast ? 0 : 1,
318 borderBottomColor: separatorColor,
319 }}
320 >
321 <View
322 style={{
323 width: 30,
324 height: 30,
325 borderRadius: 9,
326 backgroundColor: bg,
327 alignItems: "center",
328 justifyContent: "center",
329 marginTop: 2,
330 }}
331 >
332 <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" />
333 </View>
334
335 <View style={{ flex: 1, gap: 3 }}>
336 <View className="flex-row items-center justify-between gap-2">
337 <Text
338 className="flex-1 text-base font-t3-bold leading-[20px] text-foreground"
339 numberOfLines={1}
340 >
341 {props.thread.title}
342 </Text>
343 <View className="flex-row items-center gap-2">
344 <View
345 className={tone.pillClassName}
346 style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }}
347 >
348 <Text className={`text-3xs font-t3-bold ${tone.textClassName}`}>
349 {tone.label}
350 </Text>
351 </View>
352 <Text
353 className="text-xs text-foreground-tertiary"
354 style={{ fontVariant: ["tabular-nums"] }}
355 >
356 {timestamp}
357 </Text>
358 </View>
359 </View>
360
361 {subtitleParts.length > 0 ? (
362 <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}>
363 <SymbolView
364 name="arrow.triangle.branch"
365 size={10}
366 tintColor={iconSubtleColor}
367 type="monochrome"
368 />
369 <Text
370 className="text-2xs text-foreground-tertiary"
371 numberOfLines={1}
372 style={{ fontFamily: "monospace" }}
373 >
374 {subtitleParts.join(" · ")}
375 </Text>
376 </View>
377 ) : null}
378 </View>
379 </View>
380 </Pressable>
381 </ReanimatedSwipeable>
382 );
383}143}
384144
385/* ─── Main screen ────────────────────────────────────────────────────── */145/* ─── Main screen ────────────────────────────────────────────────────── */
386146
387function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string {
388 if (props.catalogState.networkStatus === "offline") {
389 return "You are offline";
390 }
391 const connectingEnvironments = props.catalogState.connectingEnvironments;
392 if (connectingEnvironments.length === 1) {
393 return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`;
394 }
395 if (connectingEnvironments.length > 1) {
396 return `Reconnecting ${connectingEnvironments.length} environments`;
397 }
398 return "Not connected";
399}
400
401function StaleCatalogStatusPill(props: {
402 readonly catalogState: WorkspaceState;
403 readonly onPress: () => void;
404}) {
405 const iconColor = useThemeColor("--color-icon-muted");
406 const label = staleCatalogPillLabel(props);
407 const isReconnecting = props.catalogState.connectingEnvironments.length > 0;
408
409 return (
410 <Pressable
411 accessibilityRole="button"
412 onPress={props.onPress}
413 className="flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5"
414 style={{
415 shadowColor: "#000",
416 shadowOffset: { width: 0, height: 10 },
417 shadowOpacity: 0.12,
418 shadowRadius: 24,
419 }}
420 >
421 {isReconnecting ? (
422 <ActivityIndicator color={iconColor} size="small" />
423 ) : (
424 <SymbolView
425 name="wifi.slash"
426 size={15}
427 tintColor={iconColor}
428 type="monochrome"
429 weight="semibold"
430 />
431 )}
432 <Text className="max-w-[260px] text-sm font-t3-bold text-foreground" numberOfLines={1}>
433 {label}
434 </Text>
435 </Pressable>
436 );
437}
438
439export function HomeScreen(props: HomeScreenProps) {147export function HomeScreen(props: HomeScreenProps) {
440 const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set());148 const [groupDisplayStates, setGroupDisplayStates] = useState<
149 ReadonlyMap<string, HomeGroupDisplayState>
150 >(() => new Map());
441 const openSwipeableRef = useRef<SwipeableMethods | null>(null);151 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
152 const listRef = useRef<LegendListRef | null>(null);
442 const insets = useSafeAreaInsets();153 const insets = useSafeAreaInsets();
443 const accentColor = useThemeColor("--color-icon-muted");154 const accentColor = useThemeColor("--color-icon-muted");
444155
445 const toggleExpanded = useCallback((key: string) => {156 const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => {
446 setExpandedProjects((prev) => {157 setGroupDisplayStates((previous) => {
447 const next = new Set(prev);158 const next = new Map(previous);
448 if (next.has(key)) next.delete(key);159 next.set(
449 else next.add(key);160 key,
161 nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action),
162 );
450 return next;163 return next;
451 });164 });
452 }, []);165 }, []);
...@@ -464,6 +177,13 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -464,6 +177,13 @@ export function HomeScreen(props: HomeScreenProps) {
464 }177 }
465 }, []);178 }, []);
466179
180 const handleScrollBeginDrag = useCallback(() => {
181 openSwipeableRef.current?.close();
182 }, []);
183 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
184 onScrollBeginDrag: handleScrollBeginDrag,
185 });
186
467 const projectGroups = useMemo(187 const projectGroups = useMemo(
468 () =>188 () =>
469 buildHomeThreadGroups({189 buildHomeThreadGroups({
...@@ -486,6 +206,94 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -486,6 +206,94 @@ export function HomeScreen(props: HomeScreenProps) {
486 ],206 ],
487 );207 );
488208
209 const hasSearchQuery = props.searchQuery.trim().length > 0;
210 const listLayout = useMemo(
211 () =>
212 buildHomeListLayout({
213 groups: projectGroups,
214 displayStates: groupDisplayStates,
215 showAllThreads: hasSearchQuery,
216 }),
217 [projectGroups, groupDisplayStates, hasSearchQuery],
218 );
219
220 const projectCwdByKey = useMemo(() => {
221 const map = new Map<string, string>();
222 for (const project of props.projects) {
223 map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot);
224 }
225 return map;
226 }, [props.projects]);
227
228 const extraData = useMemo(
229 () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),
230 [props.savedConnectionsById, projectCwdByKey],
231 );
232
233 const renderItem = useCallback(
234 ({ item }: LegendListRenderItemProps<HomeListItem>) => {
235 switch (item.type) {
236 case "header":
237 return (
238 <ThreadListGroupHeader
239 variant="compact"
240 collapsed={item.collapsed}
241 isFirst={item.isFirst}
242 groupKey={item.group.key}
243 onGroupAction={updateGroupDisplay}
244 project={item.group.representative}
245 threadCount={item.group.threads.length}
246 title={item.group.title}
247 />
248 );
249 case "thread": {
250 const thread = item.thread;
251 return (
252 <ThreadListRow
253 variant="compact"
254 thread={thread}
255 environmentLabel={
256 props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
257 }
258 projectCwd={
259 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
260 null
261 }
262 isLast={item.isLast}
263 onArchiveThread={props.onArchiveThread}
264 onDeleteThread={props.onDeleteThread}
265 onSelectThread={props.onSelectThread}
266 onSwipeableClose={handleSwipeableClose}
267 onSwipeableWillOpen={handleSwipeableWillOpen}
268 />
269 );
270 }
271 case "show-more":
272 return (
273 <ThreadListShowMoreRow
274 variant="compact"
275 hiddenCount={item.hiddenCount}
276 canShowLess={item.canShowLess}
277 groupKey={item.groupKey}
278 onGroupAction={updateGroupDisplay}
279 />
280 );
281 }
282 },
283 [
284 handleSwipeableClose,
285 handleSwipeableWillOpen,
286 projectCwdByKey,
287 props.onArchiveThread,
288 props.onDeleteThread,
289 props.onSelectThread,
290 props.savedConnectionsById,
291 updateGroupDisplay,
292 ],
293 );
294
295 const keyExtractor = useCallback((item: HomeListItem) => item.key, []);
296
489 /* Empty states */297 /* Empty states */
490 const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null);298 const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null);
491 const hasResults = projectGroups.length > 0;299 const hasResults = projectGroups.length > 0;
...@@ -494,127 +302,121 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -494,127 +302,121 @@ export function HomeScreen(props: HomeScreenProps) {
494 ? null302 ? null
495 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??303 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
496 "this environment");304 "this environment");
497 const hasSearchQuery = props.searchQuery.trim().length > 0;305 const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState);
498 const shouldShowConnectionStatus =
499 props.catalogState.networkStatus === "offline" ||
500 props.catalogState.hasConnectingEnvironment ||
501 (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment);
502 const emptyState = deriveEmptyState({306 const emptyState = deriveEmptyState({
503 catalogState: props.catalogState,307 catalogState: props.catalogState,
504 projectCount: props.projects.length,308 projectCount: props.projects.length,
505 });309 });
506310 const connectionStatus =
507 return (311 shouldShowConnectionStatus && Platform.OS !== "ios" ? (
508 <View className="flex-1 bg-screen">312 <View
509 <ScrollView313 className="absolute left-0 right-0 items-center"
510 contentInsetAdjustmentBehavior="automatic"314 style={{ bottom: Math.max(insets.bottom, 18) + 76 }}
511 showsVerticalScrollIndicator={false}315 >
512 keyboardDismissMode="on-drag"316 <WorkspaceConnectionStatus state={props.catalogState} onPress={props.onOpenEnvironments} />
513 keyboardShouldPersistTaps="handled"317 </View>
514 onScrollBeginDrag={() => openSwipeableRef.current?.close()}318 ) : null;
515 className="flex-1"319
516 contentContainerStyle={{320 if (!hasAnyThreads) {
517 paddingHorizontal: 16,321 return (
518 paddingTop: 8,322 <View
519 paddingBottom: 24,323 className="flex-1 items-center justify-center bg-screen px-8"
520 gap: 20,324 style={{
325 paddingBottom: Math.max(insets.bottom, 24),
326 paddingTop: Platform.OS === "ios" ? insets.top + 72 : insets.top,
521 }}327 }}
522 >328 >
523 {!hasAnyThreads ? (329 <View className="w-full max-w-[430px]">
524 <View>
525 <EmptyState
526 title={emptyState.title}
527 detail={emptyState.detail}
528 actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
529 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
530 />
531 {emptyState.loading ? (
532 <View className="absolute right-5 top-5">
533 <ActivityIndicator color={accentColor} />
534 </View>
535 ) : null}
536 </View>
537 ) : !hasResults && hasSearchQuery ? (
538 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
539 ) : !hasResults && selectedEnvironmentLabel ? (
540 <EmptyState
541 title={`No threads in ${selectedEnvironmentLabel}`}
542 detail="Choose another environment or create a new task."
543 />
544 ) : !hasResults ? (
545 <EmptyState330 <EmptyState
546 title="No threads yet"331 title={emptyState.title}
547 detail="Create a task to start a new coding session."332 detail={emptyState.detail}
333 actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
334 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
335 variant="plain"
548 />336 />
549 ) : (337 {emptyState.loading ? (
550 projectGroups.map((group) => {338 <View className="mt-4 items-center">
551 const isExpanded = expandedProjects.has(group.key);339 <ActivityIndicator color={accentColor} />
552 const visibleThreads = isExpanded340 </View>
553 ? group.threads341 ) : null}
554 : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);342 </View>
343 {connectionStatus}
344 </View>
345 );
346 }
555347
556 return (348 const listHeader = (
557 <Animated.View349 <>
558 key={group.key}350 {Platform.OS === "ios" ? null : <HomeTopContentSpacer topInset={insets.top} />}
559 collapsable={false}351
560 exiting={threadRowExit}352 {shouldShowConnectionStatus && Platform.OS === "ios" ? (
561 layout={THREAD_LAYOUT_TRANSITION}353 <View style={{ paddingBottom: 16 }}>
562 style={{ overflow: "hidden" }}354 <WorkspaceConnectionStatus
563 >355 state={props.catalogState}
564 <ProjectGroupLabel
565 isExpanded={isExpanded}
566 onToggleExpand={() => toggleExpanded(group.key)}
567 project={group.representative}
568 title={group.title}
569 totalThreadCount={group.threads.length}
570 />
571 <View
572 className="overflow-hidden rounded-[20px] bg-card"
573 style={{ borderCurve: "continuous" }}
574 >
575 {visibleThreads.map((thread, i) => {
576 const threadKey = `${thread.environmentId}:${thread.id}`;
577 return (
578 <Animated.View
579 key={threadKey}
580 collapsable={false}
581 exiting={threadRowExit}
582 layout={THREAD_LAYOUT_TRANSITION}
583 style={{ overflow: "hidden" }}
584 >
585 <ThreadRow
586 thread={thread}
587 environmentLabel={
588 props.savedConnectionsById[thread.environmentId]?.environmentLabel ??
589 null
590 }
591 isLast={i === visibleThreads.length - 1}
592 onArchive={() => props.onArchiveThread(thread)}
593 onDelete={() => props.onDeleteThread(thread)}
594 onPress={() => props.onSelectThread(thread)}
595 onSwipeableClose={handleSwipeableClose}
596 onSwipeableWillOpen={handleSwipeableWillOpen}
597 />
598 </Animated.View>
599 );
600 })}
601 </View>
602 </Animated.View>
603 );
604 })
605 )}
606 </ScrollView>
607 {shouldShowConnectionStatus ? (
608 <View
609 className="absolute left-0 right-0 items-center"
610 style={{ bottom: Math.max(insets.bottom, 18) + 76 }}
611 >
612 <StaleCatalogStatusPill
613 catalogState={props.catalogState}
614 onPress={props.onOpenEnvironments}356 onPress={props.onOpenEnvironments}
357 variant="sidebar"
615 />358 />
616 </View>359 </View>
617 ) : null}360 ) : null}
361 </>
362 );
363
364 const listEmpty = !hasResults ? (
365 hasSearchQuery ? (
366 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
367 ) : selectedEnvironmentLabel ? (
368 <EmptyState
369 title={`No threads in ${selectedEnvironmentLabel}`}
370 detail="Choose another environment or create a new task."
371 />
372 ) : (
373 <EmptyState title="No threads yet" detail="Create a task to start a new coding session." />
374 )
375 ) : null;
376
377 return (
378 <View className="flex-1 bg-screen">
379 {/* Sticky headers are deliberately not wired up: LegendList's JS sticky
380 implementation mispositions pinned headers at mount under iOS
381 automatic content insets (headers render one nav-inset too low until
382 the first scroll event) and blanks non-pinned headers after
383 collapse/expand data changes. The flattened layout still exposes
384 `stickyHeaderIndices` if this gets revisited. */}
385 <SwipeableScrollGateProvider enabled={swipeEnabled}>
386 <LegendList
387 ref={listRef}
388 data={listLayout.items}
389 renderItem={renderItem}
390 keyExtractor={keyExtractor}
391 itemsAreEqual={homeListItemsAreEqual}
392 drawDistance={500}
393 estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT}
394 extraData={extraData}
395 ListHeaderComponent={listHeader}
396 ListEmptyComponent={listEmpty}
397 style={{ flex: 1 }}
398 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
399 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
400 showsVerticalScrollIndicator={false}
401 keyboardDismissMode="on-drag"
402 keyboardShouldPersistTaps="handled"
403 {...scrollGateHandlers}
404 recycleItems
405 scrollEventThrottle={16}
406 contentContainerStyle={{
407 paddingBottom: Platform.OS === "ios" ? Math.max(insets.bottom, 24) + 24 : 24,
408 }}
409 scrollIndicatorInsets={
410 Platform.OS === "ios"
411 ? {
412 bottom: Math.max(insets.bottom, 16) + 24,
413 top: 0,
414 }
415 : undefined
416 }
417 />
418 </SwipeableScrollGateProvider>
419 {connectionStatus}
618 </View>420 </View>
619 );421 );
620}422}
apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts created+70
...@@ -0,0 +1,70 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { WorkspaceState } from "../../state/workspaceModel";
4import {
5 shouldShowWorkspaceConnectionStatus,
6 workspaceConnectionStatusLabel,
7} from "./workspace-connection-status";
8
9function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState {
10 return {
11 isLoadingConnections: false,
12 hasConnections: true,
13 hasLoadedShellSnapshot: true,
14 hasPendingShellSnapshot: false,
15 hasReadyEnvironment: true,
16 hasConnectingEnvironment: false,
17 connectingEnvironments: [],
18 connectionState: "connected",
19 connectionError: null,
20 shellSnapshotError: null,
21 latestCachedSnapshotReceivedAt: null,
22 networkStatus: "online",
23 ...overrides,
24 };
25}
26
27describe("workspace connection status", () => {
28 it("stays hidden while a ready environment is connected", () => {
29 expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false);
30 });
31
32 it("surfaces offline snapshots", () => {
33 const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false });
34
35 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
36 expect(workspaceConnectionStatusLabel(state)).toBe("You are offline");
37 });
38
39 it("names the environment while reconnecting", () => {
40 const state = workspaceState({
41 hasConnectingEnvironment: true,
42 hasReadyEnvironment: false,
43 connectingEnvironments: [
44 {
45 environmentId: "environment-1" as never,
46 environmentLabel: "Julius’s Mac mini",
47 displayUrl: "",
48 isRelayManaged: false,
49 connectionState: "reconnecting",
50 connectionError: null,
51 connectionErrorTraceId: null,
52 },
53 ],
54 });
55
56 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
57 expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini");
58 });
59
60 it("surfaces connection errors before the generic disconnected fallback", () => {
61 const state = workspaceState({
62 connectionError: "Could not reach Julius’s Mac mini",
63 hasLoadedShellSnapshot: false,
64 hasReadyEnvironment: false,
65 });
66
67 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
68 expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini");
69 });
70});
apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx created+53
...@@ -0,0 +1,53 @@
1import { SymbolView } from "expo-symbols";
2import { ActivityIndicator, Pressable } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { useThemeColor } from "../../lib/useThemeColor";
6import type { WorkspaceState } from "../../state/workspaceModel";
7import { workspaceConnectionStatusLabel } from "./workspace-connection-status";
8
9export function WorkspaceConnectionStatus(props: {
10 readonly state: WorkspaceState;
11 readonly onPress: () => void;
12 readonly variant?: "floating" | "sidebar";
13}) {
14 const iconColor = useThemeColor("--color-icon-muted");
15 const isReconnecting = props.state.connectingEnvironments.length > 0;
16 const variant = props.variant ?? "floating";
17
18 return (
19 <Pressable
20 accessibilityHint="Opens environment settings"
21 accessibilityLabel={workspaceConnectionStatusLabel(props.state)}
22 accessibilityRole="button"
23 onPress={props.onPress}
24 className={
25 variant === "sidebar"
26 ? "mx-3 flex-row items-center gap-2 rounded-xl bg-subtle px-3 py-2.5"
27 : "flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5"
28 }
29 style={
30 variant === "floating"
31 ? {
32 shadowColor: "#000",
33 shadowOffset: { width: 0, height: 10 },
34 shadowOpacity: 0.12,
35 shadowRadius: 24,
36 }
37 : undefined
38 }
39 >
40 {isReconnecting ? (
41 <ActivityIndicator color={iconColor} size="small" />
42 ) : (
43 <SymbolView name="wifi.slash" size={15} tintColor={iconColor} type="monochrome" />
44 )}
45 <Text className="min-w-0 flex-1 text-sm font-t3-bold text-foreground" numberOfLines={1}>
46 {workspaceConnectionStatusLabel(props.state)}
47 </Text>
48 {variant === "sidebar" ? (
49 <SymbolView name="chevron.right" size={11} tintColor={iconColor} type="monochrome" />
50 ) : null}
51 </Pressable>
52 );
53}
apps/mobile/src/features/home/home-list-filter-menu.ts created+120
...@@ -0,0 +1,120 @@
1import type {
2 EnvironmentId,
3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";
6
7import type { HomeProjectSortOrder } from "./homeThreadList";
8import {
9 PROJECT_GROUPING_OPTIONS,
10 PROJECT_SORT_OPTIONS,
11 THREAD_SORT_OPTIONS,
12} from "./home-list-options";
13
14export interface HomeListFilterMenuEnvironment {
15 readonly environmentId: EnvironmentId;
16 readonly label: string;
17}
18
19type HomeListFilterMenuAction = {
20 readonly type: "action";
21 readonly title: string;
22 readonly subtitle?: string;
23 readonly state?: "on" | "off";
24 readonly onPress: () => void;
25};
26
27type HomeListFilterMenuSubmenu = {
28 readonly type: "submenu";
29 readonly title: string;
30 readonly items: HomeListFilterMenuAction[];
31};
32
33export interface HomeListFilterMenu {
34 readonly title: string;
35 readonly items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu>;
36}
37
38export function buildHomeListFilterMenu(props: {
39 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
40 readonly selectedEnvironmentId: EnvironmentId | null;
41 readonly projectSortOrder: HomeProjectSortOrder;
42 readonly threadSortOrder: SidebarThreadSortOrder;
43 readonly projectGroupingMode: SidebarProjectGroupingMode;
44 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
45 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
46 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
47 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
48 readonly onOpenSettings?: () => void;
49}): HomeListFilterMenu {
50 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];
51
52 if (props.onOpenSettings) {
53 items.push({
54 type: "action",
55 title: "Settings",
56 onPress: props.onOpenSettings,
57 });
58 }
59
60 items.push(
61 {
62 type: "submenu",
63 title: "Environment",
64 items: [
65 {
66 type: "action",
67 title: "All environments",
68 subtitle: "Show threads from every environment",
69 state: props.selectedEnvironmentId === null ? "on" : "off",
70 onPress: () => props.onEnvironmentChange(null),
71 },
72 ...props.environments.map((environment) => ({
73 type: "action" as const,
74 title: environment.label,
75 state:
76 props.selectedEnvironmentId === environment.environmentId
77 ? ("on" as const)
78 : ("off" as const),
79 onPress: () => props.onEnvironmentChange(environment.environmentId),
80 })),
81 ],
82 },
83 {
84 type: "submenu",
85 title: "Sort projects",
86 items: PROJECT_SORT_OPTIONS.map((option) => ({
87 type: "action",
88 title: option.label,
89 state: props.projectSortOrder === option.value ? "on" : "off",
90 onPress: () => props.onProjectSortOrderChange(option.value),
91 })),
92 },
93 {
94 type: "submenu",
95 title: "Sort threads",
96 items: THREAD_SORT_OPTIONS.map((option) => ({
97 type: "action",
98 title: option.label,
99 state: props.threadSortOrder === option.value ? "on" : "off",
100 onPress: () => props.onThreadSortOrderChange(option.value),
101 })),
102 },
103 {
104 type: "submenu",
105 title: "Group projects",
106 items: PROJECT_GROUPING_OPTIONS.map((option) => ({
107 type: "action",
108 title: option.label,
109 subtitle: option.subtitle,
110 state: props.projectGroupingMode === option.value ? "on" : "off",
111 onPress: () => props.onProjectGroupingModeChange(option.value),
112 })),
113 },
114 );
115
116 return {
117 title: "Thread list options",
118 items,
119 };
120}
apps/mobile/src/features/home/home-list-options.test.ts created+31
...@@ -0,0 +1,31 @@
1import {
2 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
3 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
4 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options";
9
10const defaults: HomeListOptions = {
11 selectedEnvironmentId: null,
12 projectSortOrder:
13 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
14 ? "updated_at"
15 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
16 threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
17 projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
18};
19
20describe("home list options", () => {
21 it("recognizes default options", () => {
22 expect(hasCustomHomeListOptions(defaults)).toBe(false);
23 });
24
25 it("marks environment filters and grouping changes as customized", () => {
26 expect(
27 hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }),
28 ).toBe(true);
29 expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true);
30 });
31});
apps/mobile/src/features/home/home-list-options.ts created+144
...@@ -0,0 +1,144 @@
1import type {
2 EnvironmentId,
3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";
6import {
7 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10} from "@t3tools/contracts";
11import {
12 createContext,
13 createElement,
14 useCallback,
15 useContext,
16 useMemo,
17 useState,
18 type PropsWithChildren,
19 type Dispatch,
20 type SetStateAction,
21} from "react";
22
23import type { HomeProjectSortOrder } from "./homeThreadList";
24
25export interface HomeListOptions {
26 readonly selectedEnvironmentId: EnvironmentId | null;
27 readonly projectSortOrder: HomeProjectSortOrder;
28 readonly threadSortOrder: SidebarThreadSortOrder;
29 readonly projectGroupingMode: SidebarProjectGroupingMode;
30}
31
32export const PROJECT_SORT_OPTIONS: ReadonlyArray<{
33 readonly value: HomeProjectSortOrder;
34 readonly label: string;
35}> = [
36 { value: "updated_at", label: "Last user message" },
37 { value: "created_at", label: "Created at" },
38];
39
40export const THREAD_SORT_OPTIONS: ReadonlyArray<{
41 readonly value: SidebarThreadSortOrder;
42 readonly label: string;
43}> = [
44 { value: "updated_at", label: "Last user message" },
45 { value: "created_at", label: "Created at" },
46];
47
48export const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{
49 readonly value: SidebarProjectGroupingMode;
50 readonly label: string;
51 readonly subtitle: string;
52}> = [
53 {
54 value: "repository",
55 label: "Group by repository",
56 subtitle: "Combine matching repositories across environments",
57 },
58 {
59 value: "repository_path",
60 label: "Group by repository path",
61 subtitle: "Combine only matching paths within a repository",
62 },
63 {
64 value: "separate",
65 label: "Keep separate",
66 subtitle: "Show every project path separately",
67 },
68];
69
70function defaultHomeListOptions(): HomeListOptions {
71 return {
72 selectedEnvironmentId: null,
73 projectSortOrder:
74 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
75 ? "updated_at"
76 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
77 threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
78 projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
79 };
80}
81
82interface HomeListOptionsContextValue {
83 readonly options: HomeListOptions;
84 readonly setOptions: Dispatch<SetStateAction<HomeListOptions>>;
85}
86
87const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>(null);
88
89/** Keeps list preferences stable while the app moves between compact and split shells. */
90export function HomeListOptionsProvider({ children }: PropsWithChildren) {
91 const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions);
92 const value = useMemo(() => ({ options, setOptions }), [options]);
93 return createElement(HomeListOptionsContext, { value }, children);
94}
95
96export function hasCustomHomeListOptions(options: HomeListOptions): boolean {
97 const defaultProjectSortOrder =
98 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
99 ? "updated_at"
100 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER;
101 return (
102 options.selectedEnvironmentId !== null ||
103 options.projectSortOrder !== defaultProjectSortOrder ||
104 options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||
105 options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE
106 );
107}
108
109export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) {
110 const shared = useContext(HomeListOptionsContext);
111 const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions);
112 const options = shared?.options ?? localOptions;
113 const setOptions = shared?.setOptions ?? setLocalOptions;
114 const selectedEnvironmentId =
115 options.selectedEnvironmentId !== null &&
116 availableEnvironmentIds.has(options.selectedEnvironmentId)
117 ? options.selectedEnvironmentId
118 : null;
119 const resolvedOptions =
120 selectedEnvironmentId === options.selectedEnvironmentId
121 ? options
122 : { ...options, selectedEnvironmentId };
123
124 const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => {
125 setOptions((current) => ({ ...current, selectedEnvironmentId: value }));
126 }, []);
127 const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => {
128 setOptions((current) => ({ ...current, projectSortOrder: value }));
129 }, []);
130 const setThreadSortOrder = useCallback((value: SidebarThreadSortOrder) => {
131 setOptions((current) => ({ ...current, threadSortOrder: value }));
132 }, []);
133 const setProjectGroupingMode = useCallback((value: SidebarProjectGroupingMode) => {
134 setOptions((current) => ({ ...current, projectGroupingMode: value }));
135 }, []);
136
137 return {
138 options: resolvedOptions,
139 setSelectedEnvironmentId,
140 setProjectSortOrder,
141 setThreadSortOrder,
142 setProjectGroupingMode,
143 } as const;
144}
apps/mobile/src/features/home/homeListItems.test.ts created+192
...@@ -0,0 +1,192 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import {
9 buildHomeListLayout,
10 DEFAULT_GROUP_DISPLAY_STATE,
11 HOME_INITIAL_VISIBLE_THREADS,
12 HOME_SHOW_MORE_STEP,
13 nextGroupDisplayState,
14 type HomeGroupDisplayState,
15 type HomeListItem,
16} from "./homeListItems";
17import type { HomeThreadGroup } from "./homeThreadList";
18
19const environmentId = EnvironmentId.make("environment-1");
20
21function makeProject(id: string, title: string): EnvironmentProject {
22 return {
23 environmentId,
24 id: ProjectId.make(id),
25 title,
26 workspaceRoot: `/workspaces/${id}`,
27 repositoryIdentity: null,
28 defaultModelSelection: null,
29 scripts: [],
30 createdAt: "2026-06-01T00:00:00.000Z",
31 updatedAt: "2026-06-01T00:00:00.000Z",
32 };
33}
34
35function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
36 return {
37 environmentId,
38 id: ThreadId.make(id),
39 projectId,
40 title: `Thread ${id}`,
41 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
42 runtimeMode: "full-access",
43 interactionMode: "default",
44 branch: null,
45 worktreePath: null,
46 latestTurn: null,
47 createdAt: "2026-06-01T00:00:00.000Z",
48 updatedAt: "2026-06-01T00:00:00.000Z",
49 archivedAt: null,
50 session: null,
51 latestUserMessageAt: null,
52 hasPendingApprovals: false,
53 hasPendingUserInput: false,
54 hasActionableProposedPlan: false,
55 };
56}
57
58function makeGroup(key: string, threadCount: number): HomeThreadGroup {
59 const project = makeProject(key, key);
60 return {
61 key,
62 title: key,
63 representative: project,
64 projects: [project],
65 threads: Array.from({ length: threadCount }, (_, index) =>
66 makeThread(`${key}-thread-${index}`, project.id),
67 ),
68 };
69}
70
71function itemTypes(items: ReadonlyArray<HomeListItem>): string[] {
72 return items.map((item) => item.type);
73}
74
75function displayStates(
76 entries: Record<string, HomeGroupDisplayState>,
77): ReadonlyMap<string, HomeGroupDisplayState> {
78 return new Map(Object.entries(entries));
79}
80
81describe("buildHomeListLayout", () => {
82 it("renders a header plus all threads for a small group without a show-more row", () => {
83 const layout = buildHomeListLayout({
84 groups: [makeGroup("alpha", 3)],
85 displayStates: displayStates({}),
86 });
87
88 expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]);
89 expect(layout.stickyHeaderIndices).toEqual([0]);
90 expect(layout.items.at(-1)).toMatchObject({ type: "thread", isLast: true });
91 });
92
93 it("limits large groups to the initial visible count with a show-more row", () => {
94 const layout = buildHomeListLayout({
95 groups: [makeGroup("alpha", 133)],
96 displayStates: displayStates({}),
97 });
98
99 const threadItems = layout.items.filter((item) => item.type === "thread");
100 expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS);
101 expect(layout.items.at(-1)).toMatchObject({
102 type: "show-more",
103 groupKey: "alpha",
104 hiddenCount: 133 - HOME_INITIAL_VISIBLE_THREADS,
105 canShowLess: false,
106 });
107 // The show-more row takes over the last slot, so no thread is marked last.
108 expect(threadItems.every((item) => item.type === "thread" && !item.isLast)).toBe(true);
109 });
110
111 it("reveals more threads per show-more step and offers show-less when exhausted", () => {
112 const group = makeGroup("alpha", 20);
113
114 const expandedOnce = buildHomeListLayout({
115 groups: [group],
116 displayStates: displayStates({
117 alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
118 }),
119 });
120 expect(expandedOnce.items.filter((item) => item.type === "thread")).toHaveLength(
121 HOME_INITIAL_VISIBLE_THREADS + HOME_SHOW_MORE_STEP,
122 );
123 expect(expandedOnce.items.at(-1)).toMatchObject({
124 type: "show-more",
125 hiddenCount: 4,
126 canShowLess: true,
127 });
128
129 const fullyExpanded = buildHomeListLayout({
130 groups: [group],
131 displayStates: displayStates({
132 alpha: nextGroupDisplayState(
133 nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
134 "show-more",
135 ),
136 }),
137 });
138 expect(fullyExpanded.items.filter((item) => item.type === "thread")).toHaveLength(20);
139 expect(fullyExpanded.items.at(-1)).toMatchObject({
140 type: "show-more",
141 hiddenCount: 0,
142 canShowLess: true,
143 });
144
145 const reset = nextGroupDisplayState(
146 nextGroupDisplayState(
147 nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
148 "show-more",
149 ),
150 "show-less",
151 );
152 expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS);
153 });
154
155 it("hides threads and the show-more row for collapsed groups", () => {
156 const layout = buildHomeListLayout({
157 groups: [makeGroup("alpha", 12), makeGroup("beta", 2)],
158 displayStates: displayStates({
159 alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"),
160 }),
161 });
162
163 expect(itemTypes(layout.items)).toEqual(["header", "header", "thread", "thread"]);
164 expect(layout.items[0]).toMatchObject({ type: "header", collapsed: true, isFirst: true });
165 expect(layout.items[1]).toMatchObject({ type: "header", collapsed: false, isFirst: false });
166 expect(layout.stickyHeaderIndices).toEqual([0, 1]);
167 });
168
169 it("suspends collapse and pagination while searching", () => {
170 const layout = buildHomeListLayout({
171 groups: [makeGroup("alpha", 12)],
172 displayStates: displayStates({
173 alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"),
174 }),
175 showAllThreads: true,
176 });
177
178 expect(layout.items.filter((item) => item.type === "thread")).toHaveLength(12);
179 expect(layout.items.some((item) => item.type === "show-more")).toBe(false);
180 });
181
182 it("keeps sticky indices aligned across multiple expanded groups", () => {
183 const layout = buildHomeListLayout({
184 groups: [makeGroup("alpha", 8), makeGroup("beta", 1)],
185 displayStates: displayStates({}),
186 });
187
188 // header + 6 threads + show-more = 8 items, so beta's header is index 8.
189 expect(layout.stickyHeaderIndices).toEqual([0, 8]);
190 expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false });
191 });
192});
apps/mobile/src/features/home/homeListItems.ts created+158
...@@ -0,0 +1,158 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2
3import type { HomeThreadGroup } from "./homeThreadList";
4
5/** Threads shown per project before the "Show more" affordance appears. */
6export const HOME_INITIAL_VISIBLE_THREADS = 6;
7/** Additional threads revealed per "Show more" tap. */
8export const HOME_SHOW_MORE_STEP = 10;
9
10export interface HomeGroupDisplayState {
11 readonly collapsed: boolean;
12 /** How many threads are currently revealed (clamped to the group size). */
13 readonly visibleCount: number;
14}
15
16export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = {
17 collapsed: false,
18 visibleCount: HOME_INITIAL_VISIBLE_THREADS,
19};
20
21export interface HomeHeaderListItem {
22 readonly type: "header";
23 readonly key: string;
24 readonly group: HomeThreadGroup;
25 readonly collapsed: boolean;
26 readonly isFirst: boolean;
27}
28
29export interface HomeThreadListItem {
30 readonly type: "thread";
31 readonly key: string;
32 readonly thread: EnvironmentThreadShell;
33 readonly isLast: boolean;
34}
35
36export interface HomeShowMoreListItem {
37 readonly type: "show-more";
38 readonly key: string;
39 readonly groupKey: string;
40 /** Threads still hidden. 0 means the group is fully expanded. */
41 readonly hiddenCount: number;
42 /** Whether more than the initial count is revealed, so "Show less" applies. */
43 readonly canShowLess: boolean;
44}
45
46export type HomeListItem = HomeHeaderListItem | HomeThreadListItem | HomeShowMoreListItem;
47
48export interface HomeListLayout {
49 readonly items: ReadonlyArray<HomeListItem>;
50 readonly stickyHeaderIndices: ReadonlyArray<number>;
51}
52
53export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less";
54
55export function nextGroupDisplayState(
56 current: HomeGroupDisplayState,
57 action: HomeGroupDisplayAction,
58): HomeGroupDisplayState {
59 switch (action) {
60 case "toggle-collapsed":
61 return { ...current, collapsed: !current.collapsed };
62 case "show-more":
63 return { ...current, visibleCount: current.visibleCount + HOME_SHOW_MORE_STEP };
64 case "show-less":
65 return { ...current, visibleCount: HOME_INITIAL_VISIBLE_THREADS };
66 }
67}
68
69/**
70 * Structural equality for list items. Item objects are rebuilt on every
71 * collapse/show-more toggle; without this the lists would consider every
72 * mounted row changed and re-render all of them (each carrying a swipeable +
73 * a vcs-status subscription). Group/thread references are stable across
74 * toggles.
75 */
76export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean {
77 switch (item.type) {
78 case "header":
79 return (
80 previous.type === "header" &&
81 previous.group === item.group &&
82 previous.collapsed === item.collapsed &&
83 previous.isFirst === item.isFirst
84 );
85 case "thread":
86 return (
87 previous.type === "thread" &&
88 previous.thread === item.thread &&
89 previous.isLast === item.isLast
90 );
91 case "show-more":
92 return (
93 previous.type === "show-more" &&
94 previous.groupKey === item.groupKey &&
95 previous.hiddenCount === item.hiddenCount &&
96 previous.canShowLess === item.canShowLess
97 );
98 }
99}
100
101export function buildHomeListLayout(input: {
102 readonly groups: ReadonlyArray<HomeThreadGroup>;
103 readonly displayStates: ReadonlyMap<string, HomeGroupDisplayState>;
104 /**
105 * When searching, pagination is suspended so every match stays visible.
106 */
107 readonly showAllThreads?: boolean;
108}): HomeListLayout {
109 const items: HomeListItem[] = [];
110 const stickyHeaderIndices: number[] = [];
111
112 for (const [groupIndex, group] of input.groups.entries()) {
113 const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE;
114 const collapsed = display.collapsed && input.showAllThreads !== true;
115
116 stickyHeaderIndices.push(items.length);
117 items.push({
118 type: "header",
119 key: `header:${group.key}`,
120 group,
121 collapsed,
122 isFirst: groupIndex === 0,
123 });
124
125 if (collapsed) {
126 continue;
127 }
128
129 const totalCount = group.threads.length;
130 const visibleCount = input.showAllThreads
131 ? totalCount
132 : Math.min(Math.max(display.visibleCount, HOME_INITIAL_VISIBLE_THREADS), totalCount);
133 const visibleThreads = group.threads.slice(0, visibleCount);
134 const hiddenCount = totalCount - visibleCount;
135 const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS;
136
137 for (const [threadIndex, thread] of visibleThreads.entries()) {
138 items.push({
139 type: "thread",
140 key: `thread:${thread.environmentId}:${thread.id}`,
141 thread,
142 isLast: threadIndex === visibleThreads.length - 1 && !hasShowMoreRow,
143 });
144 }
145
146 if (hasShowMoreRow) {
147 items.push({
148 type: "show-more",
149 key: `show-more:${group.key}`,
150 groupKey: group.key,
151 hiddenCount,
152 canShowLess: visibleCount > HOME_INITIAL_VISIBLE_THREADS,
153 });
154 }
155 }
156
157 return { items, stickyHeaderIndices };
158}
apps/mobile/src/features/home/thread-swipe-actions.tsx+247-9
...@@ -1,8 +1,26 @@...@@ -1,8 +1,26 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "expo-symbols";
2import type { ComponentProps } from "react";2import * as Haptics from "expo-haptics";
3import type { ColorValue } from "react-native";3import {
4 createContext,
5 use,
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10 type ComponentProps,
11 type ReactNode,
12} from "react";
13import type {
14 ColorValue,
15 NativeScrollEvent,
16 NativeSyntheticEvent,
17 StyleProp,
18 ViewStyle,
19} from "react-native";
4import { Pressable, View } from "react-native";20import { Pressable, View } from "react-native";
5import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";21import ReanimatedSwipeable, {
22 type SwipeableMethods,
23} from "react-native-gesture-handler/ReanimatedSwipeable";
6import Animated, {24import Animated, {
7 Extrapolation,25 Extrapolation,
8 interpolate,26 interpolate,
...@@ -26,6 +44,231 @@ export const THREAD_SWIPE_SPRING = {...@@ -26,6 +44,231 @@ export const THREAD_SWIPE_SPRING = {
26 stiffness: 330,44 stiffness: 330,
27};45};
2846
47interface ThreadSwipePrimaryAction {
48 readonly accessibilityLabel: string;
49 readonly icon: ComponentProps<typeof SymbolView>["name"];
50 readonly label: string;
51 readonly onPress: () => void;
52}
53
54/**
55 * Delivers the scroll gate to swipeables via context so that flipping it does
56 * NOT re-render whole rows: putting the flag in list extraData/renderItem deps
57 * re-rendered every visible row (hooks, subscriptions and all) exactly at
58 * scroll start — peak frame pressure. As a context value only the
59 * ThreadSwipeable consumers re-render.
60 */
61const SwipeableScrollGateContext = createContext(true);
62
63export function SwipeableScrollGateProvider(props: {
64 readonly enabled: boolean;
65 readonly children: ReactNode;
66}) {
67 return (
68 <SwipeableScrollGateContext.Provider value={props.enabled}>
69 {props.children}
70 </SwipeableScrollGateContext.Provider>
71 );
72}
73
74/**
75 * Gates row swipes on list scroll activity, mirroring UIKit's own swipe
76 * actions (`!isDragging && !isDecelerating`). failOffsetY on the swipe pan
77 * covers the first pan of a scroll, but trackpad scroll sessions spawn fresh
78 * gesture sessions (momentum catch, direction changes) whose reset
79 * translation can re-activate a swipe mid-scroll — so while the list has
80 * moved vertically during an active drag/momentum phase, row swipes are
81 * disabled entirely.
82 *
83 * Spread the returned handlers onto the list and pass `swipeEnabled` to rows.
84 */
85export function useSwipeableScrollGate(options?: {
86 readonly onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
87 readonly onScrollBeginDrag?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
88}) {
89 const [gateActive, setGateActive] = useState(false);
90 const gateActiveRef = useRef(false);
91 const draggingRef = useRef(false);
92 const dragStartYRef = useRef(0);
93 const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
94 const externalOnScroll = options?.onScroll;
95 const externalOnScrollBeginDrag = options?.onScrollBeginDrag;
96
97 const update = useCallback((next: boolean) => {
98 if (gateActiveRef.current !== next) {
99 gateActiveRef.current = next;
100 setGateActive(next);
101 }
102 }, []);
103 const clearSettle = useCallback(() => {
104 if (settleTimerRef.current !== null) {
105 clearTimeout(settleTimerRef.current);
106 settleTimerRef.current = null;
107 }
108 }, []);
109 useEffect(() => clearSettle, [clearSettle]);
110
111 const onScrollBeginDrag = useCallback(
112 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
113 draggingRef.current = true;
114 dragStartYRef.current = event.nativeEvent.contentOffset.y;
115 clearSettle();
116 externalOnScrollBeginDrag?.(event);
117 },
118 [clearSettle, externalOnScrollBeginDrag],
119 );
120 const onScroll = useCallback(
121 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
122 // Only vertical movement during a user drag arms the gate — a purely
123 // horizontal row swipe never moves contentOffset.y, and inset-driven
124 // offset changes at mount happen outside a drag.
125 if (
126 draggingRef.current &&
127 !gateActiveRef.current &&
128 Math.abs(event.nativeEvent.contentOffset.y - dragStartYRef.current) > 4
129 ) {
130 update(true);
131 }
132 externalOnScroll?.(event);
133 },
134 [externalOnScroll, update],
135 );
136 const onScrollEndDrag = useCallback(() => {
137 draggingRef.current = false;
138 clearSettle();
139 // If momentum follows, onMomentumScrollBegin cancels this and the gate
140 // stays armed until the deceleration finishes.
141 settleTimerRef.current = setTimeout(() => update(false), 160);
142 }, [clearSettle, update]);
143 const onMomentumScrollBegin = useCallback(() => {
144 clearSettle();
145 }, [clearSettle]);
146 const onMomentumScrollEnd = useCallback(() => {
147 update(false);
148 }, [update]);
149
150 return {
151 swipeEnabled: !gateActive,
152 scrollGateHandlers: {
153 onScroll,
154 onScrollBeginDrag,
155 onScrollEndDrag,
156 onMomentumScrollBegin,
157 onMomentumScrollEnd,
158 },
159 };
160}
161
162export function ThreadSwipeable(props: {
163 readonly backgroundColor: ColorValue;
164 readonly children: (close: () => void) => ReactNode;
165 readonly containerStyle?: StyleProp<ViewStyle>;
166 /** Disables NEW swipe activations (e.g. while the list scrolls). */
167 readonly enabled?: boolean;
168 readonly enableTrackpadSwipe?: boolean;
169 readonly fullSwipeWidth: number;
170 readonly onDelete: () => void;
171 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
172 readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;
173 readonly primaryAction: ThreadSwipePrimaryAction;
174 /**
175 * Identity of the content being wrapped. When a recycled list reuses this
176 * component for a different item, the swipeable snaps back to closed so an
177 * open/mid-drag state can't leak onto another row.
178 */
179 readonly resetKey?: string;
180 readonly simultaneousWithExternalGesture?: ComponentProps<
181 typeof ReanimatedSwipeable
182 >["simultaneousWithExternalGesture"];
183 readonly threadTitle: string;
184}) {
185 const swipeableRef = useRef<SwipeableMethods | null>(null);
186 const fullSwipeArmedRef = useRef(false);
187 const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, props.fullSwipeWidth * 0.58);
188 const close = useCallback(() => swipeableRef.current?.close(), []);
189 const gateEnabled = use(SwipeableScrollGateContext);
190 const resetKey = props.resetKey;
191 useEffect(() => {
192 if (resetKey === undefined) {
193 return;
194 }
195 fullSwipeArmedRef.current = false;
196 swipeableRef.current?.reset();
197 }, [resetKey]);
198 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
199 if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
200 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
201 }
202 fullSwipeArmedRef.current = armed;
203 }, []);
204
205 return (
206 <ReanimatedSwipeable
207 ref={swipeableRef}
208 animationOptions={THREAD_SWIPE_SPRING}
209 childrenContainerStyle={{ backgroundColor: props.backgroundColor }}
210 containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]}
211 dragOffsetFromRightEdge={8}
212 enabled={props.enabled !== false && gateEnabled}
213 enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true}
214 // Fail the swipe once the pan is vertically dominant (patched-in RNGH
215 // prop) — otherwise trackpad scrolls with ~8px of horizontal drift
216 // start opening rows because the swipe pan runs simultaneously with
217 // the list scroll gesture and never gets disqualified by Y movement.
218 failOffsetY={[-10, 10]}
219 friction={1}
220 onSwipeableClose={() => {
221 fullSwipeArmedRef.current = false;
222 if (swipeableRef.current) {
223 props.onSwipeableClose?.(swipeableRef.current);
224 }
225 }}
226 onSwipeableOpenStartDrag={() => {
227 if (swipeableRef.current) {
228 props.onSwipeableWillOpen?.(swipeableRef.current);
229 }
230 }}
231 onSwipeableWillOpen={() => {
232 const methods = swipeableRef.current;
233 if (!methods) {
234 return;
235 }
236
237 props.onSwipeableWillOpen?.(methods);
238 if (fullSwipeArmedRef.current) {
239 fullSwipeArmedRef.current = false;
240 methods.close();
241 props.onDelete();
242 }
243 }}
244 overshootFriction={1}
245 overshootRight
246 renderRightActions={(_progress, translation, methods) => (
247 <ThreadSwipeActions
248 backgroundColor={props.backgroundColor}
249 fullSwipeThreshold={fullSwipeThreshold}
250 onDelete={props.onDelete}
251 onFullSwipeArmedChange={handleFullSwipeArmedChange}
252 primaryAction={{
253 ...props.primaryAction,
254 onPress: () => {
255 methods.close();
256 props.primaryAction.onPress();
257 },
258 }}
259 swipeableMethods={methods}
260 threadTitle={props.threadTitle}
261 translation={translation}
262 />
263 )}
264 rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
265 simultaneousWithExternalGesture={props.simultaneousWithExternalGesture}
266 >
267 {props.children(close)}
268 </ReanimatedSwipeable>
269 );
270}
271
29function SwipeActionButton(props: {272function SwipeActionButton(props: {
30 readonly accessibilityLabel: string;273 readonly accessibilityLabel: string;
31 readonly backgroundColor: string;274 readonly backgroundColor: string;
...@@ -179,12 +422,7 @@ export function ThreadSwipeActions(props: {...@@ -179,12 +422,7 @@ export function ThreadSwipeActions(props: {
179 readonly fullSwipeThreshold: number;422 readonly fullSwipeThreshold: number;
180 readonly onDelete: () => void;423 readonly onDelete: () => void;
181 readonly onFullSwipeArmedChange: (armed: boolean) => void;424 readonly onFullSwipeArmedChange: (armed: boolean) => void;
182 readonly primaryAction: {425 readonly primaryAction: ThreadSwipePrimaryAction;
183 readonly accessibilityLabel: string;
184 readonly icon: ComponentProps<typeof SymbolView>["name"];
185 readonly label: string;
186 readonly onPress: () => void;
187 };
188 readonly swipeableMethods: SwipeableMethods;426 readonly swipeableMethods: SwipeableMethods;
189 readonly threadTitle: string;427 readonly threadTitle: string;
190 readonly translation: SharedValue<number>;428 readonly translation: SharedValue<number>;
apps/mobile/src/features/home/workspace-connection-status.ts created+22
...@@ -0,0 +1,22 @@
1import type { WorkspaceState } from "../../state/workspaceModel";
2
3export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean {
4 return (
5 state.networkStatus === "offline" ||
6 state.connectionError !== null ||
7 state.hasConnectingEnvironment ||
8 (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment)
9 );
10}
11
12export function workspaceConnectionStatusLabel(state: WorkspaceState): string {
13 if (state.networkStatus === "offline") return "You are offline";
14 if (state.connectingEnvironments.length === 1) {
15 return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`;
16 }
17 if (state.connectingEnvironments.length > 1) {
18 return `Reconnecting ${state.connectingEnvironments.length} environments`;
19 }
20 if (state.connectionError !== null) return state.connectionError;
21 return "Not connected";
22}
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx created+73
...@@ -0,0 +1,73 @@
1import { StackActions, useNavigation } from "@react-navigation/native";
2import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react";
3
4import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
5import {
6 dispatchHardwareKeyboardCommand,
7 getHardwareKeyboardCommandRegistrationVersion,
8 getRegisteredHardwareKeyboardCommands,
9 parseActiveThreadPath,
10 subscribeToHardwareKeyboardCommandRegistrations,
11 type HardwareKeyboardCommand,
12} from "./hardwareKeyboardCommands";
13
14export function HardwareKeyboardCommandProvider({
15 children,
16 pathname,
17}: PropsWithChildren<{ readonly pathname: string }>) {
18 const navigation = useNavigation();
19 const registrationVersion = useSyncExternalStore(
20 subscribeToHardwareKeyboardCommandRegistrations,
21 getHardwareKeyboardCommandRegistrationVersion,
22 getHardwareKeyboardCommandRegistrationVersion,
23 );
24 const enabledCommands = useMemo(() => {
25 const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
26 commands.add("newTask");
27 if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
28 if (parseActiveThreadPath(pathname)) {
29 commands.add("files");
30 commands.add("terminal");
31 commands.add("review");
32 }
33 return [...commands];
34 }, [pathname, registrationVersion, navigation]);
35
36 const onCommand = useCallback(
37 (command: HardwareKeyboardCommand) => {
38 if (dispatchHardwareKeyboardCommand(command)) return;
39
40 if (command === "newTask") {
41 navigation.navigate("NewTaskSheet", { screen: "NewTask" });
42 return;
43 }
44 if (command === "back") {
45 if (navigation.canGoBack()) {
46 navigation.goBack();
47 } else {
48 navigation.dispatch(StackActions.replace("Home"));
49 }
50 return;
51 }
52
53 const thread = parseActiveThreadPath(pathname);
54 if (!thread) return;
55 if (command === "files" && !/\/files(?:\/|$)/.test(pathname)) {
56 navigation.navigate("ThreadFiles", thread);
57 }
58 if (command === "terminal" && !/\/terminal(?:\/|$)/.test(pathname)) {
59 navigation.navigate("ThreadTerminal", thread);
60 }
61 if (command === "review" && !/\/review(?:\/|$)/.test(pathname)) {
62 navigation.navigate("ThreadReview", thread);
63 }
64 },
65 [pathname, navigation],
66 );
67
68 return (
69 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
70 {children}
71 </T3KeyboardCommands>
72 );
73}
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.test.ts created+28
...@@ -0,0 +1,28 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { parseActiveThreadPath } from "./hardwareKeyboardCommands";
4
5describe("parseActiveThreadPath", () => {
6 it("extracts the active thread from thread subroutes", () => {
7 expect(parseActiveThreadPath("/threads/environment-1/thread-1/files/src/index.ts")).toEqual({
8 environmentId: "environment-1",
9 threadId: "thread-1",
10 });
11 });
12
13 it("decodes route components", () => {
14 expect(parseActiveThreadPath("/threads/local%20machine/thread%2Fone/review")).toEqual({
15 environmentId: "local machine",
16 threadId: "thread/one",
17 });
18 });
19
20 it("ignores non-thread routes", () => {
21 expect(parseActiveThreadPath("/settings")).toBeNull();
22 expect(parseActiveThreadPath("/threads/environment-only")).toBeNull();
23 });
24
25 it("ignores malformed encoded route components", () => {
26 expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull();
27 });
28});
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts created+78
...@@ -0,0 +1,78 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useEffect } from "react";
3
4export type HardwareKeyboardCommand =
5 | "newTask"
6 | "focusSearch"
7 | "back"
8 | "files"
9 | "terminal"
10 | "review"
11 | "toggleSidebar";
12
13type CommandHandler = () => boolean | void;
14
15const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>();
16const registrationListeners = new Set<() => void>();
17let registrationVersion = 0;
18
19/**
20 * Registers a context-specific hardware-keyboard action. The most recently mounted handler gets
21 * the first chance to consume the command, allowing focused screens to override app defaults.
22 */
23export function useHardwareKeyboardCommand(
24 command: HardwareKeyboardCommand,
25 handler: CommandHandler,
26): void {
27 useEffect(() => {
28 const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
29 commandHandlers.add(handler);
30 handlers.set(command, commandHandlers);
31 registrationVersion += 1;
32 registrationListeners.forEach((listener) => listener());
33 return () => {
34 commandHandlers.delete(handler);
35 if (commandHandlers.size === 0) handlers.delete(command);
36 registrationVersion += 1;
37 registrationListeners.forEach((listener) => listener());
38 };
39 }, [command, handler]);
40}
41
42export function getRegisteredHardwareKeyboardCommands(): ReadonlySet<HardwareKeyboardCommand> {
43 return new Set(handlers.keys());
44}
45
46export function getHardwareKeyboardCommandRegistrationVersion(): number {
47 return registrationVersion;
48}
49
50export function subscribeToHardwareKeyboardCommandRegistrations(listener: () => void): () => void {
51 registrationListeners.add(listener);
52 return () => registrationListeners.delete(listener);
53}
54
55export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean {
56 const commandHandlers = handlers.get(command);
57 if (!commandHandlers) return false;
58 for (const handler of [...commandHandlers].toReversed()) {
59 if (handler() !== false) return true;
60 }
61 return false;
62}
63
64export function parseActiveThreadPath(pathname: string): {
65 readonly environmentId: EnvironmentId;
66 readonly threadId: ThreadId;
67} | null {
68 const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname);
69 if (!match?.[1] || !match[2]) return null;
70 try {
71 return {
72 environmentId: EnvironmentId.make(decodeURIComponent(match[1])),
73 threadId: ThreadId.make(decodeURIComponent(match[2])),
74 };
75 } catch {
76 return null;
77 }
78}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx created+507
...@@ -0,0 +1,507 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { useFocusEffect } from "@react-navigation/native";
4import {
5 NavigationContext,
6 NavigationRouteContext,
7 StackActions,
8 useNavigation,
9} from "@react-navigation/native";
10import {
11 createContext,
12 use,
13 useCallback,
14 useEffect,
15 useMemo,
16 useRef,
17 useState,
18 type ReactNode,
19} from "react";
20import { useWindowDimensions, View } from "react-native";
21import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
22
23import {
24 deriveFileInspectorPaneLayout,
25 deriveLayout,
26 deriveWorkspacePaneLayout,
27 type FileInspectorPaneLayout,
28 type Layout,
29 type WorkspaceAuxiliaryPaneRole,
30 type WorkspacePaneLayout,
31} from "../../lib/layout";
32import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation";
33import { scopedThreadKey } from "../../lib/scopedEntities";
34import {
35 parseActiveThreadPath,
36 useHardwareKeyboardCommand,
37} from "../keyboard/hardwareKeyboardCommands";
38import { HomeListOptionsProvider } from "../home/home-list-options";
39import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
40import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
41import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
42
43interface AdaptiveWorkspaceContextValue {
44 readonly layout: Layout;
45 readonly panes: WorkspacePaneLayout;
46 readonly fileInspector: FileInspectorPaneLayout;
47 readonly primarySidebarSearchQuery: string;
48 readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void;
49 /**
50 * Route screens hand their inspector pane content to the workspace so it
51 * renders BESIDE the navigator (outside the native stack header) instead of
52 * inside the route. Returns a deactivate callback: the pane animates closed
53 * (content kept mounted for the exit transition) unless a newer
54 * registration already took over — stale deactivates never clobber it.
55 * Prefer useRegisterWorkspaceInspector over calling this directly.
56 */
57 readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void;
58 readonly setPrimarySidebarSearchQuery: (query: string) => void;
59 readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void;
60 readonly toggleAuxiliaryPane: () => void;
61 readonly togglePrimarySidebar: () => void;
62 readonly setAuxiliaryPaneWidth: (width: number) => void;
63}
64
65const compactLayout = deriveLayout({ width: 0, height: 0 });
66const compactPanes = deriveWorkspacePaneLayout({
67 layout: compactLayout,
68 viewportWidth: 0,
69 primarySidebarPreferredVisible: true,
70 auxiliaryPanePreferredVisible: true,
71});
72const compactFileInspector = deriveFileInspectorPaneLayout({
73 layout: compactLayout,
74 viewportWidth: 0,
75});
76const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({
77 layout: compactLayout,
78 panes: compactPanes,
79 fileInspector: compactFileInspector,
80 primarySidebarSearchQuery: "",
81 activateAuxiliaryPaneRole: () => () => undefined,
82 registerWorkspaceInspector: () => () => undefined,
83 setPrimarySidebarSearchQuery: () => undefined,
84 showAuxiliaryPane: () => undefined,
85 toggleAuxiliaryPane: () => undefined,
86 togglePrimarySidebar: () => undefined,
87 setAuxiliaryPaneWidth: () => undefined,
88});
89
90export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue {
91 return use(AdaptiveWorkspaceContext);
92}
93
94export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) {
95 const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout();
96
97 useFocusEffect(
98 useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]),
99 );
100}
101
102/**
103 * Register this screen's inspector pane content with the workspace column.
104 *
105 * The column renders BESIDE the navigator — outside any screen — so the
106 * registering screen's navigation and route contexts are captured here and
107 * re-provided around the portal content. Without them, useNavigation/useRoute
108 * inside the pane (e.g. GitOverviewSheet via useThreadSelection) throw
109 * "Couldn't find a route object".
110 *
111 * Registration is FOCUS-scoped, driven by navigation events rather than the
112 * screen's own render cycle: react-native-screens freezes blurred screens, so
113 * a cleanup that depends on the blurred subtree re-rendering never runs and
114 * would leak the pane into the next route. Blur deactivates the pane (it
115 * animates closed, or is replaced seamlessly when the next route registers in
116 * the same commit); focus re-registers it.
117 */
118export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) {
119 const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout();
120 // Raw context values (not the useNavigation/useRoute wrappers) so the
121 // portal re-provides exactly what this screen sees.
122 const navigation = use(NavigationContext);
123 const route = use(NavigationRouteContext);
124
125 const wrappedRender = useMemo(() => {
126 if (render === undefined) {
127 return undefined;
128 }
129 return () => (
130 <NavigationContext.Provider value={navigation}>
131 <NavigationRouteContext.Provider value={route}>{render()}</NavigationRouteContext.Provider>
132 </NavigationContext.Provider>
133 );
134 }, [navigation, render, route]);
135
136 const wrappedRenderRef = useRef(wrappedRender);
137 wrappedRenderRef.current = wrappedRender;
138 const focusedRef = useRef(false);
139 const deactivateRef = useRef<(() => void) | null>(null);
140
141 const syncRegistration = useCallback(() => {
142 if (!focusedRef.current || wrappedRenderRef.current === undefined) {
143 deactivateRef.current?.();
144 return;
145 }
146 deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current);
147 }, [registerWorkspaceInspector]);
148
149 // Focus lifecycle. Blur/focus events fire even when the blurred subtree is
150 // frozen (events are navigation-driven, renders are not).
151 useFocusEffect(
152 useCallback(() => {
153 focusedRef.current = true;
154 syncRegistration();
155 return () => {
156 focusedRef.current = false;
157 syncRegistration();
158 };
159 }, [syncRegistration]),
160 );
161
162 // Content changes while focused re-register in place.
163 useEffect(() => {
164 if (focusedRef.current) {
165 syncRegistration();
166 }
167 }, [syncRegistration, wrappedRender]);
168
169 // Unmount: hand the pane back (owner-guarded, so a route that already
170 // took over is unaffected).
171 useEffect(
172 () => () => {
173 deactivateRef.current?.();
174 deactivateRef.current = null;
175 },
176 [],
177 );
178}
179
180export function AdaptiveWorkspaceLayout(props: {
181 readonly children: ReactNode;
182 readonly pathname: string;
183}) {
184 const { width, height } = useWindowDimensions();
185 const pathname = props.pathname;
186 const navigation = useNavigation();
187 const activeRoleOwner = useRef<symbol | null>(null);
188 const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true);
189 const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true);
190 const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState<
191 number | null
192 >(null);
193 const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true);
194 const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState<number | null>(
195 null,
196 );
197 const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState("");
198 const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =
199 useState<WorkspaceAuxiliaryPaneRole | null>(null);
200 const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]);
201 const layout = baseLayout;
202 // In split layouts the sidebar IS the thread list — it renders on every
203 // route, including Home (which shows an empty-detail pane instead of the
204 // compact list).
205 const shouldRenderPrimarySidebar = layout.usesSplitView;
206 const fileInspector = useMemo(
207 () =>
208 deriveFileInspectorPaneLayout({
209 layout,
210 viewportWidth: width,
211 preferredWidth: fileInspectorPreferredWidth ?? undefined,
212 reservedLeadingWidth:
213 shouldRenderPrimarySidebar && primarySidebarPreferredVisible
214 ? (layout.listPaneWidth ?? 0)
215 : 0,
216 }),
217 [
218 fileInspectorPreferredWidth,
219 layout,
220 primarySidebarPreferredVisible,
221 shouldRenderPrimarySidebar,
222 width,
223 ],
224 );
225 const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole =
226 focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary");
227 const auxiliaryPanePreferredVisible =
228 auxiliaryPaneRole === "inspector"
229 ? fileInspectorPreferredVisible
230 : supplementaryPanePreferredVisible;
231 const auxiliaryPanePreferredWidth =
232 auxiliaryPaneRole === "inspector"
233 ? fileInspectorPreferredWidth
234 : supplementaryPanePreferredWidth;
235 const panes = useMemo(
236 () =>
237 deriveWorkspacePaneLayout({
238 layout,
239 viewportWidth: width,
240 primarySidebarPreferredVisible,
241 auxiliaryPanePreferredVisible,
242 auxiliaryPaneRole,
243 auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined,
244 }),
245 [
246 auxiliaryPanePreferredVisible,
247 auxiliaryPaneRole,
248 auxiliaryPanePreferredWidth,
249 layout,
250 primarySidebarPreferredVisible,
251 width,
252 ],
253 );
254 const activeThread = parseActiveThreadPath(pathname);
255 const environmentId = activeThread?.environmentId ?? null;
256 const threadId = activeThread?.threadId ?? null;
257 const selectedThreadKey = useMemo(() => {
258 if (environmentId === null || threadId === null) {
259 return null;
260 }
261 try {
262 return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId));
263 } catch {
264 return null;
265 }
266 }, [environmentId, threadId]);
267 // Wrapped in an object: bare functions in useState would be treated as
268 // lazy initializers/updaters. `active: false` keeps the outgoing route's
269 // content mounted so the pane can animate closed (or be replaced
270 // seamlessly by the next route's registration in the same commit).
271 const [workspaceInspector, setWorkspaceInspector] = useState<{
272 readonly render: () => ReactNode;
273 readonly active: boolean;
274 } | null>(null);
275 const workspaceInspectorOwner = useRef<symbol | null>(null);
276 const registerWorkspaceInspector = useCallback((render: () => ReactNode) => {
277 const owner = Symbol("workspace-inspector");
278 workspaceInspectorOwner.current = owner;
279 setWorkspaceInspector({ render, active: true });
280
281 return () => {
282 // During a push/replace the outgoing screen deactivates AFTER the
283 // incoming screen registered — only the current owner may deactivate.
284 if (workspaceInspectorOwner.current !== owner) {
285 return;
286 }
287 setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false }));
288 };
289 }, []);
290 // Once the close animation settles, drop the stale content entirely.
291 const handleWorkspaceInspectorClosed = useCallback(() => {
292 setWorkspaceInspector((current) => (current !== null && !current.active ? null : current));
293 }, []);
294 const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => {
295 const owner = Symbol(role);
296 activeRoleOwner.current = owner;
297 setFocusedAuxiliaryPaneRole(role);
298
299 return () => {
300 if (activeRoleOwner.current !== owner) {
301 return;
302 }
303 activeRoleOwner.current = null;
304 setFocusedAuxiliaryPaneRole(null);
305 };
306 }, []);
307 const togglePrimarySidebar = useCallback(() => {
308 if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) {
309 setFileInspectorPreferredVisible(false);
310 setPrimarySidebarPreferredVisible(true);
311 return;
312 }
313 setPrimarySidebarPreferredVisible((current) => !current);
314 }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]);
315 const revealPrimarySidebar = useCallback(() => {
316 if (panes.primarySidebarSuppressedByAuxiliary) {
317 setFileInspectorPreferredVisible(false);
318 }
319 setPrimarySidebarPreferredVisible(true);
320 }, [panes.primarySidebarSuppressedByAuxiliary]);
321 const handleToggleSidebarCommand = useCallback(() => {
322 togglePrimarySidebar();
323 return true;
324 }, [togglePrimarySidebar]);
325 useHardwareKeyboardCommand("toggleSidebar", handleToggleSidebarCommand);
326 const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => {
327 if (role === "inspector") {
328 setFocusedAuxiliaryPaneRole("inspector");
329 setFileInspectorPreferredVisible(true);
330 return;
331 }
332 setFocusedAuxiliaryPaneRole("supplementary");
333 setSupplementaryPanePreferredVisible(true);
334 }, []);
335 const handleOpenFilesCommand = useCallback(() => {
336 const activeThread = parseActiveThreadPath(pathname);
337 if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) {
338 return false;
339 }
340 showAuxiliaryPane("inspector");
341 if (/\/files(?:\/|$)/.test(pathname)) {
342 return true;
343 }
344 navigation.navigate("ThreadFiles", activeThread);
345 return true;
346 }, [fileInspector.supported, layout.usesSplitView, pathname, navigation, showAuxiliaryPane]);
347 useHardwareKeyboardCommand("files", handleOpenFilesCommand);
348 const toggleAuxiliaryPane = useCallback(() => {
349 if (auxiliaryPaneRole === "inspector") {
350 setFileInspectorPreferredVisible((current) => !current);
351 return;
352 }
353 setSupplementaryPanePreferredVisible((current) => !current);
354 }, [auxiliaryPaneRole]);
355 const setAuxiliaryPaneWidth = useCallback(
356 (nextWidth: number) => {
357 if (auxiliaryPaneRole === "inspector") {
358 setFileInspectorPreferredWidth(nextWidth);
359 return;
360 }
361 setSupplementaryPanePreferredWidth(nextWidth);
362 },
363 [auxiliaryPaneRole],
364 );
365 const contextValue = useMemo(
366 () => ({
367 layout,
368 panes,
369 fileInspector,
370 primarySidebarSearchQuery,
371 activateAuxiliaryPaneRole,
372 registerWorkspaceInspector,
373 setPrimarySidebarSearchQuery,
374 showAuxiliaryPane,
375 toggleAuxiliaryPane,
376 togglePrimarySidebar,
377 setAuxiliaryPaneWidth,
378 }),
379 [
380 activateAuxiliaryPaneRole,
381 fileInspector,
382 layout,
383 panes,
384 primarySidebarSearchQuery,
385 registerWorkspaceInspector,
386 showAuxiliaryPane,
387 setPrimarySidebarSearchQuery,
388 setAuxiliaryPaneWidth,
389 toggleAuxiliaryPane,
390 togglePrimarySidebar,
391 ],
392 );
393
394 const handleOpenSettings = useCallback(() => {
395 navigation.navigate("SettingsSheet", { screen: "Settings" });
396 }, [navigation]);
397
398 // Minted here (root stack navigation) so the sidebar pane stays free of
399 // navigation hooks — on iOS it renders inside an independent nav tree.
400 const handleOpenEnvironmentSettings = useCallback(() => {
401 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" });
402 }, [navigation]);
403
404 const renderedSidebarWidth = useSharedValue(
405 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,
406 );
407 useEffect(() => {
408 const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0;
409 renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
410 }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]);
411 const sidebarAnimatedStyle = useAnimatedStyle(() => ({
412 opacity: Math.min(1, renderedSidebarWidth.value / 80),
413 width: renderedSidebarWidth.value,
414 }));
415
416 // Freeze the content pane at its SETTLED width while the side panes
417 // animate. The navigator (native header + markdown feed) lays out ONCE per
418 // pane toggle instead of re-measuring on every animation frame — the
419 // animating columns merely clip/reveal it over a matching background.
420 // Continuously re-wrapping the chat feed was the main source of dropped
421 // frames during sidebar/inspector transitions.
422 const inspectorColumnTargetWidth =
423 workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible
424 ? (panes.auxiliaryPaneWidth ?? 0)
425 : 0;
426 const contentSettledWidth = layout.usesSplitView
427 ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)
428 : null;
429
430 const handleSelectThread = useCallback(
431 (thread: EnvironmentThreadShell) => {
432 const params = {
433 environmentId: String(thread.environmentId),
434 threadId: String(thread.id),
435 };
436 const navigationAction = resolveThreadSelectionNavigationAction({
437 usesSplitView: layout.usesSplitView,
438 pathname,
439 });
440 if (navigationAction === "set-params") {
441 const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id);
442 if (nextThreadKey === selectedThreadKey) {
443 return;
444 }
445 setFileInspectorPreferredVisible(false);
446 navigation.navigate("Thread", params);
447 return;
448 }
449 if (navigationAction === "replace") {
450 setFileInspectorPreferredVisible(false);
451 navigation.dispatch(StackActions.replace("Thread", params));
452 return;
453 }
454 navigation.navigate("Thread", params);
455 },
456 [layout.usesSplitView, pathname, navigation, selectedThreadKey],
457 );
458
459 return (
460 <HomeListOptionsProvider>
461 <AdaptiveWorkspaceContext.Provider value={contextValue}>
462 <View testID="adaptive-workspace-layout" style={{ flex: 1, flexDirection: "row" }}>
463 {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? (
464 <Animated.View
465 accessibilityElementsHidden={!panes.primarySidebarVisible}
466 collapsable={false}
467 importantForAccessibility={
468 panes.primarySidebarVisible ? "auto" : "no-hide-descendants"
469 }
470 pointerEvents={panes.primarySidebarVisible ? "auto" : "none"}
471 style={[{ alignSelf: "stretch", overflow: "hidden" }, sidebarAnimatedStyle]}
472 >
473 <ThreadNavigationSidebar
474 width={layout.listPaneWidth}
475 visible={panes.primarySidebarVisible}
476 onRequestVisibility={revealPrimarySidebar}
477 selectedThreadKey={selectedThreadKey}
478 onOpenSettings={handleOpenSettings}
479 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
480 onSelectThread={handleSelectThread}
481 onSearchQueryChange={setPrimarySidebarSearchQuery}
482 searchQuery={primarySidebarSearchQuery}
483 />
484 </Animated.View>
485 ) : null}
486 <View className="bg-screen" collapsable={false} style={{ flex: 1, overflow: "hidden" }}>
487 <View
488 collapsable={false}
489 style={
490 contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 }
491 }
492 >
493 {props.children}
494 </View>
495 </View>
496 <WorkspaceInspectorPane
497 active={workspaceInspector?.active ?? false}
498 panes={panes}
499 renderInspector={workspaceInspector?.render}
500 setAuxiliaryPaneWidth={setAuxiliaryPaneWidth}
501 onClosed={handleWorkspaceInspectorClosed}
502 />
503 </View>
504 </AdaptiveWorkspaceContext.Provider>
505 </HomeListOptionsProvider>
506 );
507}
apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx created+30
...@@ -0,0 +1,30 @@
1import { SymbolView } from "expo-symbols";
2import { Pressable, View } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { useThemeColor } from "../../lib/useThemeColor";
6
7export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) {
8 const iconColor = useThemeColor("--color-icon-subtle");
9
10 return (
11 <View className="flex-1 items-center justify-center bg-screen px-10">
12 <View className="max-w-[360px] items-center gap-3">
13 <SymbolView name="sidebar.left" size={34} tintColor={iconColor} type="hierarchical" />
14 <Text className="text-center text-xl font-t3-bold">Select a thread</Text>
15 <Text className="text-center text-base text-foreground-muted">
16 Choose a thread from the sidebar or start a new task.
17 </Text>
18 {props.onStartNewTask ? (
19 <Pressable
20 accessibilityRole="button"
21 className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70"
22 onPress={props.onStartNewTask}
23 >
24 <Text className="text-base font-t3-bold text-primary-foreground">New Task</Text>
25 </Pressable>
26 ) : null}
27 </View>
28 </View>
29 );
30}
apps/mobile/src/features/layout/native-glass-header-items.ts created+33
...@@ -0,0 +1,33 @@
1type NativeGlassHeaderItem = {
2 readonly type: "button" | "menu";
3 readonly glassEffect?: boolean;
4 readonly hidesSharedBackground?: boolean;
5 readonly sharesBackground?: boolean;
6 readonly variant?: "plain" | "done" | "prominent";
7 readonly width?: number;
8};
9
10/**
11 * iOS 26/27 Mail-style header controls need the native glass button
12 * shared background configuration when they are not part of a larger toolbar.
13 * Do not enable `glassEffect` for normal bar-button items: react-native-screens
14 * renders that as a custom UIButton, which creates a second skinny capsule.
15 */
16export function withNativeGlassHeaderItem<T extends NativeGlassHeaderItem>(
17 item: T,
18 options: {
19 readonly hidesSharedBackground?: boolean;
20 readonly sharesBackground?: boolean;
21 readonly width?: number;
22 } = {},
23): T {
24 const sharesBackground = options.sharesBackground ?? item.sharesBackground ?? true;
25 return {
26 ...item,
27 glassEffect: item.glassEffect ?? false,
28 hidesSharedBackground: options.hidesSharedBackground ?? item.hidesSharedBackground ?? false,
29 sharesBackground,
30 variant: item.variant ?? "plain",
31 width: options.width ?? item.width,
32 } as T;
33}
apps/mobile/src/features/layout/native-mail-search-toolbar.ts created+25
...@@ -0,0 +1,25 @@
1import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
2
3type NativeMailSearchToolbarInput = Omit<
4 HeaderBarButtonMailSearchToolbarItem,
5 "type" | "useFallbackSearchField"
6>;
7
8/**
9 * Builds the patched react-native-screens Mail-style bottom search toolbar.
10 *
11 * Keeping this behind an app-level helper makes the iOS-only RNS patch an
12 * explicit layout primitive instead of a per-screen object literal. Android can
13 * keep using platform-specific header/search primitives without depending on
14 * this helper.
15 */
16export function createNativeMailSearchToolbarItem(
17 input: NativeMailSearchToolbarInput,
18): HeaderBarButtonMailSearchToolbarItem {
19 return {
20 placeholder: "Search",
21 ...input,
22 type: "mailSearchToolbar",
23 useFallbackSearchField: true,
24 };
25}
apps/mobile/src/features/layout/workspace-inspector-pane.tsx created+145
...@@ -0,0 +1,145 @@
1import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2import Animated, {
3 runOnJS,
4 useAnimatedStyle,
5 useSharedValue,
6 withTiming,
7} from "react-native-reanimated";
8
9import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";
10import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
11import { WorkspacePaneDivider } from "./workspace-pane-divider";
12
13/**
14 * The trailing inspector column: resize divider + animated reveal.
15 *
16 * Rendered by AdaptiveWorkspaceLayout as a SIBLING of the navigator so the
17 * native stack header (and its trailing toolbar items) spans only the content
18 * pane — the inspector owns its own full-height column, mirroring how each
19 * column of a UISplitViewController has its own chrome.
20 *
21 * Receives the pane layout via props (not the workspace context hook) so this
22 * module stays import-cycle-free with AdaptiveWorkspaceLayout.
23 */
24export function WorkspaceInspectorPane(props: {
25 /**
26 * When false the pane animates closed but keeps its content mounted for the
27 * exit transition (a route that lost focus). `onClosed` fires once the
28 * close animation settles so the owner can drop the stale content.
29 */
30 readonly active?: boolean;
31 readonly onClosed?: () => void;
32 readonly panes: WorkspacePaneLayout;
33 readonly renderInspector?: () => ReactNode;
34 readonly setAuxiliaryPaneWidth: (width: number) => void;
35}) {
36 const { panes, setAuxiliaryPaneWidth } = props;
37 const inspectorWidth = panes.auxiliaryPaneWidth;
38 const inspectorSupported = props.renderInspector !== undefined && inspectorWidth !== null;
39 const inspectorVisible =
40 inspectorSupported && panes.auxiliaryPaneVisible && (props.active ?? true);
41 const resizeStartWidth = useRef(0);
42 const [resizing, setResizing] = useState(false);
43
44 // A file-to-file replace remounts the route. Initialize an already-visible
45 // inspector at its final position so route replacement never replays an
46 // entering transition. Only visibility and explicit resizing change it.
47 const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0);
48 const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0);
49 // The content keeps its own width so the reveal (outer width) clips a
50 // fully-laid-out pane instead of reflowing text every frame. When the OPEN
51 // pane's target width changes (e.g. the sidebar toggles and reserves
52 // space), animate the content width in lockstep rather than snapping.
53 const renderedContentWidth = useSharedValue(inspectorWidth ?? 0);
54
55 const onClosed = props.onClosed;
56 useEffect(() => {
57 inspectorProgress.value = withTiming(
58 inspectorVisible ? 1 : 0,
59 WORKSPACE_PANE_TIMING,
60 (finished) => {
61 if (finished === true && !inspectorVisible && onClosed !== undefined) {
62 runOnJS(onClosed)();
63 }
64 },
65 );
66 const targetWidth = inspectorVisible ? (inspectorWidth ?? 0) : 0;
67 renderedInspectorWidth.value = resizing
68 ? targetWidth
69 : withTiming(targetWidth, WORKSPACE_PANE_TIMING);
70 }, [
71 inspectorProgress,
72 inspectorVisible,
73 inspectorWidth,
74 onClosed,
75 renderedInspectorWidth,
76 resizing,
77 ]);
78
79 useEffect(() => {
80 const targetWidth = inspectorWidth ?? 0;
81 if (!inspectorVisible || resizing) {
82 // Hidden panes re-measure silently; during a divider drag the content
83 // tracks the finger directly.
84 renderedContentWidth.value = targetWidth;
85 return;
86 }
87 renderedContentWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
88 }, [inspectorVisible, inspectorWidth, renderedContentWidth, resizing]);
89
90 const inspectorStyle = useAnimatedStyle(
91 () => ({
92 opacity: inspectorProgress.value,
93 transform: [{ translateX: (1 - inspectorProgress.value) * 24 }],
94 width: renderedInspectorWidth.value,
95 }),
96 [],
97 );
98 const inspectorContentStyle = useAnimatedStyle(() => ({ width: renderedContentWidth.value }), []);
99 const beginResize = useCallback(() => {
100 resizeStartWidth.current = inspectorWidth ?? 0;
101 setResizing(true);
102 }, [inspectorWidth]);
103 const resizeBy = useCallback(
104 (delta: number) => {
105 setAuxiliaryPaneWidth(
106 constrainAuxiliaryPaneWidth({
107 preferredWidth: resizeStartWidth.current + delta,
108 availableWidth: panes.contentPaneWidth,
109 }),
110 );
111 },
112 [panes.contentPaneWidth, setAuxiliaryPaneWidth],
113 );
114 const endResize = useCallback(() => {
115 setResizing(false);
116 }, []);
117
118 return (
119 <>
120 {inspectorVisible ? (
121 <WorkspacePaneDivider
122 accessibilityLabel="Resize detail pane"
123 currentWidth={inspectorWidth ?? 0}
124 resizeDirection={-1}
125 onResizeStart={beginResize}
126 onResizeBy={resizeBy}
127 onResizeEnd={endResize}
128 />
129 ) : null}
130 {inspectorSupported ? (
131 <Animated.View
132 accessibilityElementsHidden={!inspectorVisible}
133 collapsable={false}
134 importantForAccessibility={inspectorVisible ? "auto" : "no-hide-descendants"}
135 pointerEvents={inspectorVisible ? "auto" : "none"}
136 style={[{ flexShrink: 0, overflow: "hidden" }, inspectorStyle]}
137 >
138 <Animated.View style={[{ flex: 1 }, inspectorContentStyle]}>
139 {props.renderInspector?.()}
140 </Animated.View>
141 </Animated.View>
142 ) : null}
143 </>
144 );
145}
apps/mobile/src/features/layout/workspace-pane-animation.ts created+16
...@@ -0,0 +1,16 @@
1import { Easing, ReduceMotion } from "react-native-reanimated";
2
3/**
4 * One timing curve for every workspace pane (primary sidebar + inspector).
5 *
6 * Panes frequently animate together — opening the sidebar can auto-close the
7 * inspector when both no longer fit — so identical duration and easing on
8 * every pane keeps the center content pane from wobbling while both edges
9 * move. Asymmetric open/close timings (the previous 220ms out-cubic open vs
10 * 160ms in-cubic close) read as jank during those simultaneous swaps.
11 */
12export const WORKSPACE_PANE_TIMING = {
13 duration: 260,
14 easing: Easing.inOut(Easing.cubic),
15 reduceMotion: ReduceMotion.System,
16} as const;
apps/mobile/src/features/layout/workspace-pane-divider.tsx created+116
...@@ -0,0 +1,116 @@
1import { useCallback, useMemo, useRef, useState } from "react";
2import {
3 Platform,
4 PlatformColor,
5 Pressable,
6 StyleSheet,
7 View,
8 type AccessibilityActionEvent,
9} from "react-native";
10import { Gesture, GestureDetector } from "react-native-gesture-handler";
11import { runOnJS } from "react-native-reanimated";
12
13const ACCESSIBILITY_RESIZE_STEP = 24;
14
15interface WorkspacePaneDividerProps {
16 readonly accessibilityLabel: string;
17 readonly currentWidth: number;
18 /** 1 when dragging right grows the pane, -1 when dragging left grows it. */
19 readonly resizeDirection: 1 | -1;
20 readonly onResizeStart?: () => void;
21 readonly onResizeBy: (delta: number) => void;
22 readonly onResizeEnd?: () => void;
23}
24
25/** A forgiving divider target for touch, pointer, and VoiceOver users. */
26export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
27 const latestProps = useRef(props);
28 latestProps.current = props;
29 const [hovered, setHovered] = useState(false);
30 const [dragging, setDragging] = useState(false);
31 const handleResizeStart = useCallback(() => {
32 setDragging(true);
33 latestProps.current.onResizeStart?.();
34 }, []);
35 const handleResize = useCallback((translationX: number) => {
36 latestProps.current.onResizeBy(translationX * latestProps.current.resizeDirection);
37 }, []);
38 const handleResizeEnd = useCallback(() => {
39 setDragging(false);
40 latestProps.current.onResizeEnd?.();
41 }, []);
42 const resizeGesture = useMemo(
43 () =>
44 Gesture.Pan()
45 .activeOffsetX([-4, 4])
46 .failOffsetY([-24, 24])
47 .onStart(() => {
48 runOnJS(handleResizeStart)();
49 })
50 .onUpdate((event) => {
51 runOnJS(handleResize)(event.translationX);
52 })
53 .onFinalize(() => {
54 runOnJS(handleResizeEnd)();
55 }),
56 [handleResize, handleResizeEnd, handleResizeStart],
57 );
58
59 const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
60 props.onResizeStart?.();
61 if (event.nativeEvent.actionName === "increment") {
62 props.onResizeBy(ACCESSIBILITY_RESIZE_STEP);
63 } else if (event.nativeEvent.actionName === "decrement") {
64 props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP);
65 }
66 props.onResizeEnd?.();
67 };
68
69 return (
70 <GestureDetector gesture={resizeGesture}>
71 <Pressable
72 accessibilityActions={[
73 { name: "increment", label: "Make pane wider" },
74 { name: "decrement", label: "Make pane narrower" },
75 ]}
76 accessibilityLabel={props.accessibilityLabel}
77 accessibilityRole="adjustable"
78 accessibilityValue={{
79 now: Math.round(props.currentWidth),
80 text: `${Math.round(props.currentWidth)} points wide`,
81 }}
82 onAccessibilityAction={handleAccessibilityAction}
83 onHoverIn={() => setHovered(true)}
84 onHoverOut={() => setHovered(false)}
85 style={styles.hitTarget}
86 >
87 <View style={[styles.line, (hovered || dragging) && styles.activeLine]} />
88 </Pressable>
89 </GestureDetector>
90 );
91}
92
93const styles = StyleSheet.create({
94 hitTarget: {
95 alignSelf: "stretch",
96 cursor: "pointer",
97 justifyContent: "center",
98 marginHorizontal: -22,
99 position: "relative",
100 width: 44,
101 zIndex: 100,
102 },
103 line: {
104 alignSelf: "center",
105 backgroundColor:
106 Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)",
107 height: "100%",
108 opacity: 0.7,
109 width: StyleSheet.hairlineWidth,
110 },
111 activeLine: {
112 backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff",
113 opacity: 1,
114 width: 2,
115 },
116});
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx created+31
...@@ -0,0 +1,31 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import type { ReactNode } from "react";
3
4import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";
5
6export function WorkspaceSidebarToolbar(
7 props: {
8 readonly children?: ReactNode;
9 readonly afterSidebarButton?: ReactNode;
10 } = {},
11) {
12 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
13
14 if (!layout.usesSplitView) {
15 return null;
16 }
17
18 return (
19 <NativeHeaderToolbar placement="left">
20 {props.children}
21 <NativeHeaderToolbar.Button
22 accessibilityLabel={
23 panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar"
24 }
25 icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"}
26 onPress={togglePrimarySidebar}
27 />
28 {props.afterSidebarButton}
29 </NativeHeaderToolbar>
30 );
31}
apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx created+15
...@@ -0,0 +1,15 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { AddProjectDestinationScreen } from "./AddProjectScreen";
3
4type AddProjectDestinationRouteParams = {
5 readonly environmentId?: string | string[];
6 readonly source?: string | string[];
7 readonly remoteUrl?: string | string[];
8 readonly repositoryTitle?: string | string[];
9};
10
11export function AddProjectDestinationRoute({
12 route,
13}: StaticScreenProps<AddProjectDestinationRouteParams | undefined>) {
14 return <AddProjectDestinationScreen {...(route.params ?? {})} />;
15}
apps/mobile/src/features/projects/AddProjectLocalRoute.tsx created+12
...@@ -0,0 +1,12 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { AddProjectLocalFolderScreen } from "./AddProjectScreen";
3
4type AddProjectLocalRouteParams = {
5 readonly environmentId?: string | string[];
6};
7
8export function AddProjectLocalRoute({
9 route,
10}: StaticScreenProps<AddProjectLocalRouteParams | undefined>) {
11 return <AddProjectLocalFolderScreen {...(route.params ?? {})} />;
12}
apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx created+31
...@@ -0,0 +1,31 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { NativeStackScreenOptions } from "../../native/StackHeader";
3import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
4
5import { AddProjectRepositoryScreen } from "./AddProjectScreen";
6
7type AddProjectRepositoryRouteParams = {
8 readonly environmentId?: string | string[];
9 readonly source?: string | string[];
10};
11
12export function AddProjectRepositoryRoute({
13 route,
14}: StaticScreenProps<AddProjectRepositoryRouteParams>) {
15 const params = route.params ?? {};
16 const source = Array.isArray(params.source) ? params.source[0] : params.source;
17 const title =
18 source === "github" ||
19 source === "gitlab" ||
20 source === "bitbucket" ||
21 source === "azure-devops"
22 ? addProjectRemoteSourceLabel(source)
23 : "Git URL";
24
25 return (
26 <>
27 <NativeStackScreenOptions options={{ title }} />
28 <AddProjectRepositoryScreen {...params} />
29 </>
30 );
31}
apps/mobile/src/features/projects/AddProjectScreen.tsx+56-58
...@@ -22,7 +22,7 @@ import {...@@ -22,7 +22,7 @@ import {
22 isFilesystemBrowseQuery,22 isFilesystemBrowseQuery,
23} from "@t3tools/client-runtime/state/projects";23} from "@t3tools/client-runtime/state/projects";
24import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";24import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
25import { useLocalSearchParams, useRouter } from "expo-router";25import { StackActions, useNavigation } from "@react-navigation/native";
26import { SymbolView } from "expo-symbols";26import { SymbolView } from "expo-symbols";
27import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";27import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
28import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";28import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
...@@ -168,9 +168,9 @@ function ListRow(props: {...@@ -168,9 +168,9 @@ function ListRow(props: {
168 {props.icon}168 {props.icon}
169 </View>169 </View>
170 <View className="flex-1 gap-0.5">170 <View className="flex-1 gap-0.5">
171 <Text className="text-base leading-[21px] font-t3-bold">{props.title}</Text>171 <Text className="text-base leading-snug font-t3-bold">{props.title}</Text>
172 {props.subtitle ? (172 {props.subtitle ? (
173 <Text className="text-sm leading-[17px] text-foreground-muted" numberOfLines={2}>173 <Text className="text-sm leading-snug text-foreground-muted" numberOfLines={2}>
174 {props.subtitle}174 {props.subtitle}
175 </Text>175 </Text>
176 ) : null}176 ) : null}
...@@ -215,7 +215,7 @@ function ProjectPathInput(props: {...@@ -215,7 +215,7 @@ function ProjectPathInput(props: {
215}) {215}) {
216 return (216 return (
217 <TextInput217 <TextInput
218 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-[20px]"218 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
219 value={props.value}219 value={props.value}
220 onChangeText={props.onChangeText}220 onChangeText={props.onChangeText}
221 autoCapitalize="none"221 autoCapitalize="none"
...@@ -250,35 +250,31 @@ function useSelectedEnvironment(): {...@@ -250,35 +250,31 @@ function useSelectedEnvironment(): {
250 readonly selectedEnvironment: EnvironmentOption | null;250 readonly selectedEnvironment: EnvironmentOption | null;
251 readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;251 readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;
252} {252} {
253 const router = useRouter();253 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
254 const params = useLocalSearchParams<{ environmentId?: string }>();
255 const environmentOptions = useEnvironmentOptions();254 const environmentOptions = useEnvironmentOptions();
256 const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null;
257 const selectedEnvironment =255 const selectedEnvironment =
258 environmentOptions.find(256 environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ??
259 (environment) => environment.environmentId === requestedEnvironmentId,
260 ) ??
261 environmentOptions[0] ??257 environmentOptions[0] ??
262 null;258 null;
263259
264 return {260 return {
265 environmentOptions,261 environmentOptions,
266 selectedEnvironment,262 selectedEnvironment,
267 setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }),263 setSelectedEnvironmentId,
268 };264 };
269}265}
270266
271function EmptyEnvironmentState() {267function EmptyEnvironmentState() {
272 const router = useRouter();268 const navigation = useNavigation();
273269
274 return (270 return (
275 <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8">271 <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8">
276 <Text className="text-center text-lg font-t3-bold">No environments connected</Text>272 <Text className="text-center text-lg font-t3-bold">No environments connected</Text>
277 <Text className="text-center text-sm leading-[20px] text-foreground-muted">273 <Text className="text-center text-sm leading-normal text-foreground-muted">
278 Add an environment before adding a project.274 Add an environment before adding a project.
279 </Text>275 </Text>
280 <Pressable276 <Pressable
281 onPress={() => router.replace("/connections/new")}277 onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))}
282 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"278 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
283 >279 >
284 <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text>280 <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text>
...@@ -294,7 +290,7 @@ function SourceControlRow(props: {...@@ -294,7 +290,7 @@ function SourceControlRow(props: {
294 readonly hint: string;290 readonly hint: string;
295 readonly isFirst: boolean;291 readonly isFirst: boolean;
296}) {292}) {
297 const router = useRouter();293 const navigation = useNavigation();
298 const iconColor = useThemeColor("--color-icon");294 const iconColor = useThemeColor("--color-icon");
299 const title =295 const title =
300 props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`;296 props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`;
...@@ -322,8 +318,8 @@ function SourceControlRow(props: {...@@ -322,8 +318,8 @@ function SourceControlRow(props: {
322 icon={icon}318 icon={icon}
323 isFirst={props.isFirst}319 isFirst={props.isFirst}
324 onPress={() =>320 onPress={() =>
325 router.push({321 navigation.navigate("NewTaskSheet", {
326 pathname: "/new/add-project/repository",322 screen: "AddProjectRepository",
327 params: {323 params: {
328 environmentId: props.selectedEnvironmentId,324 environmentId: props.selectedEnvironmentId,
329 source: props.source,325 source: props.source,
...@@ -335,7 +331,7 @@ function SourceControlRow(props: {...@@ -335,7 +331,7 @@ function SourceControlRow(props: {
335}331}
336332
337export function AddProjectSourceScreen() {333export function AddProjectSourceScreen() {
338 const router = useRouter();334 const navigation = useNavigation();
339 const accentColor = useThemeColor("--color-icon-muted");335 const accentColor = useThemeColor("--color-icon-muted");
340 const iconColor = useThemeColor("--color-icon");336 const iconColor = useThemeColor("--color-icon");
341 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =337 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
...@@ -409,9 +405,11 @@ export function AddProjectSourceScreen() {...@@ -409,9 +405,11 @@ export function AddProjectSourceScreen() {
409 }405 }
410 isFirst406 isFirst
411 onPress={() =>407 onPress={() =>
412 router.push({408 navigation.navigate("NewTaskSheet", {
413 pathname: "/new/add-project/local",409 screen: "AddProjectLocal",
414 params: { environmentId: selectedEnvironment.environmentId },410 params: {
411 environmentId: selectedEnvironment.environmentId,
412 },
415 })413 })
416 }414 }
417 />415 />
...@@ -440,7 +438,7 @@ export function AddProjectSourceScreen() {...@@ -440,7 +438,7 @@ export function AddProjectSourceScreen() {
440}438}
441439
442function useCreateProject(environment: EnvironmentOption | null) {440function useCreateProject(environment: EnvironmentOption | null) {
443 const router = useRouter();441 const navigation = useNavigation();
444 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });442 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
445 const projects = useProjects();443 const projects = useProjects();
446444
...@@ -455,14 +453,13 @@ function useCreateProject(environment: EnvironmentOption | null) {...@@ -455,14 +453,13 @@ function useCreateProject(environment: EnvironmentOption | null) {
455 });453 });
456 if (existing) {454 if (existing) {
457 Alert.alert("Project already exists", existing.title);455 Alert.alert("Project already exists", existing.title);
458 router.replace({456 navigation.dispatch(
459 pathname: "/new/draft",457 StackActions.replace("NewTaskDraft", {
460 params: {
461 environmentId: existing.environmentId,458 environmentId: existing.environmentId,
462 projectId: existing.id,459 projectId: existing.id,
463 title: existing.title,460 title: existing.title,
464 },461 }),
465 });462 );
466 return;463 return;
467 }464 }
468465
...@@ -480,24 +477,24 @@ function useCreateProject(environment: EnvironmentOption | null) {...@@ -480,24 +477,24 @@ function useCreateProject(environment: EnvironmentOption | null) {
480 if (AsyncResult.isFailure(result)) {477 if (AsyncResult.isFailure(result)) {
481 return result;478 return result;
482 }479 }
483 router.replace({480 navigation.dispatch(
484 pathname: "/new/draft",481 StackActions.replace("NewTaskDraft", {
485 params: {
486 environmentId: environment.environmentId,482 environmentId: environment.environmentId,
487 projectId,483 projectId,
488 title: inferProjectTitleFromPath(workspaceRoot),484 title: inferProjectTitleFromPath(workspaceRoot),
489 },485 }),
490 });486 );
491 return result;487 return result;
492 },488 },
493 [createProject, environment, projects, router],489 [createProject, environment, projects, navigation],
494 );490 );
495}491}
496492
497function useEnvironmentFromParam(): EnvironmentOption | null {493function useEnvironmentFromParam(
498 const params = useLocalSearchParams<{ environmentId?: string }>();494 environmentIdParam: string | string[] | undefined,
495): EnvironmentOption | null {
499 const environmentOptions = useEnvironmentOptions();496 const environmentOptions = useEnvironmentOptions();
500 const environmentId = stringParam(params.environmentId) as EnvironmentId | null;497 const environmentId = stringParam(environmentIdParam) as EnvironmentId | null;
501 return (498 return (
502 environmentOptions.find((environment) => environment.environmentId === environmentId) ??499 environmentOptions.find((environment) => environment.environmentId === environmentId) ??
503 environmentOptions[0] ??500 environmentOptions[0] ??
...@@ -505,14 +502,16 @@ function useEnvironmentFromParam(): EnvironmentOption | null {...@@ -505,14 +502,16 @@ function useEnvironmentFromParam(): EnvironmentOption | null {
505 );502 );
506}503}
507504
508export function AddProjectRepositoryScreen() {505export function AddProjectRepositoryScreen(props: {
506 readonly environmentId?: string | string[];
507 readonly source?: string | string[];
508}) {
509 const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, {509 const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, {
510 reportFailure: false,510 reportFailure: false,
511 });511 });
512 const router = useRouter();512 const navigation = useNavigation();
513 const params = useLocalSearchParams<{ environmentId?: string; source?: string }>();513 const environment = useEnvironmentFromParam(props.environmentId);
514 const environment = useEnvironmentFromParam();514 const source = sourceFromParam(props.source);
515 const source = sourceFromParam(params.source);
516 const [repositoryInput, setRepositoryInput] = useState("");515 const [repositoryInput, setRepositoryInput] = useState("");
517 const [isSubmitting, setIsSubmitting] = useState(false);516 const [isSubmitting, setIsSubmitting] = useState(false);
518 const [error, setError] = useState<string | null>(null);517 const [error, setError] = useState<string | null>(null);
...@@ -524,8 +523,8 @@ export function AddProjectRepositoryScreen() {...@@ -524,8 +523,8 @@ export function AddProjectRepositoryScreen() {
524 const provider = addProjectRemoteSourceProvider(source);523 const provider = addProjectRemoteSourceProvider(source);
525 if (!provider) {524 if (!provider) {
526 const remoteUrl = repositoryInput.trim();525 const remoteUrl = repositoryInput.trim();
527 router.push({526 navigation.navigate("NewTaskSheet", {
528 pathname: "/new/add-project/destination",527 screen: "AddProjectDestination",
529 params: {528 params: {
530 environmentId: environment.environmentId,529 environmentId: environment.environmentId,
531 source,530 source,
...@@ -548,8 +547,8 @@ export function AddProjectRepositoryScreen() {...@@ -548,8 +547,8 @@ export function AddProjectRepositoryScreen() {
548 setError(errorMessage(Cause.squash(result.cause)));547 setError(errorMessage(Cause.squash(result.cause)));
549 } else {548 } else {
550 const repository = result.value;549 const repository = result.value;
551 router.push({550 navigation.navigate("NewTaskSheet", {
552 pathname: "/new/add-project/destination",551 screen: "AddProjectDestination",
553 params: {552 params: {
554 environmentId: environment.environmentId,553 environmentId: environment.environmentId,
555 source,554 source,
...@@ -559,13 +558,13 @@ export function AddProjectRepositoryScreen() {...@@ -559,13 +558,13 @@ export function AddProjectRepositoryScreen() {
559 });558 });
560 }559 }
561 setIsSubmitting(false);560 setIsSubmitting(false);
562 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]);561 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
563562
564 return (563 return (
565 <AddProjectShell>564 <AddProjectShell>
566 {error ? <ErrorBanner message={error} /> : null}565 {error ? <ErrorBanner message={error} /> : null}
567 <TextInput566 <TextInput
568 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-[20px]"567 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
569 value={repositoryInput}568 value={repositoryInput}
570 onChangeText={setRepositoryInput}569 onChangeText={setRepositoryInput}
571 autoCapitalize="none"570 autoCapitalize="none"
...@@ -681,8 +680,8 @@ function FolderBrowser(props: {...@@ -681,8 +680,8 @@ function FolderBrowser(props: {
681 );680 );
682}681}
683682
684export function AddProjectLocalFolderScreen() {683export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
685 const environment = useEnvironmentFromParam();684 const environment = useEnvironmentFromParam(props.environmentId);
686 const createProject = useCreateProject(environment);685 const createProject = useCreateProject(environment);
687 const [pathInput, setPathInput] = useState(() =>686 const [pathInput, setPathInput] = useState(() =>
688 getAddProjectInitialQuery(environment?.baseDirectory),687 getAddProjectInitialQuery(environment?.baseDirectory),
...@@ -745,19 +744,18 @@ export function AddProjectLocalFolderScreen() {...@@ -745,19 +744,18 @@ export function AddProjectLocalFolderScreen() {
745 );744 );
746}745}
747746
748export function AddProjectDestinationScreen() {747export function AddProjectDestinationScreen(props: {
748 readonly environmentId?: string | string[];
749 readonly remoteUrl?: string | string[];
750 readonly repositoryTitle?: string | string[];
751}) {
749 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {752 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
750 reportFailure: false,753 reportFailure: false,
751 });754 });
752 const params = useLocalSearchParams<{755 const environment = useEnvironmentFromParam(props.environmentId);
753 environmentId?: string;
754 remoteUrl?: string;
755 repositoryTitle?: string;
756 }>();
757 const environment = useEnvironmentFromParam();
758 const createProject = useCreateProject(environment);756 const createProject = useCreateProject(environment);
759 const remoteUrl = stringParam(params.remoteUrl);757 const remoteUrl = stringParam(props.remoteUrl);
760 const repositoryTitle = stringParam(params.repositoryTitle);758 const repositoryTitle = stringParam(props.repositoryTitle);
761 const [pathInput, setPathInput] = useState(() =>759 const [pathInput, setPathInput] = useState(() =>
762 getAddProjectInitialQuery(environment?.baseDirectory),760 getAddProjectInitialQuery(environment?.baseDirectory),
763 );761 );
apps/mobile/src/features/projects/AddProjectSourceRoute.tsx created+5
...@@ -0,0 +1,5 @@
1import { AddProjectSourceScreen } from "./AddProjectScreen";
2
3export function AddProjectSourceRoute() {
4 return <AddProjectSourceScreen />;
5}
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+21-16
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { useLocalSearchParams, useRouter } from "expo-router";1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { TextInputWrapper } from "expo-paste-input";3import { TextInputWrapper } from "expo-paste-input";
4import type { EnvironmentId, ThreadId } from "@t3tools/contracts";4import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
...@@ -25,10 +25,10 @@ import {...@@ -25,10 +25,10 @@ import {
25 getSelectedReviewCommentLines,25 getSelectedReviewCommentLines,
26 useReviewCommentTarget,26 useReviewCommentTarget,
27} from "./reviewCommentSelection";27} from "./reviewCommentSelection";
28import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
28import {29import {
29 changeTone,30 changeTone,
30 DiffTokenText,31 DiffTokenText,
31 REVIEW_DIFF_LINE_HEIGHT,
32 REVIEW_MONO_FONT_FAMILY,32 REVIEW_MONO_FONT_FAMILY,
33 ReviewChangeBar,33 ReviewChangeBar,
34} from "./reviewDiffRendering";34} from "./reviewDiffRendering";
...@@ -40,17 +40,20 @@ import {...@@ -40,17 +40,20 @@ import {
4040
41const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;41const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
4242
43export function ReviewCommentComposerSheet() {43type ReviewCommentComposerSheetProps = StaticScreenProps<{
44 const router = useRouter();44 readonly environmentId: EnvironmentId;
45 readonly threadId: ThreadId;
46}>;
47
48export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) {
49 const navigation = useNavigation();
45 const insets = useSafeAreaInsets();50 const insets = useSafeAreaInsets();
46 const { width } = useWindowDimensions();51 const { width } = useWindowDimensions();
47 const colorScheme = useColorScheme();52 const colorScheme = useColorScheme();
48 const iconTint = String(useThemeColor("--color-icon"));53 const iconTint = String(useThemeColor("--color-icon"));
49 const target = useReviewCommentTarget();54 const target = useReviewCommentTarget();
50 const { environmentId, threadId } = useLocalSearchParams<{55 const { codeSurface } = useAppearanceCodeSurface();
51 environmentId: EnvironmentId;56 const { environmentId, threadId } = props.route.params;
52 threadId: ThreadId;
53 }>();
54 const [commentText, setCommentText] = useState("");57 const [commentText, setCommentText] = useState("");
55 const [highlightedLinesById, setHighlightedLinesById] = useState<58 const [highlightedLinesById, setHighlightedLinesById] = useState<
56 Record<string, ReadonlyArray<ReviewHighlightedToken>>59 Record<string, ReadonlyArray<ReviewHighlightedToken>>
...@@ -78,14 +81,14 @@ export function ReviewCommentComposerSheet() {...@@ -78,14 +81,14 @@ export function ReviewCommentComposerSheet() {
78 ? `Lines ${firstNumber}-${lastNumber}`81 ? `Lines ${firstNumber}-${lastNumber}`
79 : `${selectedLines.length} lines selected`;82 : `${selectedLines.length} lines selected`;
80 const previewHeight = Math.max(83 const previewHeight = Math.max(
81 Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT,84 Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * codeSurface.rowHeight,
82 REVIEW_DIFF_LINE_HEIGHT,85 codeSurface.rowHeight,
83 );86 );
84 const previewViewportWidth = Math.max(width - 40, 280);87 const previewViewportWidth = Math.max(width - 40, 280);
85 const dismissComposer = useCallback(() => {88 const dismissComposer = useCallback(() => {
86 clearReviewCommentTarget();89 clearReviewCommentTarget();
87 router.dismiss();90 navigation.goBack();
88 }, [router]);91 }, [navigation]);
89 const handleNativePaste = useNativePaste((uris) => {92 const handleNativePaste = useNativePaste((uris) => {
90 void (async () => {93 void (async () => {
91 try {94 try {
...@@ -167,7 +170,7 @@ export function ReviewCommentComposerSheet() {...@@ -167,7 +170,7 @@ export function ReviewCommentComposerSheet() {
167 {!target ? (170 {!target ? (
168 <View className="rounded-[22px] border border-border bg-card px-4 py-5">171 <View className="rounded-[22px] border border-border bg-card px-4 py-5">
169 <Text className="text-base font-t3-bold text-foreground">No selection</Text>172 <Text className="text-base font-t3-bold text-foreground">No selection</Text>
170 <Text className="mt-1 text-sm leading-[19px] text-foreground-muted">173 <Text className="mt-1 text-sm leading-normal text-foreground-muted">
171 Select a diff line or range first.174 Select a diff line or range first.
172 </Text>175 </Text>
173 </View>176 </View>
...@@ -178,7 +181,7 @@ export function ReviewCommentComposerSheet() {...@@ -178,7 +181,7 @@ export function ReviewCommentComposerSheet() {
178 {selectionLabel}181 {selectionLabel}
179 </Text>182 </Text>
180 <Text183 <Text
181 className="font-mono text-xs leading-[17px] text-foreground-muted"184 className="font-mono text-xs leading-snug text-foreground-muted"
182 ellipsizeMode="middle"185 ellipsizeMode="middle"
183 numberOfLines={2}186 numberOfLines={2}
184 >187 >
...@@ -211,9 +214,9 @@ export function ReviewCommentComposerSheet() {...@@ -211,9 +214,9 @@ export function ReviewCommentComposerSheet() {
211 <View214 <View
212 key={line.id}215 key={line.id}
213 className={cn("flex-row items-start", changeTone(line.change))}216 className={cn("flex-row items-start", changeTone(line.change))}
214 style={{ height: REVIEW_DIFF_LINE_HEIGHT }}217 style={{ height: codeSurface.rowHeight }}
215 >218 >
216 <ReviewChangeBar change={line.change} />219 <ReviewChangeBar change={line.change} height={codeSurface.rowHeight} />
217 <Text220 <Text
218 className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted"221 className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted"
219 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}222 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
...@@ -225,6 +228,8 @@ export function ReviewCommentComposerSheet() {...@@ -225,6 +228,8 @@ export function ReviewCommentComposerSheet() {
225 fallback={line.content}228 fallback={line.content}
226 tokens={highlightedLinesById[line.id] ?? null}229 tokens={highlightedLinesById[line.id] ?? null}
227 change={line.change}230 change={line.change}
231 fontSize={codeSurface.fontSize}
232 lineHeight={codeSurface.rowHeight}
228 />233 />
229 </View>234 </View>
230 </View>235 </View>
apps/mobile/src/features/review/ReviewSheet.tsx+478-182
...@@ -1,14 +1,30 @@...@@ -1,14 +1,30 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useLocalSearchParams } from "expo-router";2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import Stack from "expo-router/stack";3import {
4 NativeHeaderToolbar,
5 NativeStackScreenOptions,
6 nativeHeaderScrollEdgeEffects,
7} from "../../native/StackHeader";
8import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
4import { SymbolView } from "expo-symbols";9import { SymbolView } from "expo-symbols";
5import { memo, type ReactElement, useCallback, useMemo } from "react";10import {
11 memo,
12 type Ref,
13 type ReactElement,
14 useCallback,
15 useEffect,
16 useImperativeHandle,
17 useMemo,
18 useRef,
19 useState,
20} from "react";
6import {21import {
7 ActivityIndicator,22 ActivityIndicator,
23 FlatList,
24 Platform,
8 Pressable,25 Pressable,
9 ScrollView,26 ScrollView,
10 type NativeSyntheticEvent,27 type NativeSyntheticEvent,
11 Text as NativeText,
12 StyleSheet,28 StyleSheet,
13 useColorScheme,29 useColorScheme,
14 View,30 View,
...@@ -20,23 +36,38 @@ import { environmentCatalog } from "../../connection/catalog";...@@ -20,23 +36,38 @@ import { environmentCatalog } from "../../connection/catalog";
20import { useEnvironmentPresentation } from "../../state/presentation";36import { useEnvironmentPresentation } from "../../state/presentation";
21import { useAtomCommand } from "../../state/use-atom-command";37import { useAtomCommand } from "../../state/use-atom-command";
22import { useThemeColor } from "../../lib/useThemeColor";38import { useThemeColor } from "../../lib/useThemeColor";
23import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
24import { useThreadDraftForThread } from "../../state/use-thread-composer-state";39import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
25import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";40import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
41import {
42 useAdaptiveWorkspaceLayout,
43 useAdaptiveWorkspacePaneRole,
44 useRegisterWorkspaceInspector,
45} from "../layout/AdaptiveWorkspaceLayout";
46import { useEnvironmentQuery } from "../../state/query";
47import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
48import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
49import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
50import { useThreadSelection } from "../../state/use-thread-selection";
51import { vcsEnvironment } from "../../state/vcs";
52import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
53import { ThreadGitMenu } from "../threads/ThreadGitControls";
26import { useReviewCacheForThread } from "./reviewState";54import { useReviewCacheForThread } from "./reviewState";
27import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
28import {55import {
29 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,56 type NativeReviewDiffViewHandle,
30 NATIVE_REVIEW_DIFF_ROW_HEIGHT,57 resolveNativeReviewDiffView,
31} from "./nativeReviewDiffAdapter";58} from "../diffs/nativeReviewDiffSurface";
59import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter";
60import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
32import { useReviewDiffData } from "./useReviewDiffData";61import { useReviewDiffData } from "./useReviewDiffData";
62import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming";
33import { useReviewFileVisibility } from "./reviewFileVisibility";63import { useReviewFileVisibility } from "./reviewFileVisibility";
34import { useReviewSections } from "./useReviewSections";64import { useReviewSections } from "./useReviewSections";
35import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";65import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";
36import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";66import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";
37import { resolveReviewAvailability } from "./reviewAvailability";67import { resolveReviewAvailability } from "./reviewAvailability";
68import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
69import { buildReviewSectionMenu } from "./review-section-menu";
3870
39const IOS_NAV_BAR_HEIGHT = 44;
40const REVIEW_HEADER_SPACING = 0;71const REVIEW_HEADER_SPACING = 0;
4172
42const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {73const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
...@@ -45,7 +76,7 @@ const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string...@@ -45,7 +76,7 @@ const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string
45 <Text className="text-xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">76 <Text className="text-xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">
46 Partial diff77 Partial diff
47 </Text>78 </Text>
48 <Text className="text-xs leading-[18px] text-amber-800 dark:text-amber-200">79 <Text className="text-xs leading-normal text-amber-800 dark:text-amber-200">
49 {props.notice}80 {props.notice}
50 </Text>81 </Text>
51 </View>82 </View>
...@@ -110,36 +141,244 @@ function ReviewSelectionActionBar(props: {...@@ -110,36 +141,244 @@ function ReviewSelectionActionBar(props: {
110 );141 );
111}142}
112143
113export function ReviewSheet() {144interface ReviewNavigatorFile {
145 readonly id: string;
146 readonly path: string;
147 readonly additions: number;
148 readonly deletions: number;
149}
150
151const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: {
152 readonly file: ReviewNavigatorFile;
153 readonly selected: boolean;
154 readonly onSelectFile: (fileId: string | null) => void;
155}) {
156 const { file, selected, onSelectFile } = props;
157 // Tapping the selected file again returns to the all-files diff.
158 const handlePress = useCallback(() => {
159 onSelectFile(selected ? null : file.id);
160 }, [file.id, onSelectFile, selected]);
161
162 return (
163 <Pressable
164 accessibilityRole="button"
165 accessibilityState={{ selected }}
166 className={
167 selected
168 ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2"
169 : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle"
170 }
171 onPress={handlePress}
172 >
173 <Text
174 className={
175 selected
176 ? "text-xs font-t3-bold text-foreground"
177 : "text-xs font-t3-medium text-foreground-secondary"
178 }
179 numberOfLines={2}
180 >
181 {file.path}
182 </Text>
183 <View className="mt-1 flex-row gap-2">
184 <Text className="text-2xs font-t3-bold text-emerald-600">+{file.additions}</Text>
185 <Text className="text-2xs font-t3-bold text-rose-600">-{file.deletions}</Text>
186 </View>
187 </Pressable>
188 );
189});
190
191interface ReviewFileNavigatorHandle {
192 readonly setVisibleFile: (fileId: string | null) => void;
193}
194
195interface ReviewFileNavigatorProps {
196 readonly files: ReadonlyArray<ReviewNavigatorFile>;
197 readonly headerInset: number;
198 readonly sectionId: string | null;
199 readonly onSelectFile: (fileId: string | null) => void;
200 readonly ref?: Ref<ReviewFileNavigatorHandle>;
201}
202
203function ReviewFileNavigator({
204 files,
205 headerInset,
206 sectionId,
207 onSelectFile,
208 ref,
209}: ReviewFileNavigatorProps) {
210 const insets = useSafeAreaInsets();
211 const sheetColor = String(useThemeColor("--color-sheet"));
212 const foregroundColor = String(useThemeColor("--color-foreground"));
213 const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
214 const [fileSelection, setFileSelection] = useState<{
215 readonly sectionId: string | null;
216 readonly fileId: string | null;
217 }>({ sectionId: null, fileId: null });
218 const availableFileIds = useMemo(() => files.map((file) => file.id), [files]);
219 const selectedFileId = resolveSelectedReviewFileId({
220 selection: fileSelection,
221 sectionId,
222 availableFileIds,
223 });
224
225 useImperativeHandle(
226 ref,
227 () => ({
228 setVisibleFile: (fileId) => {
229 if (fileId !== null && !availableFileIds.includes(fileId)) {
230 return;
231 }
232 setFileSelection((current) => {
233 if (current.sectionId === sectionId && current.fileId === fileId) {
234 return current;
235 }
236 return { sectionId, fileId };
237 });
238 },
239 }),
240 [availableFileIds, sectionId],
241 );
242
243 const handleSelectFile = useCallback(
244 (fileId: string | null) => {
245 setFileSelection({ sectionId, fileId });
246 onSelectFile(fileId);
247 },
248 [onSelectFile, sectionId],
249 );
250
251 const renderFile = useCallback(
252 ({ item }: { readonly item: ReviewNavigatorFile }) => (
253 <ReviewFileNavigatorRow
254 file={item}
255 selected={selectedFileId === item.id}
256 onSelectFile={handleSelectFile}
257 />
258 ),
259 [handleSelectFile, selectedFileId],
260 );
261
262 const fileList = (
263 <FlatList
264 data={files}
265 extraData={selectedFileId}
266 keyExtractor={(file) => file.id}
267 contentContainerStyle={{
268 paddingHorizontal: 8,
269 paddingBottom: 8,
270 // The nested native header is translucent; start the list below it so
271 // the scroll-edge effect can sample the content (same treatment as
272 // FileTreeBrowser in the Files pane).
273 paddingTop: Platform.OS === "ios" ? insets.top + 44 + 8 : 8,
274 }}
275 scrollIndicatorInsets={Platform.OS === "ios" ? { top: insets.top + 44 } : undefined}
276 renderItem={renderFile}
277 />
278 );
279
280 if (Platform.OS === "ios") {
281 return (
282 <View className="flex-1 border-l border-border bg-sheet">
283 <ScreenStack style={{ flex: 1 }}>
284 <Screen
285 activityState={2}
286 enabled
287 isNativeStack
288 screenId="review-file-navigator-native"
289 scrollEdgeEffects={headerScrollEdgeEffects}
290 style={{ backgroundColor: sheetColor, flex: 1 }}
291 >
292 {fileList}
293 <ScreenStackHeaderConfig
294 backgroundColor="rgba(0,0,0,0)"
295 color={foregroundColor}
296 hideBackButton
297 hideShadow={false}
298 navigationItemStyle="editor"
299 subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`}
300 title="Changed files"
301 titleColor={foregroundColor}
302 titleFontSize={17}
303 titleFontWeight="700"
304 translucent
305 />
306 </Screen>
307 </ScreenStack>
308 </View>
309 );
310 }
311
312 return (
313 <View className="flex-1 border-l border-border bg-sheet">
314 <View className="border-b border-border" style={{ paddingTop: headerInset }}>
315 <View className="px-4 py-3">
316 <Text className="text-sm font-t3-bold text-foreground">Changed files</Text>
317 <Text className="text-xs text-foreground-muted">
318 {files.length} {files.length === 1 ? "file" : "files"}
319 </Text>
320 </View>
321 </View>
322 {fileList}
323 </View>
324 );
325}
326
327type ReviewSheetProps = StaticScreenProps<{
328 readonly environmentId: EnvironmentId;
329 readonly threadId: ThreadId;
330}>;
331
332export function ReviewSheet(props: ReviewSheetProps) {
333 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
334 useAdaptiveWorkspacePaneRole("inspector");
335 const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
336 const navigation = useNavigation();
114 const insets = useSafeAreaInsets();337 const insets = useSafeAreaInsets();
115 const colorScheme = useColorScheme();338 const colorScheme = useColorScheme();
116 const headerForeground = String(useThemeColor("--color-foreground"));
117 const headerMuted = String(useThemeColor("--color-foreground-muted"));
118 const headerIcon = String(useThemeColor("--color-icon"));339 const headerIcon = String(useThemeColor("--color-icon"));
119 const { environmentId, threadId } = useLocalSearchParams<{340 const { environmentId, threadId } = props.route.params;
120 environmentId: EnvironmentId;
121 threadId: ThreadId;
122 }>();
123 const environment = useEnvironmentPresentation(environmentId);341 const environment = useEnvironmentPresentation(environmentId);
124 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");342 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
125 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";343 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
126 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });344 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
127 const reviewCache = useReviewCacheForThread({ environmentId, threadId });345 const reviewCache = useReviewCacheForThread({ environmentId, threadId });
346 /* ─── Git actions for the toolbar menu (commit/push without leaving review) ── */
347 const { selectedThread } = useThreadSelection();
348 const { selectedThreadCwd } = useSelectedThreadWorktree();
349 const gitState = useSelectedThreadGitState();
350 const gitActions = useSelectedThreadGitActions();
351 const gitStatusQuery = useEnvironmentQuery(
352 selectedThread !== null && selectedThreadCwd !== null
353 ? vcsEnvironment.status({
354 environmentId: selectedThread.environmentId,
355 input: { cwd: selectedThreadCwd },
356 })
357 : null,
358 );
359 // The selection-based git hooks only apply when this review belongs to the
360 // selected thread (it always does when reached from the thread's toolbar).
361 const gitMenuAvailable =
362 selectedThread !== null && String(selectedThread.id) === String(threadId);
128 const selectedTheme = colorScheme === "dark" ? "dark" : "light";363 const selectedTheme = colorScheme === "dark" ? "dark" : "light";
129 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;364 // With a solid (non-overlay) header the content lays out below the header
130 const {365 // natively, so no manual top inset is needed.
131 error,366 const topContentInset = 0;
132 loadingGitDiffs,367
133 loadingTurnIds,368 useEffect(() => {
134 reviewSections,369 showAuxiliaryPane("inspector");
135 selectedSection,370 }, [environmentId, showAuxiliaryPane, threadId]);
136 refreshSelectedSection,371 const { error, reviewSections, selectedSection, refreshSelectedSection, selectSection } =
137 selectSection,372 useReviewSections({
138 } = useReviewSections({373 enabled: isEnvironmentReady,
139 enabled: isEnvironmentReady,374 environmentId,
140 environmentId,375 threadId,
141 threadId,376 reviewCache,
142 reviewCache,377 });
378 useReviewDiffPrewarming({
379 threadKey: reviewCache.threadKey,
380 sections: reviewSections,
381 selectedSectionId: selectedSection?.id ?? null,
143 });382 });
144 const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =383 const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =
145 useReviewDiffData({384 useReviewDiffData({
...@@ -148,6 +387,18 @@ export function ReviewSheet() {...@@ -148,6 +387,18 @@ export function ReviewSheet() {
148 draftMessage,387 draftMessage,
149 });388 });
150 const NativeReviewDiffView = resolveNativeReviewDiffView()!;389 const NativeReviewDiffView = resolveNativeReviewDiffView()!;
390 const nativeReviewDiffViewRef = useRef<NativeReviewDiffViewHandle>(null);
391 // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item).
392 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
393 const handlePullToRefresh = useCallback(async () => {
394 setIsPullRefreshing(true);
395 try {
396 await refreshSelectedSection();
397 } finally {
398 setIsPullRefreshing(false);
399 }
400 }, [refreshSelectedSection]);
401 const reviewFileNavigatorRef = useRef<ReviewFileNavigatorHandle>(null);
151 const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : [];402 const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : [];
152 const fileVisibility = useReviewFileVisibility({403 const fileVisibility = useReviewFileVisibility({
153 threadKey: reviewCache.threadKey,404 threadKey: reviewCache.threadKey,
...@@ -179,6 +430,43 @@ export function ReviewSheet() {...@@ -179,6 +430,43 @@ export function ReviewSheet() {
179 canHighlight: parsedDiff.kind === "files",430 canHighlight: parsedDiff.kind === "files",
180 });431 });
181432
433 const handleSelectFile = useCallback(
434 (fileId: string | null) => {
435 commentSelection.clearSelection();
436 if (fileId !== null && collapsedFileIds.includes(fileId)) {
437 toggleExpandedFile(fileId);
438 }
439 const navigation =
440 fileId === null
441 ? nativeReviewDiffViewRef.current?.scrollToTop(true)
442 : nativeReviewDiffViewRef.current?.scrollToFile(fileId, true);
443 void navigation?.catch((error: unknown) => {
444 console.error("[review] Failed to navigate to diff file", error);
445 });
446 },
447 [collapsedFileIds, commentSelection, toggleExpandedFile],
448 );
449 const handleVisibleFileChange = useCallback(
450 (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => {
451 reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null);
452 },
453 [],
454 );
455 const renderInspector = useCallback(
456 () => (
457 <ReviewFileNavigator
458 ref={reviewFileNavigatorRef}
459 files={nativeReviewDiffData.files}
460 // The workspace inspector column spans the full window height, so the
461 // pane clears the status bar itself.
462 headerInset={insets.top}
463 sectionId={selectedSection?.id ?? null}
464 onSelectFile={handleSelectFile}
465 />
466 ),
467 [handleSelectFile, insets.top, nativeReviewDiffData.files, selectedSection?.id],
468 );
469
182 const handleNativeToggleFile = useCallback(470 const handleNativeToggleFile = useCallback(
183 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {471 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
184 const { fileId } = event.nativeEvent;472 const { fileId } = event.nativeEvent;
...@@ -203,6 +491,7 @@ export function ReviewSheet() {...@@ -203,6 +491,7 @@ export function ReviewSheet() {
203 parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;491 parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;
204 const hasCachedSelectedDiff = selectedSection?.diff != null;492 const hasCachedSelectedDiff = selectedSection?.diff != null;
205 const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null);493 const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null);
494 const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]);
206 const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({495 const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({
207 hasEnvironmentPresentation: environment.isReady,496 hasEnvironmentPresentation: environment.isReady,
208 isEnvironmentConnected: isEnvironmentReady,497 isEnvironmentConnected: isEnvironmentReady,
...@@ -212,6 +501,22 @@ export function ReviewSheet() {...@@ -212,6 +501,22 @@ export function ReviewSheet() {
212 const handleRetryEnvironment = useCallback(() => {501 const handleRetryEnvironment = useCallback(() => {
213 void retryEnvironment(environmentId);502 void retryEnvironment(environmentId);
214 }, [environmentId, retryEnvironment]);503 }, [environmentId, retryEnvironment]);
504 const handleReturnToThread = useCallback(() => {
505 if (navigation.canGoBack()) {
506 navigation.goBack();
507 return;
508 }
509 navigation.navigate("Thread", {
510 environmentId: String(environmentId),
511 threadId: String(threadId),
512 });
513 }, [environmentId, navigation, threadId]);
514
515 // The changed-files navigator lives in the workspace inspector column —
516 // the single right-hand pane per route — instead of an in-screen panel.
517 const showChangedFilesPane =
518 !showConnectionNotice && selectedSection !== null && parsedDiff.kind === "files";
519 useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined);
215520
216 const listHeader = useMemo(() => {521 const listHeader = useMemo(() => {
217 const children: ReactElement[] = [];522 const children: ReactElement[] = [];
...@@ -220,7 +525,7 @@ export function ReviewSheet() {...@@ -220,7 +525,7 @@ export function ReviewSheet() {
220 children.push(525 children.push(
221 <View key="review-error" className="border-b border-border bg-card px-4 py-3">526 <View key="review-error" className="border-b border-border bg-card px-4 py-3">
222 <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text>527 <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text>
223 <Text className="text-xs leading-[18px] text-foreground-muted">{error}</Text>528 <Text className="text-xs leading-normal text-foreground-muted">{error}</Text>
224 </View>,529 </View>,
225 );530 );
226 }531 }
...@@ -235,134 +540,117 @@ export function ReviewSheet() {...@@ -235,134 +540,117 @@ export function ReviewSheet() {
235540
236 return <>{children}</>;541 return <>{children}</>;
237 }, [error, parsedDiffNotice]);542 }, [error, parsedDiffNotice]);
543 const headerSubtitle = [
544 headerDiffSummary.additions,
545 headerDiffSummary.deletions,
546 pendingReviewCommentCount > 0
547 ? `${pendingReviewCommentCount} comment${pendingReviewCommentCount === 1 ? "" : "s"}`
548 : null,
549 ]
550 .filter(Boolean)
551 .join(" · ");
552 const headerTitleText = selectedSection?.title ?? "Review changes";
238553
239 return (554 return (
240 <>555 <>
241 <Stack.Screen556 <NativeStackScreenOptions
242 options={{557 options={{
243 headerTransparent: true,558 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native
244 headerShadowVisible: false,559 // diff scrolls internally, nothing for glass to sample). Only dynamic values
560 // here.
245 headerTintColor: headerIcon,561 headerTintColor: headerIcon,
246 headerStyle: {562 headerTitle: headerTitleText,
247 backgroundColor: "transparent",563 title: headerTitleText,
248 },564 unstable_headerSubtitle:
249 headerTitle: () => (565 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
250 <View style={{ alignItems: "center" }}>
251 <NativeText
252 numberOfLines={1}
253 style={{
254 fontFamily: "DMSans_700Bold",
255 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
256 fontWeight: "900",
257 color: headerForeground,
258 letterSpacing: -0.4,
259 }}
260 >
261 Files Changed
262 </NativeText>
263 <View
264 style={{
265 flexDirection: "row",
266 alignItems: "center",
267 justifyContent: "center",
268 gap: 6,
269 flexWrap: "wrap",
270 }}
271 >
272 {headerDiffSummary.additions && headerDiffSummary.deletions ? (
273 <>
274 <NativeText
275 style={{
276 fontFamily: "DMSans_700Bold",
277 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
278 fontWeight: "700",
279 color: "#16a34a",
280 }}
281 >
282 {headerDiffSummary.additions}
283 </NativeText>
284 <NativeText
285 style={{
286 fontFamily: "DMSans_700Bold",
287 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
288 fontWeight: "700",
289 color: "#e11d48",
290 }}
291 >
292 {headerDiffSummary.deletions}
293 </NativeText>
294 {pendingReviewCommentCount > 0 ? (
295 <NativeText
296 style={{
297 fontFamily: "DMSans_700Bold",
298 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
299 fontWeight: "700",
300 color: "#b45309",
301 }}
302 >
303 {pendingReviewCommentCount} pending
304 </NativeText>
305 ) : null}
306 </>
307 ) : (
308 <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
309 <NativeText
310 numberOfLines={1}
311 style={{
312 fontFamily: "DMSans_700Bold",
313 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
314 fontWeight: "700",
315 color: headerMuted,
316 }}
317 >
318 {selectedSection?.title ?? "Review changes"}
319 </NativeText>
320 {pendingReviewCommentCount > 0 ? (
321 <NativeText
322 style={{
323 fontFamily: "DMSans_700Bold",
324 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
325 fontWeight: "700",
326 color: "#b45309",
327 }}
328 >
329 {pendingReviewCommentCount} pending
330 </NativeText>
331 ) : null}
332 </View>
333 )}
334 </View>
335 </View>
336 ),
337 }}566 }}
338 />567 />
339568
340 {showSectionToolbar ? (569 <WorkspaceSidebarToolbar>
341 <Stack.Toolbar placement="right">570 <NativeHeaderToolbar.Button
342 <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground>571 accessibilityLabel="Back to chat"
343 {reviewSections.map((section) => (572 icon="chevron.left"
344 <Stack.Toolbar.MenuAction573 onPress={handleReturnToThread}
345 key={section.id}574 />
346 icon={section.id === selectedSection?.id ? "checkmark" : "circle"}575 </WorkspaceSidebarToolbar>
347 onPress={() => selectSection(section.id)}576
348 subtitle={section.subtitle ?? undefined}577 {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? (
349 >578 <NativeHeaderToolbar placement="right">
350 <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label>579 {panes.supportsAuxiliaryPane ? (
351 </Stack.Toolbar.MenuAction>580 <NativeHeaderToolbar.Button
352 ))}581 accessibilityLabel={
353 <Stack.Toolbar.MenuAction582 panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files"
354 icon="arrow.clockwise"
355 disabled={
356 loadingGitDiffs ||
357 (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true)
358 }583 }
359 onPress={() => void refreshSelectedSection()}584 icon="sidebar.right"
360 subtitle="Reload current diff"585 onPress={toggleAuxiliaryPane}
361 >586 separateBackground
362 <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label>587 />
363 </Stack.Toolbar.MenuAction>588 ) : null}
364 </Stack.Toolbar.Menu>589 {gitMenuAvailable && selectedThread !== null ? (
365 </Stack.Toolbar>590 <ThreadGitMenu
591 environmentId={environmentId}
592 threadId={threadId}
593 currentBranch={selectedThread.branch ?? null}
594 gitStatus={gitStatusQuery.data}
595 gitOperationLabel={gitState.gitOperationLabel}
596 onPull={gitActions.onPullSelectedThreadBranch}
597 onRunAction={gitActions.onRunSelectedThreadGitAction}
598 />
599 ) : null}
600 {showSectionToolbar ? (
601 <NativeHeaderToolbar.Menu icon="ellipsis" title="Select diff" separateBackground>
602 <NativeHeaderToolbar.Menu inline>
603 <NativeHeaderToolbar.MenuAction
604 disabled={sectionMenu.workingTree === null}
605 isOn={selectedSection?.id === sectionMenu.workingTree?.id}
606 onPress={() => {
607 if (sectionMenu.workingTree) {
608 selectSection(sectionMenu.workingTree.id);
609 }
610 }}
611 >
612 <NativeHeaderToolbar.Label>Working tree</NativeHeaderToolbar.Label>
613 </NativeHeaderToolbar.MenuAction>
614 <NativeHeaderToolbar.MenuAction
615 disabled={sectionMenu.branchChanges === null}
616 isOn={selectedSection?.id === sectionMenu.branchChanges?.id}
617 onPress={() => {
618 if (sectionMenu.branchChanges) {
619 selectSection(sectionMenu.branchChanges.id);
620 }
621 }}
622 >
623 <NativeHeaderToolbar.Label>Branch changes</NativeHeaderToolbar.Label>
624 </NativeHeaderToolbar.MenuAction>
625 <NativeHeaderToolbar.MenuAction
626 disabled={sectionMenu.latestTurn === null}
627 isOn={selectedSection?.id === sectionMenu.latestTurn?.id}
628 onPress={() => {
629 if (sectionMenu.latestTurn) {
630 selectSection(sectionMenu.latestTurn.id);
631 }
632 }}
633 >
634 <NativeHeaderToolbar.Label>Latest turn</NativeHeaderToolbar.Label>
635 </NativeHeaderToolbar.MenuAction>
636 {sectionMenu.turns.length > 0 ? (
637 <NativeHeaderToolbar.Menu title="Turn">
638 {sectionMenu.turns.map((section) => (
639 <NativeHeaderToolbar.MenuAction
640 key={section.id}
641 isOn={section.id === selectedSection?.id}
642 onPress={() => selectSection(section.id)}
643 subtitle={section.subtitle ?? undefined}
644 >
645 <NativeHeaderToolbar.Label>{section.title}</NativeHeaderToolbar.Label>
646 </NativeHeaderToolbar.MenuAction>
647 ))}
648 </NativeHeaderToolbar.Menu>
649 ) : null}
650 </NativeHeaderToolbar.Menu>
651 </NativeHeaderToolbar.Menu>
652 ) : null}
653 </NativeHeaderToolbar>
366 ) : null}654 ) : null}
367655
368 <View className="flex-1 bg-sheet">656 <View className="flex-1 bg-sheet">
...@@ -386,34 +674,42 @@ export function ReviewSheet() {...@@ -386,34 +674,42 @@ export function ReviewSheet() {
386 className="flex-1"674 className="flex-1"
387 style={{675 style={{
388 backgroundColor: nativeBridge.theme.background,676 backgroundColor: nativeBridge.theme.background,
389 paddingTop: topContentInset + REVIEW_HEADER_SPACING,
390 }}677 }}
391 >678 >
392 {listHeader}679 <View
393 <View className="flex-1" collapsable={false}>680 className="min-w-0 flex-1"
394 <NativeReviewDiffView681 style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }}
395 key={`${reviewCache.threadKey}:${selectedSection.id}`}682 >
396 collapsable={false}683 {listHeader}
397 testID="review-native-diff-view"684 <View className="min-w-0 flex-1" collapsable={false}>
398 style={StyleSheet.absoluteFill}685 <NativeReviewDiffView
399 appearanceScheme={selectedTheme}686 collapsable={false}
400 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}687 testID="review-native-diff-view"
401 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}688 refreshing={isPullRefreshing}
402 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}689 onPullToRefresh={() => void handlePullToRefresh()}
403 rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}690 style={StyleSheet.absoluteFill}
404 rowsJson={nativeBridge.rowsJson}691 appearanceScheme={selectedTheme}
405 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}692 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
406 styleJson={nativeBridge.styleJson}693 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
407 themeJson={nativeBridge.themeJson}694 contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}
408 tokensPatchJson={nativeBridge.tokensPatchJson}695 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
409 tokensResetKey={nativeBridge.tokensResetKey}696 nativeViewRef={nativeReviewDiffViewRef}
410 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}697 rowHeight={nativeReviewDiffStyle.rowHeight}
411 onDebug={nativeBridge.onDebug}698 rowsJson={nativeBridge.rowsJson}
412 onPressLine={commentSelection.onPressLine}699 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
413 onToggleComment={nativeBridge.onToggleComment}700 styleJson={nativeBridge.styleJson}
414 onToggleFile={handleNativeToggleFile}701 themeJson={nativeBridge.themeJson}
415 onToggleViewedFile={handleNativeToggleViewedFile}702 tokensPatchJson={nativeBridge.tokensPatchJson}
416 />703 tokensResetKey={nativeBridge.tokensResetKey}
704 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
705 onDebug={nativeBridge.onDebug}
706 onPressLine={commentSelection.onPressLine}
707 onVisibleFileChange={handleVisibleFileChange}
708 onToggleComment={nativeBridge.onToggleComment}
709 onToggleFile={handleNativeToggleFile}
710 onToggleViewedFile={handleNativeToggleViewedFile}
711 />
712 </View>
417 </View>713 </View>
418 </View>714 </View>
419 ) : (715 ) : (
...@@ -432,7 +728,7 @@ export function ReviewSheet() {...@@ -432,7 +728,7 @@ export function ReviewSheet() {
432 {!selectedSection ? (728 {!selectedSection ? (
433 <View className="border-b border-border bg-card px-4 py-5">729 <View className="border-b border-border bg-card px-4 py-5">
434 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>730 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
435 <Text className="text-xs leading-[18px] text-foreground-muted">731 <Text className="text-xs leading-normal text-foreground-muted">
436 This thread has no ready turn diffs and the worktree diff is empty.732 This thread has no ready turn diffs and the worktree diff is empty.
437 </Text>733 </Text>
438 </View>734 </View>
...@@ -444,17 +740,17 @@ export function ReviewSheet() {...@@ -444,17 +740,17 @@ export function ReviewSheet() {
444 ) : parsedDiff.kind === "empty" ? (740 ) : parsedDiff.kind === "empty" ? (
445 <View className="border-b border-border bg-card px-4 py-5">741 <View className="border-b border-border bg-card px-4 py-5">
446 <Text className="text-sm font-t3-bold text-foreground">No changes</Text>742 <Text className="text-sm font-t3-bold text-foreground">No changes</Text>
447 <Text className="text-xs leading-[18px] text-foreground-muted">743 <Text className="text-xs leading-normal text-foreground-muted">
448 {selectedSection.subtitle ?? "This diff is empty."}744 {selectedSection.subtitle ?? "This diff is empty."}
449 </Text>745 </Text>
450 </View>746 </View>
451 ) : parsedDiff.kind === "raw" ? (747 ) : parsedDiff.kind === "raw" ? (
452 <View className="gap-3 border-b border-border bg-card px-4 py-4">748 <View className="gap-3 border-b border-border bg-card px-4 py-4">
453 <Text className="text-xs leading-[18px] text-foreground-muted">749 <Text className="text-xs leading-normal text-foreground-muted">
454 {parsedDiff.reason}750 {parsedDiff.reason}
455 </Text>751 </Text>
456 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>752 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
457 <Text selectable className="font-mono text-xs leading-[19px] text-foreground">753 <Text selectable className="font-mono text-xs leading-relaxed text-foreground">
458 {parsedDiff.text}754 {parsedDiff.text}
459 </Text>755 </Text>
460 </ScrollView>756 </ScrollView>
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts created+56
...@@ -0,0 +1,56 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 getCachedNativeReviewDiffData,
5 type BuildNativeReviewDiffDataInput,
6} from "./nativeReviewDiffAdapter";
7import type { ReviewInlineComment } from "./reviewCommentSelection";
8import { buildReviewParsedDiff } from "./reviewModel";
9
10const parsedDiff = buildReviewParsedDiff(
11 [
12 "diff --git a/example.ts b/example.ts",
13 "--- a/example.ts",
14 "+++ b/example.ts",
15 "@@ -1 +1 @@",
16 "-const before = 1;",
17 "+const after = 2;",
18 ].join("\n"),
19 "native-review-cache-test",
20);
21
22function makeComment(text: string): ReviewInlineComment {
23 return {
24 id: "comment-1",
25 sectionId: "git:working-tree",
26 sectionTitle: "Dirty worktree",
27 filePath: "example.ts",
28 startIndex: 0,
29 endIndex: 0,
30 rangeLabel: "-1",
31 text,
32 diff: "@@ -1,1 +1,0 @@\n-const before = 1;",
33 };
34}
35
36function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) {
37 return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput;
38}
39
40describe("getCachedNativeReviewDiffData", () => {
41 it("reuses the row model for equivalent empty comment arrays", () => {
42 const first = getCachedNativeReviewDiffData(buildInput([]));
43 const second = getCachedNativeReviewDiffData(buildInput([]));
44
45 expect(second).toBe(first);
46 });
47
48 it("reuses equivalent comment contents and invalidates changed comments", () => {
49 const first = getCachedNativeReviewDiffData(buildInput([makeComment("First")]));
50 const equivalent = getCachedNativeReviewDiffData(buildInput([makeComment("First")]));
51 const changed = getCachedNativeReviewDiffData(buildInput([makeComment("Changed")]));
52
53 expect(equivalent).toBe(first);
54 expect(changed).not.toBe(first);
55 });
56});
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+98-36
...@@ -5,6 +5,8 @@ import type {...@@ -5,6 +5,8 @@ import type {
5} from "../diffs/nativeReviewDiffTypes";5} from "../diffs/nativeReviewDiffTypes";
6import * as Arr from "effect/Array";6import * as Arr from "effect/Array";
7import { pipe } from "effect/Function";7import { pipe } from "effect/Function";
8import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
9import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
8import { MOBILE_CODE_SURFACE } from "../../lib/typography";10import { MOBILE_CODE_SURFACE } from "../../lib/typography";
9import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";11import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
10import { computeWordAltDiffRanges } from "./reviewWordDiffs";12import { computeWordAltDiffRanges } from "./reviewWordDiffs";
...@@ -22,38 +24,44 @@ const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;...@@ -22,38 +24,44 @@ const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;
22export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;24export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
23export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;25export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
2426
25export const NATIVE_REVIEW_DIFF_STYLE = {27export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle(
26 rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT,28 resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
27 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,29);
28 changeBarWidth: 4,30
29 gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,31export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {
30 codePadding: MOBILE_CODE_SURFACE.codePadding,32 return {
31 textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,33 rowHeight: codeSurface.rowHeight,
32 fileHeaderHeight: 56,34 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
33 fileHeaderHorizontalMargin: 8,35 changeBarWidth: 4,
34 fileHeaderVerticalMargin: 6,36 gutterWidth: codeSurface.gutterWidth,
35 fileHeaderCornerRadius: 10,37 codePadding: codeSurface.codePadding,
36 fileHeaderHorizontalPadding: 10,38 textVerticalInset: codeSurface.textVerticalInset,
37 fileHeaderPathRightPadding: 118,39 fileHeaderHeight: 56,
38 fileHeaderCountColumnWidth: 38,40 fileHeaderHorizontalMargin: 8,
39 fileHeaderCountGap: 5,41 fileHeaderVerticalMargin: 6,
40 codeFontSize: MOBILE_CODE_SURFACE.fontSize,42 fileHeaderCornerRadius: 10,
41 codeFontWeight: "regular",43 fileHeaderHorizontalPadding: 10,
42 lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,44 fileHeaderPathRightPadding: 118,
43 lineNumberFontWeight: "regular",45 fileHeaderCountColumnWidth: 38,
44 hunkFontSize: 11,46 fileHeaderCountGap: 5,
45 hunkFontWeight: "medium",47 codeFontSize: codeSurface.fontSize,
46 fileHeaderFontSize: 11,48 codeFontWeight: "regular",
47 fileHeaderFontWeight: "semibold",49 lineNumberFontSize: codeSurface.lineNumberFontSize,
48 fileHeaderMetaFontSize: 10,50 lineNumberFontWeight: "regular",
49 fileHeaderMetaFontWeight: "semibold",51 hunkFontSize: 11,
50 fileHeaderSubtextFontSize: 11,52 hunkFontWeight: "medium",
51 fileHeaderSubtextFontWeight: "medium",53 fileHeaderFontSize: 11,
52 fileHeaderStatusFontSize: 9,54 fileHeaderFontWeight: "semibold",
53 fileHeaderStatusFontWeight: "bold",55 fileHeaderMetaFontSize: 10,
54 emptyStateFontSize: 12,56 fileHeaderMetaFontWeight: "semibold",
55 emptyStateFontWeight: "medium",57 fileHeaderSubtextFontSize: 11,
56} as const;58 fileHeaderSubtextFontWeight: "medium",
59 fileHeaderStatusFontSize: 9,
60 fileHeaderStatusFontWeight: "bold",
61 emptyStateFontSize: 12,
62 emptyStateFontWeight: "medium",
63 } as const;
64}
5765
58export interface NativeReviewDiffData {66export interface NativeReviewDiffData {
59 readonly rows: ReadonlyArray<NativeReviewDiffRow>;67 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
...@@ -75,6 +83,33 @@ export interface BuildNativeReviewDiffDataInput {...@@ -75,6 +83,33 @@ export interface BuildNativeReviewDiffDataInput {
75 readonly comments?: ReadonlyArray<ReviewInlineComment>;83 readonly comments?: ReadonlyArray<ReviewInlineComment>;
76}84}
7785
86interface CachedNativeReviewDiffData {
87 readonly commentsKey: string;
88 readonly data: NativeReviewDiffData;
89}
90
91const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>();
92
93function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string {
94 if (comments.length === 0) {
95 return "none";
96 }
97
98 return comments
99 .map((comment) =>
100 [
101 comment.id,
102 comment.sectionId,
103 comment.filePath,
104 comment.startIndex,
105 comment.endIndex,
106 comment.rangeLabel,
107 comment.text,
108 ].join("\u001f"),
109 )
110 .join("\u001e");
111}
112
78export function createNativeReviewDiffTheme(113export function createNativeReviewDiffTheme(
79 scheme: TerminalAppearanceScheme,114 scheme: TerminalAppearanceScheme,
80): NativeReviewDiffTheme {115): NativeReviewDiffTheme {
...@@ -83,10 +118,12 @@ export function createNativeReviewDiffTheme(...@@ -83,10 +118,12 @@ export function createNativeReviewDiffTheme(
83118
84 if (scheme === "dark") {119 if (scheme === "dark") {
85 return {120 return {
86 background: terminalTheme.background,121 // Match the app surface (--color-sheet) so code views blend with the rest of
122 // the app instead of using a distinct code-editor background.
123 background: "#0e0e0e",
87 text: terminalTheme.foreground,124 text: terminalTheme.foreground,
88 mutedText: terminalTheme.mutedForeground,125 mutedText: terminalTheme.mutedForeground,
89 headerBackground: terminalTheme.background,126 headerBackground: "#0e0e0e",
90 border: terminalTheme.border,127 border: terminalTheme.border,
91 hunkBackground: "#071f28",128 hunkBackground: "#071f28",
92 hunkText: terminalBlue ?? "#009fff",129 hunkText: terminalBlue ?? "#009fff",
...@@ -100,10 +137,12 @@ export function createNativeReviewDiffTheme(...@@ -100,10 +137,12 @@ export function createNativeReviewDiffTheme(
100 }137 }
101138
102 return {139 return {
103 background: "#ffffff",140 // Match the app surface (--color-sheet) so code views blend with the rest of the
141 // app instead of using a distinct code-editor background.
142 background: "#f2f2f7",
104 text: "#070707",143 text: "#070707",
105 mutedText: terminalTheme.mutedForeground,144 mutedText: terminalTheme.mutedForeground,
106 headerBackground: "#ffffff",145 headerBackground: "#f2f2f7",
107 border: terminalTheme.border,146 border: terminalTheme.border,
108 hunkBackground: "#e0f2ff",147 hunkBackground: "#e0f2ff",
109 hunkText: terminalBlue ?? "#009fff",148 hunkText: terminalBlue ?? "#009fff",
...@@ -430,3 +469,26 @@ export function buildNativeReviewDiffData(...@@ -430,3 +469,26 @@ export function buildNativeReviewDiffData(
430 deletions: parsedDiff.deletions,469 deletions: parsedDiff.deletions,
431 };470 };
432}471}
472
473/**
474 * Reuses the expensive flattened native row model across React development
475 * render probes and unrelated draft updates. Only the latest comment version
476 * is retained for each parsed diff so editing a comment cannot grow the cache.
477 */
478export function getCachedNativeReviewDiffData(
479 input: BuildNativeReviewDiffDataInput,
480): NativeReviewDiffData {
481 const comments = input.comments ?? [];
482 const commentsKey = buildReviewCommentsCacheKey(comments);
483 const cached = nativeReviewDiffDataCache.get(input.parsedDiff);
484 if (cached?.commentsKey === commentsKey) {
485 return cached.data;
486 }
487
488 const data = buildNativeReviewDiffData({
489 parsedDiff: input.parsedDiff,
490 comments,
491 });
492 nativeReviewDiffDataCache.set(input.parsedDiff, { commentsKey, data });
493 return data;
494}
apps/mobile/src/features/review/review-section-menu.test.ts created+40
...@@ -0,0 +1,40 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { ReviewSectionItem, ReviewSectionKind } from "./reviewModel";
4import { buildReviewSectionMenu } from "./review-section-menu";
5
6function section(id: string, kind: ReviewSectionKind): ReviewSectionItem {
7 return {
8 id,
9 kind,
10 title: id,
11 subtitle: null,
12 diff: null,
13 isLoading: false,
14 };
15}
16
17describe("buildReviewSectionMenu", () => {
18 it("exposes git scopes and the latest turn at the top level", () => {
19 const turn28 = section("turn:28", "turn");
20 const turn27 = section("turn:27", "turn");
21 const workingTree = section("git:working-tree", "working-tree");
22 const branchChanges = section("git:branch-range", "branch-range");
23
24 expect(buildReviewSectionMenu([turn28, turn27, workingTree, branchChanges])).toEqual({
25 workingTree,
26 branchChanges,
27 latestTurn: turn28,
28 turns: [turn28, turn27],
29 });
30 });
31
32 it("keeps unavailable scopes empty while data loads", () => {
33 expect(buildReviewSectionMenu([])).toEqual({
34 workingTree: null,
35 branchChanges: null,
36 latestTurn: null,
37 turns: [],
38 });
39 });
40});
apps/mobile/src/features/review/review-section-menu.ts created+21
...@@ -0,0 +1,21 @@
1import type { ReviewSectionItem } from "./reviewModel";
2
3export interface ReviewSectionMenu {
4 readonly workingTree: ReviewSectionItem | null;
5 readonly branchChanges: ReviewSectionItem | null;
6 readonly latestTurn: ReviewSectionItem | null;
7 readonly turns: ReadonlyArray<ReviewSectionItem>;
8}
9
10export function buildReviewSectionMenu(
11 sections: ReadonlyArray<ReviewSectionItem>,
12): ReviewSectionMenu {
13 const turns = sections.filter((section) => section.kind === "turn");
14
15 return {
16 workingTree: sections.find((section) => section.kind === "working-tree") ?? null,
17 branchChanges: sections.find((section) => section.kind === "branch-range") ?? null,
18 latestTurn: turns[0] ?? null,
19 turns,
20 };
21}
apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts created+27
...@@ -0,0 +1,27 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys";
4
5describe("native review diff bridge", () => {
6 it("builds stable reset keys from the rendered diff identity", () => {
7 const input = {
8 threadKey: "env:thread",
9 sectionId: "turn:2",
10 scheme: "dark" as const,
11 diff: "diff --git a/a.ts b/a.ts",
12 fileCount: 1,
13 rowCount: 4,
14 };
15
16 expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input));
17 expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe(
18 buildNativeReviewTokensResetKey(input),
19 );
20 expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:");
21 });
22
23 it("includes diff length in the hash key to reduce accidental collisions", () => {
24 expect(hashReviewDiffKey("abc")).toMatch(/^3:/);
25 expect(hashReviewDiffKey("abcd")).toMatch(/^4:/);
26 });
27});
apps/mobile/src/features/review/reviewDiffBridgeKeys.ts created+35
...@@ -0,0 +1,35 @@
1import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
2
3// Pure key-derivation helpers for the native review diff bridge. Kept free of
4// react-native / hook imports so they stay unit-testable in node.
5
6export function hashReviewDiffKey(diff: string | null | undefined): string {
7 if (!diff) {
8 return "empty";
9 }
10
11 let hash = 5381;
12 for (let index = 0; index < diff.length; index += 1) {
13 hash = (hash * 33) ^ diff.charCodeAt(index);
14 }
15
16 return `${diff.length}:${(hash >>> 0).toString(36)}`;
17}
18
19export function buildNativeReviewTokensResetKey(input: {
20 readonly threadKey: string | null;
21 readonly sectionId: string | null;
22 readonly scheme: NativeReviewDiffHighlightScheme;
23 readonly diff: string | null | undefined;
24 readonly fileCount: number;
25 readonly rowCount: number;
26}): string {
27 return [
28 input.threadKey ?? "none",
29 input.sectionId ?? "none",
30 input.scheme,
31 hashReviewDiffKey(input.diff),
32 input.fileCount,
33 input.rowCount,
34 ].join(":");
35}
apps/mobile/src/features/review/reviewDiffRendering.tsx+16-9
...@@ -13,7 +13,6 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({...@@ -13,7 +13,6 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({
13});13});
1414
15export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;15export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
16const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2;
1716
18export function renderVisibleWhitespace(value: string): string {17export function renderVisibleWhitespace(value: string): string {
19 const expandedTabs = value.replace(/\t/g, " ");18 const expandedTabs = value.replace(/\t/g, " ");
...@@ -38,12 +37,16 @@ function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string |...@@ -38,12 +37,16 @@ function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string |
38 return undefined;37 return undefined;
39}38}
4039
41export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRow["change"] }) {40export function ReviewChangeBar(props: {
41 readonly change: ReviewRenderableLineRow["change"];
42 readonly height?: number;
43}) {
44 const height = props.height ?? REVIEW_DIFF_LINE_HEIGHT;
42 if (props.change === "delete") {45 if (props.change === "delete") {
43 return (46 return (
44 <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>47 <View className="w-[5px] overflow-hidden" style={{ height }}>
45 <View>48 <View>
46 {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => (49 {Array.from({ length: Math.ceil(height / 2) }, (_, index) => (
47 <View key={index}>50 <View key={index}>
48 <View className="w-[5px] bg-rose-400" style={{ height: 1 }} />51 <View className="w-[5px] bg-rose-400" style={{ height: 1 }} />
49 <View style={{ height: 1 }} />52 <View style={{ height: 1 }} />
...@@ -55,7 +58,7 @@ export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRo...@@ -55,7 +58,7 @@ export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRo
55 }58 }
5659
57 return (60 return (
58 <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}>61 <View className="w-[5px] overflow-hidden" style={{ height }}>
59 <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} />62 <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} />
60 </View>63 </View>
61 );64 );
...@@ -66,7 +69,11 @@ export function DiffTokenText(props: {...@@ -66,7 +69,11 @@ export function DiffTokenText(props: {
66 readonly fallback: string;69 readonly fallback: string;
67 readonly change?: ReviewRenderableLineRow["change"];70 readonly change?: ReviewRenderableLineRow["change"];
68 readonly className?: string;71 readonly className?: string;
72 readonly fontSize?: number;
73 readonly lineHeight?: number;
69}) {74}) {
75 const fontSize = props.fontSize ?? MOBILE_CODE_SURFACE.fontSize;
76 const lineHeight = props.lineHeight ?? MOBILE_CODE_SURFACE.rowHeight;
70 if (!props.tokens || props.tokens.length === 0) {77 if (!props.tokens || props.tokens.length === 0) {
71 return (78 return (
72 <NativeText79 <NativeText
...@@ -75,8 +82,8 @@ export function DiffTokenText(props: {...@@ -75,8 +82,8 @@ export function DiffTokenText(props: {
75 className={cn("font-normal text-foreground", props.className)}82 className={cn("font-normal text-foreground", props.className)}
76 style={{83 style={{
77 fontFamily: REVIEW_MONO_FONT_FAMILY,84 fontFamily: REVIEW_MONO_FONT_FAMILY,
78 fontSize: MOBILE_CODE_SURFACE.fontSize,85 fontSize,
79 lineHeight: MOBILE_CODE_SURFACE.rowHeight,86 lineHeight,
80 }}87 }}
81 >88 >
82 {renderVisibleWhitespace(props.fallback || " ")}89 {renderVisibleWhitespace(props.fallback || " ")}
...@@ -91,8 +98,8 @@ export function DiffTokenText(props: {...@@ -91,8 +98,8 @@ export function DiffTokenText(props: {
91 className={cn("font-normal text-foreground", props.className)}98 className={cn("font-normal text-foreground", props.className)}
92 style={{99 style={{
93 fontFamily: REVIEW_MONO_FONT_FAMILY,100 fontFamily: REVIEW_MONO_FONT_FAMILY,
94 fontSize: MOBILE_CODE_SURFACE.fontSize,101 fontSize,
95 lineHeight: MOBILE_CODE_SURFACE.rowHeight,102 lineHeight,
96 }}103 }}
97 >104 >
98 {(() => {105 {(() => {
apps/mobile/src/features/review/reviewPaneSelection.test.ts created+35
...@@ -0,0 +1,35 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
4
5describe("resolveSelectedReviewFileId", () => {
6 it("keeps a visible file selected within the active section", () => {
7 expect(
8 resolveSelectedReviewFileId({
9 selection: { sectionId: "worktree", fileId: "second" },
10 sectionId: "worktree",
11 availableFileIds: ["first", "second"],
12 }),
13 ).toBe("second");
14 });
15
16 it("clears selection when the review section changes", () => {
17 expect(
18 resolveSelectedReviewFileId({
19 selection: { sectionId: "turn-1", fileId: "first" },
20 sectionId: "turn-2",
21 availableFileIds: ["first"],
22 }),
23 ).toBeNull();
24 });
25
26 it("clears a file that no longer exists in the diff", () => {
27 expect(
28 resolveSelectedReviewFileId({
29 selection: { sectionId: "worktree", fileId: "removed" },
30 sectionId: "worktree",
31 availableFileIds: ["first", "second"],
32 }),
33 ).toBeNull();
34 });
35});
apps/mobile/src/features/review/reviewPaneSelection.ts created+16
...@@ -0,0 +1,16 @@
1export interface ReviewPaneFileSelection {
2 readonly sectionId: string | null;
3 readonly fileId: string | null;
4}
5
6export function resolveSelectedReviewFileId(input: {
7 readonly selection: ReviewPaneFileSelection;
8 readonly sectionId: string | null;
9 readonly availableFileIds: ReadonlyArray<string>;
10}): string | null {
11 if (input.selection.sectionId !== input.sectionId || input.selection.fileId === null) {
12 return null;
13 }
14
15 return input.availableFileIds.includes(input.selection.fileId) ? input.selection.fileId : null;
16}
apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts deleted-27
...@@ -1,27 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./useNativeReviewDiffBridge";
4
5describe("native review diff bridge", () => {
6 it("builds stable reset keys from the rendered diff identity", () => {
7 const input = {
8 threadKey: "env:thread",
9 sectionId: "turn:2",
10 scheme: "dark" as const,
11 diff: "diff --git a/a.ts b/a.ts",
12 fileCount: 1,
13 rowCount: 4,
14 };
15
16 expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input));
17 expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe(
18 buildNativeReviewTokensResetKey(input),
19 );
20 expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:");
21 });
22
23 it("includes diff length in the hash key to reduce accidental collisions", () => {
24 expect(hashReviewDiffKey("abc")).toMatch(/^3:/);
25 expect(hashReviewDiffKey("abcd")).toMatch(/^4:/);
26 });
27});
apps/mobile/src/features/review/useNativeReviewDiffBridge.ts+6-36
...@@ -2,43 +2,12 @@ import { useCallback, useMemo, useState } from "react";...@@ -2,43 +2,12 @@ import { useCallback, useMemo, useState } from "react";
2import type { NativeSyntheticEvent } from "react-native";2import type { NativeSyntheticEvent } from "react-native";
33
4import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";4import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
5import {5import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter";
6 createNativeReviewDiffTheme,6import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
7 NATIVE_REVIEW_DIFF_STYLE,
8 type NativeReviewDiffData,
9} from "./nativeReviewDiffAdapter";
10import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";7import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";
8import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys";
119
12export function hashReviewDiffKey(diff: string | null | undefined): string {10export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys";
13 if (!diff) {
14 return "empty";
15 }
16
17 let hash = 5381;
18 for (let index = 0; index < diff.length; index += 1) {
19 hash = (hash * 33) ^ diff.charCodeAt(index);
20 }
21
22 return `${diff.length}:${(hash >>> 0).toString(36)}`;
23}
24
25export function buildNativeReviewTokensResetKey(input: {
26 readonly threadKey: string | null;
27 readonly sectionId: string | null;
28 readonly scheme: NativeReviewDiffHighlightScheme;
29 readonly diff: string | null | undefined;
30 readonly fileCount: number;
31 readonly rowCount: number;
32}): string {
33 return [
34 input.threadKey ?? "none",
35 input.sectionId ?? "none",
36 input.scheme,
37 hashReviewDiffKey(input.diff),
38 input.fileCount,
39 input.rowCount,
40 ].join(":");
41}
4211
43export function useNativeReviewDiffBridge(input: {12export function useNativeReviewDiffBridge(input: {
44 readonly threadKey: string | null;13 readonly threadKey: string | null;
...@@ -62,6 +31,7 @@ export function useNativeReviewDiffBridge(input: {...@@ -62,6 +31,7 @@ export function useNativeReviewDiffBridge(input: {
62 threadKey,31 threadKey,
63 viewedFileIds,32 viewedFileIds,
64 } = input;33 } = input;
34 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
65 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(35 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(
66 () => new Set(),36 () => new Set(),
67 );37 );
...@@ -76,7 +46,7 @@ export function useNativeReviewDiffBridge(input: {...@@ -76,7 +46,7 @@ export function useNativeReviewDiffBridge(input: {
76 [collapsedCommentIds],46 [collapsedCommentIds],
77 );47 );
78 const themeJson = useMemo(() => JSON.stringify(theme), [theme]);48 const themeJson = useMemo(() => JSON.stringify(theme), [theme]);
79 const styleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []);49 const styleJson = useMemo(() => JSON.stringify(nativeReviewDiffStyle), [nativeReviewDiffStyle]);
80 const tokensResetKey = useMemo(50 const tokensResetKey = useMemo(
81 () =>51 () =>
82 buildNativeReviewTokensResetKey({52 buildNativeReviewTokensResetKey({
apps/mobile/src/features/review/useReviewCommentSelectionController.ts+6-7
...@@ -1,12 +1,11 @@...@@ -1,12 +1,11 @@
1import { useCallback, useEffect, useMemo, useState } from "react";1import { useCallback, useEffect, useMemo, useState } from "react";
2import type { NativeSyntheticEvent } from "react-native";2import type { NativeSyntheticEvent } from "react-native";
3import { useRouter } from "expo-router";3import { useNavigation } from "@react-navigation/native";
4import * as Arr from "effect/Array";4import * as Arr from "effect/Array";
5import { pipe } from "effect/Function";5import { pipe } from "effect/Function";
6import * as Result from "effect/Result";6import * as Result from "effect/Result";
77
8import type { EnvironmentId, ThreadId } from "@t3tools/contracts";8import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
9
10import {9import {
11 buildReviewCommentTarget,10 buildReviewCommentTarget,
12 clearReviewCommentTarget,11 clearReviewCommentTarget,
...@@ -34,7 +33,7 @@ export function useReviewCommentSelectionController(input: {...@@ -34,7 +33,7 @@ export function useReviewCommentSelectionController(input: {
34 readonly nativeReviewDiffData: NativeReviewDiffData;33 readonly nativeReviewDiffData: NativeReviewDiffData;
35}) {34}) {
36 const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input;35 const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input;
37 const { push } = useRouter();36 const navigation = useNavigation();
38 const activeCommentTarget = useReviewCommentTarget();37 const activeCommentTarget = useReviewCommentTarget();
39 const [pendingNativeCommentSelection, setPendingNativeCommentSelection] =38 const [pendingNativeCommentSelection, setPendingNativeCommentSelection] =
40 useState<PendingNativeCommentSelection | null>(null);39 useState<PendingNativeCommentSelection | null>(null);
...@@ -44,11 +43,11 @@ export function useReviewCommentSelectionController(input: {...@@ -44,11 +43,11 @@ export function useReviewCommentSelectionController(input: {
44 return;43 return;
45 }44 }
4645
47 push({46 navigation.navigate("ThreadReviewComment", {
48 pathname: "/threads/[environmentId]/[threadId]/review-comment",47 environmentId,
49 params: { environmentId, threadId },48 threadId,
50 });49 });
51 }, [environmentId, push, threadId]);50 }, [environmentId, navigation, threadId]);
5251
53 const selectedRowIds = useMemo(() => {52 const selectedRowIds = useMemo(() => {
54 if (53 if (
apps/mobile/src/features/review/useReviewDiffData.ts+11-9
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import { useEffect, useMemo } from "react";1import { useEffect, useMemo } from "react";
22
3import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection";3import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection";
4import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter";4import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter";
5import { markReviewEvent, measureReviewWork } from "./reviewPerf";5import { markReviewEvent, measureReviewWork } from "./reviewPerf";
6import { getCachedReviewParsedDiff } from "./reviewState";6import { getCachedReviewParsedDiff } from "./reviewState";
7import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel";7import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel";
88
9const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]);
10
9function isReviewDiffDebugLoggingEnabled(): boolean {11function isReviewDiffDebugLoggingEnabled(): boolean {
10 return typeof __DEV__ !== "undefined" ? __DEV__ : false;12 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
11}13}
...@@ -43,6 +45,7 @@ export function useReviewDiffData(input: {...@@ -43,6 +45,7 @@ export function useReviewDiffData(input: {
43 readonly draftMessage: string;45 readonly draftMessage: string;
44}) {46}) {
45 const { draftMessage, selectedSection, threadKey } = input;47 const { draftMessage, selectedSection, threadKey } = input;
48 const selectedSectionId = selectedSection?.id ?? null;
46 const parsedDiff = useMemo(49 const parsedDiff = useMemo(
47 () =>50 () =>
48 measureReviewWork("parse-diff", () =>51 measureReviewWork("parse-diff", () =>
...@@ -59,17 +62,16 @@ export function useReviewDiffData(input: {...@@ -59,17 +62,16 @@ export function useReviewDiffData(input: {
59 () => parseReviewInlineComments(draftMessage),62 () => parseReviewInlineComments(draftMessage),
60 [draftMessage],63 [draftMessage],
61 );64 );
62 const selectedSectionInlineComments = useMemo(65 const selectedSectionInlineComments = useMemo(() => {
63 () =>66 if (!selectedSectionId || inlineReviewComments.length === 0) {
64 selectedSection67 return EMPTY_INLINE_REVIEW_COMMENTS;
65 ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id)68 }
66 : [],69 return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId);
67 [inlineReviewComments, selectedSection],70 }, [inlineReviewComments, selectedSectionId]);
68 );
69 const nativeReviewDiffData = useMemo(71 const nativeReviewDiffData = useMemo(
70 () =>72 () =>
71 measureReviewWork("build-native-diff-data", () =>73 measureReviewWork("build-native-diff-data", () =>
72 buildNativeReviewDiffData({74 getCachedNativeReviewDiffData({
73 parsedDiff,75 parsedDiff,
74 comments: selectedSectionInlineComments,76 comments: selectedSectionInlineComments,
75 }),77 }),
apps/mobile/src/features/review/useReviewDiffPrewarming.ts created+101
...@@ -0,0 +1,101 @@
1import { useEffect } from "react";
2
3import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter";
4import type { ReviewSectionItem } from "./reviewModel";
5import { getCachedReviewParsedDiff } from "./reviewState";
6
7interface IdleDeadlineLike {
8 readonly didTimeout: boolean;
9 timeRemaining(): number;
10}
11
12type IdleCallback = (deadline: IdleDeadlineLike) => void;
13
14function scheduleIdle(callback: IdleCallback): number {
15 if (typeof globalThis.requestIdleCallback === "function") {
16 return globalThis.requestIdleCallback(callback, { timeout: 2_000 });
17 }
18
19 return setTimeout(
20 () => callback({ didTimeout: true, timeRemaining: () => 0 }),
21 100,
22 ) as unknown as number;
23}
24
25function cancelIdle(handle: number): void {
26 if (typeof globalThis.cancelIdleCallback === "function") {
27 globalThis.cancelIdleCallback(handle);
28 return;
29 }
30 clearTimeout(handle);
31}
32
33export function prewarmReviewDiffSection(input: {
34 readonly threadKey: string;
35 readonly section: ReviewSectionItem;
36}): void {
37 const { section, threadKey } = input;
38 if (section.diff === null) {
39 return;
40 }
41
42 const parsedDiff = getCachedReviewParsedDiff({
43 threadKey,
44 sectionId: section.id,
45 diff: section.diff,
46 });
47 getCachedNativeReviewDiffData({ parsedDiff, comments: [] });
48}
49
50/** Warms one cached section per idle period, after navigation animations finish. */
51export function useReviewDiffPrewarming(input: {
52 readonly threadKey: string | null;
53 readonly sections: ReadonlyArray<ReviewSectionItem>;
54 readonly selectedSectionId: string | null;
55}): void {
56 const { sections, selectedSectionId, threadKey } = input;
57
58 useEffect(() => {
59 if (!threadKey) {
60 return;
61 }
62
63 const pendingSections = sections.filter(
64 (section) => section.id !== selectedSectionId && section.diff !== null,
65 );
66 if (pendingSections.length === 0) {
67 return;
68 }
69
70 let cancelled = false;
71 let idleHandle: number | null = null;
72 let nextSectionIndex = 0;
73
74 const scheduleNext = () => {
75 idleHandle = scheduleIdle(() => {
76 if (cancelled) {
77 return;
78 }
79
80 const section = pendingSections[nextSectionIndex];
81 if (!section) {
82 return;
83 }
84 nextSectionIndex += 1;
85 prewarmReviewDiffSection({ threadKey, section });
86
87 if (nextSectionIndex < pendingSections.length) {
88 scheduleNext();
89 }
90 });
91 };
92
93 scheduleNext();
94 return () => {
95 cancelled = true;
96 if (idleHandle !== null) {
97 cancelIdle(idleHandle);
98 }
99 };
100 }, [sections, selectedSectionId, threadKey]);
101}
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx created+30
...@@ -0,0 +1,30 @@
1import { ScrollView, View } from "react-native";
2import { useSafeAreaInsets } from "react-native-safe-area-context";
3
4import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
5import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
6import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
7
8export function SettingsAppearanceRouteScreen() {
9 const insets = useSafeAreaInsets();
10
11 return (
12 <View collapsable={false} className="flex-1 bg-sheet">
13 <ScrollView
14 contentInsetAdjustmentBehavior="automatic"
15 showsVerticalScrollIndicator={false}
16 style={{ flex: 1 }}
17 contentContainerStyle={{
18 gap: 24,
19 paddingBottom: Math.max(insets.bottom, 18) + 18,
20 paddingHorizontal: 20,
21 paddingTop: 16,
22 }}
23 >
24 <TextAppearanceSection />
25 <TerminalAppearanceSection />
26 <CodeAppearanceSection />
27 </ScrollView>
28 </View>
29 );
30}
apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx created+39
...@@ -0,0 +1,39 @@
1import { useAuth } from "@clerk/expo";
2import { AuthView, UserProfileView } from "@clerk/expo/native";
3import { StackActions, useNavigation } from "@react-navigation/native";
4import { NativeStackScreenOptions } from "../../native/StackHeader";
5import { useEffect } from "react";
6import { View } from "react-native";
7
8import { hasCloudPublicConfig } from "../cloud/publicConfig";
9
10export function SettingsAuthRouteScreen() {
11 const navigation = useNavigation();
12
13 useEffect(() => {
14 if (!hasCloudPublicConfig()) {
15 navigation.dispatch(StackActions.replace("Settings"));
16 }
17 }, [navigation]);
18
19 return hasCloudPublicConfig() ? <ConfiguredSettingsAuthRouteScreen /> : null;
20}
21
22function ConfiguredSettingsAuthRouteScreen() {
23 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
24
25 return (
26 <>
27 <NativeStackScreenOptions options={{ title: isSignedIn ? "Account" : "Sign in" }} />
28 <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
29 {isLoaded ? (
30 isSignedIn ? (
31 <UserProfileView isDismissible={false} />
32 ) : (
33 <AuthView isDismissible={false} />
34 )
35 ) : null}
36 </View>
37 </>
38 );
39}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx created+448
...@@ -0,0 +1,448 @@
1import { useAuth } from "@clerk/expo";
2import { NativeHeaderToolbar } from "../../native/StackHeader";
3import { useNavigation } from "@react-navigation/native";
4import { SymbolView } from "expo-symbols";
5import {
6 connectionStatusText,
7 type EnvironmentConnectionPhase,
8} from "@t3tools/client-runtime/connection";
9import type { EnvironmentId } from "@t3tools/contracts";
10import { useCallback, useState } from "react";
11import {
12 ActivityIndicator,
13 Pressable,
14 ScrollView,
15 Switch,
16 type NativeSyntheticEvent,
17 type TextLayoutEventData,
18 View,
19} from "react-native";
20import { useSafeAreaInsets } from "react-native-safe-area-context";
21
22import { AppText as Text } from "../../components/AppText";
23import {
24 type RelayEnvironmentView,
25 useConnectionController,
26} from "../connection/useConnectionController";
27import { hasCloudPublicConfig } from "../cloud/publicConfig";
28import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
29import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
30import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
31import { splitEnvironmentSections } from "../connection/environmentSections";
32import { cn } from "../../lib/cn";
33import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
34import { useThemeColor } from "../../lib/useThemeColor";
35import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
36import { useRemoteConnections } from "../../state/use-remote-environment-registry";
37
38export function SettingsEnvironmentsRouteScreen() {
39 const {
40 connectedEnvironments,
41 onReconnectEnvironment,
42 onRemoveEnvironmentPress,
43 onUpdateEnvironment,
44 } = useRemoteConnections();
45 const navigation = useNavigation();
46 const insets = useSafeAreaInsets();
47 const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({
48 connectedEnvironments,
49 cloudEnvironments: null,
50 });
51 const hasLocalEnvironments = localEnvironments.length > 0;
52 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
53 const accentColor = useThemeColor("--color-icon-muted");
54 const headerIconColor = useThemeColor("--color-icon");
55
56 const handleToggle = useCallback((environmentId: EnvironmentId) => {
57 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
58 }, []);
59
60 return (
61 <View collapsable={false} className="flex-1 bg-sheet">
62 <NativeHeaderToolbar placement="right">
63 <NativeHeaderToolbar.Button
64 icon="plus"
65 onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })}
66 separateBackground
67 tintColor={headerIconColor}
68 />
69 </NativeHeaderToolbar>
70 <ScrollView
71 contentInsetAdjustmentBehavior="automatic"
72 showsVerticalScrollIndicator={false}
73 style={{ flex: 1 }}
74 contentContainerStyle={{
75 paddingBottom: Math.max(insets.bottom, 18) + 18,
76 paddingHorizontal: 20,
77 paddingTop: 16,
78 }}
79 >
80 {hasLocalEnvironments ? (
81 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
82 {localEnvironments.map((environment, index) => (
83 <View
84 key={environment.environmentId}
85 collapsable={false}
86 style={{
87 borderTopWidth: index === 0 ? 0 : 1,
88 }}
89 className={cn(index !== 0 && "border-border")}
90 >
91 <ConnectionEnvironmentRow
92 environment={environment}
93 expanded={expandedId === environment.environmentId}
94 onToggle={() => handleToggle(environment.environmentId)}
95 onReconnect={onReconnectEnvironment}
96 onRemove={onRemoveEnvironmentPress}
97 onUpdate={onUpdateEnvironment}
98 />
99 </View>
100 ))}
101 </View>
102 ) : (
103 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
104 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
105 <SymbolView
106 name="point.3.connected.trianglepath.dotted"
107 size={20}
108 tintColor={accentColor}
109 type="monochrome"
110 />
111 </View>
112 <Text className="text-center text-sm leading-normal text-foreground-muted">
113 No environments connected yet.{"\n"}Tap{" "}
114 <Text className="font-t3-bold text-foreground">+</Text> to add one.
115 </Text>
116 </View>
117 )}
118
119 {hasCloudPublicConfig() ? (
120 <ConfiguredCloudEnvironmentRows
121 connectedCloudEnvironments={connectedCloudEnvironments}
122 onReconnectEnvironment={onReconnectEnvironment}
123 />
124 ) : null}
125 </ScrollView>
126 </View>
127 );
128}
129
130function ConfiguredCloudEnvironmentRows(props: {
131 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
132 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
133}) {
134 const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
135 const controller = useConnectionController();
136 const iconColor = useThemeColor("--color-icon");
137 const availableCloudEnvironments = controller.availableRelayEnvironments;
138 const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
139 const hasCloudRows =
140 props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0;
141
142 const handleConnectCloudEnvironment = useCallback(
143 (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment),
144 [controller],
145 );
146
147 const handleDisconnectCloudEnvironment = useCallback(
148 (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId),
149 [controller],
150 );
151
152 const handleToggleCloudError = useCallback((environmentId: string) => {
153 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));
154 }, []);
155
156 if (!isSignedIn) return null;
157
158 return (
159 <View collapsable={false} className="mt-5 gap-3">
160 <View className="flex-row items-center justify-between px-1">
161 <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Cloud</Text>
162 <Pressable
163 accessibilityRole="button"
164 disabled={controller.relayDiscovery.isRefreshing}
165 onPress={() => {
166 void controller.refreshRelayEnvironments();
167 }}
168 className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
169 >
170 {controller.relayDiscovery.isRefreshing ? (
171 <ActivityIndicator color={iconColor} size="small" />
172 ) : (
173 <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" />
174 )}
175 </Pressable>
176 </View>
177
178 {hasCloudRows ? (
179 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
180 {props.connectedCloudEnvironments.map((environment, index) => (
181 <ConnectedCloudEnvironmentRow
182 key={environment.environmentId}
183 environment={environment}
184 borderTop={index !== 0}
185 onConnect={() => props.onReconnectEnvironment(environment.environmentId)}
186 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}
187 errorExpanded={expandedErrorId === environment.environmentId}
188 onToggleError={() => handleToggleCloudError(environment.environmentId)}
189 />
190 ))}
191 {availableCloudEnvironments.map((environment, index) => (
192 <CloudEnvironmentRow
193 key={environment.environment.environmentId}
194 environment={environment}
195 borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0}
196 onConnect={() => handleConnectCloudEnvironment(environment)}
197 errorExpanded={expandedErrorId === environment.environment.environmentId}
198 onToggleError={() => handleToggleCloudError(environment.environment.environmentId)}
199 />
200 ))}
201 </View>
202 ) : controller.relayDiscovery.isRefreshing ? (
203 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">
204 <ActivityIndicator color={iconColor} />
205 <Text className="text-center text-sm leading-normal text-foreground-muted">
206 Loading linked cloud environments.
207 </Text>
208 </View>
209 ) : controller.relayDiscovery.error ? (
210 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">
211 <Text className="text-base font-t3-bold text-foreground">
212 Could not load T3 Cloud environments
213 </Text>
214 <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text>
215 {controller.relayDiscovery.errorTraceId ? (
216 <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} />
217 ) : null}
218 </View>
219 ) : (
220 <View collapsable={false} className="rounded-[24px] bg-card p-5">
221 <Text className="text-sm leading-normal text-foreground-muted">
222 No additional linked cloud environments.
223 </Text>
224 </View>
225 )}
226 </View>
227 );
228}
229
230function ConnectedCloudEnvironmentRow(props: {
231 readonly environment: ConnectedEnvironmentSummary;
232 readonly borderTop: boolean;
233 readonly errorExpanded: boolean;
234 readonly onConnect: () => void;
235 readonly onDisconnect: () => void;
236 readonly onToggleError: () => void;
237}) {
238 return (
239 <CloudEnvironmentRowShell
240 borderTop={props.borderTop}
241 connectionError={props.environment.connectionError}
242 connectionErrorTraceId={props.environment.connectionErrorTraceId}
243 connectionState={props.environment.connectionState}
244 errorExpanded={props.errorExpanded}
245 label={props.environment.environmentLabel}
246 onValueChange={(enabled) => {
247 if (enabled) {
248 props.onConnect();
249 return;
250 }
251 props.onDisconnect();
252 }}
253 onToggleError={props.onToggleError}
254 value={props.environment.connectionState !== "available"}
255 />
256 );
257}
258
259function CloudEnvironmentRow(props: {
260 readonly environment: RelayEnvironmentView;
261 readonly borderTop: boolean;
262 readonly errorExpanded: boolean;
263 readonly onConnect: () => void;
264 readonly onToggleError: () => void;
265}) {
266 const presentation = availableCloudEnvironmentPresentation({
267 isStatusPending: props.environment.availability === "checking",
268 status: props.environment.status,
269 statusError: props.environment.error,
270 statusErrorTraceId: props.environment.traceId,
271 });
272
273 return (
274 <CloudEnvironmentRowShell
275 borderTop={props.borderTop}
276 connectionError={presentation.connectionError}
277 connectionErrorTraceId={presentation.connectionErrorTraceId}
278 connectionState={presentation.connectionState}
279 errorExpanded={props.errorExpanded}
280 label={props.environment.environment.label}
281 onValueChange={(enabled) => {
282 if (enabled) {
283 props.onConnect();
284 }
285 }}
286 onToggleError={props.onToggleError}
287 statusText={presentation.statusText}
288 value={false}
289 />
290 );
291}
292
293function CloudEnvironmentRowShell(props: {
294 readonly borderTop: boolean;
295 readonly connectionError: string | null;
296 readonly connectionErrorTraceId: string | null;
297 readonly connectionState: EnvironmentConnectionPhase;
298 readonly disabled?: boolean;
299 readonly errorExpanded: boolean;
300 readonly label: string;
301 readonly onToggleError: () => void;
302 readonly onValueChange: (enabled: boolean) => void;
303 readonly statusText?: string;
304 readonly value: boolean;
305}) {
306 const activeTrack = String(useThemeColor("--color-switch-active"));
307 const track = String(useThemeColor("--color-secondary-border"));
308 const chevron = useThemeColor("--color-chevron");
309 const isRetrying =
310 props.connectionState === "connecting" || props.connectionState === "reconnecting";
311 const shouldPulse = isRetrying;
312 const statusText =
313 props.statusText ??
314 connectionStatusText({
315 phase: props.connectionState,
316 error: props.connectionError,
317 traceId: props.connectionErrorTraceId,
318 });
319 const statusClassName = props.connectionError
320 ? "text-rose-500 dark:text-rose-400"
321 : "text-foreground-muted";
322 const [errorMeasurement, setErrorMeasurement] = useState<{
323 readonly text: string;
324 readonly lineCount: number;
325 } | null>(null);
326 const errorTraceId = props.connectionErrorTraceId;
327 const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText;
328 const errorLineCount =
329 errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0;
330 const errorCanExpand = props.connectionError !== null && errorLineCount > 1;
331 const isErrorExpanded = errorCanExpand && props.errorExpanded;
332 const StatusContainer = errorCanExpand ? Pressable : View;
333 const onMeasuredErrorTextLayout = useCallback(
334 (event: NativeSyntheticEvent<TextLayoutEventData>) => {
335 if (!props.connectionError) {
336 return;
337 }
338 const nextLineCount = event.nativeEvent.lines.length;
339 setErrorMeasurement((currentMeasurement) =>
340 currentMeasurement?.text === measuredErrorText &&
341 currentMeasurement.lineCount === nextLineCount
342 ? currentMeasurement
343 : { text: measuredErrorText, lineCount: nextLineCount },
344 );
345 },
346 [measuredErrorText, props.connectionError],
347 );
348 return (
349 <View
350 collapsable={false}
351 className={cn(
352 "flex-row items-center gap-3 bg-card px-4 py-3.5",
353 props.borderTop && "border-t border-border",
354 )}
355 >
356 <View className="min-w-0 flex-1 gap-0.5">
357 <View className="min-w-0 flex-row items-center gap-2">
358 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
359 <Text
360 className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"
361 numberOfLines={1}
362 >
363 {props.label}
364 </Text>
365 </View>
366 {props.connectionError ? (
367 <Text
368 aria-hidden
369 className={cn("absolute left-0 right-0 text-xs", statusClassName)}
370 onTextLayout={onMeasuredErrorTextLayout}
371 style={{ opacity: 0, zIndex: -1 }}
372 >
373 {measuredErrorText}
374 </Text>
375 ) : null}
376 <StatusContainer
377 {...(errorCanExpand
378 ? { accessibilityRole: "button" as const, onPress: props.onToggleError }
379 : {})}
380 className="min-w-0 flex-row items-start gap-1"
381 >
382 <Text
383 className={cn("min-w-0 flex-1 text-xs", statusClassName)}
384 numberOfLines={isErrorExpanded ? undefined : 1}
385 >
386 {statusText}
387 {errorTraceId ? (
388 <>
389 {" Trace ID: "}
390 <Text
391 accessibilityHint="Copies the trace ID"
392 accessibilityRole="button"
393 className={cn("text-xs underline", statusClassName)}
394 onLongPress={(event) => {
395 event.stopPropagation();
396 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });
397 }}
398 onPress={(event) => {
399 event.stopPropagation();
400 }}
401 style={{ textDecorationStyle: "dotted" }}
402 >
403 {errorTraceId}
404 </Text>
405 </>
406 ) : null}
407 </Text>
408 {errorCanExpand ? (
409 <SymbolView
410 name="chevron.down"
411 size={10}
412 tintColor={chevron}
413 type="monochrome"
414 style={{
415 marginTop: 3,
416 transform: [{ rotate: isErrorExpanded ? "180deg" : "0deg" }],
417 }}
418 />
419 ) : null}
420 </StatusContainer>
421 </View>
422 <Switch
423 disabled={props.disabled}
424 ios_backgroundColor={track}
425 onValueChange={props.onValueChange}
426 trackColor={{ false: track, true: activeTrack }}
427 value={props.value}
428 />
429 </View>
430 );
431}
432
433function CopyTraceIdButton(props: { readonly traceId: string }) {
434 const iconColor = useThemeColor("--color-icon");
435
436 return (
437 <Pressable
438 accessibilityRole="button"
439 onPress={() => {
440 copyTextWithHaptic(props.traceId, { target: "connection-trace-id" });
441 }}
442 className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70"
443 >
444 <SymbolView name="doc.on.doc" size={12} tintColor={iconColor} type="monochrome" />
445 <Text className="text-xs font-t3-bold text-foreground">Copy trace ID</Text>
446 </Pressable>
447 );
448}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx created+448
...@@ -0,0 +1,448 @@
1import { useAuth, useUser } from "@clerk/expo";
2import * as Notifications from "expo-notifications";
3import { useNavigation } from "@react-navigation/native";
4import { NativeStackScreenOptions } from "../../native/StackHeader";
5import { SymbolView } from "expo-symbols";
6import * as Effect from "effect/Effect";
7import { useCallback, useEffect, useMemo, useState } from "react";
8import { Alert, Linking, Platform, ScrollView, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import {
12 isAtomCommandInterrupted,
13 reportAtomCommandResult,
14 settleAsyncResult,
15 settlePromise,
16 squashAtomCommandFailure,
17} from "@t3tools/client-runtime/state/runtime";
18import { AppText as Text } from "../../components/AppText";
19import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
20import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
21import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration";
22import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
23import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
24import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
25import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
26import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
27import { runtime } from "../../lib/runtime";
28import { loadPreferences } from "../../lib/storage";
29import { useThemeColor } from "../../lib/useThemeColor";
30import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
31import { SettingsRow } from "./components/SettingsRow";
32import { SettingsSection } from "./components/SettingsSection";
33import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
34
35type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
36type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
37
38export function SettingsRouteScreen() {
39 const navigation = useNavigation();
40
41 return (
42 <>
43 <WorkspaceSidebarToolbar />
44 <NativeStackScreenOptions
45 options={{
46 unstable_headerRightItems:
47 Platform.OS === "ios"
48 ? () => [
49 withNativeGlassHeaderItem({
50 accessibilityLabel: "Close settings",
51 icon: { name: "xmark", type: "sfSymbol" } as const,
52 identifier: "settings-close",
53 label: "",
54 onPress: () => navigation.goBack(),
55 type: "button",
56 }),
57 ]
58 : undefined,
59 }}
60 />
61 {hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />}
62 </>
63 );
64}
65
66function LocalSettingsRouteScreen() {
67 const insets = useSafeAreaInsets();
68 const { savedConnectionsById } = useSavedRemoteConnections();
69 const environmentCount = Object.keys(savedConnectionsById).length;
70
71 return (
72 <View collapsable={false} className="flex-1 bg-sheet">
73 <ScrollView
74 contentInsetAdjustmentBehavior="automatic"
75 showsVerticalScrollIndicator={false}
76 style={{ flex: 1 }}
77 contentContainerStyle={{
78 gap: 24,
79 paddingBottom: Math.max(insets.bottom, 18) + 18,
80 paddingHorizontal: 20,
81 paddingTop: 16,
82 }}
83 >
84 <SettingsSection title="Configuration">
85 <SettingsRow
86 icon="desktopcomputer"
87 label="Environments"
88 value={`${environmentCount}`}
89 target="SettingsEnvironments"
90 />
91 </SettingsSection>
92
93 <SettingsSection title="Appearance">
94 <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
95 </SettingsSection>
96
97 <ArchivedThreadsSettingsSection />
98
99 <AppSettingsSection />
100 </ScrollView>
101 </View>
102 );
103}
104
105function ConfiguredSettingsRouteScreen() {
106 const insets = useSafeAreaInsets();
107 const navigation = useNavigation();
108 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
109 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
110 const { user } = useUser();
111 const { savedConnectionsById } = useSavedRemoteConnections();
112 const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking");
113 const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking");
114
115 const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]);
116 const environmentCount = connections.length;
117 const accountLabel = useMemo(() => {
118 if (!isLoaded) return "Checking";
119 if (!isSignedIn) return "Request access";
120 return user?.primaryEmailAddress?.emailAddress ?? "Signed in";
121 }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]);
122
123 const refreshNotifications = useCallback(async () => {
124 if (process.env.EXPO_OS !== "ios") {
125 setNotificationStatus("unsupported");
126 return;
127 }
128 const result = await settlePromise(() => Notifications.getPermissionsAsync());
129 if (result._tag === "Failure") {
130 reportAtomCommandResult(result, { label: "notification permission refresh" });
131 setNotificationStatus("disabled");
132 return;
133 }
134 setNotificationStatus(result.value.granted ? "enabled" : "disabled");
135 }, []);
136
137 useEffect(() => {
138 void refreshNotifications();
139 }, [refreshNotifications]);
140
141 useEffect(() => {
142 if (!isLoaded) {
143 setLiveActivityStatus("checking");
144 return;
145 }
146 if (!isSignedIn) {
147 setLiveActivityStatus("signed-out");
148 return;
149 }
150 void (async () => {
151 const result = await settlePromise(() => loadPreferences());
152 if (result._tag === "Failure") {
153 reportAtomCommandResult(result, { label: "live activity preference load" });
154 setLiveActivityStatus("enabled");
155 return;
156 }
157 setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled");
158 })();
159 }, [isLoaded, isSignedIn]);
160
161 const requestNotifications = useCallback(async () => {
162 const result = await settleAsyncResult(() =>
163 runtime.runPromiseExit(
164 requestAgentNotificationPermission.pipe(
165 Effect.tap((permission) =>
166 permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void,
167 ),
168 ),
169 ),
170 );
171 if (result._tag === "Failure") {
172 if (!isAtomCommandInterrupted(result)) {
173 const error = squashAtomCommandFailure(result);
174 Alert.alert(
175 "Notifications unavailable",
176 error instanceof Error ? error.message : "Could not request notification permission.",
177 );
178 }
179 return;
180 }
181 if (result.value.type === "granted") {
182 setNotificationStatus("enabled");
183 Alert.alert(
184 "Notifications enabled",
185 "Live Activity notifications are enabled for this device.",
186 );
187 return;
188 }
189 if (result.value.type === "unsupported") {
190 setNotificationStatus("unsupported");
191 Alert.alert(
192 "Notifications unavailable",
193 "Live Activity notifications are only available on iOS.",
194 );
195 return;
196 }
197 setNotificationStatus("disabled");
198 if (result.value.canAskAgain) {
199 Alert.alert("Notifications disabled", "Notifications were not enabled.");
200 return;
201 }
202 Alert.alert(
203 "Notifications disabled",
204 "Notifications were denied for this app. Open Settings to enable them.",
205 [
206 { text: "Cancel", style: "cancel" },
207 { text: "Open Settings", onPress: () => void Linking.openSettings() },
208 ],
209 );
210 }, []);
211
212 const promptSignIn = useCallback(() => {
213 Alert.alert(
214 "Request T3 Cloud access",
215 "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",
216 [
217 { text: "Cancel", style: "cancel" },
218 {
219 text: "Continue",
220 onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }),
221 },
222 ],
223 );
224 }, [navigation]);
225
226 const linkEnvironments = useCallback(async () => {
227 if (!isSignedIn) {
228 promptSignIn();
229 return;
230 }
231
232 setLiveActivityStatus("linking");
233 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
234 if (tokenResult._tag === "Failure") {
235 setLiveActivityStatus("disabled");
236 const error = squashAtomCommandFailure(tokenResult);
237 Alert.alert(
238 "Live Activities unavailable",
239 error instanceof Error ? error.message : "Could not enable Live Activity updates.",
240 );
241 return;
242 }
243 if (!tokenResult.value) {
244 promptSignIn();
245 setLiveActivityStatus("signed-out");
246 return;
247 }
248
249 const updateResult = await settleAsyncResult(() =>
250 runtime.runPromiseExit(
251 setLiveActivityUpdatesEnabled({
252 enabled: true,
253 clerkToken: tokenResult.value,
254 connections,
255 }),
256 ),
257 );
258 if (updateResult._tag === "Failure") {
259 setLiveActivityStatus("disabled");
260 if (!isAtomCommandInterrupted(updateResult)) {
261 const error = squashAtomCommandFailure(updateResult);
262 Alert.alert(
263 "Live Activities unavailable",
264 error instanceof Error ? error.message : "Could not enable Live Activity updates.",
265 );
266 }
267 return;
268 }
269
270 refreshManagedRelayEnvironments();
271 setLiveActivityStatus("enabled");
272 Alert.alert(
273 "Live Activities enabled",
274 environmentCount > 0
275 ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.`
276 : "Live Activity updates are enabled. Add an environment to start receiving updates.",
277 );
278 }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]);
279
280 const handleDeviceNotificationsChange = useCallback(
281 (enabled: boolean) => {
282 if (enabled) {
283 void requestNotifications();
284 return;
285 }
286
287 Alert.alert(
288 "Disable notifications",
289 "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.",
290 [
291 { text: "Cancel", style: "cancel" },
292 { text: "Open Settings", onPress: () => void Linking.openSettings() },
293 ],
294 );
295 },
296 [requestNotifications],
297 );
298
299 const handleLiveActivitiesChange = useCallback(
300 (enabled: boolean) => {
301 if (!enabled) {
302 setLiveActivityStatus("disabled");
303 void (async () => {
304 let token: string | null = null;
305 if (isSignedIn) {
306 const tokenResult = await settlePromise(() =>
307 getToken(resolveRelayClerkTokenOptions()),
308 );
309 if (tokenResult._tag === "Failure") {
310 reportAtomCommandResult(tokenResult, {
311 label: "live activity disable token lookup",
312 });
313 return;
314 }
315 token = tokenResult.value;
316 }
317
318 const updateResult = await settleAsyncResult(() =>
319 runtime.runPromiseExit(
320 setLiveActivityUpdatesEnabled({
321 enabled: false,
322 clerkToken: token,
323 connections,
324 }),
325 ),
326 );
327 if (updateResult._tag === "Failure") {
328 reportAtomCommandResult(updateResult, {
329 label: "live activity disable",
330 });
331 return;
332 }
333 refreshManagedRelayEnvironments();
334 })();
335 return;
336 }
337
338 if (!isSignedIn) {
339 promptSignIn();
340 return;
341 }
342
343 void linkEnvironments();
344 },
345 [connections, getToken, isSignedIn, linkEnvironments, promptSignIn],
346 );
347
348 const openAccount = useCallback(() => {
349 if (!isLoaded) return;
350 if (!isSignedIn) {
351 navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" });
352 return;
353 }
354 expandClerkSheet();
355 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
356 }, [expandClerkSheet, isLoaded, isSignedIn, navigation]);
357
358 return (
359 <View collapsable={false} className="flex-1 bg-sheet">
360 <ScrollView
361 contentInsetAdjustmentBehavior="automatic"
362 showsVerticalScrollIndicator={false}
363 style={{ flex: 1 }}
364 contentContainerStyle={{
365 gap: 24,
366 paddingBottom: Math.max(insets.bottom, 18) + 18,
367 paddingHorizontal: 20,
368 paddingTop: 16,
369 }}
370 >
371 <View className="gap-3">
372 <SettingsSection title="Account">
373 <SettingsRow
374 icon="person.crop.circle"
375 label="T3 Account"
376 value={accountLabel}
377 onPress={openAccount}
378 />
379 </SettingsSection>
380 <Text className="px-2 text-sm text-foreground-muted">
381 T3 Code works locally without signing in. Cloud features are optional.
382 </Text>
383 </View>
384
385 <SettingsSection title="Configuration">
386 <SettingsRow
387 icon="desktopcomputer"
388 label="Environments"
389 value={`${environmentCount}`}
390 target="SettingsEnvironments"
391 />
392 <SettingsSwitchRow
393 icon="bell.badge"
394 label="Device Notifications"
395 disabled={notificationStatus === "checking" || notificationStatus === "unsupported"}
396 value={notificationStatus === "enabled"}
397 onValueChange={handleDeviceNotificationsChange}
398 />
399 <SettingsSwitchRow
400 disabled={
401 !isLoaded || liveActivityStatus === "checking" || liveActivityStatus === "linking"
402 }
403 icon="bolt.circle"
404 label="Live Activity Updates"
405 value={liveActivityStatus === "enabled" || liveActivityStatus === "linking"}
406 onValueChange={handleLiveActivitiesChange}
407 />
408 </SettingsSection>
409
410 <SettingsSection title="Appearance">
411 <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
412 </SettingsSection>
413
414 <ArchivedThreadsSettingsSection />
415
416 <AppSettingsSection />
417 </ScrollView>
418 </View>
419 );
420}
421
422function AppSettingsSection() {
423 const icon = useThemeColor("--color-icon");
424
425 return (
426 <SettingsSection title="App">
427 <View className="flex-row items-center gap-4 p-4">
428 <SymbolView
429 name="info.circle"
430 size={22}
431 tintColor={icon}
432 type="monochrome"
433 weight="regular"
434 />
435 <Text className="flex-1 text-lg text-foreground">Version</Text>
436 <Text className="text-lg text-foreground-muted">Alpha</Text>
437 </View>
438 </SettingsSection>
439 );
440}
441
442function ArchivedThreadsSettingsSection() {
443 return (
444 <SettingsSection title="Threads">
445 <SettingsRow icon="archivebox" label="Archived Threads" target="SettingsArchive" />
446 </SettingsSection>
447 );
448}
apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx created+60
...@@ -0,0 +1,60 @@
1import { useAuth } from "@clerk/expo";
2import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
3import { useCallback } from "react";
4import { ScrollView } from "react-native";
5
6import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment";
7import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
8import { hasCloudPublicConfig } from "../cloud/publicConfig";
9
10export function SettingsWaitlistRouteScreen() {
11 const navigation = useNavigation();
12
13 useFocusEffect(
14 useCallback(() => {
15 if (!hasCloudPublicConfig()) {
16 navigation.dispatch(StackActions.replace("Settings"));
17 }
18 }, [navigation]),
19 );
20
21 return hasCloudPublicConfig() ? <ConfiguredSettingsWaitlistRouteScreen /> : null;
22}
23
24function ConfiguredSettingsWaitlistRouteScreen() {
25 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
26 const { expand } = useClerkSettingsSheetDetent();
27 const navigation = useNavigation();
28
29 useFocusEffect(
30 useCallback(() => {
31 if (isLoaded && isSignedIn) {
32 navigation.dispatch(StackActions.replace("Settings"));
33 }
34 }, [isLoaded, isSignedIn, navigation]),
35 );
36
37 return (
38 <>
39 <ScrollView
40 automaticallyAdjustKeyboardInsets
41 contentContainerStyle={{
42 paddingBottom: 32,
43 paddingHorizontal: 20,
44 paddingTop: 12,
45 }}
46 contentInsetAdjustmentBehavior="automatic"
47 keyboardDismissMode="interactive"
48 keyboardShouldPersistTaps="handled"
49 showsVerticalScrollIndicator={false}
50 >
51 <CloudWaitlistEnrollment
52 onSignIn={() => {
53 expand();
54 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
55 }}
56 />
57 </ScrollView>
58 </>
59 );
60}
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx created+158
...@@ -0,0 +1,158 @@
1import {
2 createContext,
3 useCallback,
4 useContext,
5 useEffect,
6 useMemo,
7 useState,
8 type ReactNode,
9} from "react";
10
11import { Uniwind } from "uniwind";
12
13import {
14 resolveAppearance,
15 resolveAppearancePreferences,
16 resolveTextScaleVariables,
17 type AppearancePreferences,
18 type ResolvedAppearance,
19} from "../../../lib/appearancePreferences";
20import { loadPreferences, savePreferencesPatch } from "../../../lib/storage";
21import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
22
23interface AppearancePreferencesContextValue {
24 /** Effective values with base-size derivation applied. Use this for rendering. */
25 readonly appearance: ResolvedAppearance;
26 readonly isReady: boolean;
27 readonly setBaseFontSize: (value: number) => void;
28 /** Pass null to clear the override and follow the base font size. */
29 readonly setTerminalFontSize: (value: number | null) => void;
30 /** Pass null to clear the override and follow the base font size. */
31 readonly setCodeFontSize: (value: number | null) => void;
32 readonly setCodeWordBreak: (value: boolean) => void;
33}
34
35const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null);
36
37/**
38 * Injects the scaled `--text-*` variables into Uniwind so every
39 * className-based text size (`text-sm`, `text-base`, ...) re-resolves live.
40 * Updates the current theme last so the active stylesheet settles correctly.
41 */
42function applyTextScaleVariables(baseFontSize: number) {
43 const variables = resolveTextScaleVariables(baseFontSize);
44 const currentTheme = Uniwind.currentTheme;
45
46 for (const theme of ["light", "dark"] as const) {
47 if (theme !== currentTheme) {
48 Uniwind.updateCSSVariables(theme, variables);
49 }
50 }
51 Uniwind.updateCSSVariables(currentTheme, variables);
52}
53
54export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
55 const [preferences, setPreferences] = useState<AppearancePreferences>(() =>
56 resolveAppearancePreferences(null),
57 );
58 const [isReady, setIsReady] = useState(false);
59
60 useEffect(() => {
61 let cancelled = false;
62
63 void loadPreferences()
64 .then((stored) => {
65 if (cancelled) {
66 return;
67 }
68
69 const resolved = resolveAppearancePreferences(stored);
70 setPreferences(resolved);
71 cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize);
72 setIsReady(true);
73 })
74 .catch(() => {
75 if (cancelled) {
76 return;
77 }
78
79 setIsReady(true);
80 });
81
82 return () => {
83 cancelled = true;
84 };
85 }, []);
86
87 useEffect(() => {
88 applyTextScaleVariables(preferences.baseFontSize);
89 }, [preferences.baseFontSize]);
90
91 const updatePreferences = useCallback((patch: Partial<AppearancePreferences>) => {
92 setPreferences((current) => {
93 const next = resolveAppearancePreferences({ ...current, ...patch });
94 cacheTerminalFontSize(resolveAppearance(next).terminalFontSize);
95 void savePreferencesPatch({
96 baseFontSize: next.baseFontSize,
97 terminalFontSize: next.terminalFontSize,
98 codeFontSize: next.codeFontSize,
99 codeWordBreak: next.codeWordBreak,
100 }).catch(() => undefined);
101 return next;
102 });
103 }, []);
104
105 const setBaseFontSize = useCallback(
106 (value: number) => {
107 updatePreferences({ baseFontSize: value });
108 },
109 [updatePreferences],
110 );
111
112 const setTerminalFontSize = useCallback(
113 (value: number | null) => {
114 updatePreferences({ terminalFontSize: value });
115 },
116 [updatePreferences],
117 );
118
119 const setCodeFontSize = useCallback(
120 (value: number | null) => {
121 updatePreferences({ codeFontSize: value });
122 },
123 [updatePreferences],
124 );
125
126 const setCodeWordBreak = useCallback(
127 (value: boolean) => {
128 updatePreferences({ codeWordBreak: value });
129 },
130 [updatePreferences],
131 );
132
133 const value = useMemo(
134 (): AppearancePreferencesContextValue => ({
135 appearance: resolveAppearance(preferences),
136 isReady,
137 setBaseFontSize,
138 setTerminalFontSize,
139 setCodeFontSize,
140 setCodeWordBreak,
141 }),
142 [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak],
143 );
144
145 return (
146 <AppearancePreferencesContext.Provider value={value}>
147 {props.children}
148 </AppearancePreferencesContext.Provider>
149 );
150}
151
152export function useAppearancePreferences(): AppearancePreferencesContextValue {
153 const context = useContext(AppearancePreferencesContext);
154 if (!context) {
155 throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider");
156 }
157 return context;
158}
apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx created+177
...@@ -0,0 +1,177 @@
1import { Platform, ScrollView, View, useColorScheme } from "react-native";
2
3import { AppText as Text } from "../../../../components/AppText";
4import {
5 resolveMarkdownFontSizes,
6 resolveMobileCodeSurface,
7} from "../../../../lib/appearancePreferences";
8import { useThemeColor } from "../../../../lib/useThemeColor";
9import { getPierreTerminalTheme } from "../../../terminal/terminalTheme";
10
11const CODE_FONT_FAMILY = Platform.select({
12 ios: "ui-monospace",
13 android: "monospace",
14 default: "monospace",
15});
16
17/** Hairline between a section's preview surface and its control rows. */
18export function AppearancePreviewSeparator() {
19 return <View className="h-px bg-separator" />;
20}
21
22/** Live sample of body text rendered at the chosen base font size. */
23export function TextAppearancePreview(props: { readonly fontSize: number }) {
24 const sizes = resolveMarkdownFontSizes(props.fontSize);
25
26 return (
27 <View className="gap-1 p-4">
28 <Text
29 className="text-foreground"
30 style={{ fontSize: sizes.m, lineHeight: sizes.bodyLineHeight }}
31 >
32 The quick brown fox jumps over the lazy dog.
33 </Text>
34 <Text
35 className="text-foreground-muted"
36 style={{ fontSize: sizes.s, lineHeight: Math.round(sizes.s * 1.4) }}
37 >
38 Messages, labels, and headings scale with this size.
39 </Text>
40 </View>
41 );
42}
43
44/**
45 * Live terminal sample using the real terminal theme's text colors and font,
46 * on the shared card background so it reads like the other previews.
47 */
48export function TerminalAppearancePreview(props: { readonly fontSize: number }) {
49 const scheme = useColorScheme() === "light" ? "light" : "dark";
50 const theme = getPierreTerminalTheme(scheme);
51 const lineHeight = Math.round(props.fontSize * 1.6);
52 const lineStyle = {
53 fontFamily: "Menlo",
54 fontSize: props.fontSize,
55 lineHeight,
56 } as const;
57
58 return (
59 <View className="p-4">
60 <Text style={[lineStyle, { color: theme.foreground }]}>$ npm run dev</Text>
61 <Text style={[lineStyle, { color: theme.palette[2] }]}>✓ Ready in 430ms</Text>
62 <Text style={[lineStyle, { color: theme.foreground }]}>
63 Local: http://localhost:3000{" "}
64 <Text style={[lineStyle, { color: theme.cursorForeground }]}>▏</Text>
65 </Text>
66 </View>
67 );
68}
69
70interface CodePreviewToken {
71 readonly text: string;
72 readonly keyword?: boolean;
73}
74
75interface CodePreviewLine {
76 readonly id: string;
77 readonly tokens: ReadonlyArray<CodePreviewToken>;
78}
79
80const CODE_PREVIEW_LINES: ReadonlyArray<CodePreviewLine> = [
81 {
82 id: "signature",
83 tokens: [{ text: "function", keyword: true }, { text: " formatUser(user) {" }],
84 },
85 {
86 id: "body",
87 tokens: [
88 { text: " " },
89 { text: "return", keyword: true },
90 { text: " `${user.name} <${user.email}>` // demonstrates how long lines behave" },
91 ],
92 },
93 { id: "close", tokens: [{ text: "}" }] },
94];
95
96/**
97 * Live code sample matching the code & diff surface metrics. Long lines wrap
98 * when word break is on and scroll horizontally when it is off, mirroring the
99 * real code surface.
100 */
101export function CodeAppearancePreview(props: {
102 readonly fontSize: number;
103 readonly wordBreak: boolean;
104}) {
105 const surface = resolveMobileCodeSurface(props.fontSize);
106 const lineNumberColor = useThemeColor("--color-icon-subtle");
107 const keywordColor = useThemeColor("--color-md-link");
108
109 const lineNumber = (line: CodePreviewLine, index: number) => (
110 <Text
111 className="text-right"
112 key={line.id}
113 style={{
114 color: lineNumberColor,
115 fontFamily: CODE_FONT_FAMILY,
116 fontSize: surface.lineNumberFontSize,
117 lineHeight: surface.rowHeight,
118 width: 22,
119 }}
120 >
121 {index + 1}
122 </Text>
123 );
124
125 const codeLine = (line: CodePreviewLine, wrap: boolean) => (
126 <Text
127 className="text-foreground"
128 key={line.id}
129 numberOfLines={wrap ? undefined : 1}
130 style={{
131 fontFamily: CODE_FONT_FAMILY,
132 fontSize: surface.fontSize,
133 lineHeight: surface.rowHeight,
134 }}
135 >
136 {line.tokens.map((token) => (
137 <Text
138 key={token.text}
139 style={{
140 color: token.keyword ? keywordColor : undefined,
141 fontFamily: CODE_FONT_FAMILY,
142 fontSize: surface.fontSize,
143 lineHeight: surface.rowHeight,
144 }}
145 >
146 {token.text}
147 </Text>
148 ))}
149 </Text>
150 );
151
152 if (props.wordBreak) {
153 return (
154 <View className="p-4">
155 {CODE_PREVIEW_LINES.map((line, index) => (
156 <View className="flex-row" key={line.id}>
157 {lineNumber(line, index)}
158 <View className="flex-1 pl-3">{codeLine(line, true)}</View>
159 </View>
160 ))}
161 </View>
162 );
163 }
164
165 return (
166 <View className="flex-row p-4">
167 <View>{CODE_PREVIEW_LINES.map((line, index) => lineNumber(line, index))}</View>
168 <ScrollView
169 horizontal
170 contentContainerStyle={{ paddingLeft: 12 }}
171 showsHorizontalScrollIndicator={false}
172 >
173 <View>{CODE_PREVIEW_LINES.map((line) => codeLine(line, false))}</View>
174 </ScrollView>
175 </View>
176 );
177}
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx created+214
...@@ -0,0 +1,214 @@
1import * as Haptics from "expo-haptics";
2import { SymbolView } from "expo-symbols";
3import { useCallback, useEffect, useMemo, useRef } from "react";
4import { View, type AccessibilityActionEvent } from "react-native";
5import { Gesture, GestureDetector } from "react-native-gesture-handler";
6import Animated, {
7 runOnJS,
8 useAnimatedStyle,
9 useSharedValue,
10 withTiming,
11} from "react-native-reanimated";
12import type { ComponentProps } from "react";
13
14import { AppText as Text } from "../../../../components/AppText";
15import { useThemeColor } from "../../../../lib/useThemeColor";
16
17type SymbolName = ComponentProps<typeof SymbolView>["name"];
18
19const THUMB_SIZE = 26;
20const TRACK_HEIGHT = 4;
21const SNAP_ANIMATION = { duration: 120 } as const;
22
23function clampFraction(value: number): number {
24 "worklet";
25 return Math.min(1, Math.max(0, value));
26}
27
28export function FontSizeSliderRow(props: {
29 readonly disabled?: boolean;
30 readonly icon: SymbolName;
31 readonly label: string;
32 readonly valueLabel: string;
33 readonly min: number;
34 readonly max: number;
35 readonly step: number;
36 readonly value: number;
37 readonly onChange: (value: number) => void;
38}) {
39 const icon = useThemeColor("--color-icon");
40 const iconMuted = String(useThemeColor("--color-icon-muted"));
41 const trackColor = String(useThemeColor("--color-secondary-border"));
42 const fillColor = String(useThemeColor("--color-primary"));
43
44 const latest = useRef(props);
45 latest.current = props;
46
47 const { min, max, step, value, disabled } = props;
48 const fraction = (value - min) / (max - min);
49
50 const progress = useSharedValue(clampFraction(fraction));
51 const trackWidth = useSharedValue(0);
52 const dragging = useSharedValue(false);
53
54 useEffect(() => {
55 if (!dragging.value) {
56 progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION);
57 }
58 }, [dragging, fraction, progress]);
59
60 const commit = useCallback((next: number) => {
61 if (next === latest.current.value) {
62 return;
63 }
64 Haptics.selectionAsync().catch(() => undefined);
65 latest.current.onChange(next);
66 }, []);
67
68 const gesture = useMemo(() => {
69 const snapValue = (raw: number): number => {
70 "worklet";
71 const stepped = Math.round((raw - min) / step) * step + min;
72 return Math.min(max, Math.max(min, stepped));
73 };
74 const fractionAt = (x: number): number => {
75 "worklet";
76 const usable = trackWidth.value - THUMB_SIZE;
77 if (usable <= 0) {
78 return 0;
79 }
80 return clampFraction((x - THUMB_SIZE / 2) / usable);
81 };
82 const valueAtFraction = (f: number): number => {
83 "worklet";
84 return snapValue(min + f * (max - min));
85 };
86 const fractionOfValue = (v: number): number => {
87 "worklet";
88 return clampFraction((v - min) / (max - min));
89 };
90
91 const pan = Gesture.Pan()
92 .enabled(!disabled)
93 .activeOffsetX([-8, 8])
94 .failOffsetY([-12, 12])
95 .onUpdate((event) => {
96 dragging.value = true;
97 const f = fractionAt(event.x);
98 progress.value = f;
99 runOnJS(commit)(valueAtFraction(f));
100 })
101 .onFinalize(() => {
102 if (!dragging.value) {
103 return;
104 }
105 dragging.value = false;
106 progress.value = withTiming(
107 fractionOfValue(valueAtFraction(progress.value)),
108 SNAP_ANIMATION,
109 );
110 });
111
112 const tap = Gesture.Tap()
113 .enabled(!disabled)
114 .onEnd((event) => {
115 const next = valueAtFraction(fractionAt(event.x));
116 progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION);
117 runOnJS(commit)(next);
118 });
119
120 return Gesture.Race(pan, tap);
121 }, [commit, disabled, dragging, max, min, progress, step, trackWidth]);
122
123 const fillStyle = useAnimatedStyle(() => ({
124 width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE),
125 }));
126 const thumbStyle = useAnimatedStyle(() => ({
127 transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }],
128 }));
129
130 const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
131 if (event.nativeEvent.actionName === "increment") {
132 commit(Math.min(max, value + step));
133 } else if (event.nativeEvent.actionName === "decrement") {
134 commit(Math.max(min, value - step));
135 }
136 };
137
138 return (
139 <View className="gap-1 p-4" style={{ opacity: disabled ? 0.45 : 1 }}>
140 <View className="flex-row items-center gap-4">
141 <SymbolView
142 name={props.icon}
143 size={22}
144 tintColor={icon}
145 type="monochrome"
146 weight="regular"
147 />
148 <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
149 <Text className="text-base font-t3-medium text-foreground-muted">{props.valueLabel}</Text>
150 </View>
151 <View className="flex-row items-center gap-3">
152 <SymbolView
153 name="textformat.size.smaller"
154 size={15}
155 tintColor={iconMuted}
156 type="monochrome"
157 weight="regular"
158 />
159 <GestureDetector gesture={gesture}>
160 <View
161 accessible
162 accessibilityActions={[
163 { name: "increment", label: `Increase ${props.label}` },
164 { name: "decrement", label: `Decrease ${props.label}` },
165 ]}
166 accessibilityLabel={props.label}
167 accessibilityRole="adjustable"
168 accessibilityValue={{ min, max, now: value, text: props.valueLabel }}
169 className="h-11 flex-1 justify-center"
170 onAccessibilityAction={handleAccessibilityAction}
171 onLayout={(event) => {
172 trackWidth.value = event.nativeEvent.layout.width;
173 }}
174 >
175 <View
176 className="w-full rounded-full"
177 style={{ backgroundColor: trackColor, height: TRACK_HEIGHT }}
178 >
179 <Animated.View
180 className="absolute inset-y-0 left-0 rounded-full"
181 style={[{ backgroundColor: fillColor }, fillStyle]}
182 />
183 </View>
184 <Animated.View
185 className="absolute left-0 rounded-full bg-white"
186 style={[
187 {
188 borderColor: "rgba(0, 0, 0, 0.06)",
189 borderWidth: 1,
190 height: THUMB_SIZE,
191 marginTop: -THUMB_SIZE / 2,
192 shadowColor: "#000000",
193 shadowOffset: { height: 2, width: 0 },
194 shadowOpacity: 0.18,
195 shadowRadius: 3,
196 top: "50%",
197 width: THUMB_SIZE,
198 },
199 thumbStyle,
200 ]}
201 />
202 </View>
203 </GestureDetector>
204 <SymbolView
205 name="textformat.size.larger"
206 size={22}
207 tintColor={iconMuted}
208 type="monochrome"
209 weight="regular"
210 />
211 </View>
212 </View>
213 );
214}
apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx created+64
...@@ -0,0 +1,64 @@
1import { useCallback } from "react";
2
3import {
4 CODE_FONT_SIZE_STEP,
5 MAX_CODE_FONT_SIZE,
6 MIN_CODE_FONT_SIZE,
7} from "../../../../lib/appearancePreferences";
8import { SettingsSection } from "../../components/SettingsSection";
9import { SettingsSwitchRow } from "../../components/SettingsSwitchRow";
10import { useAppearancePreferences } from "../AppearancePreferencesProvider";
11import {
12 AppearancePreviewSeparator,
13 CodeAppearancePreview,
14} from "../components/AppearancePreviews";
15import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
16
17export function CodeAppearanceSection() {
18 const { isReady, appearance, setCodeFontSize, setCodeWordBreak } = useAppearancePreferences();
19 const custom = appearance.isCodeFontSizeCustom;
20
21 const handleToggleCustom = useCallback(
22 (enabled: boolean) => {
23 setCodeFontSize(enabled ? appearance.codeFontSize : null);
24 },
25 [appearance.codeFontSize, setCodeFontSize],
26 );
27
28 return (
29 <SettingsSection title="Code & Diffs">
30 <CodeAppearancePreview
31 fontSize={appearance.codeFontSize}
32 wordBreak={appearance.codeWordBreak}
33 />
34 <AppearancePreviewSeparator />
35 <SettingsSwitchRow
36 disabled={!isReady}
37 icon="chevron.left.forwardslash.chevron.right"
38 label="Custom font size"
39 onValueChange={handleToggleCustom}
40 value={custom}
41 />
42 {custom ? (
43 <FontSizeSliderRow
44 disabled={!isReady}
45 icon="textformat.size"
46 label="Font size"
47 max={MAX_CODE_FONT_SIZE}
48 min={MIN_CODE_FONT_SIZE}
49 onChange={setCodeFontSize}
50 step={CODE_FONT_SIZE_STEP}
51 value={appearance.codeFontSize}
52 valueLabel={`${appearance.codeFontSize} pt`}
53 />
54 ) : null}
55 <SettingsSwitchRow
56 disabled={!isReady}
57 icon="text.word.spacing"
58 label="Word break"
59 onValueChange={setCodeWordBreak}
60 value={appearance.codeWordBreak}
61 />
62 </SettingsSection>
63 );
64}
apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx created+54
...@@ -0,0 +1,54 @@
1import { useCallback } from "react";
2
3import {
4 MAX_TERMINAL_FONT_SIZE,
5 MIN_TERMINAL_FONT_SIZE,
6 TERMINAL_FONT_SIZE_STEP,
7} from "../../../../lib/appearancePreferences";
8import { SettingsSection } from "../../components/SettingsSection";
9import { SettingsSwitchRow } from "../../components/SettingsSwitchRow";
10import { useAppearancePreferences } from "../AppearancePreferencesProvider";
11import {
12 AppearancePreviewSeparator,
13 TerminalAppearancePreview,
14} from "../components/AppearancePreviews";
15import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
16
17export function TerminalAppearanceSection() {
18 const { isReady, appearance, setTerminalFontSize } = useAppearancePreferences();
19 const custom = appearance.isTerminalFontSizeCustom;
20
21 const handleToggleCustom = useCallback(
22 (enabled: boolean) => {
23 setTerminalFontSize(enabled ? appearance.terminalFontSize : null);
24 },
25 [appearance.terminalFontSize, setTerminalFontSize],
26 );
27
28 return (
29 <SettingsSection title="Terminal">
30 <TerminalAppearancePreview fontSize={appearance.terminalFontSize} />
31 <AppearancePreviewSeparator />
32 <SettingsSwitchRow
33 disabled={!isReady}
34 icon="terminal"
35 label="Custom font size"
36 onValueChange={handleToggleCustom}
37 value={custom}
38 />
39 {custom ? (
40 <FontSizeSliderRow
41 disabled={!isReady}
42 icon="textformat.size"
43 label="Font size"
44 max={MAX_TERMINAL_FONT_SIZE}
45 min={MIN_TERMINAL_FONT_SIZE}
46 onChange={setTerminalFontSize}
47 step={TERMINAL_FONT_SIZE_STEP}
48 value={appearance.terminalFontSize}
49 valueLabel={`${appearance.terminalFontSize.toFixed(1)} pt`}
50 />
51 ) : null}
52 </SettingsSection>
53 );
54}
apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx created+34
...@@ -0,0 +1,34 @@
1import {
2 BASE_FONT_SIZE_STEP,
3 MAX_BASE_FONT_SIZE,
4 MIN_BASE_FONT_SIZE,
5} from "../../../../lib/appearancePreferences";
6import { SettingsSection } from "../../components/SettingsSection";
7import { useAppearancePreferences } from "../AppearancePreferencesProvider";
8import {
9 AppearancePreviewSeparator,
10 TextAppearancePreview,
11} from "../components/AppearancePreviews";
12import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
13
14export function TextAppearanceSection() {
15 const { isReady, appearance, setBaseFontSize } = useAppearancePreferences();
16
17 return (
18 <SettingsSection title="Text">
19 <TextAppearancePreview fontSize={appearance.baseFontSize} />
20 <AppearancePreviewSeparator />
21 <FontSizeSliderRow
22 disabled={!isReady}
23 icon="textformat.size"
24 label="Text size"
25 max={MAX_BASE_FONT_SIZE}
26 min={MIN_BASE_FONT_SIZE}
27 onChange={setBaseFontSize}
28 step={BASE_FONT_SIZE_STEP}
29 value={appearance.baseFontSize}
30 valueLabel={`${appearance.baseFontSize} pt`}
31 />
32 </SettingsSection>
33 );
34}
apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts created+34
...@@ -0,0 +1,34 @@
1import { useMemo } from "react";
2
3import {
4 resolveMobileCodeSurface,
5 type ResolvedMobileCodeSurface,
6} from "../../../lib/appearancePreferences";
7import { createNativeReviewDiffStyle } from "../../review/nativeReviewDiffAdapter";
8import { createNativeSourceStyle } from "../../files/nativeSourceFileAdapter";
9import { useAppearancePreferences } from "./AppearancePreferencesProvider";
10
11export function useAppearanceCodeSurface(): {
12 readonly codeSurface: ResolvedMobileCodeSurface;
13 readonly codeWordBreak: boolean;
14 readonly nativeSourceStyle: ReturnType<typeof createNativeSourceStyle>;
15 readonly nativeReviewDiffStyle: ReturnType<typeof createNativeReviewDiffStyle>;
16} {
17 const { appearance } = useAppearancePreferences();
18 const codeSurface = useMemo(
19 () => resolveMobileCodeSurface(appearance.codeFontSize),
20 [appearance.codeFontSize],
21 );
22 const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]);
23 const nativeReviewDiffStyle = useMemo(
24 () => createNativeReviewDiffStyle(codeSurface),
25 [codeSurface],
26 );
27
28 return {
29 codeSurface,
30 codeWordBreak: appearance.codeWordBreak,
31 nativeSourceStyle,
32 nativeReviewDiffStyle,
33 };
34}
apps/mobile/src/features/settings/appearance/useScaledTextRole.ts created+36
...@@ -0,0 +1,36 @@
1import { useCSSVariable } from "uniwind";
2
3import { MOBILE_TYPOGRAPHY } from "../../../lib/typography";
4
5const TEXT_ROLE_VARIABLES = {
6 micro: "--text-3xs",
7 caption: "--text-2xs",
8 label: "--text-xs",
9 footnote: "--text-sm",
10 body: "--text-base",
11 headline: "--text-lg",
12 title: "--text-xl",
13 largeTitle: "--text-2xl",
14 display: "--text-3xl",
15} as const satisfies Record<keyof typeof MOBILE_TYPOGRAPHY, string>;
16
17export interface ScaledTextRole {
18 readonly fontSize: number;
19 readonly lineHeight: number;
20}
21
22/**
23 * Reads a typography role's current size from the Uniwind `--text-*` CSS
24 * variables (scaled at runtime with the base font size). Use for style-prop
25 * consumers that can't express their size as a `text-*` className. Reactive:
26 * re-renders when the appearance provider re-injects the variables.
27 */
28export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole {
29 const variable = TEXT_ROLE_VARIABLES[role];
30 const [fontSize, lineHeight] = useCSSVariable([variable, `${variable}--line-height`]);
31
32 return {
33 fontSize: typeof fontSize === "number" ? fontSize : MOBILE_TYPOGRAPHY[role].fontSize,
34 lineHeight: typeof lineHeight === "number" ? lineHeight : MOBILE_TYPOGRAPHY[role].lineHeight,
35 };
36}
apps/mobile/src/features/settings/components/SettingsRow.tsx created+76
...@@ -0,0 +1,76 @@
1import { useNavigation } from "@react-navigation/native";
2import { SymbolView } from "expo-symbols";
3import type { ComponentProps } from "react";
4import { Pressable, View } from "react-native";
5
6import { AppText as Text } from "../../../components/AppText";
7import { useThemeColor } from "../../../lib/useThemeColor";
8import type { SettingsSheetTarget } from "./settings-sheet-targets";
9
10type SymbolName = ComponentProps<typeof SymbolView>["name"];
11
12export function SettingsRow(props: {
13 readonly disabled?: boolean;
14 readonly icon: SymbolName;
15 readonly label: string;
16 readonly value?: string;
17 readonly target?: SettingsSheetTarget;
18 readonly onPress?: () => void;
19}) {
20 const navigation = useNavigation();
21 const icon = useThemeColor("--color-icon");
22 const chevron = useThemeColor("--color-chevron");
23 const content = (
24 <View
25 className="flex-row items-center gap-4 p-4"
26 style={{ opacity: props.disabled ? 0.45 : 1 }}
27 >
28 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
29 <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
30 {props.label}
31 </Text>
32 <View className="min-w-0 flex-1 items-end">
33 {props.value ? (
34 <Text
35 className="max-w-[180px] text-right text-base text-foreground-muted"
36 ellipsizeMode="middle"
37 numberOfLines={1}
38 >
39 {props.value}
40 </Text>
41 ) : null}
42 </View>
43 <SymbolView
44 name="chevron.right"
45 size={16}
46 tintColor={chevron}
47 type="monochrome"
48 weight="semibold"
49 />
50 </View>
51 );
52
53 const target = props.target;
54 if (target) {
55 return (
56 <Pressable
57 accessibilityLabel={props.label}
58 accessibilityRole="button"
59 disabled={props.disabled}
60 onPress={() =>
61 navigation.navigate("SettingsSheet", {
62 screen: target,
63 })
64 }
65 >
66 {content}
67 </Pressable>
68 );
69 }
70
71 return (
72 <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}>
73 {content}
74 </Pressable>
75 );
76}
apps/mobile/src/features/settings/components/SettingsSection.tsx created+18
...@@ -0,0 +1,18 @@
1import type { ReactNode } from "react";
2import { View } from "react-native";
3
4import { AppText as Text } from "../../../components/AppText";
5
6export function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {
7 return (
8 <View className="gap-2">
9 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>
10 <View
11 className="overflow-hidden rounded-[28px] bg-card"
12 style={{ borderCurve: "continuous" }}
13 >
14 {props.children}
15 </View>
16 </View>
17 );
18}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx created+37
...@@ -0,0 +1,37 @@
1import { SymbolView } from "expo-symbols";
2import type { ComponentProps } from "react";
3import { Switch, View } from "react-native";
4
5import { AppText as Text } from "../../../components/AppText";
6import { useThemeColor } from "../../../lib/useThemeColor";
7
8type SymbolName = ComponentProps<typeof SymbolView>["name"];
9
10export function SettingsSwitchRow(props: {
11 readonly disabled?: boolean;
12 readonly icon: SymbolName;
13 readonly label: string;
14 readonly value: boolean;
15 readonly onValueChange: (value: boolean) => void;
16}) {
17 const icon = useThemeColor("--color-icon");
18 const activeTrack = String(useThemeColor("--color-switch-active"));
19 const track = String(useThemeColor("--color-secondary-border"));
20
21 return (
22 <View
23 className="flex-row items-center gap-4 p-4"
24 style={{ opacity: props.disabled ? 0.45 : 1 }}
25 >
26 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
27 <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
28 <Switch
29 disabled={props.disabled}
30 ios_backgroundColor={track}
31 onValueChange={props.onValueChange}
32 trackColor={{ false: track, true: activeTrack }}
33 value={props.value}
34 />
35 </View>
36 );
37}
apps/mobile/src/features/settings/components/settings-sheet-targets.ts created+1
...@@ -0,0 +1 @@
1export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance";
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+17-44
...@@ -12,7 +12,10 @@ import {...@@ -12,7 +12,10 @@ import {
1212
13import { AppText as Text } from "../../components/AppText";13import { AppText as Text } from "../../components/AppText";
14import { MOBILE_TYPOGRAPHY } from "../../lib/typography";14import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
15import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";15import {
16 getNativeTerminalHardwareKeyRevision,
17 resolveNativeTerminalSurfaceView,
18} from "./nativeTerminalModule";
16import {19import {
17 buildGhosttyThemeConfig,20 buildGhosttyThemeConfig,
18 getPierreTerminalTheme,21 getPierreTerminalTheme,
...@@ -92,9 +95,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter...@@ -92,9 +95,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
92 >95 >
93 <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}>96 <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}>
94 <Text97 <Text
98 className="text-2xs"
95 style={{99 style={{
96 color: theme.mutedForeground,100 color: theme.mutedForeground,
97 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
98 paddingBottom: 8,101 paddingBottom: 8,
99 }}102 }}
100 >103 >
...@@ -137,11 +140,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter...@@ -137,11 +140,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
137 placeholder="type and press return"140 placeholder="type and press return"
138 placeholderTextColor={theme.mutedForeground}141 placeholderTextColor={theme.mutedForeground}
139 returnKeyType="send"142 returnKeyType="send"
143 className="text-sm"
140 style={{144 style={{
141 color: theme.foreground,145 color: theme.foreground,
142 flex: 1,146 flex: 1,
143 fontFamily: "Menlo",147 fontFamily: "Menlo",
144 fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize,
145 padding: 0,148 padding: 0,
146 }}149 }}
147 onSubmitEditing={(event) => {150 onSubmitEditing={(event) => {
...@@ -163,10 +166,10 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter...@@ -163,10 +166,10 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
163 onPress={() => props.onInput("\u0003")}166 onPress={() => props.onInput("\u0003")}
164 >167 >
165 <Text168 <Text
169 className="text-2xs"
166 style={{170 style={{
167 color: theme.foreground,171 color: theme.foreground,
168 fontFamily: "DMSans_700Bold",172 fontFamily: "DMSans_700Bold",
169 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
170 }}173 }}
171 >174 >
172 Ctrl-C175 Ctrl-C
...@@ -179,7 +182,6 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter...@@ -179,7 +182,6 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
179182
180export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {183export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
181 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;184 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
182 const keyboardInputRef = useRef<TextInput>(null);
183 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";185 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
184 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);186 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
185 const { onInput, onResize } = props;187 const { onInput, onResize } = props;
...@@ -190,15 +192,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf...@@ -190,15 +192,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
190 terminalDebugLog("native:surface", {192 terminalDebugLog("native:surface", {
191 terminalKey: props.terminalKey,193 terminalKey: props.terminalKey,
192 native: hasNativeSurface,194 native: hasNativeSurface,
195 // null = installed binary predates native hardware-key handling (rebuild needed).
196 hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(),
193 bufferLen: props.buffer.length,197 bufferLen: props.buffer.length,
194 isRunning: props.isRunning,198 isRunning: props.isRunning,
195 });199 });
196 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);200 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
197 const handleNativeInput = useCallback(201 const handleNativeInput = useCallback(
198 (event: NativeSyntheticEvent<TerminalInputEvent>) => {202 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
203 if (!props.isRunning) {
204 return;
205 }
206 terminalDebugLog("native:onInput", {
207 codes: Array.from(event.nativeEvent.data, (char) => char.codePointAt(0)),
208 });
199 onInput(event.nativeEvent.data);209 onInput(event.nativeEvent.data);
200 },210 },
201 [onInput],211 [onInput, props.isRunning],
202 );212 );
203 const handleNativeResize = useCallback(213 const handleNativeResize = useCallback(
204 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {214 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {
...@@ -210,33 +220,13 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf...@@ -210,33 +220,13 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
210 [onResize],220 [onResize],
211 );221 );
212222
213 // Reopen focus through React and forward input normally; this avoids a native focus-command bridge.
214 useEffect(() => {
215 if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) {
216 return undefined;
217 }
218
219 keyboardInputRef.current?.blur();
220 const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus());
221 return () => cancelAnimationFrame(focusFrame);
222 }, [NativeTerminalSurfaceView, props.keyboardFocusRequest]);
223
224 const handleKeyboardInput = useCallback(
225 (data: string) => {
226 if (data.length > 0) {
227 onInput(data);
228 keyboardInputRef.current?.clear();
229 }
230 },
231 [onInput],
232 );
233
234 if (NativeTerminalSurfaceView) {223 if (NativeTerminalSurfaceView) {
235 return (224 return (
236 <View style={props.style}>225 <View style={props.style}>
237 <NativeTerminalSurfaceView226 <NativeTerminalSurfaceView
238 appearanceScheme={appearanceScheme}227 appearanceScheme={appearanceScheme}
239 backgroundColor={theme.background}228 backgroundColor={theme.background}
229 focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}
240 foregroundColor={theme.foreground}230 foregroundColor={theme.foreground}
241 mutedForegroundColor={theme.mutedForeground}231 mutedForegroundColor={theme.mutedForeground}
242 terminalKey={props.terminalKey}232 terminalKey={props.terminalKey}
...@@ -247,23 +237,6 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf...@@ -247,23 +237,6 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
247 onInput={handleNativeInput}237 onInput={handleNativeInput}
248 onResize={handleNativeResize}238 onResize={handleNativeResize}
249 />239 />
250 <TextInput
251 ref={keyboardInputRef}
252 autoCapitalize="none"
253 autoCorrect={false}
254 blurOnSubmit={false}
255 caretHidden
256 editable={props.isRunning}
257 keyboardType="ascii-capable"
258 style={{ bottom: 0, height: 1, left: 0, opacity: 0.01, position: "absolute", width: 1 }}
259 onChangeText={handleKeyboardInput}
260 onKeyPress={(event) => {
261 if (event.nativeEvent.key === "Backspace") {
262 onInput("\u007f");
263 }
264 }}
265 onSubmitEditing={() => onInput("\n")}
266 />
267 </View>240 </View>
268 );241 );
269 }242 }
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+98-165
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
4import { Stack, useLocalSearchParams, useRouter } from "expo-router";4import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
5import { useCallback, useEffect, useMemo, useRef, useState } from "react";6import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6import { Pressable, Text as RNText, View, useColorScheme } from "react-native";7import { Platform, Pressable, View, useColorScheme } from "react-native";
7import {8import {
8 KeyboardController,9 KeyboardController,
9 KeyboardEvents,10 KeyboardEvents,
...@@ -24,8 +25,13 @@ import { useEnvironmentPresentation } from "../../state/presentation";...@@ -24,8 +25,13 @@ import { useEnvironmentPresentation } from "../../state/presentation";
24import { terminalEnvironment } from "../../state/terminal";25import { terminalEnvironment } from "../../state/terminal";
25import { useAtomCommand } from "../../state/use-atom-command";26import { useAtomCommand } from "../../state/use-atom-command";
26import { useWorkspaceState } from "../../state/workspace";27import { useWorkspaceState } from "../../state/workspace";
27import { buildThreadTerminalNavigation } from "../../lib/routes";28import {
28import { MOBILE_TYPOGRAPHY } from "../../lib/typography";29 MAX_TERMINAL_FONT_SIZE,
30 MIN_TERMINAL_FONT_SIZE,
31 TERMINAL_FONT_SIZE_STEP,
32 stepTerminalFontSize,
33} from "../../lib/appearancePreferences";
34import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
29import {35import {
30 useAttachedTerminalSession,36 useAttachedTerminalSession,
31 useKnownTerminalSessions,37 useKnownTerminalSessions,
...@@ -33,9 +39,9 @@ import {...@@ -33,9 +39,9 @@ import {
33import { useThreadSelection } from "../../state/use-thread-selection";39import { useThreadSelection } from "../../state/use-thread-selection";
34import { useSelectedThreadDetail } from "../../state/use-thread-detail";40import { useSelectedThreadDetail } from "../../state/use-thread-detail";
35import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";41import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
42import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
36import { TerminalSurface } from "./NativeTerminalSurface";43import { TerminalSurface } from "./NativeTerminalSurface";
37import { getPierreTerminalTheme } from "./terminalTheme";44import { getPierreTerminalTheme } from "./terminalTheme";
38import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
39import { terminalDebugLog } from "./terminalDebugLog";45import { terminalDebugLog } from "./terminalDebugLog";
40import {46import {
41 getTerminalBufferReplayKey,47 getTerminalBufferReplayKey,
...@@ -55,19 +61,7 @@ import {...@@ -55,19 +61,7 @@ import {
55 resolveTerminalSessionLabel,61 resolveTerminalSessionLabel,
56 type TerminalMenuSession,62 type TerminalMenuSession,
57} from "./terminalMenu";63} from "./terminalMenu";
58import {64import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
59 DEFAULT_TERMINAL_FONT_SIZE,
60 MAX_TERMINAL_FONT_SIZE,
61 MIN_TERMINAL_FONT_SIZE,
62 TERMINAL_FONT_SIZE_STEP,
63 normalizeTerminalFontSize,
64} from "./terminalPreferences";
65import {
66 cacheTerminalFontSize,
67 cacheTerminalGridSize,
68 getCachedTerminalFontSize,
69 getCachedTerminalGridSize,
70} from "./terminalUiState";
7165
72const DEFAULT_TERMINAL_COLS = 80;66const DEFAULT_TERMINAL_COLS = 80;
73const DEFAULT_TERMINAL_ROWS = 24;67const DEFAULT_TERMINAL_ROWS = 24;
...@@ -153,19 +147,22 @@ function pickRunningTerminalSessionForBootstrap(...@@ -153,19 +147,22 @@ function pickRunningTerminalSessionForBootstrap(
153 );147 );
154}148}
155149
156export function ThreadTerminalRouteScreen() {150type ThreadTerminalRouteScreenProps = StaticScreenProps<{
157 const router = useRouter();151 readonly environmentId: string;
152 readonly threadId: string;
153 readonly terminalId?: string;
154}>;
155
156export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
157 const navigation = useNavigation();
158 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");158 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
159 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");159 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
160 const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");160 const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");
161 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");161 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
162 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";162 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
163 const { state: workspaceState } = useWorkspaceState();163 const { state: workspaceState } = useWorkspaceState();
164 const params = useLocalSearchParams<{164 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
165 environmentId?: string | string[];165 const params = props.route.params;
166 threadId?: string | string[];
167 terminalId?: string | string[];
168 }>();
169 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =166 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
170 useThreadSelection();167 useThreadSelection();
171 const selectedThreadDetail = useSelectedThreadDetail();168 const selectedThreadDetail = useSelectedThreadDetail();
...@@ -179,7 +176,12 @@ export function ThreadTerminalRouteScreen() {...@@ -179,7 +176,12 @@ export function ThreadTerminalRouteScreen() {
179 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";176 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
180 const requestedTerminalId = firstRouteParam(params.terminalId);177 const requestedTerminalId = firstRouteParam(params.terminalId);
181 const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;178 const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;
182 const cachedFontSize = getCachedTerminalFontSize();179 const {
180 isReady: hasResolvedFontPreference,
181 appearance,
182 setTerminalFontSize,
183 } = useAppearancePreferences();
184 const fontSize = appearance.terminalFontSize;
183 const cachedRouteGridSize =185 const cachedRouteGridSize =
184 routeEnvironmentId && routeThreadId186 routeEnvironmentId && routeThreadId
185 ? getCachedTerminalGridSize({187 ? getCachedTerminalGridSize({
...@@ -239,7 +241,6 @@ export function ThreadTerminalRouteScreen() {...@@ -239,7 +241,6 @@ export function ThreadTerminalRouteScreen() {
239 rows: DEFAULT_TERMINAL_ROWS,241 rows: DEFAULT_TERMINAL_ROWS,
240 },242 },
241 );243 );
242 const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
243 const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);244 const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);
244 const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);245 const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);
245 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);246 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
...@@ -247,9 +248,6 @@ export function ThreadTerminalRouteScreen() {...@@ -247,9 +248,6 @@ export function ThreadTerminalRouteScreen() {
247 const lastBufferReplayKeyRef = useRef<string | null>(null);248 const lastBufferReplayKeyRef = useRef<string | null>(null);
248 const sentInitialInputKeyRef = useRef<string | null>(null);249 const sentInitialInputKeyRef = useRef<string | null>(null);
249 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);250 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);
250 const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(
251 cachedFontSize !== null,
252 );
253 /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */251 /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */
254 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true);252 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true);
255 const [pendingModifierState, setPendingModifierState] = useState<{253 const [pendingModifierState, setPendingModifierState] = useState<{
...@@ -404,24 +402,10 @@ export function ThreadTerminalRouteScreen() {...@@ -404,24 +402,10 @@ export function ThreadTerminalRouteScreen() {
404 );402 );
405403
406 const terminalTheme = getPierreTerminalTheme(appearanceScheme);404 const terminalTheme = getPierreTerminalTheme(appearanceScheme);
405 const usesNativeHeaderGlass = Platform.OS === "ios";
407 const pendingModifier =406 const pendingModifier =
408 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;407 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
409 const headerTitle = useMemo(() => {408 const headerSubtitle = selectedThreadProject?.title ?? "";
410 const topLineParts = [
411 selectedEnvironmentConnection?.environmentLabel ?? null,
412 selectedThreadProject?.title ?? null,
413 ].filter((value): value is string => Boolean(value));
414
415 return {
416 topLine: topLineParts.join(" \u00b7 "),
417 bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "",
418 };
419 }, [
420 cwd,
421 selectedEnvironmentConnection?.environmentLabel,
422 selectedThreadProject?.title,
423 selectedThreadProject?.workspaceRoot,
424 ]);
425 const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => {409 const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => {
426 const modifierActions: ReadonlyArray<TerminalToolbarAction> =410 const modifierActions: ReadonlyArray<TerminalToolbarAction> =
427 hostPlatform === "mac"411 hostPlatform === "mac"
...@@ -546,8 +530,14 @@ export function ThreadTerminalRouteScreen() {...@@ -546,8 +530,14 @@ export function ThreadTerminalRouteScreen() {
546 if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) {530 if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) {
547 return;531 return;
548 }532 }
549 router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId));533 navigation.dispatch(
550 }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]);534 StackActions.replace("ThreadTerminal", {
535 environmentId: String(selectedThread.environmentId),
536 threadId: String(selectedThread.id),
537 terminalId: runningSession.target.terminalId,
538 }),
539 );
540 }, [navigation, runningSession, selectedThread, shouldRedirectToRunningTerminal]);
551541
552 useEffect(() => {542 useEffect(() => {
553 const initialInput = pendingLaunch?.initialInput;543 const initialInput = pendingLaunch?.initialInput;
...@@ -637,42 +627,6 @@ export function ThreadTerminalRouteScreen() {...@@ -637,42 +627,6 @@ export function ThreadTerminalRouteScreen() {
637 setHasMeasuredSurface(true);627 setHasMeasuredSurface(true);
638 }, [routeEnvironmentId, routeThreadId, terminalId]);628 }, [routeEnvironmentId, routeThreadId, terminalId]);
639629
640 useEffect(() => {
641 let cancelled = false;
642
643 void loadPreferences()
644 .then((preferences) => {
645 if (cancelled) {
646 return;
647 }
648
649 setFontSize(cacheTerminalFontSize(preferences.terminalFontSize));
650 setHasResolvedFontPreference(true);
651 })
652 .catch(() => {
653 if (cancelled) {
654 return;
655 }
656
657 setHasResolvedFontPreference(true);
658 });
659
660 return () => {
661 cancelled = true;
662 };
663 }, []);
664
665 useEffect(() => {
666 if (!hasResolvedFontPreference) {
667 return;
668 }
669
670 cacheTerminalFontSize(fontSize);
671 void savePreferencesPatch({
672 terminalFontSize: normalizeTerminalFontSize(fontSize),
673 });
674 }, [fontSize, hasResolvedFontPreference]);
675
676 const writeInput = useCallback(630 const writeInput = useCallback(
677 (data: string) => {631 (data: string) => {
678 if (!selectedThread || !isRunning) {632 if (!selectedThread || !isRunning) {
...@@ -772,9 +726,15 @@ export function ThreadTerminalRouteScreen() {...@@ -772,9 +726,15 @@ export function ThreadTerminalRouteScreen() {
772 return;726 return;
773 }727 }
774728
775 router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId));729 navigation.dispatch(
730 StackActions.replace("ThreadTerminal", {
731 environmentId: String(selectedThread.environmentId),
732 threadId: String(selectedThread.id),
733 terminalId: nextTerminalId,
734 }),
735 );
776 },736 },
777 [router, selectedThread, terminalId],737 [navigation, selectedThread, terminalId],
778 );738 );
779739
780 const handleOpenNewTerminal = useCallback(() => {740 const handleOpenNewTerminal = useCallback(() => {
...@@ -782,30 +742,25 @@ export function ThreadTerminalRouteScreen() {...@@ -782,30 +742,25 @@ export function ThreadTerminalRouteScreen() {
782 return;742 return;
783 }743 }
784744
785 router.replace(745 navigation.dispatch(
786 buildThreadTerminalNavigation(746 StackActions.replace("ThreadTerminal", {
787 selectedThread,747 environmentId: String(selectedThread.environmentId),
788 nextOpenTerminalId({748 threadId: String(selectedThread.id),
749 terminalId: nextOpenTerminalId({
789 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),750 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
790 activeRouteTerminalId: terminalId,751 activeRouteTerminalId: terminalId,
791 }),752 }),
792 ),753 }),
793 );754 );
794 }, [router, selectedThread, terminalId, terminalMenuSessions]);755 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);
795
796 const adjustFontSize = useCallback((delta: number) => {
797 setTimeout(() => {
798 setFontSize((current) => cacheTerminalFontSize(current + delta));
799 }, 0);
800 }, []);
801756
802 const handleDecreaseFontSize = useCallback(() => {757 const handleDecreaseFontSize = useCallback(() => {
803 adjustFontSize(-TERMINAL_FONT_SIZE_STEP);758 setTerminalFontSize(stepTerminalFontSize(fontSize, -1));
804 }, [adjustFontSize]);759 }, [fontSize, setTerminalFontSize]);
805760
806 const handleIncreaseFontSize = useCallback(() => {761 const handleIncreaseFontSize = useCallback(() => {
807 adjustFontSize(TERMINAL_FONT_SIZE_STEP);762 setTerminalFontSize(stepTerminalFontSize(fontSize, 1));
808 }, [adjustFontSize]);763 }, [fontSize, setTerminalFontSize]);
809764
810 const handleClearTerminal = useCallback(() => {765 const handleClearTerminal = useCallback(() => {
811 if (!selectedThread) {766 if (!selectedThread) {
...@@ -898,80 +853,58 @@ export function ThreadTerminalRouteScreen() {...@@ -898,80 +853,58 @@ export function ThreadTerminalRouteScreen() {
898853
899 return (854 return (
900 <>855 <>
901 <Stack.Screen856 <NativeStackScreenOptions
902 options={{857 options={{
903 headerShown: true,858 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty
904 headerBackButtonDisplayMode: "minimal",859 // scrolls internally, nothing for glass to sample). Default title/subtitle
905 headerBackTitle: "",860 // styling, like every other page.
906 headerShadowVisible: false,861 title: "Terminal",
907 headerStyle: { backgroundColor: terminalTheme.background },862 unstable_headerSubtitle:
908 headerTintColor: terminalTheme.foreground,863 usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined,
909 headerTitleAlign: "center",
910 title: "",
911 headerTitle: () => (
912 <View
913 style={{
914 alignItems: "center",
915 gap: 1,
916 maxWidth: 240,
917 }}
918 >
919 <RNText
920 numberOfLines={1}
921 style={{
922 color: terminalTheme.foreground,
923 fontFamily: "DMSans_700Bold",
924 fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize,
925 lineHeight: 16,
926 }}
927 >
928 {headerTitle.topLine}
929 </RNText>
930 <RNText
931 ellipsizeMode="middle"
932 numberOfLines={1}
933 style={{
934 color: terminalTheme.mutedForeground,
935 fontFamily: "Menlo",
936 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
937 lineHeight: 14,
938 }}
939 >
940 {headerTitle.bottomLine}
941 </RNText>
942 </View>
943 ),
944 }}864 }}
945 />865 />
946866
867 {layout.usesSplitView ? (
868 <NativeHeaderToolbar placement="left">
869 <NativeHeaderToolbar.Button
870 accessibilityLabel={panes.primarySidebarVisible ? "Maximize terminal" : "Show threads"}
871 icon={
872 panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
873 }
874 onPress={togglePrimarySidebar}
875 separateBackground
876 />
877 </NativeHeaderToolbar>
878 ) : null}
879
947 {isEnvironmentReady ? (880 {isEnvironmentReady ? (
948 <Stack.Toolbar placement="right">881 <NativeHeaderToolbar placement="right">
949 <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground>882 <NativeHeaderToolbar.Menu icon="terminal" title="Terminal options" separateBackground>
950 <Stack.Toolbar.Label>883 <NativeHeaderToolbar.Label>
951 {getTerminalStatusLabel({884 {getTerminalStatusLabel({
952 status: terminal.status,885 status: terminal.status,
953 hasRunningSubprocess: terminal.hasRunningSubprocess,886 hasRunningSubprocess: terminal.hasRunningSubprocess,
954 })}887 })}
955 </Stack.Toolbar.Label>888 </NativeHeaderToolbar.Label>
956 <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size">889 <NativeHeaderToolbar.Menu icon="textformat.size" inline title="Text size">
957 <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label>890 <NativeHeaderToolbar.Label>Text size</NativeHeaderToolbar.Label>
958 <Stack.Toolbar.MenuAction891 <NativeHeaderToolbar.MenuAction
959 disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}892 disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
960 discoverabilityLabel="Decrease terminal text size"893 discoverabilityLabel="Decrease terminal text size"
961 onPress={handleDecreaseFontSize}894 onPress={handleDecreaseFontSize}
962 >895 >
963 <Stack.Toolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>896 <NativeHeaderToolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label>
964 </Stack.Toolbar.MenuAction>897 </NativeHeaderToolbar.MenuAction>
965 <Stack.Toolbar.MenuAction898 <NativeHeaderToolbar.MenuAction
966 disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}899 disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
967 discoverabilityLabel="Increase terminal text size"900 discoverabilityLabel="Increase terminal text size"
968 onPress={handleIncreaseFontSize}901 onPress={handleIncreaseFontSize}
969 >902 >
970 <Stack.Toolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label>903 <NativeHeaderToolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label>
971 </Stack.Toolbar.MenuAction>904 </NativeHeaderToolbar.MenuAction>
972 </Stack.Toolbar.Menu>905 </NativeHeaderToolbar.Menu>
973 {terminalMenuSessions.map((session) => (906 {terminalMenuSessions.map((session) => (
974 <Stack.Toolbar.MenuAction907 <NativeHeaderToolbar.MenuAction
975 key={session.terminalId}908 key={session.terminalId}
976 icon={session.terminalId === terminalId ? "checkmark" : "terminal"}909 icon={session.terminalId === terminalId ? "checkmark" : "terminal"}
977 onPress={() => handleSelectTerminal(session.terminalId)}910 onPress={() => handleSelectTerminal(session.terminalId)}
...@@ -982,18 +915,18 @@ export function ThreadTerminalRouteScreen() {...@@ -982,18 +915,18 @@ export function ThreadTerminalRouteScreen() {
982 .filter(Boolean)915 .filter(Boolean)
983 .join(" · ")}916 .join(" · ")}
984 >917 >
985 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>918 <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label>
986 </Stack.Toolbar.MenuAction>919 </NativeHeaderToolbar.MenuAction>
987 ))}920 ))}
988 <Stack.Toolbar.MenuAction921 <NativeHeaderToolbar.MenuAction
989 icon="plus"922 icon="plus"
990 onPress={handleOpenNewTerminal}923 onPress={handleOpenNewTerminal}
991 subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`}924 subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`}
992 >925 >
993 <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>926 <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label>
994 </Stack.Toolbar.MenuAction>927 </NativeHeaderToolbar.MenuAction>
995 </Stack.Toolbar.Menu>928 </NativeHeaderToolbar.Menu>
996 </Stack.Toolbar>929 </NativeHeaderToolbar>
997 ) : null}930 ) : null}
998931
999 <View style={{ flex: 1, backgroundColor: terminalTheme.background }}>932 <View style={{ flex: 1, backgroundColor: terminalTheme.background }}>
apps/mobile/src/features/terminal/nativeTerminalModule.ts+21-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { ComponentType } from "react";1import type { ComponentType } from "react";
2import type { NativeSyntheticEvent, ViewProps } from "react-native";2import type { NativeSyntheticEvent, ViewProps } from "react-native";
3import { requireNativeView } from "expo";3import { requireNativeView, requireOptionalNativeModule } from "expo";
44
5import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";5import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
66
...@@ -23,6 +23,7 @@ interface TerminalResizeEvent {...@@ -23,6 +23,7 @@ interface TerminalResizeEvent {
2323
24export interface NativeTerminalSurfaceProps extends ViewProps {24export interface NativeTerminalSurfaceProps extends ViewProps {
25 readonly appearanceScheme?: "light" | "dark";25 readonly appearanceScheme?: "light" | "dark";
26 readonly focusRequest?: number;
26 readonly themeConfig?: string;27 readonly themeConfig?: string;
27 readonly backgroundColor?: string;28 readonly backgroundColor?: string;
28 readonly foregroundColor?: string;29 readonly foregroundColor?: string;
...@@ -74,6 +75,25 @@ export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminal...@@ -74,6 +75,25 @@ export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminal
74 return cachedNativeTerminalSurfaceView ?? null;75 return cachedNativeTerminalSurfaceView ?? null;
75}76}
7677
78/**
79 * Revision of the native hardware-keyboard handling compiled into the installed binary,
80 * or `null` when the binary predates the revision constant (or the module is missing).
81 * Used in terminal debug logs to detect stale native builds.
82 */
83export function getNativeTerminalHardwareKeyRevision(): number | null {
84 try {
85 if (typeof requireOptionalNativeModule !== "function") {
86 return null;
87 }
88 const module = requireOptionalNativeModule<{ readonly hardwareKeyRevision?: number }>(
89 NATIVE_TERMINAL_MODULE_NAME,
90 );
91 return module?.hardwareKeyRevision ?? null;
92 } catch {
93 return null;
94 }
95}
96
77export function hasNativeTerminalSurface() {97export function hasNativeTerminalSurface() {
78 return resolveNativeTerminalSurfaceView() !== null;98 return resolveNativeTerminalSurfaceView() !== null;
79}99}
apps/mobile/src/features/terminal/terminalPreferences.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1export const DEFAULT_TERMINAL_FONT_SIZE = 10;1export const DEFAULT_TERMINAL_FONT_SIZE = 10.5;
2export const TERMINAL_FONT_SIZE_STEP = 0.5;2export const TERMINAL_FONT_SIZE_STEP = 0.5;
3export const MIN_TERMINAL_FONT_SIZE = 6;3export const MIN_TERMINAL_FONT_SIZE = 6;
4export const MAX_TERMINAL_FONT_SIZE = 14;4export const MAX_TERMINAL_FONT_SIZE = 14;
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+1-3
...@@ -7,8 +7,6 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea...@@ -7,8 +7,6 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea
77
8import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
9import { PierreEntryIcon } from "../../components/PierreEntryIcon";9import { PierreEntryIcon } from "../../components/PierreEntryIcon";
10import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
11
12export type ComposerCommandItem =10export type ComposerCommandItem =
13 | {11 | {
14 readonly id: string;12 readonly id: string;
...@@ -165,11 +163,11 @@ const CommandRow = memo(function CommandRow(props: {...@@ -165,11 +163,11 @@ const CommandRow = memo(function CommandRow(props: {
165 </Text>163 </Text>
166 {props.item.description ? (164 {props.item.description ? (
167 <Text165 <Text
166 className="text-xs"
168 numberOfLines={1}167 numberOfLines={1}
169 style={{168 style={{
170 flex: 1,169 flex: 1,
171 minWidth: 0,170 minWidth: 0,
172 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
173 color: "#a1a1aa",171 color: "#a1a1aa",
174 }}172 }}
175 >173 >
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx created+32
...@@ -0,0 +1,32 @@
1import type { StaticScreenProps } from "@react-navigation/native";
2import { NativeStackScreenOptions } from "../../native/StackHeader";
3
4import { NewTaskDraftScreen } from "./NewTaskDraftScreen";
5
6type NewTaskDraftRouteParams = {
7 readonly environmentId?: string | string[];
8 readonly projectId?: string | string[];
9 readonly title?: string | string[];
10};
11
12export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {
13 const params = route.params ?? {};
14
15 return (
16 <>
17 <NativeStackScreenOptions
18 options={{
19 title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"),
20 }}
21 />
22 <NewTaskDraftScreen
23 initialProjectRef={{
24 environmentId: Array.isArray(params.environmentId)
25 ? params.environmentId[0]
26 : params.environmentId,
27 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
28 }}
29 />
30 </>
31 );
32}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+16-10
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Stack, useRouter } from "expo-router";1import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation } from "@react-navigation/native";
2import { useCallback, useEffect, useMemo, useRef } from "react";3import { useCallback, useEffect, useMemo, useRef } from "react";
3import { Alert, InteractionManager, View, useColorScheme } from "react-native";4import { Alert, InteractionManager, View, useColorScheme } from "react-native";
4import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";5import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
...@@ -29,9 +30,8 @@ import {...@@ -29,9 +30,8 @@ import {
29 providerOptionsConfigurationLabel,30 providerOptionsConfigurationLabel,
30 resolveProviderOptionDescriptors,31 resolveProviderOptionDescriptors,
31} from "../../lib/providerOptions";32} from "../../lib/providerOptions";
32import { buildThreadRoutePath } from "../../lib/routes";
33import { scopedProjectKey } from "../../lib/scopedEntities";33import { scopedProjectKey } from "../../lib/scopedEntities";
34import { MOBILE_TYPOGRAPHY } from "../../lib/typography";34import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
36import { useProjects } from "../../state/entities";36import { useProjects } from "../../state/entities";
37import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";37import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
...@@ -58,7 +58,7 @@ export function NewTaskDraftScreen(props: {...@@ -58,7 +58,7 @@ export function NewTaskDraftScreen(props: {
58 const projects = useProjects();58 const projects = useProjects();
59 const createProjectThread = useCreateProjectThread();59 const createProjectThread = useCreateProjectThread();
60 const flow = useNewTaskFlow();60 const flow = useNewTaskFlow();
61 const router = useRouter();61 const navigation = useNavigation();
62 const insets = useSafeAreaInsets();62 const insets = useSafeAreaInsets();
63 const colorScheme = useColorScheme();63 const colorScheme = useColorScheme();
64 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);64 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
...@@ -68,6 +68,7 @@ export function NewTaskDraftScreen(props: {...@@ -68,6 +68,7 @@ export function NewTaskDraftScreen(props: {
68 const loadedBranchesProjectKeyRef = useRef<string | null>(null);68 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
6969
70 const borderColor = useThemeColor("--color-border");70 const borderColor = useThemeColor("--color-border");
71 const bodyText = useScaledTextRole("body");
71 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";72 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
72 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";73 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
7374
...@@ -101,13 +102,13 @@ export function NewTaskDraftScreen(props: {...@@ -101,13 +102,13 @@ export function NewTaskDraftScreen(props: {
101 return;102 return;
102 }103 }
103104
104 router.replace("/new");105 navigation.dispatch(StackActions.replace("NewTask"));
105 }, [106 }, [
106 logicalProjects,107 logicalProjects,
107 projects,108 projects,
108 props.initialProjectRef?.environmentId,109 props.initialProjectRef?.environmentId,
109 props.initialProjectRef?.projectId,110 props.initialProjectRef?.projectId,
110 router,111 navigation,
111 selectedProject,112 selectedProject,
112 setProject,113 setProject,
113 ]);114 ]);
...@@ -429,20 +430,25 @@ export function NewTaskDraftScreen(props: {...@@ -429,20 +430,25 @@ export function NewTaskDraftScreen(props: {
429430
430 flow.setPrompt("");431 flow.setPrompt("");
431 flow.clearAttachments();432 flow.clearAttachments();
432 router.replace(buildThreadRoutePath(result.value));433 navigation.dispatch(
434 StackActions.replace("Thread", {
435 environmentId: String(result.value.environmentId),
436 threadId: String(result.value.threadId),
437 }),
438 );
433 }439 }
434440
435 if (!selectedProject) {441 if (!selectedProject) {
436 return (442 return (
437 <View className="flex-1 bg-sheet">443 <View className="flex-1 bg-sheet">
438 <Stack.Screen options={{ title: "Loading task" }} />444 <NativeStackScreenOptions options={{ title: "Loading task" }} />
439 </View>445 </View>
440 );446 );
441 }447 }
442448
443 return (449 return (
444 <View className="flex-1 bg-sheet">450 <View className="flex-1 bg-sheet">
445 <Stack.Screen options={{ title: selectedProject.title }} />451 <NativeStackScreenOptions options={{ title: selectedProject.title }} />
446452
447 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>453 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>
448 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>454 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>
...@@ -457,7 +463,7 @@ export function NewTaskDraftScreen(props: {...@@ -457,7 +463,7 @@ export function NewTaskDraftScreen(props: {
457 onPasteImages={(uris) => void handleNativePasteImages(uris)}463 onPasteImages={(uris) => void handleNativePasteImages(uris)}
458 placeholder={`Describe a coding task in ${selectedProject.title}`}464 placeholder={`Describe a coding task in ${selectedProject.title}`}
459 style={{ flex: 1, minHeight: 0 }}465 style={{ flex: 1, minHeight: 0 }}
460 textStyle={MOBILE_TYPOGRAPHY.composer}466 textStyle={bodyText}
461 />467 />
462 </View>468 </View>
463469
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx created+227
...@@ -0,0 +1,227 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";
3import { SymbolView } from "expo-symbols";
4import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
5import { useMemo } from "react";
6import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useThemeColor } from "../../lib/useThemeColor";
9
10import { AppText as Text } from "../../components/AppText";
11import { ProjectFavicon } from "../../components/ProjectFavicon";
12import { useProjects, useThreadShells } from "../../state/entities";
13import type { WorkspaceState } from "../../state/workspaceModel";
14import { useWorkspaceState } from "../../state/workspace";
15import { groupProjectsByRepository } from "../../lib/repositoryGroups";
16import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
17
18function deriveProjectEmptyState(catalogState: WorkspaceState): {
19 readonly title: string;
20 readonly detail: string;
21 readonly loading: boolean;
22} {
23 if (catalogState.isLoadingConnections) {
24 return {
25 title: "Loading environments",
26 detail: "Checking saved environments on this device.",
27 loading: true,
28 };
29 }
30
31 if (!catalogState.hasConnections) {
32 return {
33 title: "No environments connected",
34 detail: "Add an environment before creating a task.",
35 loading: false,
36 };
37 }
38
39 if (
40 (catalogState.connectionState === "available" ||
41 catalogState.connectionState === "offline" ||
42 catalogState.connectionState === "error") &&
43 !catalogState.hasLoadedShellSnapshot
44 ) {
45 return {
46 title: "Environment unavailable",
47 detail:
48 catalogState.connectionError ??
49 "The saved environment is offline. Check the URL or start the environment, then retry.",
50 loading: false,
51 };
52 }
53
54 if (
55 catalogState.hasConnectingEnvironment &&
56 !catalogState.hasLoadedShellSnapshot &&
57 catalogState.connectionError === null
58 ) {
59 return {
60 title: "Connecting to environment",
61 detail: "Loading projects from the saved environment.",
62 loading: true,
63 };
64 }
65
66 return {
67 title: "No projects found",
68 detail: "The connected environment did not report any projects.",
69 loading: false,
70 };
71}
72
73export function NewTaskRouteScreen() {
74 const projects = useProjects();
75 const threads = useThreadShells();
76 const { state: catalogState } = useWorkspaceState();
77 const navigation = useNavigation();
78 const { layout } = useAdaptiveWorkspaceLayout();
79 const insets = useSafeAreaInsets();
80 const chevronColor = useThemeColor("--color-chevron");
81 const accentColor = useThemeColor("--color-icon-muted");
82 const borderSubtleColor = useThemeColor("--color-border-subtle");
83 const repositoryGroups = useMemo(
84 () => groupProjectsByRepository({ projects, threads }),
85 [projects, threads],
86 );
87 const items = useMemo(() => {
88 const nextItems: Array<{
89 readonly environmentId: EnvironmentId;
90 readonly id: ProjectId;
91 readonly key: string;
92 readonly title: string;
93 readonly workspaceRoot: string;
94 }> = [];
95 for (const group of repositoryGroups) {
96 const project = group.projects[0]?.project;
97 if (!project) {
98 continue;
99 }
100 nextItems.push({
101 environmentId: project.environmentId,
102 id: project.id,
103 key: group.key,
104 title: project.title,
105 workspaceRoot: project.workspaceRoot,
106 });
107 }
108 return nextItems;
109 }, [repositoryGroups]);
110 const projectEmptyState = deriveProjectEmptyState(catalogState);
111
112 return (
113 <View collapsable={false} className="flex-1 bg-sheet">
114 <NativeHeaderToolbar placement="right">
115 {layout.usesSplitView ? (
116 <NativeHeaderToolbar.Button
117 accessibilityLabel="Close new task"
118 icon="xmark"
119 onPress={() => navigation.goBack()}
120 separateBackground
121 />
122 ) : null}
123 <NativeHeaderToolbar.Button
124 icon="plus"
125 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
126 separateBackground
127 />
128 </NativeHeaderToolbar>
129
130 <ScrollView
131 contentInsetAdjustmentBehavior="automatic"
132 showsVerticalScrollIndicator={false}
133 style={{ flex: 1 }}
134 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
135 contentContainerStyle={{
136 paddingHorizontal: 20,
137 paddingTop: 8,
138 }}
139 >
140 {items.length === 0 ? (
141 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
142 {projectEmptyState.loading ? <ActivityIndicator color={accentColor} /> : null}
143 <Text className="text-center text-lg font-t3-bold text-foreground">
144 {projectEmptyState.title}
145 </Text>
146 <Text className="text-center text-sm leading-normal text-foreground-muted">
147 {projectEmptyState.detail}
148 </Text>
149 {!catalogState.hasReadyEnvironment ? (
150 <Pressable
151 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
152 onPress={() => navigation.navigate("ConnectionsNew")}
153 >
154 <Text className="text-sm font-t3-bold text-primary-foreground">
155 Add environment
156 </Text>
157 </Pressable>
158 ) : (
159 <Pressable
160 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
161 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
162 >
163 <Text className="text-sm font-t3-bold text-primary-foreground">
164 Add new project
165 </Text>
166 </Pressable>
167 )}
168 </View>
169 ) : (
170 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
171 {items.map((item, index) => {
172 const isFirst = index === 0;
173 const isLast = index === items.length - 1;
174
175 return (
176 <Pressable
177 key={item.key}
178 className="bg-card"
179 onPress={() =>
180 navigation.navigate("NewTaskSheet", {
181 screen: "NewTaskDraft",
182 params: {
183 environmentId: item.environmentId,
184 projectId: item.id,
185 title: item.title,
186 },
187 })
188 }
189 style={{
190 paddingHorizontal: 16,
191 paddingVertical: 14,
192 borderTopWidth: isFirst ? 0 : 1,
193 borderTopColor: borderSubtleColor,
194 borderTopLeftRadius: isFirst ? 24 : 0,
195 borderTopRightRadius: isFirst ? 24 : 0,
196 borderBottomLeftRadius: isLast ? 24 : 0,
197 borderBottomRightRadius: isLast ? 24 : 0,
198 }}
199 >
200 <View className="flex-row items-center justify-between gap-3">
201 <View className="h-7 w-7 items-center justify-center">
202 <ProjectFavicon
203 environmentId={item.environmentId}
204 size={20}
205 projectTitle={item.title}
206 workspaceRoot={item.workspaceRoot}
207 />
208 </View>
209 <View className="flex-1">
210 <Text className="text-base leading-snug font-t3-bold">{item.title}</Text>
211 </View>
212 <SymbolView
213 name="chevron.right"
214 size={14}
215 tintColor={chevronColor}
216 type="monochrome"
217 />
218 </View>
219 </Pressable>
220 );
221 })}
222 </View>
223 )}
224 </ScrollView>
225 </View>
226 );
227}
apps/mobile/src/features/threads/PendingApprovalCard.tsx+1-1
...@@ -23,7 +23,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {...@@ -23,7 +23,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
23 {props.approval.requestKind}23 {props.approval.requestKind}
24 </Text>24 </Text>
25 {props.approval.detail ? (25 {props.approval.detail ? (
26 <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400">26 <Text className="font-sans text-sm leading-normal text-neutral-600 dark:text-neutral-400">
27 {props.approval.detail}27 {props.approval.detail}
28 </Text>28 </Text>
29 ) : null}29 ) : null}
apps/mobile/src/features/threads/PendingUserInputCard.tsx+1-1
...@@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
39 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">39 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">
40 {question.header}40 {question.header}
41 </Text>41 </Text>
42 <Text className="font-sans text-base leading-[21px] text-neutral-950 dark:text-neutral-50">42 <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50">
43 {question.question}43 {question.question}
44 </Text>44 </Text>
45 <View className="flex-row flex-wrap gap-2.5">45 <View className="flex-row flex-wrap gap-2.5">
apps/mobile/src/features/threads/ThreadComposer.tsx+51-17
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26} from "react-native";26} from "react-native";
27import ImageViewing from "react-native-image-viewing";27import ImageViewing from "react-native-image-viewing";
28import { useThemeColor } from "../../lib/useThemeColor";28import { useThemeColor } from "../../lib/useThemeColor";
29import { scopedThreadKey } from "../../lib/scopedEntities";
2930
30import { AppText as Text } from "../../components/AppText";31import { AppText as Text } from "../../components/AppText";
31import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";32import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
...@@ -44,7 +45,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";...@@ -44,7 +45,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
44import { ProviderIcon } from "../../components/ProviderIcon";45import { ProviderIcon } from "../../components/ProviderIcon";
45import type { DraftComposerImageAttachment } from "../../lib/composerImages";46import type { DraftComposerImageAttachment } from "../../lib/composerImages";
46import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";47import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
47import { MOBILE_TYPOGRAPHY } from "../../lib/typography";48import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
48import type { RemoteClientConnectionState } from "../../lib/connection";49import type { RemoteClientConnectionState } from "../../lib/connection";
49import {50import {
50 insertRankedSearchResult,51 insertRankedSearchResult,
...@@ -76,10 +77,17 @@ export interface ThreadComposerProps {...@@ -76,10 +77,17 @@ export interface ThreadComposerProps {
76 readonly draftMessage: string;77 readonly draftMessage: string;
77 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;78 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
78 readonly placeholder: string;79 readonly placeholder: string;
80 readonly contentMaxWidth?: number;
79 readonly bottomInset?: number;81 readonly bottomInset?: number;
80 readonly connectionState: RemoteClientConnectionState;82 readonly connectionState: RemoteClientConnectionState;
81 readonly connectionError: string | null;83 readonly connectionError: string | null;
82 readonly environmentLabel: string | null;84 readonly environmentLabel: string | null;
85 /**
86 * Message sync phase for the selected thread (drives the status pill):
87 * "loading" = first fetch, nothing to show yet; "syncing" = cached messages
88 * are on screen while they reconcile with the server.
89 */
90 readonly threadSyncPhase?: "loading" | "syncing" | null;
83 readonly selectedThread: OrchestrationThreadShell;91 readonly selectedThread: OrchestrationThreadShell;
84 readonly serverConfig: T3ServerConfig | null;92 readonly serverConfig: T3ServerConfig | null;
85 readonly queueCount: number;93 readonly queueCount: number;
...@@ -139,11 +147,17 @@ function ComposerSurface(props: {...@@ -139,11 +147,17 @@ function ComposerSurface(props: {
139 );147 );
140}148}
141149
150type ComposerStatusPillState = {
151 readonly kind: "unavailable" | "reconnecting" | "syncing";
152 readonly label: string;
153};
154
142function composerConnectionStatus(input: {155function composerConnectionStatus(input: {
143 readonly connectionError: string | null;156 readonly connectionError: string | null;
144 readonly connectionState: RemoteClientConnectionState;157 readonly connectionState: RemoteClientConnectionState;
145 readonly environmentLabel: string | null;158 readonly environmentLabel: string | null;
146}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null {159 readonly threadSyncPhase?: "loading" | "syncing" | null;
160}): ComposerStatusPillState | null {
147 const environmentLabel = input.environmentLabel ?? "Environment";161 const environmentLabel = input.environmentLabel ?? "Environment";
148162
149 switch (input.connectionState) {163 switch (input.connectionState) {
...@@ -168,15 +182,27 @@ function composerConnectionStatus(input: {...@@ -168,15 +182,27 @@ function composerConnectionStatus(input: {
168 case "available":182 case "available":
169 return { kind: "unavailable", label: `${environmentLabel} is not connected` };183 return { kind: "unavailable", label: `${environmentLabel} is not connected` };
170 case "connected":184 case "connected":
185 break;
186 }
187
188 // Connected: the pill is the single loading/sync indicator. One stable
189 // label per open — "Loading" when starting from scratch, "Syncing" when
190 // cached messages are already visible.
191 switch (input.threadSyncPhase) {
192 case "loading":
193 return { kind: "syncing", label: "Loading messages..." };
194 case "syncing":
195 return { kind: "syncing", label: "Syncing messages..." };
196 default:
171 return null;197 return null;
172 }198 }
173}199}
174200
175const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: {201const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: {
176 readonly onPress: () => void;202 readonly onPress: () => void;
177 readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string };203 readonly status: ComposerStatusPillState;
178}) {204}) {
179 const isReconnecting = props.status.kind === "reconnecting";205 const isReconnecting = props.status.kind !== "unavailable";
180206
181 return (207 return (
182 <View className="items-center pb-2">208 <View className="items-center pb-2">
...@@ -191,7 +217,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(...@@ -191,7 +217,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
191 <View className="h-2 w-2 rounded-full bg-red-500" />217 <View className="h-2 w-2 rounded-full bg-red-500" />
192 )}218 )}
193 <Text219 <Text
194 className="max-w-[260px] text-sm font-t3-bold leading-[17px] text-foreground"220 className="max-w-[260px] text-sm font-t3-bold leading-snug text-foreground"
195 numberOfLines={1}221 numberOfLines={1}
196 >222 >
197 {props.status.label}223 {props.status.label}
...@@ -204,10 +230,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(...@@ -204,10 +230,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
204export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {230export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
205 const isDarkMode = useColorScheme() === "dark";231 const isDarkMode = useColorScheme() === "dark";
206 const foregroundColor = useThemeColor("--color-foreground");232 const foregroundColor = useThemeColor("--color-foreground");
233 const bodyText = useScaledTextRole("body");
207 const fallbackInputRef = useRef<ComposerEditorHandle>(null);234 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
208 const inputRef = props.editorRef ?? fallbackInputRef;235 const inputRef = props.editorRef ?? fallbackInputRef;
209 const [isFocused, setIsFocused] = useState(false);236 const [isFocused, setIsFocused] = useState(false);
210 const wasExpandedBeforePreviewRef = useRef(false);237 const wasExpandedBeforePreviewRef = useRef(false);
238 const inFlightThreadIdsRef = useRef(new Set<string>());
211 const { onExpandedChange } = props;239 const { onExpandedChange } = props;
212240
213 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);241 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
...@@ -254,6 +282,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -254,6 +282,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
254 connectionError: props.connectionError,282 connectionError: props.connectionError,
255 connectionState: props.connectionState,283 connectionState: props.connectionState,
256 environmentLabel: props.environmentLabel,284 environmentLabel: props.environmentLabel,
285 threadSyncPhase: props.threadSyncPhase,
257 });286 });
258 const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";287 const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";
259 const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)";288 const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)";
...@@ -447,9 +476,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -447,9 +476,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
447 // ── Handle command selection ──────────────────────────────476 // ── Handle command selection ──────────────────────────────
448 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;477 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
449478
450 const handleSend = useCallback(() => {479 const handleSend = useCallback(async () => {
451 void onSendMessage();480 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
452 }, [onSendMessage]);481 if (inFlightThreadIdsRef.current.has(threadKey)) return;
482 inFlightThreadIdsRef.current.add(threadKey);
483 try {
484 await onSendMessage();
485 } finally {
486 inFlightThreadIdsRef.current.delete(threadKey);
487 }
488 }, [onSendMessage, props.environmentId, props.selectedThread.id]);
453 const handleCommandSelect = useCallback(489 const handleCommandSelect = useCallback(
454 (item: ComposerCommandItem) => {490 (item: ComposerCommandItem) => {
455 if (!composerTrigger) return;491 if (!composerTrigger) return;
...@@ -629,7 +665,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -629,7 +665,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
629 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",665 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",
630 }}666 }}
631 >667 >
632 <View className="w-full" style={{ position: "relative" }}>668 <View
669 className="w-full"
670 style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }}
671 >
633 {composerTrigger && composerMenuItems.length > 0 ? (672 {composerTrigger && composerMenuItems.length > 0 ? (
634 <View673 <View
635 style={{674 style={{
...@@ -702,6 +741,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -702,6 +741,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
702 placeholder={props.placeholder}741 placeholder={props.placeholder}
703 onFocus={handleFocus}742 onFocus={handleFocus}
704 onBlur={handleBlur}743 onBlur={handleBlur}
744 onSubmit={handleSend}
705 scrollEnabled={isExpanded}745 scrollEnabled={isExpanded}
706 contentInsetVertical={isExpanded ? 0 : 6}746 contentInsetVertical={isExpanded ? 0 : 6}
707 style={747 style={
...@@ -717,7 +757,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -717,7 +757,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
717 }757 }
718 }758 }
719 textStyle={{759 textStyle={{
720 ...MOBILE_TYPOGRAPHY.composer,760 ...bodyText,
721 color: foregroundColor,761 color: foregroundColor,
722 fontFamily: "DMSans_400Regular",762 fontFamily: "DMSans_400Regular",
723 }}763 }}
...@@ -827,13 +867,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -827,13 +867,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
827867
828 {/* Queue count */}868 {/* Queue count */}
829 {props.queueCount > 0 ? (869 {props.queueCount > 0 ? (
830 <Text870 <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}>
831 className="text-foreground-muted"
832 style={{
833 ...MOBILE_TYPOGRAPHY.label,
834 paddingTop: 8,
835 }}
836 >
837 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send871 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
838 automatically.872 automatically.
839 </Text>873 </Text>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+62-31
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
2import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";3import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
3import type { LegendListRef } from "@legendapp/list/react-native";4import type { LegendListRef } from "@legendapp/list/react-native";
4import type {5import type {
...@@ -15,7 +16,6 @@ import type {...@@ -15,7 +16,6 @@ import type {
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
16import { formatElapsed } from "@t3tools/shared/orchestrationTiming";17import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
17import * as Haptics from "expo-haptics";18import * as Haptics from "expo-haptics";
18import { useHeaderHeight } from "expo-router/build/react-navigation/elements";
19import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";19import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
20import { View, type GestureResponderEvent } from "react-native";20import { View, type GestureResponderEvent } from "react-native";
21import { Gesture, GestureDetector } from "react-native-gesture-handler";21import { Gesture, GestureDetector } from "react-native-gesture-handler";
...@@ -27,7 +27,7 @@ import { AppText as Text } from "../../components/AppText";...@@ -27,7 +27,7 @@ import { AppText as Text } from "../../components/AppText";
27import type { ComposerEditorHandle } from "../../components/ComposerEditor";27import type { ComposerEditorHandle } from "../../components/ComposerEditor";
28import type { StatusTone } from "../../components/StatusPill";28import type { StatusTone } from "../../components/StatusPill";
29import type { DraftComposerImageAttachment } from "../../lib/composerImages";29import type { DraftComposerImageAttachment } from "../../lib/composerImages";
30import type { LayoutVariant } from "../../lib/layout";30import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
31import { scopedThreadKey } from "../../lib/scopedEntities";31import { scopedThreadKey } from "../../lib/scopedEntities";
32import type {32import type {
33 PendingApproval,33 PendingApproval,
...@@ -62,6 +62,8 @@ export interface ThreadDetailScreenProps {...@@ -62,6 +62,8 @@ export interface ThreadDetailScreenProps {
62 readonly draftMessage: string;62 readonly draftMessage: string;
63 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;63 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
64 readonly connectionStateLabel: EnvironmentConnectionPhase;64 readonly connectionStateLabel: EnvironmentConnectionPhase;
65 /** Message sync status for the selected thread (drives the composer status pill). */
66 readonly threadSyncStatus?: EnvironmentThreadStatus;
65 readonly activeThreadBusy: boolean;67 readonly activeThreadBusy: boolean;
66 readonly environmentId: EnvironmentId;68 readonly environmentId: EnvironmentId;
67 readonly projectWorkspaceRoot: string | null;69 readonly projectWorkspaceRoot: string | null;
...@@ -69,6 +71,8 @@ export interface ThreadDetailScreenProps {...@@ -69,6 +71,8 @@ export interface ThreadDetailScreenProps {
69 readonly selectedThreadQueueCount: number;71 readonly selectedThreadQueueCount: number;
70 readonly serverConfig: T3ServerConfig | null;72 readonly serverConfig: T3ServerConfig | null;
71 readonly layoutVariant?: LayoutVariant;73 readonly layoutVariant?: LayoutVariant;
74 readonly usesAutomaticContentInsets?: boolean;
75 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
72 readonly onOpenDrawer: () => void;76 readonly onOpenDrawer: () => void;
73 readonly onOpenConnectionEditor: () => void;77 readonly onOpenConnectionEditor: () => void;
74 readonly onChangeDraftMessage: (value: string) => void;78 readonly onChangeDraftMessage: (value: string) => void;
...@@ -207,7 +211,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -207,7 +211,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
207 const { onOpenDrawer } = props;211 const { onOpenDrawer } = props;
208212
209 const insets = useSafeAreaInsets();213 const insets = useSafeAreaInsets();
210 const headerHeight = useHeaderHeight();
211 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;214 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
212 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);215 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
213 const composerEditorRef = useRef<ComposerEditorHandle>(null);216 const composerEditorRef = useRef<ComposerEditorHandle>(null);
...@@ -220,6 +223,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -220,6 +223,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
220 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);223 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
221 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);224 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
222 const contentPresentationKind = props.contentPresentation.kind;225 const contentPresentationKind = props.contentPresentation.kind;
226 // The raw sync status enters "synchronizing" on every full fetch, cached or
227 // not. Whether messages are already on screen decides the pill label: no
228 // data yet → "Loading messages", cached data reconciling → "Syncing".
229 const threadSyncPhase = (() => {
230 switch (props.threadSyncStatus) {
231 case "empty":
232 case "cached":
233 case "synchronizing":
234 if (contentPresentationKind === "ready") {
235 return "syncing" as const;
236 }
237 return contentPresentationKind === "loading" ? ("loading" as const) : null;
238 default:
239 return null;
240 }
241 })();
223 const selectedThreadFeed = props.selectedThreadFeed;242 const selectedThreadFeed = props.selectedThreadFeed;
224 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;243 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
225 const composerOverlapHeight = composerChrome + composerBottomInset;244 const composerOverlapHeight = composerChrome + composerBottomInset;
...@@ -234,6 +253,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -234,6 +253,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
234 const showContent = props.showContent ?? true;253 const showContent = props.showContent ?? true;
235 const layoutVariant = props.layoutVariant ?? "compact";254 const layoutVariant = props.layoutVariant ?? "compact";
236 const isSplitLayout = layoutVariant === "split";255 const isSplitLayout = layoutVariant === "split";
256 const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
237 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;257 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
238 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);258 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
239 const selectedProviderSkills = useMemo(259 const selectedProviderSkills = useMemo(
...@@ -382,9 +402,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -382,9 +402,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
382 freeze={freeze}402 freeze={freeze}
383 anchorMessageId={anchorMessageId}403 anchorMessageId={anchorMessageId}
384 contentInsetEndAdjustment={contentInsetEndAdjustment}404 contentInsetEndAdjustment={contentInsetEndAdjustment}
385 contentTopInset={headerHeight}405 contentTopInset={0}
386 contentBottomInset={estimatedOverlayHeight}406 contentBottomInset={estimatedOverlayHeight}
407 contentMaxWidth={contentMaxWidth}
387 layoutVariant={layoutVariant}408 layoutVariant={layoutVariant}
409 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
410 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
388 skills={selectedProviderSkills}411 skills={selectedProviderSkills}
389 />412 />
390 </View>413 </View>
...@@ -398,42 +421,50 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -398,42 +421,50 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
398 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}421 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
399 offset={{ closed: 0, opened: 0 }}422 offset={{ closed: 0, opened: 0 }}
400 >423 >
401 <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}>424 <View
402 {props.activeWorkStartedAt ? (425 ref={composerOverlayRef}
403 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />426 onLayout={onComposerLayout}
404 ) : null}427 style={{ width: "100%", paddingTop: 8 }}
405428 >
406 {props.activePendingApproval || props.activePendingUserInput ? (429 <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}>
407 <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>430 {props.activeWorkStartedAt ? (
408 {props.activePendingApproval ? (431 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
409 <PendingApprovalCard432 ) : null}
410 approval={props.activePendingApproval}433
411 respondingApprovalId={props.respondingApprovalId}434 {props.activePendingApproval || props.activePendingUserInput ? (
412 onRespond={props.onRespondToApproval}435 <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
413 />436 {props.activePendingApproval ? (
414 ) : null}437 <PendingApprovalCard
415 {props.activePendingUserInput ? (438 approval={props.activePendingApproval}
416 <PendingUserInputCard439 respondingApprovalId={props.respondingApprovalId}
417 pendingUserInput={props.activePendingUserInput}440 onRespond={props.onRespondToApproval}
418 drafts={props.activePendingUserInputDrafts}441 />
419 answers={props.activePendingUserInputAnswers}442 ) : null}
420 respondingUserInputId={props.respondingUserInputId}443 {props.activePendingUserInput ? (
421 onSelectOption={props.onSelectUserInputOption}444 <PendingUserInputCard
422 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}445 pendingUserInput={props.activePendingUserInput}
423 onSubmit={props.onSubmitUserInput}446 drafts={props.activePendingUserInputDrafts}
424 />447 answers={props.activePendingUserInputAnswers}
425 ) : null}448 respondingUserInputId={props.respondingUserInputId}
426 </View>449 onSelectOption={props.onSelectUserInputOption}
427 ) : null}450 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
451 onSubmit={props.onSubmitUserInput}
452 />
453 ) : null}
454 </View>
455 ) : null}
456 </View>
428457
429 <ThreadComposer458 <ThreadComposer
430 editorRef={composerEditorRef}459 editorRef={composerEditorRef}
431 draftMessage={props.draftMessage}460 draftMessage={props.draftMessage}
432 draftAttachments={props.draftAttachments}461 draftAttachments={props.draftAttachments}
433 placeholder="Ask the repo agent, or run a command…"462 placeholder="Ask the repo agent, or run a command…"
463 contentMaxWidth={contentMaxWidth}
434 connectionState={props.connectionStateLabel}464 connectionState={props.connectionStateLabel}
435 connectionError={props.connectionError}465 connectionError={props.connectionError}
436 environmentLabel={props.environmentLabel}466 environmentLabel={props.environmentLabel}
467 threadSyncPhase={threadSyncPhase}
437 selectedThread={props.selectedThread}468 selectedThread={props.selectedThread}
438 serverConfig={props.serverConfig}469 serverConfig={props.serverConfig}
439 queueCount={props.selectedThreadQueueCount}470 queueCount={props.selectedThreadQueueCount}
apps/mobile/src/features/threads/ThreadFeed.tsx+176-107
...@@ -4,7 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native";...@@ -4,7 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native";
4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
6import { SymbolView } from "expo-symbols";6import { SymbolView } from "expo-symbols";
7import { useRouter } from "expo-router";7import { useNavigation } from "@react-navigation/native";
8import {8import {
9 memo,9 memo,
10 useCallback,10 useCallback,
...@@ -25,6 +25,10 @@ import {...@@ -25,6 +25,10 @@ import {
25 ActivityIndicator,25 ActivityIndicator,
26 Image,26 Image,
27 Linking,27 Linking,
28 Platform,
29 type LayoutChangeEvent,
30 type NativeScrollEvent,
31 type NativeSyntheticEvent,
28 Pressable,32 Pressable,
29 ScrollView,33 ScrollView,
30 StyleSheet,34 StyleSheet,
...@@ -37,7 +41,7 @@ import {...@@ -37,7 +41,7 @@ import {
37import { TouchableOpacity } from "react-native-gesture-handler";41import { TouchableOpacity } from "react-native-gesture-handler";
38import ImageViewing from "react-native-image-viewing";42import ImageViewing from "react-native-image-viewing";
39import { useSafeAreaInsets } from "react-native-safe-area-context";43import { useSafeAreaInsets } from "react-native-safe-area-context";
40import type { SharedValue } from "react-native-reanimated";44import { type SharedValue } from "react-native-reanimated";
41import { useThemeColor } from "../../lib/useThemeColor";45import { useThemeColor } from "../../lib/useThemeColor";
42import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";46import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
43import {47import {
...@@ -58,14 +62,16 @@ import {...@@ -58,14 +62,16 @@ import {
58 buildNativeReviewDiffData,62 buildNativeReviewDiffData,
59 createNativeReviewDiffTheme,63 createNativeReviewDiffTheme,
60 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,64 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
61 NATIVE_REVIEW_DIFF_ROW_HEIGHT,
62 NATIVE_REVIEW_DIFF_STYLE,
63} from "../review/nativeReviewDiffAdapter";65} from "../review/nativeReviewDiffAdapter";
64import { buildReviewParsedDiff } from "../review/reviewModel";66import { buildReviewParsedDiff } from "../review/reviewModel";
65import { cn } from "../../lib/cn";67import { cn } from "../../lib/cn";
66import type { LayoutVariant } from "../../lib/layout";68import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";
67import { buildThreadFilesNavigation } from "../../lib/routes";69import {
68import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";70 resolveMarkdownFontSizes,
71 resolveNativeMarkdownTypography,
72} from "../../lib/appearancePreferences";
73import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
74import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
69import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";75import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
70import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";76import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
71import {77import {
...@@ -82,7 +88,6 @@ const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {...@@ -82,7 +88,6 @@ const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
82 hour: "numeric",88 hour: "numeric",
83 minute: "2-digit",89 minute: "2-digit",
84});90});
85
86function formatMessageTime(input: string): string {91function formatMessageTime(input: string): string {
87 const timestamp = Date.parse(input);92 const timestamp = Date.parse(input);
88 if (Number.isNaN(timestamp)) {93 if (Number.isNaN(timestamp)) {
...@@ -105,7 +110,10 @@ export interface ThreadFeedProps {...@@ -105,7 +110,10 @@ export interface ThreadFeedProps {
105 readonly contentInsetEndAdjustment: SharedValue<number>;110 readonly contentInsetEndAdjustment: SharedValue<number>;
106 readonly contentTopInset?: number;111 readonly contentTopInset?: number;
107 readonly contentBottomInset?: number;112 readonly contentBottomInset?: number;
113 readonly contentMaxWidth?: number;
108 readonly layoutVariant?: LayoutVariant;114 readonly layoutVariant?: LayoutVariant;
115 readonly usesAutomaticContentInsets?: boolean;
116 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
109 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;117 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
110}118}
111119
...@@ -273,6 +281,15 @@ function useReviewCommentColors(): ReviewCommentColors {...@@ -273,6 +281,15 @@ function useReviewCommentColors(): ReviewCommentColors {
273281
274function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {282function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
275 const colorScheme = useColorScheme();283 const colorScheme = useColorScheme();
284 const { appearance } = useAppearancePreferences();
285 const markdownFontSizes = useMemo(
286 () => resolveMarkdownFontSizes(appearance.baseFontSize),
287 [appearance.baseFontSize],
288 );
289 const nativeMarkdownTypography = useMemo(
290 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
291 [appearance.baseFontSize],
292 );
276 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];293 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];
277 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));294 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
278295
...@@ -316,14 +333,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -316,14 +333,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
316 xl: 16,333 xl: 16,
317 },334 },
318 fontSizes: {335 fontSizes: {
319 s: 13,336 s: markdownFontSizes.s,
320 m: 15,337 m: markdownFontSizes.m,
321 h1: 20,338 h1: markdownFontSizes.h1,
322 h2: 18,339 h2: markdownFontSizes.h2,
323 h3: 16,340 h3: markdownFontSizes.h3,
324 h4: 14,341 h4: markdownFontSizes.h4,
325 h5: 14,342 h5: markdownFontSizes.h5,
326 h6: 14,343 h6: markdownFontSizes.h6,
327 },344 },
328 fontFamilies: {345 fontFamilies: {
329 regular: "DMSans_400Regular",346 regular: "DMSans_400Regular",
...@@ -345,7 +362,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -345,7 +362,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
345 list: { marginTop: 4, marginBottom: 8 },362 list: { marginTop: 4, marginBottom: 8 },
346 list_item: { marginTop: 0, marginBottom: 4 },363 list_item: { marginTop: 0, marginBottom: 4 },
347 task_list_item: { marginTop: 0, marginBottom: 4 },364 task_list_item: { marginTop: 0, marginBottom: 4 },
348 text: { lineHeight: 22 },365 text: { lineHeight: markdownFontSizes.bodyLineHeight },
349 bold: {366 bold: {
350 fontWeight: "700",367 fontWeight: "700",
351 color: markdownStrongColor,368 color: markdownStrongColor,
...@@ -454,7 +471,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -454,7 +471,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
454 marginRight: 5,471 marginRight: 5,
455 color: inlineTextColor,472 color: inlineTextColor,
456 fontFamily: "DMSans_400Regular",473 fontFamily: "DMSans_400Regular",
457 ...MOBILE_TYPOGRAPHY.body,474 fontSize: markdownFontSizes.m,
475 lineHeight: markdownFontSizes.bodyLineHeight,
458 textAlign: ordered ? "right" : "center",476 textAlign: ordered ? "right" : "center",
459 }}477 }}
460 >478 >
...@@ -475,8 +493,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -475,8 +493,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
475 style={{493 style={{
476 color: inlineCodeTextColor,494 color: inlineCodeTextColor,
477 fontFamily: "ui-monospace",495 fontFamily: "ui-monospace",
478 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,496 fontSize: markdownFontSizes.codeBlockFontSize,
479 lineHeight: 22,497 lineHeight: markdownFontSizes.bodyLineHeight,
480 }}498 }}
481 >499 >
482 {value}500 {value}
...@@ -512,7 +530,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -512,7 +530,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
512 style={{530 style={{
513 color: markdownBodyColor,531 color: markdownBodyColor,
514 fontFamily: "ui-monospace",532 fontFamily: "ui-monospace",
515 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,533 fontSize: markdownFontSizes.codeBlockFontSize,
516 opacity: 0.7,534 opacity: 0.7,
517 textTransform: "uppercase",535 textTransform: "uppercase",
518 }}536 }}
...@@ -532,8 +550,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -532,8 +550,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
532 style={{550 style={{
533 color: blockTextColor,551 color: blockTextColor,
534 fontFamily: "ui-monospace",552 fontFamily: "ui-monospace",
535 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,553 fontSize: markdownFontSizes.codeBlockFontSize,
536 lineHeight: 18,554 lineHeight: markdownFontSizes.codeBlockLineHeight,
537 }}555 }}
538 >556 >
539 {content}557 {content}
...@@ -612,7 +630,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -612,7 +630,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
612 skillTextColor: "#f0abfc",630 skillTextColor: "#f0abfc",
613 quoteMarkerColor: markdownUserBodyColor,631 quoteMarkerColor: markdownUserBodyColor,
614 dividerColor: markdownUserBodyColor,632 dividerColor: markdownUserBodyColor,
615 ...MOBILE_TYPOGRAPHY.body,633 fontSize: nativeMarkdownTypography.fontSize,
634 lineHeight: nativeMarkdownTypography.lineHeight,
635 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
616 fontFamily: "DMSans_400Regular",636 fontFamily: "DMSans_400Regular",
617 headingFontFamily: "DMSans_700Bold",637 headingFontFamily: "DMSans_700Bold",
618 boldFontFamily: "DMSans_700Bold",638 boldFontFamily: "DMSans_700Bold",
...@@ -641,14 +661,16 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -641,14 +661,16 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
641 skillTextColor: inlineSkillForeground,661 skillTextColor: inlineSkillForeground,
642 quoteMarkerColor: markdownBlockquoteBorder,662 quoteMarkerColor: markdownBlockquoteBorder,
643 dividerColor: markdownHrColor,663 dividerColor: markdownHrColor,
644 ...MOBILE_TYPOGRAPHY.body,664 fontSize: nativeMarkdownTypography.fontSize,
665 lineHeight: nativeMarkdownTypography.lineHeight,
666 headingFontSizes: nativeMarkdownTypography.headingFontSizes,
645 fontFamily: "DMSans_400Regular",667 fontFamily: "DMSans_400Regular",
646 headingFontFamily: "DMSans_700Bold",668 headingFontFamily: "DMSans_700Bold",
647 boldFontFamily: "DMSans_700Bold",669 boldFontFamily: "DMSans_700Bold",
648 },670 },
649 },671 },
650 };672 };
651 }, [colors, inlineSkillForeground, onLinkPress]);673 }, [colors, inlineSkillForeground, markdownFontSizes, nativeMarkdownTypography, onLinkPress]);
652}674}
653675
654function renderFeedEntry(676function renderFeedEntry(
...@@ -924,6 +946,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {...@@ -924,6 +946,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
924 readonly comment: ReviewInlineComment;946 readonly comment: ReviewInlineComment;
925 readonly colors: ReviewCommentColors;947 readonly colors: ReviewCommentColors;
926}) {948}) {
949 const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();
927 const colorScheme = useColorScheme();950 const colorScheme = useColorScheme();
928 const appearanceScheme = colorScheme === "light" ? "light" : "dark";951 const appearanceScheme = colorScheme === "light" ? "light" : "dark";
929 const NativeReviewDiffView = resolveNativeReviewDiffView();952 const NativeReviewDiffView = resolveNativeReviewDiffView();
...@@ -946,18 +969,21 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {...@@ -946,18 +969,21 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
946 () => JSON.stringify(nativeReviewDiffTheme),969 () => JSON.stringify(nativeReviewDiffTheme),
947 [nativeReviewDiffTheme],970 [nativeReviewDiffTheme],
948 );971 );
949 const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []);972 const nativeStyleJson = useMemo(
973 () => JSON.stringify(nativeReviewDiffStyle),
974 [nativeReviewDiffStyle],
975 );
950 const nativeDiffHeight = useMemo(976 const nativeDiffHeight = useMemo(
951 () =>977 () =>
952 Math.min(978 Math.min(
953 360,979 360,
954 Math.max(980 Math.max(
955 112,981 112,
956 compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT +982 compactNativeRows.length * nativeReviewDiffStyle.rowHeight +
957 NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin,983 nativeReviewDiffStyle.fileHeaderVerticalMargin,
958 ),984 ),
959 ),985 ),
960 [compactNativeRows.length],986 [compactNativeRows.length, nativeReviewDiffStyle],
961 );987 );
962 const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0;988 const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0;
963989
...@@ -987,7 +1013,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {...@@ -987,7 +1013,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
987 </View>1013 </View>
988 <View className="min-w-0 flex-1">1014 <View className="min-w-0 flex-1">
989 <Text1015 <Text
990 className="font-mono text-xs leading-[16px]"1016 className="font-mono text-xs"
991 numberOfLines={1}1017 numberOfLines={1}
992 style={{ color: props.colors.text }}1018 style={{ color: props.colors.text }}
993 >1019 >
...@@ -1010,7 +1036,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {...@@ -1010,7 +1036,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
1010 style={StyleSheet.absoluteFill}1036 style={StyleSheet.absoluteFill}
1011 appearanceScheme={appearanceScheme}1037 appearanceScheme={appearanceScheme}
1012 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}1038 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
1013 rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT}1039 rowHeight={nativeReviewDiffStyle.rowHeight}
1014 rowsJson={nativeRowsJson}1040 rowsJson={nativeRowsJson}
1015 styleJson={nativeStyleJson}1041 styleJson={nativeStyleJson}
1016 themeJson={nativeThemeJson}1042 themeJson={nativeThemeJson}
...@@ -1032,8 +1058,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {...@@ -1032,8 +1058,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
1032 style={{1058 style={{
1033 color: props.colors.text,1059 color: props.colors.text,
1034 fontFamily: "ui-monospace",1060 fontFamily: "ui-monospace",
1035 fontSize: MOBILE_CODE_SURFACE.fontSize,1061 fontSize: codeSurface.fontSize,
1036 lineHeight: MOBILE_CODE_SURFACE.rowHeight,1062 lineHeight: codeSurface.rowHeight,
1037 }}1063 }}
1038 >1064 >
1039 {props.comment.diff.trim()}1065 {props.comment.diff.trim()}
...@@ -1042,11 +1068,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {...@@ -1042,11 +1068,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
1042 ) : null}1068 ) : null}
1043 {props.comment.text.length > 0 ? (1069 {props.comment.text.length > 0 ? (
1044 <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}>1070 <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}>
1045 <Text1071 <Text selectable className="text-base leading-snug" style={{ color: props.colors.text }}>
1046 selectable
1047 className="text-base leading-[21px]"
1048 style={{ color: props.colors.text }}
1049 >
1050 {props.comment.text}1072 {props.comment.text}
1051 </Text>1073 </Text>
1052 </View>1074 </View>
...@@ -1087,7 +1109,6 @@ function ThreadFeedPlaceholder(props: {...@@ -1087,7 +1109,6 @@ function ThreadFeedPlaceholder(props: {
1087 readonly bottomInset: number;1109 readonly bottomInset: number;
1088 readonly detail: string;1110 readonly detail: string;
1089 readonly horizontalPadding: number;1111 readonly horizontalPadding: number;
1090 readonly loading?: boolean;
1091 readonly title: string;1112 readonly title: string;
1092 readonly topInset: number;1113 readonly topInset: number;
1093}) {1114}) {
...@@ -1104,9 +1125,8 @@ function ThreadFeedPlaceholder(props: {...@@ -1104,9 +1125,8 @@ function ThreadFeedPlaceholder(props: {
1104 }}1125 }}
1105 >1126 >
1106 <View className="max-w-[320px] items-center gap-2">1127 <View className="max-w-[320px] items-center gap-2">
1107 {props.loading ? <ActivityIndicator style={{ marginBottom: 6 }} /> : null}
1108 <Text className="text-center font-t3-bold text-lg text-foreground">{props.title}</Text>1128 <Text className="text-center font-t3-bold text-lg text-foreground">{props.title}</Text>
1109 <Text className="text-center text-sm leading-5 text-foreground-secondary">1129 <Text className="text-center text-sm leading-normal text-foreground-secondary">
1110 {props.detail}1130 {props.detail}
1111 </Text>1131 </Text>
1112 </View>1132 </View>
...@@ -1115,13 +1135,17 @@ function ThreadFeedPlaceholder(props: {...@@ -1115,13 +1135,17 @@ function ThreadFeedPlaceholder(props: {
1115}1135}
11161136
1117export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {1137export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1118 const router = useRouter();1138 const navigation = useNavigation();
1119 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);1139 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1120 const foldSettleFrameRef = useRef<number | null>(null);1140 const foldSettleFrameRef = useRef<number | null>(null);
1121 const foldSettleSecondFrameRef = useRef<number | null>(null);1141 const foldSettleSecondFrameRef = useRef<number | null>(null);
1122 const disclosureAnchorKeyRef = useRef<string | null>(null);1142 const disclosureAnchorKeyRef = useRef<string | null>(null);
1143 const headerMaterialVisibleRef = useRef(false);
1123 const previousLatestTurnRef = useRef(props.latestTurn);1144 const previousLatestTurnRef = useRef(props.latestTurn);
1124 const { width: viewportWidth } = useWindowDimensions();1145 const { width: windowWidth } = useWindowDimensions();
1146 const [viewportWidth, setViewportWidth] = useState(() =>
1147 props.layoutVariant === "split" ? 0 : windowWidth,
1148 );
1125 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);1149 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
1126 const [interactionState, setInteractionState] = useState<{1150 const [interactionState, setInteractionState] = useState<{
1127 readonly copiedRowId: string | null;1151 readonly copiedRowId: string | null;
...@@ -1140,12 +1164,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1140,12 +1164,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1140 headers?: Record<string, string>;1164 headers?: Record<string, string>;
1141 } | null>(null);1165 } | null>(null);
1142 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;1166 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
1143 const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2);1167 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
1168 viewportWidth,
1169 maxContentWidth: props.contentMaxWidth ?? null,
1170 minimumPadding: horizontalPadding,
1171 });
1172 const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2);
1144 const userBubbleMaxWidth = contentWidth * 0.85;1173 const userBubbleMaxWidth = contentWidth * 0.85;
1145 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);1174 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
1146 const insets = useSafeAreaInsets();1175 const insets = useSafeAreaInsets();
1147 const topContentInset = props.contentTopInset ?? insets.top + 44;1176 const topContentInset = props.contentTopInset ?? insets.top + 44;
1148 const bottomContentInset = props.contentBottomInset ?? 18;1177 const bottomContentInset = props.contentBottomInset ?? 18;
1178 const usesNativeAutomaticInsets =
1179 props.usesAutomaticContentInsets === true && Platform.OS === "ios";
11491180
1150 const iconSubtleColor = useThemeColor("--color-icon-subtle");1181 const iconSubtleColor = useThemeColor("--color-icon-subtle");
1151 const userBubbleColor = useThemeColor("--color-user-bubble");1182 const userBubbleColor = useThemeColor("--color-user-bubble");
...@@ -1159,13 +1190,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1159,13 +1190,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1159 );1190 );
1160 if (relativePath) {1191 if (relativePath) {
1161 void Haptics.selectionAsync();1192 void Haptics.selectionAsync();
1162 router.push(1193 navigation.navigate("ThreadFile", {
1163 buildThreadFilesNavigation(1194 environmentId: String(props.environmentId),
1164 { environmentId: props.environmentId, threadId: props.threadId },1195 threadId: String(props.threadId),
1165 relativePath,1196 path: relativePath.split("/").filter((segment) => segment.length > 0),
1166 presentation.line,1197 ...(presentation.line ? { line: String(presentation.line) } : {}),
1167 ),1198 });
1168 );
1169 }1199 }
1170 return;1200 return;
1171 }1201 }
...@@ -1174,7 +1204,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1174,7 +1204,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1174 void Linking.openURL(presentation.href);1204 void Linking.openURL(presentation.href);
1175 }1205 }
1176 },1206 },
1177 [props.environmentId, props.threadId, props.workspaceRoot, router],1207 [props.environmentId, props.threadId, props.workspaceRoot, navigation],
1178 );1208 );
1179 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress);1209 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress);
1180 const reviewCommentColors = useReviewCommentColors();1210 const reviewCommentColors = useReviewCommentColors();
...@@ -1188,6 +1218,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1188,6 +1218,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1188 markdownStyles,1218 markdownStyles,
1189 reviewCommentColors,1219 reviewCommentColors,
1190 userBubbleColor,1220 userBubbleColor,
1221 viewportWidth,
1191 }),1222 }),
1192 [1223 [
1193 copiedRowId,1224 copiedRowId,
...@@ -1196,8 +1227,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1196,8 +1227,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1196 markdownStyles,1227 markdownStyles,
1197 reviewCommentColors,1228 reviewCommentColors,
1198 userBubbleColor,1229 userBubbleColor,
1230 viewportWidth,
1199 ],1231 ],
1200 );1232 );
1233 const reportHeaderMaterialVisibility = useCallback(
1234 (visible: boolean) => {
1235 if (headerMaterialVisibleRef.current === visible) {
1236 return;
1237 }
1238 headerMaterialVisibleRef.current = visible;
1239 props.onHeaderMaterialVisibilityChange?.(visible);
1240 },
1241 [props.onHeaderMaterialVisibilityChange],
1242 );
1243 const handleScroll = useCallback(
1244 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
1245 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6);
1246 },
1247 [reportHeaderMaterialVisibility, topContentInset],
1248 );
1249 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
1250 const nextWidth = Math.round(event.nativeEvent.layout.width);
1251 setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
1252 }, []);
1253
1254 useEffect(() => {
1255 reportHeaderMaterialVisibility(false);
1256 }, [props.threadId, reportHeaderMaterialVisibility]);
1257
1201 const expandedWorkGroupIds = useMemo(() => {1258 const expandedWorkGroupIds = useMemo(() => {
1202 const ids = new Set<string>();1259 const ids = new Set<string>();
1203 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {1260 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
...@@ -1217,6 +1274,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1217,6 +1274,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1217 ),1274 ),
1218 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],1275 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
1219 );1276 );
1277
1220 const anchoredEndSpace = useMemo(1278 const anchoredEndSpace = useMemo(
1221 () =>1279 () =>
1222 resolveChatListAnchoredEndSpace(1280 resolveChatListAnchoredEndSpace(
...@@ -1424,19 +1482,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1424,19 +1482,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1424 ],1482 ],
1425 );1483 );
14261484
1427 if (props.contentPresentation.kind === "loading") {
1428 return (
1429 <ThreadFeedPlaceholder
1430 title="Loading conversation"
1431 detail="Fetching the latest messages from this environment."
1432 loading
1433 topInset={topContentInset}
1434 bottomInset={bottomContentInset}
1435 horizontalPadding={horizontalPadding}
1436 />
1437 );
1438 }
1439
1440 if (props.contentPresentation.kind === "unavailable") {1485 if (props.contentPresentation.kind === "unavailable") {
1441 return (1486 return (
1442 <ThreadFeedPlaceholder1487 <ThreadFeedPlaceholder
...@@ -1451,48 +1496,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1451,48 +1496,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14511496
1452 return (1497 return (
1453 <>1498 <>
1454 <View style={{ flex: 1 }}>1499 <View style={{ flex: 1 }} onLayout={handleViewportLayout}>
1455 <KeyboardAwareLegendList1500 <View style={{ flex: 1 }}>
1456 ref={props.listRef}1501 <KeyboardAwareLegendList
1457 key={props.threadId}1502 ref={props.listRef}
1458 style={{ flex: 1 }}1503 // The empty↔filled key remounts the list when messages first
1459 automaticallyAdjustsScrollIndicatorInsets={false}1504 // arrive. LegendList's maintainScrollAtEnd calls scrollToEnd(),
1460 scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }}1505 // which is blind to UIKit's adjustedContentInset — inserting into
1461 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}1506 // an already-attached list under a transparent header can pin
1462 contentInsetEndAdjustment={props.contentInsetEndAdjustment}1507 // short content at offset 0 (one header-height too high). A fresh
1463 freeze={props.freeze}1508 // mount positions during attach, where UIKit applies the inset.
1464 maintainScrollAtEnd={1509 key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`}
1465 disclosureToggleSettling1510 style={{ flex: 1 }}
1466 ? false1511 contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"}
1467 : {1512 automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}
1468 animated: false,1513 {...(usesNativeAutomaticInsets
1469 on: {1514 ? {
1470 dataChange: true,1515 // Do NOT pass a manual `contentInset` here. Like the Home
1471 itemLayout: true,1516 // ScrollView, we rely purely on `contentInsetAdjustmentBehavior:
1472 layout: true,1517 // "automatic"` so UIKit derives the top inset from the transparent
1473 },1518 // header. A manual contentInset (which LegendList consumes into its
1519 // own layout math) collapses the scroll view's adjustedContentInset
1520 // top to 0, leaving the iOS 26/27 scroll-edge effect no region to
1521 // render into — which is why the header blur was missing on threads.
1522 scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 },
1474 }1523 }
1475 }1524 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
1476 maintainVisibleContentPosition={maintainVisibleContentPosition}1525 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1477 data={presentedFeed}1526 contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1478 extraData={listAppearanceData}1527 freeze={props.freeze}
1479 renderItem={renderItem}1528 maintainScrollAtEnd={
1480 keyExtractor={(entry) => entry.id}1529 disclosureToggleSettling
1481 getItemType={(entry) =>1530 ? false
1482 entry.type === "message" ? `message:${entry.message.role}` : entry.type1531 : {
1483 }1532 animated: false,
1484 keyboardShouldPersistTaps="always"1533 on: {
1485 keyboardDismissMode="none"1534 dataChange: true,
1486 keyboardLiftBehavior="whenAtEnd"1535 itemLayout: true,
1487 estimatedItemSize={180}1536 layout: true,
1488 initialScrollAtEnd1537 },
1489 ListHeaderComponent={<View style={{ height: topContentInset }} />}1538 }
1490 contentContainerStyle={{1539 }
1491 paddingTop: 12,1540 maintainVisibleContentPosition={maintainVisibleContentPosition}
1492 paddingHorizontal: horizontalPadding,1541 data={presentedFeed}
1493 }}1542 extraData={listAppearanceData}
1494 />1543 renderItem={renderItem}
1495 {props.feed.length === 0 ? (1544 keyExtractor={(entry) => entry.id}
1545 getItemType={(entry) =>
1546 entry.type === "message" ? `message:${entry.message.role}` : entry.type
1547 }
1548 keyboardShouldPersistTaps="always"
1549 keyboardDismissMode="none"
1550 keyboardLiftBehavior="whenAtEnd"
1551 estimatedItemSize={180}
1552 initialScrollAtEnd
1553 onScroll={handleScroll}
1554 scrollEventThrottle={16}
1555 ListHeaderComponent={
1556 usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />
1557 }
1558 contentContainerStyle={{
1559 paddingTop: 12,
1560 paddingHorizontal: contentHorizontalPadding,
1561 }}
1562 />
1563 </View>
1564 {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? (
1496 <View pointerEvents="none" style={StyleSheet.absoluteFill}>1565 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
1497 <ThreadFeedPlaceholder1566 <ThreadFeedPlaceholder
1498 title="No conversation yet"1567 title="No conversation yet"
apps/mobile/src/features/threads/ThreadGitControls.tsx+382-122
...@@ -1,20 +1,19 @@...@@ -1,20 +1,19 @@
1import type {1import {
2 EnvironmentId,2 EnvironmentId,
3 GitRunStackedActionResult,3 type GitRunStackedActionResult,
4 ProjectScript,4 type ProjectScript,
5 ThreadId,5 ThreadId,
6 VcsStatusResult,6 type VcsStatusResult,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import {8import {
9 type GitActionRequestInput,9 type GitActionRequestInput,
10 requiresDefaultBranchConfirmation,10 requiresDefaultBranchConfirmation,
11 resolveQuickAction,11 resolveQuickAction,
12} from "@t3tools/client-runtime/state/vcs";12} from "@t3tools/client-runtime/state/vcs";
13import { useLocalSearchParams, useRouter } from "expo-router";13import { useNavigation } from "@react-navigation/native";
14import Stack from "expo-router/stack";14import { NativeHeaderToolbar } from "../../native/StackHeader";
15import { useCallback, useMemo } from "react";15import { useCallback, useMemo } from "react";
16import { Alert } from "react-native";16import { Alert } from "react-native";
17import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";
18import { tryOpenExternalUrl } from "../../lib/openExternalUrl";17import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
19import {18import {
20 basename,19 basename,
...@@ -65,25 +64,52 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string {...@@ -65,25 +64,52 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string {
65 return parts.join(" · ");64 return parts.join(" · ");
66}65}
6766
68export function ThreadGitControls(props: {67type HeaderItem = Record<string, unknown>;
68type HeaderItems = HeaderItem[];
69type ThreadGitHeaderActionItems = {
70 readonly terminal: HeaderItem;
71 readonly files: HeaderItem;
72 readonly git: HeaderItem;
73};
74type QuickActionIcon =
75 | "arrow.down.circle"
76 | "arrow.up.right.circle"
77 | "checkmark.circle"
78 | "arrow.up.circle";
79
80/** The subset of git-control wiring the standalone git menu needs. */
81export type ThreadGitMenuProps = {
82 readonly environmentId: EnvironmentId | string;
83 readonly threadId: ThreadId | string;
69 readonly currentBranch: string | null;84 readonly currentBranch: string | null;
70 readonly gitStatus: VcsStatusResult | null;85 readonly gitStatus: VcsStatusResult | null;
71 readonly gitOperationLabel: string | null;86 readonly gitOperationLabel: string | null;
87 readonly onOpenFilesInspector?: () => void;
88 readonly onOpenGitInspector?: () => void;
89 readonly onPull: () => Promise<void>;
90 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
91};
92
93type ThreadGitControlsProps = ThreadGitMenuProps & {
94 readonly auxiliaryPaneControl?: {
95 readonly accessibilityLabel: string;
96 readonly onPress: () => void;
97 };
72 readonly canOpenTerminal: boolean;98 readonly canOpenTerminal: boolean;
73 readonly canOpenFiles: boolean;99 readonly canOpenFiles: boolean;
74 readonly projectScripts: ReadonlyArray<ProjectScript>;100 readonly projectScripts: ReadonlyArray<ProjectScript>;
75 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;101 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
102 readonly showActionControls?: boolean;
103 readonly showDirectFileControl?: boolean;
76 readonly onOpenTerminal: (terminalId?: string | null) => void;104 readonly onOpenTerminal: (terminalId?: string | null) => void;
77 readonly onOpenNewTerminal: () => void;105 readonly onOpenNewTerminal: () => void;
78 readonly onRunProjectScript: (script: ProjectScript) => Promise<void>;106 readonly onRunProjectScript: (script: ProjectScript) => Promise<void>;
79 readonly onPull: () => Promise<void>;107};
80 readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;108
81}) {109function useThreadGitControlModel(props: ThreadGitMenuProps) {
82 const router = useRouter();110 const navigation = useNavigation();
83 const { environmentId, threadId } = useLocalSearchParams<{111 const environmentId = props.environmentId;
84 environmentId: EnvironmentId;112 const threadId = props.threadId;
85 threadId: ThreadId;
86 }>();
87 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;113 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
88114
89 const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD";115 const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD";
...@@ -109,7 +135,7 @@ export function ThreadGitControls(props: {...@@ -109,7 +135,7 @@ export function ThreadGitControls(props: {
109 ? (quickAction.hint ?? "This action is unavailable.")135 ? (quickAction.hint ?? "This action is unavailable.")
110 : null;136 : null;
111137
112 const quickActionIcon = (() => {138 const quickActionIcon: QuickActionIcon = (() => {
113 if (quickAction.kind === "run_pull") return "arrow.down.circle";139 if (quickAction.kind === "run_pull") return "arrow.down.circle";
114 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";140 if (quickAction.kind === "open_pr") return "arrow.up.right.circle";
115 if (quickAction.kind === "run_action") {141 if (quickAction.kind === "run_action") {
...@@ -147,24 +173,21 @@ export function ThreadGitControls(props: {...@@ -147,24 +173,21 @@ export function ThreadGitControls(props: {
147 !input.featureBranch &&173 !input.featureBranch &&
148 requiresDefaultBranchConfirmation(input.action, isDefaultRef)174 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
149 ) {175 ) {
150 router.push({176 navigation.navigate("GitConfirm", {
151 pathname: "/threads/[environmentId]/[threadId]/git-confirm",177 environmentId: String(environmentId),
152 params: {178 threadId: String(threadId),
153 environmentId,179 confirmAction: confirmableAction,
154 threadId,180 branchName,
155 confirmAction: confirmableAction,181 includesCommit: String(
156 branchName,182 input.action === "commit_push" || input.action === "commit_push_pr",
157 includesCommit: String(183 ),
158 input.action === "commit_push" || input.action === "commit_push_pr",
159 ),
160 },
161 });184 });
162 return;185 return;
163 }186 }
164187
165 await onRunAction(input);188 await onRunAction(input);
166 },189 },
167 [environmentId, gitStatus, isDefaultRef, onRunAction, router, threadId],190 [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId],
168 );191 );
169192
170 const runQuickAction = useCallback(async () => {193 const runQuickAction = useCallback(async () => {
...@@ -181,102 +204,339 @@ export function ThreadGitControls(props: {...@@ -181,102 +204,339 @@ export function ThreadGitControls(props: {
181 }204 }
182 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);205 }, [onPull, openExistingPr, quickAction, runActionWithPrompt]);
183206
207 const openFiles = useCallback(() => {
208 if (props.onOpenFilesInspector) {
209 props.onOpenFilesInspector();
210 return;
211 }
212 navigation.navigate("ThreadFiles", {
213 environmentId: String(environmentId),
214 threadId: String(threadId),
215 });
216 }, [environmentId, props.onOpenFilesInspector, navigation, threadId]);
217
218 const openReview = useCallback(() => {
219 navigation.navigate("ThreadReview", {
220 environmentId: EnvironmentId.make(String(environmentId)),
221 threadId: ThreadId.make(String(threadId)),
222 });
223 }, [environmentId, navigation, threadId]);
224
225 const openGitInspector = useCallback(() => {
226 if (props.onOpenGitInspector) {
227 props.onOpenGitInspector();
228 return;
229 }
230 navigation.navigate("GitOverview", {
231 environmentId: String(environmentId),
232 threadId: String(threadId),
233 });
234 }, [environmentId, props.onOpenGitInspector, navigation, threadId]);
235
236 return {
237 currentBranchLabel,
238 isRepo,
239 openFiles,
240 openGitInspector,
241 openReview,
242 quickAction,
243 quickActionHint,
244 quickActionIcon,
245 runQuickAction,
246 };
247}
248
249function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGitHeaderActionItems {
250 const model = useThreadGitControlModel(props);
251
252 return useMemo(
253 () => ({
254 terminal: {
255 accessibilityLabel: "Open terminal",
256 disabled: !props.canOpenTerminal,
257 icon: { name: "terminal", type: "sfSymbol" },
258 identifier: "thread-right-terminal",
259 label: "Terminal",
260 menu: {
261 items: [
262 ...props.projectScripts.map((script) => ({
263 description: script.command,
264 icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },
265 label: projectScriptMenuLabel(script),
266 onPress: () => void props.onRunProjectScript(script),
267 type: "action" as const,
268 })),
269 ...(props.projectScripts.length === 0
270 ? [
271 {
272 description: "This project has no saved scripts yet",
273 disabled: true,
274 icon: { name: "play", type: "sfSymbol" as const },
275 label: "No project scripts",
276 onPress: () => {},
277 type: "action" as const,
278 },
279 ]
280 : []),
281 ...props.terminalSessions.map((session) => ({
282 description: [
283 getTerminalStatusLabel({
284 status: session.status,
285 hasRunningSubprocess: session.hasRunningSubprocess,
286 }),
287 basename(session.cwd),
288 ]
289 .filter(Boolean)
290 .join(" · "),
291 icon: { name: "terminal", type: "sfSymbol" as const },
292 label: session.displayLabel,
293 onPress: () => props.onOpenTerminal(session.terminalId),
294 type: "action" as const,
295 })),
296 {
297 description: "Start another shell for this thread",
298 icon: { name: "plus", type: "sfSymbol" },
299 label: "Open new terminal",
300 onPress: props.onOpenNewTerminal,
301 type: "action",
302 },
303 ],
304 title: "Terminal",
305 },
306 sharesBackground: true,
307 type: "menu",
308 variant: "plain",
309 },
310 files: {
311 accessibilityLabel: "Open files",
312 disabled: !props.canOpenFiles,
313 icon: { name: "folder", type: "sfSymbol" },
314 identifier: "thread-right-files",
315 label: "Files",
316 onPress: model.openFiles,
317 sharesBackground: true,
318 type: "button",
319 variant: "plain",
320 },
321 git: {
322 accessibilityLabel: "Git actions",
323 icon: { name: "point.topleft.down.curvedto.point.bottomright.up", type: "sfSymbol" },
324 identifier: "thread-right-git",
325 label: "Git",
326 menu: {
327 items: [
328 {
329 description: compactMenuStatus(props.gitStatus),
330 disabled: true,
331 icon: {
332 name: "point.topleft.down.curvedto.point.bottomright.up",
333 type: "sfSymbol",
334 },
335 label: compactMenuBranchLabel(model.currentBranchLabel),
336 onPress: (): void => {},
337 type: "action",
338 },
339 {
340 description: model.quickActionHint ?? undefined,
341 disabled: model.quickAction.disabled,
342 icon: { name: model.quickActionIcon, type: "sfSymbol" },
343 label: model.quickAction.label,
344 onPress: (): void => void model.runQuickAction(),
345 type: "action",
346 },
347 {
348 description: "Turn diffs and worktree changes",
349 disabled: !model.isRepo,
350 icon: { name: "text.bubble", type: "sfSymbol" },
351 label: "Review changes",
352 onPress: model.openReview,
353 type: "action",
354 },
355 {
356 description: "Commit, files, branches",
357 icon: { name: "ellipsis", type: "sfSymbol" },
358 label: "More",
359 onPress: model.openGitInspector,
360 type: "action",
361 },
362 ],
363 title: "Git",
364 },
365 sharesBackground: true,
366 type: "menu",
367 variant: "plain",
368 },
369 }),
370 [
371 model.currentBranchLabel,
372 model.isRepo,
373 model.openFiles,
374 model.openGitInspector,
375 model.openReview,
376 model.quickAction.disabled,
377 model.quickAction.label,
378 model.quickActionHint,
379 model.quickActionIcon,
380 model.runQuickAction,
381 props.canOpenFiles,
382 props.canOpenTerminal,
383 props.gitStatus,
384 props.onOpenNewTerminal,
385 props.onOpenTerminal,
386 props.onRunProjectScript,
387 props.projectScripts,
388 props.terminalSessions,
389 ],
390 );
391}
392
393export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems {
394 const actionItems = useThreadGitHeaderActionItems(props);
395 return useMemo(
396 () => [actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems,
397 [actionItems],
398 );
399}
400
401export function useThreadGitCenterHeaderItems(props: ThreadGitControlsProps): HeaderItems {
402 const actionItems = useThreadGitHeaderActionItems(props);
403 return useMemo(
404 () => [actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems,
405 [actionItems],
406 );
407}
408
409export function ThreadGitControls(props: ThreadGitControlsProps) {
410 const model = useThreadGitControlModel(props);
411 const showActionControls = props.showActionControls ?? true;
412
413 if (!showActionControls) {
414 return null;
415 }
416
184 return (417 return (
185 <Stack.Toolbar placement="right">418 <NativeHeaderToolbar placement="right">
186 <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground>419 {showActionControls && props.auxiliaryPaneControl ? (
187 {props.projectScripts.length > 0 ? (420 <NativeHeaderToolbar.Button
188 props.projectScripts.map((script) => (421 accessibilityLabel={props.auxiliaryPaneControl.accessibilityLabel}
189 <Stack.Toolbar.MenuAction422 icon="sidebar.right"
190 key={script.id}423 onPress={props.auxiliaryPaneControl.onPress}
191 icon={projectScriptMenuIcon(script.icon)}424 separateBackground
192 onPress={() => void props.onRunProjectScript(script)}425 />
193 subtitle={script.command}426 ) : null}
427 {showActionControls ? (
428 <NativeHeaderToolbar.Menu
429 icon="terminal"
430 disabled={!props.canOpenTerminal}
431 separateBackground
432 >
433 {props.projectScripts.length > 0 ? (
434 props.projectScripts.map((script) => (
435 <NativeHeaderToolbar.MenuAction
436 key={script.id}
437 icon={projectScriptMenuIcon(script.icon)}
438 onPress={() => void props.onRunProjectScript(script)}
439 subtitle={script.command}
440 >
441 <NativeHeaderToolbar.Label>
442 {projectScriptMenuLabel(script)}
443 </NativeHeaderToolbar.Label>
444 </NativeHeaderToolbar.MenuAction>
445 ))
446 ) : (
447 <NativeHeaderToolbar.MenuAction
448 icon="play"
449 disabled
450 onPress={() => {}}
451 subtitle="This project has no saved scripts yet"
194 >452 >
195 <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label>453 <NativeHeaderToolbar.Label>No project scripts</NativeHeaderToolbar.Label>
196 </Stack.Toolbar.MenuAction>454 </NativeHeaderToolbar.MenuAction>
197 ))455 )}
198 ) : (456 {props.terminalSessions.map((session) => (
199 <Stack.Toolbar.MenuAction457 <NativeHeaderToolbar.MenuAction
200 icon="play"458 key={session.terminalId}
201 disabled459 icon="terminal"
202 onPress={() => {}}460 onPress={() => props.onOpenTerminal(session.terminalId)}
203 subtitle="This project has no saved scripts yet"461 subtitle={[
204 >462 getTerminalStatusLabel({
205 <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label>463 status: session.status,
206 </Stack.Toolbar.MenuAction>464 hasRunningSubprocess: session.hasRunningSubprocess,
207 )}465 }),
208 {props.terminalSessions.map((session) => (466 basename(session.cwd),
209 <Stack.Toolbar.MenuAction467 ]
210 key={session.terminalId}468 .filter(Boolean)
211 icon="terminal"469 .join(" · ")}
212 onPress={() => props.onOpenTerminal(session.terminalId)}470 >
213 subtitle={[471 <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label>
214 getTerminalStatusLabel({472 </NativeHeaderToolbar.MenuAction>
215 status: session.status,473 ))}
216 hasRunningSubprocess: session.hasRunningSubprocess,474 <NativeHeaderToolbar.MenuAction
217 }),475 icon="plus"
218 basename(session.cwd),476 onPress={props.onOpenNewTerminal}
219 ]477 subtitle="Start another shell for this thread"
220 .filter(Boolean)
221 .join(" · ")}
222 >478 >
223 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>479 <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label>
224 </Stack.Toolbar.MenuAction>480 </NativeHeaderToolbar.MenuAction>
225 ))}481 </NativeHeaderToolbar.Menu>
226 <Stack.Toolbar.MenuAction482 ) : null}
227 icon="plus"483 {showActionControls && props.showDirectFileControl ? (
228 onPress={props.onOpenNewTerminal}484 <NativeHeaderToolbar.Button
229 subtitle="Start another shell for this thread"485 accessibilityLabel="Open files"
230 >
231 <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>
232 </Stack.Toolbar.MenuAction>
233 </Stack.Toolbar.Menu>
234 <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
235 <Stack.Toolbar.MenuAction
236 icon="point.topleft.down.curvedto.point.bottomright.up"
237 disabled
238 onPress={() => {}}
239 subtitle={compactMenuStatus(gitStatus)}
240 >
241 <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label>
242 </Stack.Toolbar.MenuAction>
243 <Stack.Toolbar.MenuAction
244 icon={quickActionIcon}
245 disabled={quickAction.disabled}
246 onPress={() => void runQuickAction()}
247 subtitle={quickActionHint ?? undefined}
248 >
249 <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label>
250 </Stack.Toolbar.MenuAction>
251 <Stack.Toolbar.MenuAction
252 icon="text.bubble"
253 disabled={!isRepo}
254 onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))}
255 subtitle="Turn diffs and worktree changes"
256 >
257 <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label>
258 </Stack.Toolbar.MenuAction>
259 <Stack.Toolbar.MenuAction
260 icon="folder"
261 disabled={!props.canOpenFiles}486 disabled={!props.canOpenFiles}
262 onPress={() => router.push(buildThreadFilesNavigation({ environmentId, threadId }))}487 icon="folder"
263 subtitle="Browse this workspace"488 onPress={model.openFiles}
264 >489 separateBackground
265 <Stack.Toolbar.Label>Files</Stack.Toolbar.Label>490 />
266 </Stack.Toolbar.MenuAction>491 ) : null}
267 <Stack.Toolbar.MenuAction492 {showActionControls ? <ThreadGitMenu {...props} /> : null}
268 icon="ellipsis.circle"493 </NativeHeaderToolbar>
269 onPress={() =>494 );
270 router.push({495}
271 pathname: "/threads/[environmentId]/[threadId]/git",496
272 params: { environmentId, threadId },497/**
273 })498 * The standalone git actions menu (branch status, quick commit/push action,
274 }499 * review, more). Rendered inside a NativeHeaderToolbar by both the thread
275 subtitle="Commit, files, branches"500 * chat header and the review screen's toolbar.
276 >501 */
277 <Stack.Toolbar.Label>More</Stack.Toolbar.Label>502export function ThreadGitMenu(props: ThreadGitMenuProps) {
278 </Stack.Toolbar.MenuAction>503 const model = useThreadGitControlModel(props);
279 </Stack.Toolbar.Menu>504
280 </Stack.Toolbar>505 return (
506 <NativeHeaderToolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
507 <NativeHeaderToolbar.MenuAction
508 icon="point.topleft.down.curvedto.point.bottomright.up"
509 disabled
510 onPress={() => {}}
511 subtitle={compactMenuStatus(props.gitStatus)}
512 >
513 <NativeHeaderToolbar.Label>
514 {compactMenuBranchLabel(model.currentBranchLabel)}
515 </NativeHeaderToolbar.Label>
516 </NativeHeaderToolbar.MenuAction>
517 <NativeHeaderToolbar.MenuAction
518 icon={model.quickActionIcon}
519 disabled={model.quickAction.disabled}
520 onPress={() => void model.runQuickAction()}
521 subtitle={model.quickActionHint ?? undefined}
522 >
523 <NativeHeaderToolbar.Label>{model.quickAction.label}</NativeHeaderToolbar.Label>
524 </NativeHeaderToolbar.MenuAction>
525 <NativeHeaderToolbar.MenuAction
526 icon="text.bubble"
527 disabled={!model.isRepo}
528 onPress={model.openReview}
529 subtitle="Turn diffs and worktree changes"
530 >
531 <NativeHeaderToolbar.Label>Review changes</NativeHeaderToolbar.Label>
532 </NativeHeaderToolbar.MenuAction>
533 <NativeHeaderToolbar.MenuAction
534 icon="ellipsis"
535 onPress={model.openGitInspector}
536 subtitle="Commit, files, branches"
537 >
538 <NativeHeaderToolbar.Label>More</NativeHeaderToolbar.Label>
539 </NativeHeaderToolbar.MenuAction>
540 </NativeHeaderToolbar.Menu>
281 );541 );
282}542}
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+7-34
...@@ -8,8 +8,6 @@ import {...@@ -8,8 +8,6 @@ import {
8 useWindowDimensions,8 useWindowDimensions,
9 View,9 View,
10} from "react-native";10} from "react-native";
11import * as Arr from "effect/Array";
12import * as Order from "effect/Order";
13import { Gesture, GestureDetector } from "react-native-gesture-handler";11import { Gesture, GestureDetector } from "react-native-gesture-handler";
14import { useSafeAreaInsets } from "react-native-safe-area-context";12import { useSafeAreaInsets } from "react-native-safe-area-context";
15import Animated, {13import Animated, {
...@@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor";...@@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor";
23import { AppText as Text } from "../../components/AppText";21import { AppText as Text } from "../../components/AppText";
24import { StatusPill } from "../../components/StatusPill";22import { StatusPill } from "../../components/StatusPill";
25import { useProjects, useThreadShells } from "../../state/entities";23import { useProjects, useThreadShells } from "../../state/entities";
26import { groupProjectsByRepository } from "../../lib/repositoryGroups";
27import { scopedThreadKey } from "../../lib/scopedEntities";24import { scopedThreadKey } from "../../lib/scopedEntities";
28import { relativeTime } from "../../lib/time";25import { relativeTime } from "../../lib/time";
29import { threadStatusTone } from "./threadPresentation";26import { resolveThreadStatus } from "./threadPresentation";
30import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";27import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3128import { buildThreadNavigationGroups } from "./thread-navigation-groups";
32const threadActivityOrder = Order.mapInput(
33 Order.Struct({
34 activityAt: Order.flip(Order.Number),
35 title: Order.String,
36 }),
37 (thread: EnvironmentThreadShell) => ({
38 activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),
39 title: thread.title,
40 }),
41);
4229
43export function ThreadNavigationDrawer(props: {30export function ThreadNavigationDrawer(props: {
44 readonly visible: boolean;31 readonly visible: boolean;
...@@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: {...@@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: {
192}) {179}) {
193 const projects = useProjects();180 const projects = useProjects();
194 const threads = useThreadShells();181 const threads = useThreadShells();
195 const repositoryGroups = useMemo(
196 () => groupProjectsByRepository({ projects, threads }),
197 [projects, threads],
198 );
199 const groupedThreads = useMemo(182 const groupedThreads = useMemo(
200 () =>183 () => buildThreadNavigationGroups({ projects, threads }),
201 repositoryGroups.map((group) => {184 [projects, threads],
202 const threads: EnvironmentThreadShell[] = [];
203 for (const projectGroup of group.projects) {
204 threads.push(...projectGroup.threads);
205 }
206 return {
207 key: group.key,
208 title: group.projects[0]?.project.title ?? group.title,
209 threads: Arr.sort(threads, threadActivityOrder),
210 };
211 }),
212 [repositoryGroups],
213 );185 );
214186
215 return (187 return (
...@@ -239,6 +211,7 @@ function ThreadNavigationDrawerContent(props: {...@@ -239,6 +211,7 @@ function ThreadNavigationDrawerContent(props: {
239 group.threads.map((thread, index) => {211 group.threads.map((thread, index) => {
240 const threadKey = scopedThreadKey(thread.environmentId, thread.id);212 const threadKey = scopedThreadKey(thread.environmentId, thread.id);
241 const selected = props.selectedThreadKey === threadKey;213 const selected = props.selectedThreadKey === threadKey;
214 const status = resolveThreadStatus(thread);
242215
243 return (216 return (
244 <Pressable217 <Pressable
...@@ -268,7 +241,7 @@ function ThreadNavigationDrawerContent(props: {...@@ -268,7 +241,7 @@ function ThreadNavigationDrawerContent(props: {
268 {relativeTime(thread.updatedAt ?? thread.createdAt)}241 {relativeTime(thread.updatedAt ?? thread.createdAt)}
269 </Text>242 </Text>
270 </View>243 </View>
271 <StatusPill {...threadStatusTone(thread)} />244 {status ? <StatusPill {...status} /> : null}
272 </View>245 </View>
273 </Pressable>246 </Pressable>
274 );247 );
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx created+794
...@@ -0,0 +1,794 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { LegendList } from "@legendapp/list/react-native";
4import type { MenuAction } from "@react-native-menu/menu";
5import { SymbolView } from "expo-symbols";
6import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
7import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
8import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native";
9import { Gesture, GestureDetector } from "react-native-gesture-handler";
10import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
11import { useSafeAreaInsets } from "react-native-safe-area-context";
12import type { SearchBarCommands } from "react-native-screens";
13import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
14
15import { AppText as Text } from "../../components/AppText";
16import { ControlPillMenu } from "../../components/ControlPill";
17import { NativeStackScreenOptions } from "../../native/StackHeader";
18import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
19import { useThemeColor } from "../../lib/useThemeColor";
20import { useProjects, useThreadShells } from "../../state/entities";
21import { useWorkspaceState } from "../../state/workspace";
22import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
23import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
24import {
25 hasCustomHomeListOptions,
26 PROJECT_GROUPING_OPTIONS,
27 PROJECT_SORT_OPTIONS,
28 THREAD_SORT_OPTIONS,
29 useHomeListOptions,
30} from "../home/home-list-options";
31import { buildHomeListFilterMenu } from "../home/home-list-filter-menu";
32import {
33 buildHomeListLayout,
34 DEFAULT_GROUP_DISPLAY_STATE,
35 homeListItemsAreEqual,
36 nextGroupDisplayState,
37 type HomeGroupDisplayAction,
38 type HomeGroupDisplayState,
39 type HomeListItem,
40} from "../home/homeListItems";
41import { buildHomeThreadGroups } from "../home/homeThreadList";
42import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
43import { useThreadListActions } from "../home/useThreadListActions";
44import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus";
45import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status";
46import { SidebarHeaderActions } from "./sidebar-header-actions";
47import { SidebarFilterButton } from "./sidebar-filter-button";
48import { createSidebarHeaderItems } from "./sidebar-native-header-items";
49import { SidebarNavigationShell } from "./sidebar-navigation-shell";
50import { ThreadListGroupHeader, ThreadListRow, ThreadListShowMoreRow } from "./thread-list-items";
51
52/**
53 * Shared capsule behind the sidebar header buttons — a native liquid-glass
54 * surface on iOS 26+, a tinted pill everywhere else.
55 */
56function SidebarHeaderButtonGroup(props: {
57 readonly children: ReactNode;
58 readonly colorScheme: "light" | "dark";
59}) {
60 if (isLiquidGlassSupported) {
61 return (
62 <LiquidGlassView
63 colorScheme={props.colorScheme}
64 effect="regular"
65 interactive
66 style={styles.headerButtonGroup}
67 >
68 {props.children}
69 </LiquidGlassView>
70 );
71 }
72
73 return (
74 <View
75 style={[
76 styles.headerButtonGroup,
77 props.colorScheme === "dark"
78 ? { backgroundColor: "rgba(118,118,128,0.24)", borderColor: "rgba(255,255,255,0.08)" }
79 : { backgroundColor: "rgba(255,255,255,0.72)", borderColor: "rgba(0,0,0,0.08)" },
80 { borderWidth: StyleSheet.hairlineWidth },
81 ]}
82 >
83 {props.children}
84 </View>
85 );
86}
87
88const SIDEBAR_STICKY_HEADER_HEIGHT = 106;
89const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44;
90const IOS_SEARCH_FILL_DARK = "rgba(118, 118, 128, 0.24)";
91const IOS_SEARCH_FILL_LIGHT = "rgba(118, 118, 128, 0.12)";
92const SIDEBAR_HEADER_WASH_OPACITY = {
93 dark: [0.22, 0.14, 0.04],
94 light: [0.46, 0.3, 0.08],
95} as const;
96
97interface ThreadNavigationSidebarProps {
98 readonly width: number;
99 readonly visible: boolean;
100 readonly selectedThreadKey: string | null;
101 readonly onOpenSettings: () => void;
102 readonly onOpenEnvironmentSettings: () => void;
103 readonly onSearchQueryChange: (query: string) => void;
104 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
105 readonly onRequestVisibility: () => void;
106 readonly searchQuery: string;
107}
108
109/**
110 * iPad/large-width sidebar column.
111 *
112 * On iOS the pane is hosted inside its own navigation-inert single-screen
113 * native stack (SidebarNavigationShell) so the header is a real
114 * UINavigationBar: large title, native bar-button items, and a
115 * UISearchController search field — the same chrome a UISplitViewController
116 * column gets. Other platforms keep the custom header chrome.
117 */
118export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) {
119 if (Platform.OS !== "ios") {
120 return <ThreadNavigationSidebarPane {...props} nativeChrome={false} />;
121 }
122 return <NativeSidebarContainer {...props} />;
123}
124
125function NativeSidebarContainer(props: ThreadNavigationSidebarProps) {
126 const backgroundColor = useThemeColor("--color-drawer");
127 const borderColor = useThemeColor("--color-border");
128
129 return (
130 <View
131 testID="thread-navigation-sidebar"
132 style={[
133 styles.container,
134 {
135 width: props.width,
136 backgroundColor,
137 borderRightColor: borderColor,
138 borderRightWidth: StyleSheet.hairlineWidth,
139 },
140 ]}
141 >
142 <SidebarNavigationShell>
143 <ThreadNavigationSidebarPane {...props} nativeChrome />
144 </SidebarNavigationShell>
145 </View>
146 );
147}
148
149function ThreadNavigationSidebarPane(
150 props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
151) {
152 const insets = useSafeAreaInsets();
153 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
154 const projects = useProjects();
155 const threads = useThreadShells();
156 const { state: catalogState } = useWorkspaceState();
157 const { savedConnectionsById } = useSavedRemoteConnections();
158 const [headerIsOverContent, setHeaderIsOverContent] = useState(false);
159 const searchInputRef = useRef<TextInput>(null);
160 const searchBarRef = useRef<SearchBarCommands>(null);
161 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
162 const headerIsOverContentRef = useRef(false);
163 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
164 const { archiveThread, confirmDeleteThread } = useThreadListActions();
165 const environments = useMemo(
166 () =>
167 Object.values(savedConnectionsById)
168 .map((connection) => ({
169 environmentId: connection.environmentId,
170 label: connection.environmentLabel,
171 }))
172 .sort((left, right) => left.label.localeCompare(right.label)),
173 [savedConnectionsById],
174 );
175 const availableEnvironmentIds = useMemo(
176 () => new Set(environments.map((environment) => environment.environmentId)),
177 [environments],
178 );
179 const {
180 options,
181 setSelectedEnvironmentId,
182 setProjectGroupingMode,
183 setProjectSortOrder,
184 setThreadSortOrder,
185 } = useHomeListOptions(availableEnvironmentIds);
186 const groups = useMemo(
187 () =>
188 buildHomeThreadGroups({
189 projects,
190 threads,
191 environmentId: options.selectedEnvironmentId,
192 searchQuery: props.searchQuery,
193 projectSortOrder: options.projectSortOrder,
194 threadSortOrder: options.threadSortOrder,
195 projectGroupingMode: options.projectGroupingMode,
196 }),
197 [options, projects, props.searchQuery, threads],
198 );
199 const [groupDisplayStates, setGroupDisplayStates] = useState<
200 ReadonlyMap<string, HomeGroupDisplayState>
201 >(() => new Map());
202 const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => {
203 setGroupDisplayStates((previous) => {
204 const next = new Map(previous);
205 next.set(
206 key,
207 nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action),
208 );
209 return next;
210 });
211 }, []);
212 const hasSearchQuery = props.searchQuery.trim().length > 0;
213 const listLayout = useMemo(
214 () =>
215 buildHomeListLayout({
216 groups,
217 displayStates: groupDisplayStates,
218 showAllThreads: hasSearchQuery,
219 }),
220 [groups, groupDisplayStates, hasSearchQuery],
221 );
222 const projectCwdByKey = useMemo(() => {
223 const map = new Map<string, string>();
224 for (const project of projects) {
225 map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot);
226 }
227 return map;
228 }, [projects]);
229 const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState);
230 const listMenuActions = useMemo<MenuAction[]>(
231 () => [
232 {
233 id: "environment",
234 title: "Environment",
235 subactions: [
236 {
237 id: "environment:all",
238 title: "All environments",
239 subtitle: "Show threads from every environment",
240 state: options.selectedEnvironmentId === null ? "on" : "off",
241 },
242 ...environments.map((environment) => ({
243 id: `environment:${environment.environmentId}`,
244 title: environment.label,
245 state:
246 options.selectedEnvironmentId === environment.environmentId
247 ? ("on" as const)
248 : ("off" as const),
249 })),
250 ],
251 },
252 {
253 id: "project-sort",
254 title: "Sort projects",
255 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
256 id: `project-sort:${option.value}`,
257 title: option.label,
258 state: options.projectSortOrder === option.value ? "on" : "off",
259 })),
260 },
261 {
262 id: "thread-sort",
263 title: "Sort threads",
264 subactions: THREAD_SORT_OPTIONS.map((option) => ({
265 id: `thread-sort:${option.value}`,
266 title: option.label,
267 state: options.threadSortOrder === option.value ? "on" : "off",
268 })),
269 },
270 {
271 id: "project-grouping",
272 title: "Group projects",
273 subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({
274 id: `project-grouping:${option.value}`,
275 title: option.label,
276 subtitle: option.subtitle,
277 state: options.projectGroupingMode === option.value ? "on" : "off",
278 })),
279 },
280 ],
281 [environments, options],
282 );
283 const handleListMenuAction = useCallback(
284 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
285 const event = nativeEvent.event;
286 if (event === "environment:all") {
287 setSelectedEnvironmentId(null);
288 return;
289 }
290 if (event.startsWith("environment:")) {
291 const environment = environments.find(
292 (candidate) => String(candidate.environmentId) === event.slice("environment:".length),
293 );
294 if (environment) setSelectedEnvironmentId(environment.environmentId);
295 return;
296 }
297 const projectSort = PROJECT_SORT_OPTIONS.find(
298 (option) => `project-sort:${option.value}` === event,
299 );
300 if (projectSort) {
301 setProjectSortOrder(projectSort.value);
302 return;
303 }
304 const threadSort = THREAD_SORT_OPTIONS.find(
305 (option) => `thread-sort:${option.value}` === event,
306 );
307 if (threadSort) {
308 setThreadSortOrder(threadSort.value);
309 return;
310 }
311 const grouping = PROJECT_GROUPING_OPTIONS.find(
312 (option) => `project-grouping:${option.value}` === event,
313 );
314 if (grouping) setProjectGroupingMode(grouping.value);
315 },
316 [
317 environments,
318 setProjectGroupingMode,
319 setProjectSortOrder,
320 setSelectedEnvironmentId,
321 setThreadSortOrder,
322 ],
323 );
324
325 const backgroundColor = useThemeColor("--color-drawer");
326 const borderColor = useThemeColor("--color-border");
327 const foregroundColor = useThemeColor("--color-foreground");
328 const mutedColor = useThemeColor("--color-foreground-muted");
329 const placeholderColor = useThemeColor("--color-placeholder");
330 const searchBackgroundColor =
331 colorScheme === "dark" ? IOS_SEARCH_FILL_DARK : IOS_SEARCH_FILL_LIGHT;
332 const headerFadeColor = String(backgroundColor);
333 const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme];
334 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
335 // The sticky header (title row, search field, optional connection status)
336 // is measured so the list inset always matches its real height — no
337 // hardcoded per-variant constants.
338 const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT;
339 const topListInset = stickyHeaderHeight + 6;
340 const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => {
341 const nextHeight = Math.round(event.nativeEvent.layout.height);
342 setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight));
343 }, []);
344 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
345 if (openSwipeableRef.current !== methods) {
346 openSwipeableRef.current?.close();
347 openSwipeableRef.current = methods;
348 }
349 }, []);
350 const handleSwipeableClose = useCallback((methods: SwipeableMethods) => {
351 if (openSwipeableRef.current === methods) {
352 openSwipeableRef.current = null;
353 }
354 }, []);
355 const handleSelectThread = useCallback(
356 (thread: EnvironmentThreadShell) => {
357 props.onSelectThread(thread);
358 openSwipeableRef.current?.close();
359 },
360 [props.onSelectThread],
361 );
362 const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
363 const next = event.nativeEvent.contentOffset.y > 6;
364 if (headerIsOverContentRef.current === next) {
365 return;
366 }
367 headerIsOverContentRef.current = next;
368 setHeaderIsOverContent(next);
369 }, []);
370 const handleScrollBeginDrag = useCallback(() => {
371 openSwipeableRef.current?.close();
372 }, []);
373 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
374 onScroll: handleScroll,
375 onScrollBeginDrag: handleScrollBeginDrag,
376 });
377 const listExtraData = props.selectedThreadKey ?? "";
378 const focusSearch = useCallback(() => {
379 const focus = () => {
380 if (props.nativeChrome) {
381 searchBarRef.current?.focus();
382 return;
383 }
384 searchInputRef.current?.focus();
385 };
386 if (!props.visible) {
387 props.onRequestVisibility();
388 setTimeout(focus, 240);
389 } else {
390 focus();
391 }
392 return true;
393 }, [props.nativeChrome, props.onRequestVisibility, props.visible]);
394 useHardwareKeyboardCommand("focusSearch", focusSearch);
395 const renderListItem = useCallback(
396 ({ item }: { readonly item: HomeListItem }) => {
397 switch (item.type) {
398 case "header":
399 return (
400 <ThreadListGroupHeader
401 variant="sidebar"
402 collapsed={item.collapsed}
403 isFirst={item.isFirst}
404 groupKey={item.group.key}
405 onGroupAction={updateGroupDisplay}
406 project={item.group.representative}
407 threadCount={item.group.threads.length}
408 title={item.group.title}
409 />
410 );
411 case "thread": {
412 const thread = item.thread;
413 return (
414 <ThreadListRow
415 variant="sidebar"
416 thread={thread}
417 environmentLabel={
418 savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
419 }
420 projectCwd={
421 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
422 null
423 }
424 isLast={item.isLast}
425 selected={
426 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
427 }
428 fullSwipeWidth={props.width - 20}
429 onArchiveThread={archiveThread}
430 onDeleteThread={confirmDeleteThread}
431 onSelectThread={handleSelectThread}
432 onSwipeableClose={handleSwipeableClose}
433 onSwipeableWillOpen={handleSwipeableWillOpen}
434 simultaneousSwipeGesture={sidebarScrollGesture}
435 />
436 );
437 }
438 case "show-more":
439 return (
440 <ThreadListShowMoreRow
441 variant="sidebar"
442 hiddenCount={item.hiddenCount}
443 canShowLess={item.canShowLess}
444 groupKey={item.groupKey}
445 onGroupAction={updateGroupDisplay}
446 />
447 );
448 }
449 },
450 [
451 archiveThread,
452 confirmDeleteThread,
453 handleSelectThread,
454 handleSwipeableClose,
455 handleSwipeableWillOpen,
456 projectCwdByKey,
457 props.selectedThreadKey,
458 props.width,
459 savedConnectionsById,
460 sidebarScrollGesture,
461 updateGroupDisplay,
462 ],
463 );
464 const filterIcon = hasCustomHomeListOptions(options)
465 ? "line.3.horizontal.decrease.circle.fill"
466 : "line.3.horizontal.decrease.circle";
467 const filterMenu = useMemo(
468 () =>
469 buildHomeListFilterMenu({
470 environments,
471 selectedEnvironmentId: options.selectedEnvironmentId,
472 projectSortOrder: options.projectSortOrder,
473 threadSortOrder: options.threadSortOrder,
474 projectGroupingMode: options.projectGroupingMode,
475 onEnvironmentChange: setSelectedEnvironmentId,
476 onProjectSortOrderChange: setProjectSortOrder,
477 onThreadSortOrderChange: setThreadSortOrder,
478 onProjectGroupingModeChange: setProjectGroupingMode,
479 }),
480 [
481 environments,
482 options,
483 setProjectGroupingMode,
484 setProjectSortOrder,
485 setSelectedEnvironmentId,
486 setThreadSortOrder,
487 ],
488 );
489 const nativeHeaderItems = useMemo(
490 () =>
491 createSidebarHeaderItems({
492 filterIcon,
493 filterMenu,
494 onOpenSettings: props.onOpenSettings,
495 }),
496 [filterIcon, filterMenu, props.onOpenSettings],
497 );
498 const listEmpty = (
499 <Text className="px-2 py-4 text-sm" style={{ color: mutedColor }}>
500 {catalogState.isLoadingConnections
501 ? "Loading threads…"
502 : props.searchQuery.trim().length > 0
503 ? "No matching threads"
504 : "No threads yet"}
505 </Text>
506 );
507
508 if (props.nativeChrome) {
509 return (
510 <>
511 <NativeStackScreenOptions
512 options={{
513 headerSearchBarOptions: {
514 ref: searchBarRef,
515 autoCapitalize: "none",
516 hideNavigationBar: false,
517 // Keep the search bar pinned under the title — UIKit's default
518 // hidesSearchBarWhenScrolling collapses it on scroll.
519 hideWhenScrolling: false,
520 obscureBackground: false,
521 placeholder: "Search",
522 placement: "stacked",
523 onCancelButtonPress: () => {
524 props.onSearchQueryChange("");
525 },
526 onChangeText: (event) => {
527 props.onSearchQueryChange(event.nativeEvent.text);
528 },
529 },
530 unstable_headerRightItems: () => nativeHeaderItems,
531 }}
532 />
533 <View style={styles.container}>
534 <SwipeableScrollGateProvider enabled={swipeEnabled}>
535 <GestureDetector gesture={sidebarScrollGesture}>
536 <LegendList
537 data={listLayout.items}
538 drawDistance={500}
539 estimatedItemSize={64}
540 extraData={listExtraData}
541 getItemType={(item) => item.type}
542 itemsAreEqual={homeListItemsAreEqual}
543 keyExtractor={(item) => item.key}
544 renderItem={renderListItem}
545 automaticallyAdjustsScrollIndicatorInsets
546 contentInsetAdjustmentBehavior="automatic"
547 contentContainerStyle={[
548 styles.threadListContent,
549 {
550 paddingBottom: Math.max(insets.bottom, 16) + 16,
551 paddingTop: 6,
552 },
553 ]}
554 keyboardDismissMode="on-drag"
555 keyboardShouldPersistTaps="handled"
556 {...scrollGateHandlers}
557 recycleItems
558 scrollEventThrottle={16}
559 showsVerticalScrollIndicator={false}
560 style={styles.threadList}
561 ListHeaderComponent={
562 showsConnectionStatus ? (
563 <View style={styles.connectionStatusNative}>
564 <WorkspaceConnectionStatus
565 onPress={props.onOpenEnvironmentSettings}
566 state={catalogState}
567 variant="sidebar"
568 />
569 </View>
570 ) : null
571 }
572 ListEmptyComponent={listEmpty}
573 />
574 </GestureDetector>
575 </SwipeableScrollGateProvider>
576 </View>
577 </>
578 );
579 }
580
581 return (
582 <View
583 testID="thread-navigation-sidebar"
584 style={[
585 styles.container,
586 {
587 width: props.width,
588 backgroundColor,
589 borderRightColor: borderColor,
590 borderRightWidth: StyleSheet.hairlineWidth,
591 },
592 ]}
593 >
594 <View style={{ flex: 1, paddingBottom: insets.bottom }}>
595 <SwipeableScrollGateProvider enabled={swipeEnabled}>
596 <GestureDetector gesture={sidebarScrollGesture}>
597 <LegendList
598 data={listLayout.items}
599 drawDistance={500}
600 estimatedItemSize={64}
601 extraData={listExtraData}
602 getItemType={(item) => item.type}
603 itemsAreEqual={homeListItemsAreEqual}
604 keyExtractor={(item) => item.key}
605 renderItem={renderListItem}
606 contentContainerStyle={[
607 styles.threadListContent,
608 {
609 paddingBottom: 16 + insets.bottom,
610 paddingTop: topListInset,
611 },
612 ]}
613 keyboardDismissMode="on-drag"
614 keyboardShouldPersistTaps="handled"
615 {...scrollGateHandlers}
616 recycleItems
617 scrollEventThrottle={16}
618 showsVerticalScrollIndicator={false}
619 style={styles.threadList}
620 ListEmptyComponent={listEmpty}
621 />
622 </GestureDetector>
623 </SwipeableScrollGateProvider>
624 </View>
625
626 <View
627 onLayout={handleStickyHeaderLayout}
628 pointerEvents="box-none"
629 style={[
630 styles.stickyHeader,
631 {
632 paddingTop: insets.top,
633 },
634 ]}
635 >
636 <View
637 pointerEvents="none"
638 accessibilityElementsHidden
639 importantForAccessibility="no-hide-descendants"
640 style={[
641 styles.stickyHeaderWash,
642 {
643 height: stickyHeaderHeight + SIDEBAR_STICKY_HEADER_FADE_HEIGHT,
644 },
645 ]}
646 >
647 <Svg width="100%" height="100%">
648 <Defs>
649 <LinearGradient id="sidebar-header-wash" x1="0%" x2="0%" y1="0%" y2="100%">
650 <Stop
651 offset="0%"
652 stopColor={headerFadeColor}
653 stopOpacity={headerIsOverContent ? headerWashOpacity[0] : 0}
654 />
655 <Stop
656 offset="58%"
657 stopColor={headerFadeColor}
658 stopOpacity={headerIsOverContent ? headerWashOpacity[1] : 0}
659 />
660 <Stop
661 offset="88%"
662 stopColor={headerFadeColor}
663 stopOpacity={headerIsOverContent ? headerWashOpacity[2] : 0}
664 />
665 <Stop offset="100%" stopColor={headerFadeColor} stopOpacity={0} />
666 </LinearGradient>
667 </Defs>
668 <Rect width="100%" height="100%" fill="url(#sidebar-header-wash)" />
669 </Svg>
670 </View>
671 <View style={styles.header}>
672 <Text
673 className="flex-1 text-[34px] font-t3-bold"
674 numberOfLines={1}
675 style={{ color: foregroundColor }}
676 >
677 Threads
678 </Text>
679 <SidebarHeaderButtonGroup colorScheme={colorScheme}>
680 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
681 <SidebarFilterButton
682 grouped
683 accessibilityLabel="Filter and sort threads"
684 icon={filterIcon}
685 />
686 </ControlPillMenu>
687 <SidebarHeaderActions grouped onOpenSettings={props.onOpenSettings} />
688 </SidebarHeaderButtonGroup>
689 </View>
690
691 <View
692 style={[
693 styles.searchField,
694 {
695 backgroundColor: searchBackgroundColor,
696 },
697 ]}
698 >
699 <SymbolView name="magnifyingglass" size={15} tintColor={mutedColor} type="monochrome" />
700 <TextInput
701 ref={searchInputRef}
702 accessibilityLabel="Search threads"
703 autoCapitalize="none"
704 autoCorrect={false}
705 clearButtonMode="while-editing"
706 onChangeText={props.onSearchQueryChange}
707 placeholder="Search"
708 placeholderTextColor={placeholderColor}
709 returnKeyType="search"
710 className="text-base"
711 style={[styles.searchInput, { color: foregroundColor }]}
712 value={props.searchQuery}
713 />
714 </View>
715
716 {showsConnectionStatus ? (
717 <View style={styles.connectionStatus}>
718 <WorkspaceConnectionStatus
719 onPress={props.onOpenEnvironmentSettings}
720 state={catalogState}
721 variant="sidebar"
722 />
723 </View>
724 ) : null}
725 </View>
726 </View>
727 );
728}
729
730const styles = StyleSheet.create({
731 container: {
732 flex: 1,
733 },
734 stickyHeader: {
735 left: 0,
736 position: "absolute",
737 right: 0,
738 top: 0,
739 zIndex: 4,
740 },
741 stickyHeaderWash: {
742 left: 0,
743 position: "absolute",
744 right: 0,
745 top: 0,
746 },
747 header: {
748 height: 50,
749 paddingLeft: 20,
750 paddingRight: 8,
751 flexDirection: "row",
752 alignItems: "flex-end",
753 gap: 2,
754 },
755 headerButtonGroup: {
756 alignItems: "center",
757 borderRadius: 22,
758 flexDirection: "row",
759 overflow: "hidden",
760 },
761 connectionStatus: {
762 paddingTop: 10,
763 paddingHorizontal: 14,
764 },
765 connectionStatusNative: {
766 paddingBottom: 8,
767 paddingHorizontal: 6,
768 paddingTop: 2,
769 },
770 searchField: {
771 height: 38,
772 marginTop: 9,
773 marginHorizontal: 16,
774 paddingLeft: 11,
775 paddingRight: 10,
776 borderRadius: 12,
777 flexDirection: "row",
778 alignItems: "center",
779 gap: 6,
780 },
781 searchInput: {
782 flex: 1,
783 height: 34,
784 paddingVertical: 0,
785 paddingHorizontal: 0,
786 fontFamily: "DMSans_400Regular",
787 },
788 threadList: {
789 flex: 1,
790 },
791 threadListContent: {
792 paddingHorizontal: 8,
793 },
794});
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+467-127
...@@ -1,9 +1,16 @@...@@ -1,9 +1,16 @@
1import { Stack, useLocalSearchParams, useRouter } from "expo-router";1import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { useCallback, useMemo, useState } from "react";2import {
3 StackActions,
4 useFocusEffect,
5 useNavigation,
6 type StaticScreenProps,
7} from "@react-navigation/native";
8import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3import * as Option from "effect/Option";9import * as Option from "effect/Option";
4import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";10import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
5import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";11import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
6import { Pressable, ScrollView, Text as RNText, View } from "react-native";12import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native";
13import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useWorkspaceState } from "../../state/workspace";14import { useWorkspaceState } from "../../state/workspace";
8import { useThemeColor } from "../../lib/useThemeColor";15import { useThemeColor } from "../../lib/useThemeColor";
9import { useEnvironmentQuery } from "../../state/query";16import { useEnvironmentQuery } from "../../state/query";
...@@ -12,7 +19,6 @@ import { vcsEnvironment } from "../../state/vcs";...@@ -12,7 +19,6 @@ import { vcsEnvironment } from "../../state/vcs";
1219
13import { EmptyState } from "../../components/EmptyState";20import { EmptyState } from "../../components/EmptyState";
14import { LoadingScreen } from "../../components/LoadingScreen";21import { LoadingScreen } from "../../components/LoadingScreen";
15import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes";
16import { scopedThreadKey } from "../../lib/scopedEntities";22import { scopedThreadKey } from "../../lib/scopedEntities";
17import { MOBILE_TYPOGRAPHY } from "../../lib/typography";23import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
18import { connectionTone } from "../connection/connectionTone";24import { connectionTone } from "../connection/connectionTone";
...@@ -37,7 +43,12 @@ import {...@@ -37,7 +43,12 @@ import {
37} from "../terminal/terminalLaunchContext";43} from "../terminal/terminalLaunchContext";
38import { terminalDebugLog } from "../terminal/terminalDebugLog";44import { terminalDebugLog } from "../terminal/terminalDebugLog";
39import { ThreadDetailScreen } from "./ThreadDetailScreen";45import { ThreadDetailScreen } from "./ThreadDetailScreen";
40import { ThreadGitControls } from "./ThreadGitControls";46import {
47 ThreadGitControls,
48 useThreadGitCenterHeaderItems,
49 useThreadGitRightHeaderItems,
50} from "./ThreadGitControls";
51import { GitOverviewSheet } from "./git/GitOverviewSheet";
41import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";52import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
42import { useAtomCommand } from "../../state/use-atom-command";53import { useAtomCommand } from "../../state/use-atom-command";
43import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";54import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
...@@ -47,6 +58,30 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt...@@ -47,6 +58,30 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt
47import { useThreadComposerState } from "../../state/use-thread-composer-state";58import { useThreadComposerState } from "../../state/use-thread-composer-state";
48import { threadEnvironment } from "../../state/threads";59import { threadEnvironment } from "../../state/threads";
49import { projectThreadContentPresentation } from "./threadContentPresentation";60import { projectThreadContentPresentation } from "./threadContentPresentation";
61import {
62 useAdaptiveWorkspaceLayout,
63 useAdaptiveWorkspacePaneRole,
64 useRegisterWorkspaceInspector,
65} from "../layout/AdaptiveWorkspaceLayout";
66import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
67import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";
68import {
69 ThreadInspectorContentStack,
70 type ThreadInspectorMode,
71} from "./thread-inspector-content-stack";
72import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
73
74interface ThreadInspectorSelection {
75 readonly routeThreadIdentity: string | null;
76 readonly mode: ThreadInspectorMode;
77}
78
79type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>;
80
81function InspectorPaneRoleActivation() {
82 useAdaptiveWorkspacePaneRole("inspector");
83 return null;
84}
5085
51function firstRouteParam(value: string | string[] | undefined): string | null {86function firstRouteParam(value: string | string[] | undefined): string | null {
52 if (Array.isArray(value)) {87 if (Array.isArray(value)) {
...@@ -60,13 +95,99 @@ function OpeningThreadLoadingScreen() {...@@ -60,13 +95,99 @@ function OpeningThreadLoadingScreen() {
60 return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;95 return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;
61}96}
6297
63export function ThreadRouteScreen() {98type ThreadRouteScreenRouteProps = StaticScreenProps<{
99 readonly environmentId: string;
100 readonly threadId: string;
101}>;
102
103interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {
104 readonly onReturnToThread?: () => void;
105 readonly renderInspector?: (headerInset: number) => ReactNode;
106}
107
108function ThreadUnavailableScreen() {
109 return (
110 <ScrollView
111 contentInsetAdjustmentBehavior="automatic"
112 contentContainerStyle={{
113 flexGrow: 1,
114 justifyContent: "center",
115 paddingHorizontal: 24,
116 paddingVertical: 32,
117 }}
118 className="bg-screen flex-1"
119 >
120 <EmptyState
121 title="Thread unavailable"
122 detail="This thread is not available in the current mobile snapshot."
123 />
124 </ScrollView>
125 );
126}
127
128export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
64 const { state: workspaceState } = useWorkspaceState();129 const { state: workspaceState } = useWorkspaceState();
65 const { connectionState } = useRemoteConnectionStatus();130 const { connectionState } = useRemoteConnectionStatus();
131 const { selectedThread } = useThreadSelection();
132 const params = props.route.params;
133 const environmentIdRaw = firstRouteParam(params.environmentId);
134 const threadIdRaw = firstRouteParam(params.threadId);
135 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
136 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
137 const routeConnectionState =
138 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
139 const routeThreadKey =
140 environmentId !== null && threadIdRaw !== null
141 ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw))
142 : null;
143 const selectedThreadKey =
144 selectedThread === null
145 ? null
146 : scopedThreadKey(selectedThread.environmentId, selectedThread.id);
147 const selectedThreadDetailState = useSelectedThreadDetailState();
148
149 if (environmentId === null || threadIdRaw === null) {
150 return <OpeningThreadLoadingScreen />;
151 }
152
153 // Render the full thread chrome (header, feed, composer) as soon as the
154 // thread SHELL is known — no blocking on message detail. The feed shows a
155 // loading placeholder while messages fetch, and the composer's connection
156 // pill reports connecting/reconnecting/syncing status.
157 if (selectedThread !== null && selectedThreadKey === routeThreadKey) {
158 return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />;
159 }
160
161 const stillHydrating =
162 workspaceState.isLoadingConnections ||
163 routeConnectionState === "connecting" ||
164 routeConnectionState === "reconnecting";
165
166 if (stillHydrating) {
167 return <OpeningThreadLoadingScreen />;
168 }
169
170 return <ThreadUnavailableScreen />;
171}
172
173function ThreadRouteContent(
174 props: ThreadRouteScreenProps & {
175 readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>;
176 },
177) {
178 const {
179 fileInspector,
180 layout,
181 panes,
182 showAuxiliaryPane,
183 toggleAuxiliaryPane,
184 togglePrimarySidebar,
185 } = useAdaptiveWorkspaceLayout();
186 const { connectionState } = useRemoteConnectionStatus();
66 const { onReconnectEnvironment } = useRemoteConnections();187 const { onReconnectEnvironment } = useRemoteConnections();
67 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =188 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
68 useThreadSelection();189 useThreadSelection();
69 const selectedThreadDetailState = useSelectedThreadDetailState();190 const selectedThreadDetailState = props.selectedThreadDetailState;
70 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);191 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
71 const { selectedThreadCwd } = useSelectedThreadWorktree();192 const { selectedThreadCwd } = useSelectedThreadWorktree();
72 const composer = useThreadComposerState();193 const composer = useThreadComposerState();
...@@ -74,15 +195,72 @@ export function ThreadRouteScreen() {...@@ -74,15 +195,72 @@ export function ThreadRouteScreen() {
74 const gitActions = useSelectedThreadGitActions();195 const gitActions = useSelectedThreadGitActions();
75 const requests = useSelectedThreadRequests();196 const requests = useSelectedThreadRequests();
76 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");197 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
77 const router = useRouter();198 const navigation = useNavigation();
78 const params = useLocalSearchParams<{199 const params = props.route.params;
79 environmentId?: string | string[];
80 threadId?: string | string[];
81 }>();
82 const [drawerVisible, setDrawerVisible] = useState(false);200 const [drawerVisible, setDrawerVisible] = useState(false);
83 const environmentIdRaw = firstRouteParam(params.environmentId);201 const environmentIdRaw = firstRouteParam(params.environmentId);
84 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;202 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
85 const threadId = firstRouteParam(params.threadId);203 const threadId = firstRouteParam(params.threadId);
204 const routeThreadIdentity =
205 environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null;
206 const [inspectorSelection, setInspectorSelection] = useState<ThreadInspectorSelection | null>(
207 () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null),
208 );
209 const inspectorMode = (() => {
210 if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) {
211 if (inspectorSelection.mode === "files" && selectedThreadCwd === null) {
212 return null;
213 }
214 return inspectorSelection.mode;
215 }
216 return null;
217 })();
218 useEffect(() => {
219 if (
220 fileInspector.supported &&
221 selectedThreadCwd === null &&
222 inspectorMode === null &&
223 panes.auxiliaryPaneVisible
224 ) {
225 toggleAuxiliaryPane();
226 }
227 }, [
228 fileInspector.supported,
229 inspectorMode,
230 panes.auxiliaryPaneVisible,
231 selectedThreadCwd,
232 toggleAuxiliaryPane,
233 ]);
234
235 useEffect(() => {
236 setInspectorSelection((current) => {
237 if (props.renderInspector === undefined) {
238 if (current === null || current.mode === "route") {
239 return null;
240 }
241 return { ...current, routeThreadIdentity };
242 }
243
244 if (current === null || current.mode === "route") {
245 return { routeThreadIdentity, mode: "route" };
246 }
247
248 return { ...current, routeThreadIdentity };
249 });
250 }, [props.renderInspector, routeThreadIdentity]);
251
252 useFocusEffect(
253 useCallback(() => {
254 return () => {
255 if (props.renderInspector === undefined) {
256 // Inspectors are contextual to this chat destination. Clear the
257 // hidden chat copy after a native push so returning from Files,
258 // Review, or Terminal cannot reserve an empty trailing pane.
259 setInspectorSelection(null);
260 }
261 };
262 }, [props.renderInspector]),
263 );
86 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);264 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
87 const routeConnectionState =265 const routeConnectionState =
88 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);266 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
...@@ -101,10 +279,14 @@ export function ThreadRouteScreen() {...@@ -101,10 +279,14 @@ export function ThreadRouteScreen() {
101 );279 );
102280
103 /* ─── Native header theming ──────────────────────────────────────── */281 /* ─── Native header theming ──────────────────────────────────────── */
104 const iconColor = String(useThemeColor("--color-icon"));
105 const foregroundColor = String(useThemeColor("--color-foreground"));282 const foregroundColor = String(useThemeColor("--color-foreground"));
106 const secondaryFg = String(useThemeColor("--color-foreground-secondary"));283 const usesNativeHeaderGlass = Platform.OS === "ios";
107284 const headerSubtitle = [
285 selectedThreadProject?.title ?? null,
286 selectedEnvironmentConnection?.environmentLabel ?? null,
287 ]
288 .filter(Boolean)
289 .join(" · ");
108 /* ─── Git status for native header trigger ───────────────────────── */290 /* ─── Git status for native header trigger ───────────────────────── */
109 const gitStatus = useEnvironmentQuery(291 const gitStatus = useEnvironmentQuery(
110 selectedThread !== null && selectedThreadCwd !== null292 selectedThread !== null && selectedThreadCwd !== null
...@@ -145,12 +327,134 @@ export function ThreadRouteScreen() {...@@ -145,12 +327,134 @@ export function ThreadRouteScreen() {
145 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);327 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
146328
147 const handleOpenDrawer = useCallback(() => {329 const handleOpenDrawer = useCallback(() => {
148 setDrawerVisible(true);330 if (!layout.usesSplitView) {
331 setDrawerVisible(true);
332 }
333 }, [layout.usesSplitView]);
334
335 useEffect(() => {
336 if (layout.usesSplitView) {
337 setDrawerVisible(false);
338 }
339 }, [layout.usesSplitView]);
340
341 const handleOpenGitInspector = useCallback(() => {
342 setInspectorSelection({ routeThreadIdentity, mode: "git" });
343 showAuxiliaryPane("inspector");
344 }, [routeThreadIdentity, showAuxiliaryPane]);
345 const handleOpenFilesInspector = useCallback(() => {
346 if (!fileInspector.supported || selectedThread === null || selectedThreadCwd === null) {
347 return;
348 }
349 setInspectorSelection({
350 routeThreadIdentity,
351 mode: props.renderInspector === undefined ? "files" : "route",
352 });
353 showAuxiliaryPane("inspector");
354 }, [
355 fileInspector.supported,
356 props.renderInspector,
357 routeThreadIdentity,
358 selectedThread,
359 selectedThreadCwd,
360 showAuxiliaryPane,
361 ]);
362 const inspectorToggleActionRef = useRef({
363 inspectorMode,
364 openFilesInspector: handleOpenFilesInspector,
365 toggleAuxiliaryPane,
366 });
367 inspectorToggleActionRef.current = {
368 inspectorMode,
369 openFilesInspector: handleOpenFilesInspector,
370 toggleAuxiliaryPane,
371 };
372 const handleToggleInspector = useCallback(() => {
373 const action = inspectorToggleActionRef.current;
374 if (action.inspectorMode === null) {
375 action.openFilesInspector();
376 return;
377 }
378 action.toggleAuxiliaryPane();
149 }, []);379 }, []);
380 const handleSelectInspectorFile = useCallback(
381 (path: string) => {
382 if (selectedThread === null) {
383 return;
384 }
385 const params = {
386 environmentId: String(selectedThread.environmentId),
387 threadId: String(selectedThread.id),
388 path: path.split("/").filter((segment) => segment.length > 0),
389 };
390 if (fileInspector.supported) {
391 navigation.navigate("ThreadFile", params);
392 return;
393 }
394 navigation.navigate("ThreadFile", params);
395 },
396 [fileInspector.supported, navigation, selectedThread],
397 );
398 // The workspace inspector column spans the full window height. On iOS the
399 // panes bring their own nested native headers (which underlap the status
400 // bar); elsewhere the pane content pads itself below the top inset.
401 const safeAreaInsets = useSafeAreaInsets();
402 const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top;
403 const GitInspector = useCallback(
404 () => (
405 <GitOverviewSheet
406 headerInset={inspectorHeaderInset}
407 presentation="inspector"
408 route={{ params: props.route.params }}
409 />
410 ),
411 [inspectorHeaderInset, props.route.params],
412 );
413 const FilesInspector = useCallback(
414 () =>
415 selectedThread !== null && selectedThreadCwd !== null ? (
416 <ThreadFileNavigatorPane
417 cwd={selectedThreadCwd}
418 environmentId={selectedThread.environmentId}
419 headerInset={inspectorHeaderInset}
420 projectName={selectedThreadProject?.title ?? "Files"}
421 selectedPath={null}
422 onSelectFile={handleSelectInspectorFile}
423 />
424 ) : null,
425 [
426 handleSelectInspectorFile,
427 inspectorHeaderInset,
428 selectedThread,
429 selectedThreadCwd,
430 selectedThreadProject?.title,
431 ],
432 );
433 const RouteInspector = useCallback(
434 () => props.renderInspector?.(inspectorHeaderInset),
435 [inspectorHeaderInset, props.renderInspector],
436 );
437 const renderInspectorStack = useCallback(
438 () =>
439 inspectorMode === null ? null : (
440 <ThreadInspectorContentStack
441 Files={FilesInspector}
442 Git={GitInspector}
443 mode={inspectorMode}
444 Route={props.renderInspector ? RouteInspector : undefined}
445 />
446 ),
447 [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector],
448 );
449 const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack;
450 // Hand the inspector to the workspace so it renders beside the navigator,
451 // outside this screen's native header — the terminal/git/files toolbar
452 // stays anchored to the chat pane instead of floating above the inspector.
453 useRegisterWorkspaceInspector(activeInspectorRenderer);
150454
151 const handleOpenConnectionEditor = useCallback(() => {455 const handleOpenConnectionEditor = useCallback(() => {
152 void router.push("/connections");456 void navigation.navigate("Connections");
153 }, [router]);457 }, [navigation]);
154 const handleStopThread = useCallback(() => {458 const handleStopThread = useCallback(() => {
155 if (459 if (
156 !selectedThread ||460 !selectedThread ||
...@@ -182,9 +486,13 @@ export function ThreadRouteScreen() {...@@ -182,9 +486,13 @@ export function ThreadRouteScreen() {
182 return;486 return;
183 }487 }
184488
185 void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId));489 void navigation.navigate("ThreadTerminal", {
490 environmentId: String(selectedThread.environmentId),
491 threadId: String(selectedThread.id),
492 ...(nextTerminalId ? { terminalId: nextTerminalId } : {}),
493 });
186 },494 },
187 [router, selectedThread, selectedThreadProject?.workspaceRoot],495 [navigation, selectedThread, selectedThreadProject?.workspaceRoot],
188 );496 );
189497
190 const handleOpenNewTerminal = useCallback(() => {498 const handleOpenNewTerminal = useCallback(() => {
...@@ -201,8 +509,12 @@ export function ThreadRouteScreen() {...@@ -201,8 +509,12 @@ export function ThreadRouteScreen() {
201 const nextId = nextOpenTerminalId({509 const nextId = nextOpenTerminalId({
202 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),510 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
203 });511 });
204 void router.push(buildThreadTerminalNavigation(selectedThread, nextId));512 void navigation.navigate("ThreadTerminal", {
205 }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);513 environmentId: String(selectedThread.environmentId),
514 threadId: String(selectedThread.id),
515 terminalId: nextId,
516 });
517 }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
206518
207 const handleRunProjectScript = useCallback(519 const handleRunProjectScript = useCallback(
208 async (script: ProjectScript) => {520 async (script: ProjectScript) => {
...@@ -259,48 +571,97 @@ export function ThreadRouteScreen() {...@@ -259,48 +571,97 @@ export function ThreadRouteScreen() {
259 worktreePath: preferredWorktreePath,571 worktreePath: preferredWorktreePath,
260 });572 });
261573
262 void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId));574 void navigation.navigate("ThreadTerminal", {
575 environmentId: String(selectedThread.environmentId),
576 threadId: String(selectedThread.id),
577 terminalId: targetTerminalId,
578 });
263 },579 },
264 [580 [
265 router,581 navigation,
266 selectedThread,582 selectedThread,
267 selectedThreadDetailWorktreePath,583 selectedThreadDetailWorktreePath,
268 selectedThreadProject,584 selectedThreadProject,
269 terminalMenuSessions,585 terminalMenuSessions,
270 ],586 ],
271 );587 );
588 const threadGitControlProps = {
589 environmentId: environmentIdRaw ?? "",
590 threadId: threadId ?? "",
591 auxiliaryPaneControl:
592 !layout.usesSplitView && fileInspector.supported && selectedThreadCwd !== null
593 ? {
594 accessibilityLabel: "Toggle inspector",
595 onPress: handleToggleInspector,
596 }
597 : undefined,
598 onOpenFilesInspector:
599 fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined,
600 onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined,
601 currentBranch: selectedThread?.branch ?? null,
602 gitStatus: gitStatus.data,
603 gitOperationLabel: gitState.gitOperationLabel,
604 canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot),
605 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
606 projectScripts: selectedThreadProject?.scripts ?? [],
607 terminalSessions: terminalMenuSessions,
608 showDirectFileControl: layout.usesSplitView,
609 onOpenTerminal: handleOpenTerminal,
610 onOpenNewTerminal: handleOpenNewTerminal,
611 onRunProjectScript: handleRunProjectScript,
612 onPull: gitActions.onPullSelectedThreadBranch,
613 onRunAction: gitActions.onRunSelectedThreadGitAction,
614 };
615 const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps);
616 const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps);
617 const splitLeftHeaderItems = useMemo<NativeHeaderItems>(
618 () => [
619 {
620 // Match Mail's split-view detail toolbar: the first detail action sits
621 // inside the content pane, not flush against the sidebar divider.
622 spacing: 18,
623 type: "spacing" as const,
624 },
625 ...(props.onReturnToThread
626 ? [
627 withNativeGlassHeaderItem({
628 accessibilityLabel: "Return to chat",
629 icon: { name: "chevron.left", type: "sfSymbol" as const },
630 identifier: "thread-left-return",
631 onPress: props.onReturnToThread,
632 type: "button" as const,
633 }),
634 ]
635 : []),
636 withNativeGlassHeaderItem({
637 accessibilityLabel: panes.primarySidebarVisible
638 ? "Maximize content"
639 : "Show thread sidebar",
640 icon: {
641 name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left",
642 type: "sfSymbol" as const,
643 },
644 identifier: "thread-left-sidebar",
645 onPress: togglePrimarySidebar,
646 type: "button" as const,
647 }),
648 withNativeGlassHeaderItem({
649 accessibilityLabel: "New task",
650 icon: { name: "square.and.pencil", type: "sfSymbol" as const },
651 identifier: "thread-left-new-task",
652 onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
653 type: "button" as const,
654 }),
655 ],
656 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
657 );
272658
273 if (!environmentId || !threadId) {659 if (!environmentId || !threadId) {
274 return <OpeningThreadLoadingScreen />;660 return <OpeningThreadLoadingScreen />;
275 }661 }
276662
277 if (!selectedThread) {663 if (!selectedThread) {
278 const stillHydrating =664 return <OpeningThreadLoadingScreen />;
279 workspaceState.isLoadingConnections ||
280 routeConnectionState === "connecting" ||
281 routeConnectionState === "reconnecting";
282
283 if (stillHydrating) {
284 return <OpeningThreadLoadingScreen />;
285 }
286
287 return (
288 <ScrollView
289 contentInsetAdjustmentBehavior="automatic"
290 contentContainerStyle={{
291 flexGrow: 1,
292 justifyContent: "center",
293 paddingHorizontal: 24,
294 paddingVertical: 32,
295 }}
296 className="bg-screen flex-1"
297 >
298 <EmptyState
299 title="Thread unavailable"
300 detail="This thread is not available in the current mobile snapshot."
301 />
302 </ScrollView>
303 );
304 }665 }
305666
306 const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);667 const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);
...@@ -311,74 +672,9 @@ export function ThreadRouteScreen() {...@@ -311,74 +672,9 @@ export function ThreadRouteScreen() {
311 connectionState: routeConnectionState,672 connectionState: routeConnectionState,
312 });673 });
313 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;674 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
314675 const renderThreadRouteBody = (showActionControls: boolean) => (
315 const headerSubtitle = [
316 selectedThreadProject?.title ?? null,
317 selectedEnvironmentConnection?.environmentLabel ?? null,
318 ]
319 .filter(Boolean)
320 .join(" · ");
321
322 return (
323 <>676 <>
324 <Stack.Screen677 <ThreadGitControls {...threadGitControlProps} showActionControls={showActionControls} />
325 options={{
326 headerShown: true,
327 headerTransparent: true,
328 headerStyle: { backgroundColor: "transparent" },
329 headerShadowVisible: false,
330 headerTintColor: iconColor,
331 headerBackTitle: "",
332 headerTitle: () => (
333 <Pressable
334 style={{ alignItems: "center", maxWidth: 200 }}
335 onLongPress={() => {
336 // TODO: trigger rename modal
337 }}
338 >
339 <RNText
340 numberOfLines={1}
341 style={{
342 fontFamily: "DMSans_700Bold",
343 fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,
344 fontWeight: "900",
345 color: foregroundColor,
346 letterSpacing: -0.4,
347 }}
348 >
349 {selectedThread.title}
350 </RNText>
351 <RNText
352 numberOfLines={1}
353 style={{
354 fontFamily: "DMSans_700Bold",
355 fontSize: MOBILE_TYPOGRAPHY.label.fontSize,
356 fontWeight: "700",
357 color: secondaryFg,
358 letterSpacing: 0.3,
359 }}
360 >
361 {headerSubtitle}
362 </RNText>
363 </Pressable>
364 ),
365 }}
366 />
367
368 <ThreadGitControls
369 currentBranch={selectedThread.branch}
370 gitStatus={gitStatus.data}
371 gitOperationLabel={gitState.gitOperationLabel}
372 canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)}
373 canOpenFiles={Boolean(selectedThreadProject?.workspaceRoot)}
374 projectScripts={selectedThreadProject?.scripts ?? []}
375 terminalSessions={terminalMenuSessions}
376 onOpenTerminal={handleOpenTerminal}
377 onOpenNewTerminal={handleOpenNewTerminal}
378 onRunProjectScript={handleRunProjectScript}
379 onPull={gitActions.onPullSelectedThreadBranch}
380 onRunAction={gitActions.onRunSelectedThreadGitAction}
381 />
382678
383 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />679 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
384680
...@@ -400,11 +696,14 @@ export function ThreadRouteScreen() {...@@ -400,11 +696,14 @@ export function ThreadRouteScreen() {
400 draftMessage={composer.draftMessage}696 draftMessage={composer.draftMessage}
401 draftAttachments={composer.draftAttachments}697 draftAttachments={composer.draftAttachments}
402 connectionStateLabel={routeConnectionState}698 connectionStateLabel={routeConnectionState}
699 threadSyncStatus={selectedThreadDetailState.status}
403 activeThreadBusy={composer.activeThreadBusy}700 activeThreadBusy={composer.activeThreadBusy}
404 environmentId={selectedThread.environmentId}701 environmentId={selectedThread.environmentId}
405 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}702 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
406 threadCwd={selectedThreadCwd}703 threadCwd={selectedThreadCwd}
407 selectedThreadQueueCount={composer.selectedThreadQueueCount}704 selectedThreadQueueCount={composer.selectedThreadQueueCount}
705 layoutVariant={layout.variant}
706 usesAutomaticContentInsets={usesNativeHeaderGlass}
408 onOpenDrawer={handleOpenDrawer}707 onOpenDrawer={handleOpenDrawer}
409 onOpenConnectionEditor={handleOpenConnectionEditor}708 onOpenConnectionEditor={handleOpenConnectionEditor}
410 onChangeDraftMessage={composer.onChangeDraftMessage}709 onChangeDraftMessage={composer.onChangeDraftMessage}
...@@ -424,16 +723,57 @@ export function ThreadRouteScreen() {...@@ -424,16 +723,57 @@ export function ThreadRouteScreen() {
424 onSubmitUserInput={requests.onSubmitUserInput}723 onSubmitUserInput={requests.onSubmitUserInput}
425 />724 />
426725
427 <ThreadNavigationDrawer726 {layout.usesSplitView ? null : (
428 visible={drawerVisible}727 <ThreadNavigationDrawer
429 selectedThreadKey={selectedThreadKey}728 visible={drawerVisible}
430 onClose={() => setDrawerVisible(false)}729 selectedThreadKey={selectedThreadKey}
431 onSelectThread={(thread) => {730 onClose={() => setDrawerVisible(false)}
432 router.replace(buildThreadRoutePath(thread));731 onSelectThread={(thread) => {
433 }}732 navigation.dispatch(
434 onStartNewTask={() => router.push("/new")}733 StackActions.replace("Thread", {
435 />734 environmentId: String(thread.environmentId),
735 threadId: String(thread.id),
736 }),
737 );
738 }}
739 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
740 />
741 )}
436 </View>742 </View>
437 </>743 </>
438 );744 );
745
746 return (
747 <>
748 {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null}
749 <NativeStackScreenOptions
750 options={{
751 headerTitle: selectedThread.title,
752 headerTitleStyle: usesNativeHeaderGlass
753 ? {
754 fontSize: 17,
755 fontWeight: "800",
756 }
757 : undefined,
758 title: selectedThread.title,
759 headerBackVisible: !layout.usesSplitView,
760 // Compact uses the NATIVE back button (Thread lives flat in the root
761 // stack now, so a real previous route exists); only split view needs
762 // custom left items.
763 unstable_headerLeftItems:
764 Platform.OS === "ios" && layout.usesSplitView ? () => splitLeftHeaderItems : undefined,
765 // Search lives in the persistent sidebar, so the split header keeps
766 // the git controls on the RIGHT (no center items — center space is
767 // reserved for future breadcrumbs/status).
768 unstable_headerRightItems:
769 Platform.OS === "ios"
770 ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems)
771 : undefined,
772 unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined,
773 }}
774 />
775
776 {renderThreadRouteBody(!layout.usesSplitView && !usesNativeHeaderGlass)}
777 </>
778 );
439}779}
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+11-6
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2import { useRouter } from "expo-router";2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useState } from "react";3import { useState } from "react";
4import { Pressable, ScrollView, View } from "react-native";4import { Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -14,8 +14,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo...@@ -14,8 +14,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo
14import { vcsEnvironment } from "../../../state/vcs";14import { vcsEnvironment } from "../../../state/vcs";
15import { SheetActionButton } from "./gitSheetComponents";15import { SheetActionButton } from "./gitSheetComponents";
1616
17export function GitBranchesSheet() {17type GitBranchesSheetProps = StaticScreenProps<{
18 const router = useRouter();18 readonly environmentId: string;
19 readonly threadId: string;
20}>;
21
22export function GitBranchesSheet(_props: GitBranchesSheetProps) {
23 const navigation = useNavigation();
19 const insets = useSafeAreaInsets();24 const insets = useSafeAreaInsets();
20 const { selectedThread } = useThreadSelection();25 const { selectedThread } = useThreadSelection();
21 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();26 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
...@@ -96,7 +101,7 @@ export function GitBranchesSheet() {...@@ -96,7 +101,7 @@ export function GitBranchesSheet() {
96 if (branch.length === 0) return;101 if (branch.length === 0) return;
97 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {102 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
98 setNewBranchName("");103 setNewBranchName("");
99 router.dismiss();104 navigation.goBack();
100 });105 });
101 }}106 }}
102 />107 />
...@@ -146,7 +151,7 @@ export function GitBranchesSheet() {...@@ -146,7 +151,7 @@ export function GitBranchesSheet() {
146 if (baseBranch.length === 0 || newBranch.length === 0) return;151 if (baseBranch.length === 0 || newBranch.length === 0) return;
147 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {152 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {
148 setWorktreeBranchName("");153 setWorktreeBranchName("");
149 router.dismiss();154 navigation.goBack();
150 });155 });
151 }}156 }}
152 />157 />
...@@ -188,7 +193,7 @@ export function GitBranchesSheet() {...@@ -188,7 +193,7 @@ export function GitBranchesSheet() {
188 }}193 }}
189 onPress={() => {194 onPress={() => {
190 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {195 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
191 router.dismiss();196 navigation.goBack();
192 });197 });
193 }}198 }}
194 >199 >
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+15-10
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { useRouter } from "expo-router";1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { useCallback, useState } from "react";2import { useCallback, useState } from "react";
3import { Pressable, ScrollView, View, useColorScheme } from "react-native";3import { Pressable, ScrollView, View, useColorScheme } from "react-native";
4import { useSafeAreaInsets } from "react-native-safe-area-context";4import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -13,8 +13,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo...@@ -13,8 +13,13 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo
13import { vcsEnvironment } from "../../../state/vcs";13import { vcsEnvironment } from "../../../state/vcs";
14import { SheetActionButton } from "./gitSheetComponents";14import { SheetActionButton } from "./gitSheetComponents";
1515
16export function GitCommitSheet() {16type GitCommitSheetProps = StaticScreenProps<{
17 const router = useRouter();17 readonly environmentId: string;
18 readonly threadId: string;
19}>;
20
21export function GitCommitSheet(_props: GitCommitSheetProps) {
22 const navigation = useNavigation();
18 const insets = useSafeAreaInsets();23 const insets = useSafeAreaInsets();
19 const isDarkMode = useColorScheme() === "dark";24 const isDarkMode = useColorScheme() === "dark";
20 const { selectedThread } = useThreadSelection();25 const { selectedThread } = useThreadSelection();
...@@ -55,7 +60,7 @@ export function GitCommitSheet() {...@@ -55,7 +60,7 @@ export function GitCommitSheet() {
55 const runCommitAction = useCallback(60 const runCommitAction = useCallback(
56 async (featureBranch: boolean) => {61 async (featureBranch: boolean) => {
57 const commitMessage = dialogCommitMessage.trim();62 const commitMessage = dialogCommitMessage.trim();
58 router.dismiss();63 navigation.goBack();
59 await gitActions.onRunSelectedThreadGitAction({64 await gitActions.onRunSelectedThreadGitAction({
60 action: "commit",65 action: "commit",
61 featureBranch,66 featureBranch,
...@@ -63,7 +68,7 @@ export function GitCommitSheet() {...@@ -63,7 +68,7 @@ export function GitCommitSheet() {
63 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),68 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
64 });69 });
65 },70 },
66 [allSelected, dialogCommitMessage, gitActions, router, selectedFiles],71 [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles],
67 );72 );
6873
69 return (74 return (
...@@ -86,7 +91,7 @@ export function GitCommitSheet() {...@@ -86,7 +91,7 @@ export function GitCommitSheet() {
86 </View>91 </View>
87 {isDefaultRef ? (92 {isDefaultRef ? (
88 <Text93 <Text
89 className="text-xs leading-[18px]"94 className="text-xs leading-normal"
90 style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }}95 style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }}
91 >96 >
92 Warning: this is the default branch.97 Warning: this is the default branch.
...@@ -98,7 +103,7 @@ export function GitCommitSheet() {...@@ -98,7 +103,7 @@ export function GitCommitSheet() {
98 <View className="flex-row items-center justify-between gap-3">103 <View className="flex-row items-center justify-between gap-3">
99 <View className="gap-1">104 <View className="gap-1">
100 <Text className="text-foreground text-base font-t3-bold">Files</Text>105 <Text className="text-foreground text-base font-t3-bold">Files</Text>
101 <Text className="text-foreground-muted text-xs leading-[18px]">106 <Text className="text-foreground-muted text-xs leading-normal">
102 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}107 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
103 </Text>108 </Text>
104 </View>109 </View>
...@@ -123,7 +128,7 @@ export function GitCommitSheet() {...@@ -123,7 +128,7 @@ export function GitCommitSheet() {
123 </View>128 </View>
124129
125 {allFiles.length === 0 ? (130 {allFiles.length === 0 ? (
126 <Text className="text-foreground-secondary text-sm leading-[19px]">131 <Text className="text-foreground-secondary text-sm leading-normal">
127 No changed files are available to commit.132 No changed files are available to commit.
128 </Text>133 </Text>
129 ) : !isEditingFiles ? (134 ) : !isEditingFiles ? (
...@@ -142,7 +147,7 @@ export function GitCommitSheet() {...@@ -142,7 +147,7 @@ export function GitCommitSheet() {
142 </View>147 </View>
143 ))}148 ))}
144 {selectedFiles.length > selectedFilePreview.length ? (149 {selectedFiles.length > selectedFilePreview.length ? (
145 <Text className="text-foreground-muted text-xs leading-[17px]">150 <Text className="text-foreground-muted text-xs leading-snug">
146 +{selectedFiles.length - selectedFilePreview.length} more files151 +{selectedFiles.length - selectedFilePreview.length} more files
147 </Text>152 </Text>
148 ) : null}153 ) : null}
...@@ -182,7 +187,7 @@ export function GitCommitSheet() {...@@ -182,7 +187,7 @@ export function GitCommitSheet() {
182 {file.path}187 {file.path}
183 </Text>188 </Text>
184 {!included ? (189 {!included ? (
185 <Text className="text-foreground-muted text-2xs leading-[16px]">190 <Text className="text-foreground-muted text-2xs leading-normal">
186 Excluded from this commit191 Excluded from this commit
187 </Text>192 </Text>
188 ) : null}193 ) : null}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+30-15
...@@ -2,7 +2,7 @@ import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/st...@@ -2,7 +2,7 @@ import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/st
2import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";2import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
3import * as Arr from "effect/Array";3import * as Arr from "effect/Array";
4import * as Result from "effect/Result";4import * as Result from "effect/Result";
5import { useLocalSearchParams, useRouter } from "expo-router";5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
6import { useCallback, useMemo } from "react";6import { useCallback, useMemo } from "react";
7import { View } from "react-native";7import { View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";8import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -12,19 +12,23 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-...@@ -12,19 +12,23 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-
12import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";12import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
13import { SheetActionButton } from "./gitSheetComponents";13import { SheetActionButton } from "./gitSheetComponents";
1414
15export function GitConfirmSheet() {15type GitConfirmSheetProps = StaticScreenProps<{
16 const router = useRouter();16 readonly environmentId: string;
17 readonly threadId: string;
18 readonly confirmAction?: string;
19 readonly branchName?: string;
20 readonly includesCommit?: string;
21 readonly commitMessage?: string;
22 readonly filePaths?: string;
23}>;
24
25export function GitConfirmSheet(props: GitConfirmSheetProps) {
26 const navigation = useNavigation();
17 const insets = useSafeAreaInsets();27 const insets = useSafeAreaInsets();
18 const gitState = useSelectedThreadGitState();28 const gitState = useSelectedThreadGitState();
19 const gitActions = useSelectedThreadGitActions();29 const gitActions = useSelectedThreadGitActions();
2030
21 const params = useLocalSearchParams<{31 const params = props.route.params;
22 confirmAction?: string;
23 branchName?: string;
24 includesCommit?: string;
25 commitMessage?: string;
26 filePaths?: string;
27 }>();
2832
29 const confirmAction = params.confirmAction as33 const confirmAction = params.confirmAction as
30 | "push"34 | "push"
...@@ -34,6 +38,8 @@ export function GitConfirmSheet() {...@@ -34,6 +38,8 @@ export function GitConfirmSheet() {
34 | undefined;38 | undefined;
35 const branchName = params.branchName ?? "";39 const branchName = params.branchName ?? "";
36 const includesCommit = params.includesCommit === "true";40 const includesCommit = params.includesCommit === "true";
41 const environmentId = params.environmentId ?? "";
42 const threadId = params.threadId ?? "";
3743
38 const copy = useMemo(44 const copy = useMemo(
39 () =>45 () =>
...@@ -49,17 +55,17 @@ export function GitConfirmSheet() {...@@ -49,17 +55,17 @@ export function GitConfirmSheet() {
4955
50 const continuePendingAction = useCallback(async () => {56 const continuePendingAction = useCallback(async () => {
51 if (!confirmAction) return;57 if (!confirmAction) return;
52 router.dismissAll();58 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
53 await gitActions.onRunSelectedThreadGitAction({59 await gitActions.onRunSelectedThreadGitAction({
54 action: confirmAction,60 action: confirmAction,
55 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),61 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
56 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),62 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
57 });63 });
58 }, [confirmAction, gitActions, params, router]);64 }, [confirmAction, environmentId, gitActions, params, navigation, threadId]);
5965
60 const movePendingActionToFeatureBranch = useCallback(async () => {66 const movePendingActionToFeatureBranch = useCallback(async () => {
61 if (!confirmAction) return;67 if (!confirmAction) return;
62 router.dismissAll();68 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
6369
64 if (includesCommit) {70 if (includesCommit) {
65 await gitActions.onRunSelectedThreadGitAction({71 await gitActions.onRunSelectedThreadGitAction({
...@@ -82,7 +88,16 @@ export function GitConfirmSheet() {...@@ -82,7 +88,16 @@ export function GitConfirmSheet() {
82 );88 );
83 await gitActions.onCreateSelectedThreadBranch(newBranchName);89 await gitActions.onCreateSelectedThreadBranch(newBranchName);
84 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });90 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });
85 }, [confirmAction, gitActions, gitState.selectedThreadBranches, includesCommit, params, router]);91 }, [
92 confirmAction,
93 gitActions,
94 gitState.selectedThreadBranches,
95 includesCommit,
96 params,
97 navigation,
98 environmentId,
99 threadId,
100 ]);
86101
87 return (102 return (
88 <View collapsable={false} className="flex-1 bg-sheet">103 <View collapsable={false} className="flex-1 bg-sheet">
...@@ -98,7 +113,7 @@ export function GitConfirmSheet() {...@@ -98,7 +113,7 @@ export function GitConfirmSheet() {
98 <Text className="text-center text-3xl font-t3-bold">113 <Text className="text-center text-3xl font-t3-bold">
99 {copy?.title ?? "Run action on default branch?"}114 {copy?.title ?? "Run action on default branch?"}
100 </Text>115 </Text>
101 <Text className="text-center text-foreground-secondary text-sm font-medium leading-[19px]">116 <Text className="text-center text-foreground-secondary text-sm font-medium leading-normal">
102 {copy?.description ?? "Choose how to continue."}117 {copy?.description ?? "Choose how to continue."}
103 </Text>118 </Text>
104 </View>119 </View>
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+246-97
...@@ -4,17 +4,18 @@ import {...@@ -4,17 +4,18 @@ import {
4 getGitActionDisabledReason,4 getGitActionDisabledReason,
5 requiresDefaultBranchConfirmation,5 requiresDefaultBranchConfirmation,
6} from "@t3tools/client-runtime/state/vcs";6} from "@t3tools/client-runtime/state/vcs";
7import type { EnvironmentId, ThreadId } from "@t3tools/contracts";7import { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import { useLocalSearchParams, useRouter } from "expo-router";8import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
9import { SymbolView } from "expo-symbols";9import { SymbolView } from "expo-symbols";
10import { useCallback, useEffect, useMemo } from "react";10import { useCallback, useEffect, useMemo, useState } from "react";
11import { Alert, Pressable, ScrollView, View } from "react-native";11import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
12import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
12import { useSafeAreaInsets } from "react-native-safe-area-context";13import { useSafeAreaInsets } from "react-native-safe-area-context";
13import { useThemeColor } from "../../../lib/useThemeColor";14import { useThemeColor } from "../../../lib/useThemeColor";
1415
15import { AppText as Text } from "../../../components/AppText";16import { AppText as Text } from "../../../components/AppText";
17import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";
16import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";18import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
17import { buildThreadReviewRoutePath } from "../../../lib/routes";
18import { useEnvironmentQuery } from "../../../state/query";19import { useEnvironmentQuery } from "../../../state/query";
19import { useThreadSelection } from "../../../state/use-thread-selection";20import { useThreadSelection } from "../../../state/use-thread-selection";
20import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";21import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
...@@ -23,13 +24,23 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo...@@ -23,13 +24,23 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo
23import { vcsEnvironment } from "../../../state/vcs";24import { vcsEnvironment } from "../../../state/vcs";
24import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";25import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
2526
26export function GitOverviewSheet() {27const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
27 const router = useRouter();28
29type GitOverviewSheetProps = StaticScreenProps<{
30 readonly environmentId: string;
31 readonly threadId: string;
32}> & {
33 readonly headerInset?: number;
34 readonly presentation?: "sheet" | "inspector";
35};
36
37export function GitOverviewSheet(props: GitOverviewSheetProps) {
38 const navigation = useNavigation();
28 const insets = useSafeAreaInsets();39 const insets = useSafeAreaInsets();
29 const { environmentId, threadId } = useLocalSearchParams<{40 const presentation = props.presentation ?? "sheet";
30 environmentId: EnvironmentId;41 const isInspector = presentation === "inspector";
31 threadId: ThreadId;42 const environmentId = EnvironmentId.make(props.route.params.environmentId);
32 }>();43 const threadId = ThreadId.make(props.route.params.threadId);
33 const { selectedThread } = useThreadSelection();44 const { selectedThread } = useThreadSelection();
34 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();45 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
35 const gitState = useSelectedThreadGitState();46 const gitState = useSelectedThreadGitState();
...@@ -37,6 +48,8 @@ export function GitOverviewSheet() {...@@ -37,6 +48,8 @@ export function GitOverviewSheet() {
3748
38 const iconColor = useThemeColor("--color-icon");49 const iconColor = useThemeColor("--color-icon");
39 const borderColor = useThemeColor("--color-border");50 const borderColor = useThemeColor("--color-border");
51 const foregroundColor = String(useThemeColor("--color-foreground"));
52 const sheetColor = String(useThemeColor("--color-sheet"));
4053
41 const gitStatus = useEnvironmentQuery(54 const gitStatus = useEnvironmentQuery(
42 selectedThread !== null && selectedThreadCwd !== null55 selectedThread !== null && selectedThreadCwd !== null
...@@ -48,6 +61,7 @@ export function GitOverviewSheet() {...@@ -48,6 +61,7 @@ export function GitOverviewSheet() {
48 );61 );
4962
50 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";63 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
64 const currentStatusSummary = statusSummary(gitStatus.data);
51 const currentWorktreePath = selectedThreadWorktreePath;65 const currentWorktreePath = selectedThreadWorktreePath;
52 const gitOperationLabel = gitState.gitOperationLabel;66 const gitOperationLabel = gitState.gitOperationLabel;
53 const busy = gitOperationLabel !== null;67 const busy = gitOperationLabel !== null;
...@@ -105,25 +119,24 @@ export function GitOverviewSheet() {...@@ -105,25 +119,24 @@ export function GitOverviewSheet() {
105 !input.featureBranch &&119 !input.featureBranch &&
106 requiresDefaultBranchConfirmation(input.action, isDefaultRef)120 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
107 ) {121 ) {
108 router.push({122 navigation.navigate("GitConfirm", {
109 pathname: "/threads/[environmentId]/[threadId]/git-confirm",123 environmentId: String(environmentId),
110 params: {124 threadId: String(threadId),
111 environmentId,125 confirmAction: confirmableAction,
112 threadId,126 branchName,
113 confirmAction: confirmableAction,127 includesCommit: String(
114 branchName,128 input.action === "commit_push" || input.action === "commit_push_pr",
115 includesCommit: String(129 ),
116 input.action === "commit_push" || input.action === "commit_push_pr",
117 ),
118 },
119 });130 });
120 return;131 return;
121 }132 }
122133
123 router.dismiss();134 if (!isInspector) {
135 navigation.goBack();
136 }
124 await gitActions.onRunSelectedThreadGitAction(input);137 await gitActions.onRunSelectedThreadGitAction(input);
125 },138 },
126 [environmentId, gitActions, gitStatus.data, isDefaultRef, router, threadId],139 [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId],
127 );140 );
128141
129 const onPressMenuItem = useCallback(142 const onPressMenuItem = useCallback(
...@@ -134,9 +147,9 @@ export function GitOverviewSheet() {...@@ -134,9 +147,9 @@ export function GitOverviewSheet() {
134 return;147 return;
135 }148 }
136 if (item.dialogAction === "commit") {149 if (item.dialogAction === "commit") {
137 router.push({150 navigation.navigate("GitCommit", {
138 pathname: "/threads/[environmentId]/[threadId]/git/commit",151 environmentId: String(environmentId),
139 params: { environmentId, threadId },152 threadId: String(threadId),
140 });153 });
141 return;154 return;
142 }155 }
...@@ -148,14 +161,209 @@ export function GitOverviewSheet() {...@@ -148,14 +161,209 @@ export function GitOverviewSheet() {
148 await runActionWithPrompt({ action: "create_pr" });161 await runActionWithPrompt({ action: "create_pr" });
149 }162 }
150 },163 },
151 [environmentId, openExistingPr, router, runActionWithPrompt, threadId],164 [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId],
165 );
166
167 // Status facts live on the relevant rows instead of crowding the header
168 // subtitle: files changed → Commit, ahead → Push, PR → View PR, behind → Pull.
169 const rowStatusDetail = useCallback(
170 (item: (typeof menuItems)[number]): string | undefined => {
171 const status = gitStatus.data;
172 if (status == null) {
173 return undefined;
174 }
175 if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) {
176 const fileCount = status.workingTree?.files.length ?? 0;
177 return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`;
178 }
179 if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) {
180 const ahead = status.aheadCount ?? 0;
181 return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`;
182 }
183 if (item.kind === "open_pr" && status.pr?.number != null) {
184 return `PR #${status.pr.number} ${status.pr.state ?? "open"}`;
185 }
186 return undefined;
187 },
188 [gitStatus.data, menuItems],
189 );
190
191 const behindCount = gitStatus.data?.behindCount ?? 0;
192
193 // Deterministic pull-to-refresh state. Tying RefreshControl to the query's
194 // isPending flag left the spinner stuck (the status query reports pending
195 // during quiet background refreshes too).
196 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
197 const handlePullRefresh = useCallback(async () => {
198 setIsPullRefreshing(true);
199 try {
200 await gitActions.refreshSelectedThreadGitStatus();
201 } finally {
202 setIsPullRefreshing(false);
203 }
204 }, [gitActions]);
205
206 const content = (
207 <ScrollView
208 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
209 showsVerticalScrollIndicator={false}
210 style={{ flex: 1, backgroundColor: sheetColor }}
211 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
212 contentContainerStyle={{
213 paddingHorizontal: isInspector ? 12 : 20,
214 paddingTop: 8,
215 gap: 14,
216 }}
217 refreshControl={
218 <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} />
219 }
220 >
221 <View
222 className={
223 isInspector
224 ? "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"
225 : "overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1"
226 }
227 >
228 {sheetMenuItems.map(({ item, disabledReason }, index) => (
229 <View key={`${item.id}-${item.label}`}>
230 {index > 0 ? (
231 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
232 ) : null}
233 <SheetListRow
234 icon={menuItemIconName(item.icon)}
235 title={item.label}
236 subtitle={disabledReason ?? rowStatusDetail(item)}
237 disabled={item.disabled}
238 onPress={() => void onPressMenuItem(item)}
239 />
240 </View>
241 ))}
242 {behindCount > 0 ? (
243 <>
244 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
245 <SheetListRow
246 icon="arrow.down.circle"
247 title="Pull latest"
248 subtitle={`${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`}
249 disabled={busy || !isRepo}
250 onPress={() => void gitActions.onPullSelectedThreadBranch()}
251 />
252 </>
253 ) : null}
254 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
255 <SheetListRow
256 icon="text.bubble"
257 title="Review changes"
258 subtitle="Inspect turn diffs, worktree changes, and base branch diff"
259 disabled={busy || !isRepo}
260 onPress={() => navigation.navigate("ThreadReview", { environmentId, threadId })}
261 />
262 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
263 <SheetListRow
264 icon="point.topleft.down.curvedto.point.bottomright.up"
265 title="Branches & worktrees"
266 subtitle="Switch branch, create branch, or move to a worktree"
267 disabled={busy || !isRepo}
268 onPress={() =>
269 navigation.navigate("GitBranches", {
270 environmentId: String(environmentId),
271 threadId: String(threadId),
272 })
273 }
274 />
275 </View>
276
277 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
278 </ScrollView>
152 );279 );
153280
281 if (isInspector && Platform.OS === "ios") {
282 return (
283 <View collapsable={false} className="flex-1 border-l border-border bg-sheet">
284 <ScreenStack style={{ flex: 1 }}>
285 <Screen
286 activityState={2}
287 enabled
288 isNativeStack
289 screenId="thread-git-inspector-native"
290 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
291 style={{ backgroundColor: sheetColor, flex: 1 }}
292 >
293 {content}
294 <ScreenStackHeaderConfig
295 backgroundColor="rgba(0,0,0,0)"
296 color={foregroundColor}
297 hideBackButton
298 hideShadow={false}
299 navigationItemStyle="editor"
300 title={currentBranchLabel}
301 titleColor={foregroundColor}
302 titleFontSize={17}
303 titleFontWeight="700"
304 translucent
305 />
306 </Screen>
307 </ScreenStack>
308 </View>
309 );
310 }
311
312 if (Platform.OS === "ios") {
313 // Compact form sheet: a plain screen presented as formSheet never renders a
314 // stack header, so — like the Settings sheet — the header must come from a
315 // nested native stack INSIDE the sheet. This reuses the exact structure of the
316 // inspector branch below: branch as the title, status summary as the native
317 // subtitle, refresh as a header button.
318 return (
319 <View collapsable={false} className="flex-1 bg-sheet">
320 <ScreenStack style={{ flex: 1 }}>
321 <Screen
322 activityState={2}
323 enabled
324 isNativeStack
325 screenId="thread-git-sheet-native"
326 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
327 style={{ backgroundColor: sheetColor, flex: 1 }}
328 >
329 {content}
330 <ScreenStackHeaderConfig
331 backgroundColor="rgba(0,0,0,0)"
332 color={foregroundColor}
333 hideBackButton
334 hideShadow={false}
335 navigationItemStyle="editor"
336 title={currentBranchLabel}
337 titleColor={foregroundColor}
338 titleFontSize={18}
339 titleFontWeight="800"
340 translucent
341 />
342 </Screen>
343 </ScreenStack>
344 </View>
345 );
346 }
347
154 return (348 return (
155 <View collapsable={false} className="flex-1 bg-sheet">349 <View
156 <View style={{ minHeight: 16, paddingTop: 8 }} />350 collapsable={false}
351 className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"}
352 >
353 <View
354 style={{
355 minHeight: isInspector ? (props.headerInset ?? 0) : 16,
356 paddingTop: isInspector ? (props.headerInset ?? 0) : 8,
357 }}
358 />
157359
158 <View className="items-center gap-1 px-5 pb-3 pt-4">360 <View
361 className={
362 isInspector
363 ? "gap-1 border-b border-border px-4 pb-4 pt-3"
364 : "items-center gap-1 px-5 pb-3 pt-4"
365 }
366 >
159 <Pressable367 <Pressable
160 className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"368 className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle"
161 style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }}369 style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }}
...@@ -174,76 +382,17 @@ export function GitOverviewSheet() {...@@ -174,76 +382,17 @@ export function GitOverviewSheet() {
174 className="text-xs font-t3-bold uppercase text-foreground-muted"382 className="text-xs font-t3-bold uppercase text-foreground-muted"
175 style={{ letterSpacing: 1 }}383 style={{ letterSpacing: 1 }}
176 >384 >
177 Branch385 {isInspector ? "Repository" : "Branch"}
386 </Text>
387 <Text className={isInspector ? "pr-10 text-xl font-t3-bold" : "text-3xl font-t3-bold"}>
388 {currentBranchLabel}
178 </Text>389 </Text>
179 <Text className="text-3xl font-t3-bold">{currentBranchLabel}</Text>390 <Text className="text-foreground-secondary text-sm font-medium leading-normal">
180 <Text className="text-foreground-secondary text-sm font-medium leading-[19px]">391 {currentStatusSummary}
181 {statusSummary(gitStatus.data)}
182 </Text>392 </Text>
183 </View>393 </View>
184394
185 <ScrollView395 {content}
186 showsVerticalScrollIndicator={false}
187 style={{ flex: 1 }}
188 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
189 contentContainerStyle={{
190 paddingHorizontal: 20,
191 paddingTop: 8,
192 gap: 14,
193 }}
194 >
195 <View className="overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1">
196 {sheetMenuItems.map(({ item, disabledReason }, index) => (
197 <View key={`${item.id}-${item.label}`}>
198 {index > 0 ? (
199 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
200 ) : null}
201 <SheetListRow
202 icon={menuItemIconName(item.icon)}
203 title={item.label}
204 subtitle={disabledReason}
205 disabled={item.disabled}
206 onPress={() => void onPressMenuItem(item)}
207 />
208 </View>
209 ))}
210 {(gitStatus.data?.behindCount ?? 0) > 0 ? (
211 <>
212 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
213 <SheetListRow
214 icon="arrow.down.circle"
215 title="Pull latest"
216 subtitle="Sync this branch with upstream"
217 disabled={busy || !isRepo}
218 onPress={() => void gitActions.onPullSelectedThreadBranch()}
219 />
220 </>
221 ) : null}
222 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
223 <SheetListRow
224 icon="text.bubble"
225 title="Review changes"
226 subtitle="Inspect turn diffs, worktree changes, and base branch diff"
227 disabled={busy || !isRepo}
228 onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))}
229 />
230 <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} />
231 <SheetListRow
232 icon="point.topleft.down.curvedto.point.bottomright.up"
233 title="Branches & worktrees"
234 subtitle="Switch branch, create branch, or move to a worktree"
235 disabled={busy || !isRepo}
236 onPress={() =>
237 router.push({
238 pathname: "/threads/[environmentId]/[threadId]/git/branches",
239 params: { environmentId, threadId },
240 })
241 }
242 />
243 </View>
244
245 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
246 </ScrollView>
247 </View>396 </View>
248 );397 );
249}398}
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+1-1
...@@ -104,7 +104,7 @@ export function SheetListRow(props: {...@@ -104,7 +104,7 @@ export function SheetListRow(props: {
104 <View className="flex-1 gap-0.5">104 <View className="flex-1 gap-0.5">
105 <Text className="text-foreground text-base font-t3-bold">{props.title}</Text>105 <Text className="text-foreground text-base font-t3-bold">{props.title}</Text>
106 {props.subtitle ? (106 {props.subtitle ? (
107 <Text className="text-foreground-muted text-xs leading-[17px]">{props.subtitle}</Text>107 <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text>
108 ) : null}108 ) : null}
109 </View>109 </View>
110 <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" />110 <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" />
apps/mobile/src/features/threads/sidebar-filter-button.tsx created+55
...@@ -0,0 +1,55 @@
1import { SymbolView } from "expo-symbols";
2import { Pressable, StyleSheet, useColorScheme } from "react-native";
3
4import { useThemeColor } from "../../lib/useThemeColor";
5
6export type SidebarFilterButtonIcon =
7 | "line.3.horizontal.decrease.circle"
8 | "line.3.horizontal.decrease.circle.fill";
9
10export function SidebarFilterButton(props: {
11 readonly accessibilityLabel: string;
12 readonly icon: SidebarFilterButtonIcon;
13 /** Rendered inside a shared capsule group — no own background/border. */
14 readonly grouped?: boolean;
15}) {
16 const iconColor = useThemeColor("--color-foreground");
17 const pressedBackgroundColor = useThemeColor("--color-subtle");
18 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
19 const idleBackgroundColor =
20 colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)";
21 const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
22
23 return (
24 <Pressable
25 accessibilityLabel={props.accessibilityLabel}
26 accessibilityRole="button"
27 hitSlop={4}
28 style={({ pressed }) => [
29 styles.button,
30 props.grouped
31 ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 }
32 : {
33 backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor,
34 borderColor,
35 },
36 ]}
37 >
38 <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" />
39 </Pressable>
40 );
41}
42
43const styles = StyleSheet.create({
44 button: {
45 // Match the native glass UIBarButtonItem group metrics (~50pt slots,
46 // 44pt bar height, label-colored ~20pt glyphs).
47 width: 50,
48 height: 44,
49 borderRadius: 22,
50 borderWidth: StyleSheet.hairlineWidth,
51 alignItems: "center",
52 justifyContent: "center",
53 cursor: "pointer",
54 },
55});
apps/mobile/src/features/threads/sidebar-header-actions.tsx created+74
...@@ -0,0 +1,74 @@
1import { SymbolView } from "expo-symbols";
2import { Pressable, StyleSheet, View, useColorScheme } from "react-native";
3
4import { useThemeColor } from "../../lib/useThemeColor";
5
6export interface SidebarHeaderActionsProps {
7 readonly onOpenSettings: () => void;
8 /** Rendered inside a shared capsule group — buttons drop their own chrome. */
9 readonly grouped?: boolean;
10}
11
12function FallbackHeaderButton(props: {
13 readonly accessibilityLabel: string;
14 readonly icon: "gearshape" | "square.and.pencil";
15 readonly grouped?: boolean;
16 readonly onPress: () => void;
17}) {
18 const iconColor = useThemeColor("--color-foreground");
19 const pressedBackgroundColor = useThemeColor("--color-subtle");
20 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
21 const idleBackgroundColor =
22 colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)";
23 const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
24
25 return (
26 <Pressable
27 accessibilityLabel={props.accessibilityLabel}
28 accessibilityRole="button"
29 hitSlop={4}
30 onPress={props.onPress}
31 style={({ pressed }) => [
32 styles.button,
33 props.grouped
34 ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 }
35 : {
36 backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor,
37 borderColor,
38 },
39 ]}
40 >
41 <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" />
42 </Pressable>
43 );
44}
45
46export function SidebarHeaderActions(props: SidebarHeaderActionsProps) {
47 return (
48 <View style={styles.actions}>
49 <FallbackHeaderButton
50 accessibilityLabel="Open settings"
51 grouped={props.grouped}
52 icon="gearshape"
53 onPress={props.onOpenSettings}
54 />
55 </View>
56 );
57}
58
59const styles = StyleSheet.create({
60 actions: {
61 flexDirection: "row",
62 alignItems: "center",
63 gap: 2,
64 },
65 button: {
66 // Match the native glass UIBarButtonItem group metrics.
67 width: 50,
68 height: 44,
69 borderRadius: 22,
70 borderWidth: StyleSheet.hairlineWidth,
71 alignItems: "center",
72 justifyContent: "center",
73 },
74});
apps/mobile/src/features/threads/sidebar-native-header-items.ts created+63
...@@ -0,0 +1,63 @@
1import type {
2 NativeStackHeaderItem,
3 NativeStackHeaderItemMenu,
4} from "@react-navigation/native-stack";
5
6import type { HomeListFilterMenu } from "../home/home-list-filter-menu";
7import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
8
9type NativeHeaderMenuItems = NativeStackHeaderItemMenu["menu"]["items"];
10type NativeHeaderIcon = NonNullable<Extract<NativeStackHeaderItem, { type: "button" }>["icon"]>;
11
12function sfSymbolIcon(name: string): NativeHeaderIcon {
13 return { type: "sfSymbol", name: name as never };
14}
15
16function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHeaderMenuItems {
17 return items.map((item) =>
18 item.type === "action"
19 ? {
20 type: "action" as const,
21 label: item.title,
22 description: item.subtitle,
23 onPress: item.onPress,
24 state: item.state === "on" ? ("on" as const) : undefined,
25 }
26 : {
27 type: "submenu" as const,
28 label: item.title,
29 items: toNativeHeaderMenuItems(item.items),
30 },
31 );
32}
33
34/**
35 * Right-side UINavigationBar items for the sidebar column: the thread list
36 * filter/sort menu plus the settings button, sharing one glass capsule —
37 * the Messages-style grouped header buttons.
38 */
39export function createSidebarHeaderItems(input: {
40 readonly filterIcon: string;
41 readonly filterMenu: HomeListFilterMenu;
42 readonly onOpenSettings: () => void;
43}): NativeStackHeaderItem[] {
44 return [
45 withNativeGlassHeaderItem({
46 type: "menu",
47 label: "",
48 accessibilityLabel: "Filter and sort threads",
49 icon: sfSymbolIcon(input.filterIcon),
50 menu: {
51 title: input.filterMenu.title,
52 items: toNativeHeaderMenuItems(input.filterMenu.items),
53 },
54 }),
55 withNativeGlassHeaderItem({
56 type: "button",
57 label: "",
58 accessibilityLabel: "Open settings",
59 icon: sfSymbolIcon("gearshape"),
60 onPress: input.onOpenSettings,
61 }),
62 ];
63}
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx created+72
...@@ -0,0 +1,72 @@
1import {
2 DarkTheme,
3 DefaultTheme,
4 NavigationContainer,
5 NavigationIndependentTree,
6} from "@react-navigation/native";
7import {
8 createNativeStackNavigator,
9 type NativeStackNavigationOptions,
10} from "@react-navigation/native-stack";
11import type { ReactNode } from "react";
12import { Platform, useColorScheme } from "react-native";
13
14import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
15
16const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
17
18type SidebarScreenOptions = NativeStackNavigationOptions & {
19 // Same patched RNS option the GLASS/SOLID presets in Stack.tsx use — the
20 // iOS 26 "editor" navigation-item style leading-aligns the inline title.
21 readonly unstable_navigationItemStyle?: "editor";
22};
23
24/**
25 * Static chrome for the sidebar column: a real UINavigationBar with a fixed
26 * inline title (no large title — saves vertical space, left-aligned via the
27 * editor item style) and the search bar pinned below it, scroll-edge blur
28 * sampling the list. Only genuinely dynamic values (search callbacks, header
29 * items) are set by the screen content via NativeStackScreenOptions.
30 */
31const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = {
32 contentStyle: { backgroundColor: "transparent" },
33 headerLargeTitle: false,
34 headerShadowVisible: false,
35 headerShown: true,
36 headerStyle: { backgroundColor: "transparent" },
37 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
38 headerTransparent: true,
39 scrollEdgeEffects: SCROLL_EDGE_EFFECTS,
40 title: "Threads",
41 unstable_navigationItemStyle: "editor",
42};
43
44const SidebarStack = createNativeStackNavigator();
45
46/**
47 * Hosts the iPad sidebar pane inside its own single-screen native stack.
48 *
49 * The stack is navigation-inert — nothing is ever pushed onto it. It exists so
50 * the sidebar column owns a real UINavigationBar (large title, native bar
51 * button items, UISearchController), mirroring how each column of a
52 * UISplitViewController has its own UINavigationController. All real
53 * navigation still flows through the root stack via callbacks minted in
54 * AdaptiveWorkspaceLayout; NavigationIndependentTree only isolates the
55 * navigation hooks used for header configuration inside the pane.
56 */
57export function SidebarNavigationShell(props: { readonly children: ReactNode }) {
58 const colorScheme = useColorScheme();
59
60 return (
61 <NavigationIndependentTree>
62 <NavigationContainer theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
63 <SidebarStack.Navigator
64 screenOptions={SIDEBAR_SCREEN_OPTIONS}
65 initialRouteName="SidebarThreads"
66 >
67 <SidebarStack.Screen name="SidebarThreads">{() => props.children}</SidebarStack.Screen>
68 </SidebarStack.Navigator>
69 </NavigationContainer>
70 </NavigationIndependentTree>
71 );
72}
apps/mobile/src/features/threads/thread-inspector-content-stack.tsx created+101
...@@ -0,0 +1,101 @@
1import { useEffect, useState, type ComponentType, type ReactNode } from "react";
2import { View } from "react-native";
3
4export type ThreadInspectorMode = "route" | "git" | "files";
5
6const INSPECTOR_PREWARM_DELAY_MS = 350;
7
8function InspectorContentPane(props: {
9 readonly children: ReactNode;
10 readonly mounted: boolean;
11 readonly visible: boolean;
12}) {
13 if (!props.mounted) {
14 return null;
15 }
16
17 return (
18 <View
19 accessibilityElementsHidden={!props.visible}
20 focusable={props.visible}
21 importantForAccessibility={props.visible ? "auto" : "no-hide-descendants"}
22 pointerEvents={props.visible ? "auto" : "none"}
23 style={{
24 position: "absolute",
25 inset: 0,
26 opacity: props.visible ? 1 : 0,
27 zIndex: props.visible ? 1 : 0,
28 }}
29 >
30 {props.children}
31 </View>
32 );
33}
34
35export function ThreadInspectorContentStack(props: {
36 readonly Files: ComponentType;
37 readonly Git: ComponentType;
38 readonly mode: ThreadInspectorMode;
39 readonly Route?: ComponentType;
40}) {
41 const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>(
42 () => new Set([props.mode]),
43 );
44
45 useEffect(() => {
46 setMountedModes((current) => {
47 if (current.has(props.mode)) {
48 return current;
49 }
50 return new Set([...current, props.mode]);
51 });
52
53 if (props.mode === "route") {
54 return;
55 }
56
57 // The file tree is expensive to detach because UIKit rebuilds its focus
58 // graph. Keep both chat inspectors alive after the opening animation so a
59 // later Files/Git switch only changes visibility.
60 const alternateMode = props.mode === "files" ? "git" : "files";
61 const timeout = setTimeout(() => {
62 setMountedModes((current) => {
63 if (current.has(alternateMode)) {
64 return current;
65 }
66 return new Set([...current, alternateMode]);
67 });
68 }, INSPECTOR_PREWARM_DELAY_MS);
69
70 return () => clearTimeout(timeout);
71 }, [props.mode]);
72
73 const Files = props.Files;
74 const Git = props.Git;
75 const Route = props.Route;
76
77 return (
78 <View style={{ flex: 1 }}>
79 <InspectorContentPane
80 mounted={mountedModes.has("files") || props.mode === "files"}
81 visible={props.mode === "files"}
82 >
83 <Files />
84 </InspectorContentPane>
85 <InspectorContentPane
86 mounted={mountedModes.has("git") || props.mode === "git"}
87 visible={props.mode === "git"}
88 >
89 <Git />
90 </InspectorContentPane>
91 {Route ? (
92 <InspectorContentPane
93 mounted={mountedModes.has("route") || props.mode === "route"}
94 visible={props.mode === "route"}
95 >
96 <Route />
97 </InspectorContentPane>
98 ) : null}
99 </View>
100 );
101}
apps/mobile/src/features/threads/thread-list-items.tsx created+444
...@@ -0,0 +1,444 @@
1import { useRecyclingState } from "@legendapp/list/react-native";
2import type {
3 EnvironmentProject,
4 EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";
6import type { MenuAction } from "@react-native-menu/menu";
7import { SymbolView } from "expo-symbols";
8import { memo, useCallback, useMemo, type ComponentProps } from "react";
9import { Pressable, useWindowDimensions, View } from "react-native";
10import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
11
12import { AppText as Text } from "../../components/AppText";
13import { ControlPillMenu } from "../../components/ControlPill";
14import { ProjectFavicon } from "../../components/ProjectFavicon";
15import { relativeTime } from "../../lib/time";
16import { useThemeColor } from "../../lib/useThemeColor";
17import { useThreadPr } from "../../state/use-thread-pr";
18import type { HomeGroupDisplayAction } from "../home/homeListItems";
19import { ThreadSwipeable } from "../home/thread-swipe-actions";
20import { resolveThreadStatus } from "./threadPresentation";
21
22/**
23 * Shared presentation for the thread lists: the compact (phone) Home list and
24 * the iPad sidebar render the SAME items — group headers with collapse,
25 * thread rows with status/PR/subtitle, and show-more rows — differing only in
26 * metrics and chrome via `variant`.
27 */
28export type ThreadListVariant = "compact" | "sidebar";
29
30/** Left inset that aligns compact secondary rows with the title column. */
31export const THREAD_LIST_COMPACT_INSET = 20;
32const SIDEBAR_ROW_RADIUS = 12;
33
34/* ─── Project group header ───────────────────────────────────────────── */
35
36export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: {
37 readonly variant: ThreadListVariant;
38 readonly project: EnvironmentProject;
39 readonly title: string;
40 readonly threadCount: number;
41 readonly collapsed: boolean;
42 readonly isFirst: boolean;
43 readonly groupKey: string;
44 readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;
45}) {
46 const iconSubtleColor = useThemeColor("--color-icon-subtle");
47 const { groupKey, onGroupAction } = props;
48 const compact = props.variant === "compact";
49 const handleToggle = useCallback(
50 () => onGroupAction(groupKey, "toggle-collapsed"),
51 [groupKey, onGroupAction],
52 );
53
54 return (
55 <Pressable
56 accessibilityRole="button"
57 accessibilityState={{ expanded: !props.collapsed }}
58 accessibilityLabel={`${props.title}, ${props.threadCount} threads`}
59 accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"}
60 className={compact ? "bg-screen" : undefined}
61 onPress={handleToggle}
62 >
63 <View
64 className={
65 compact
66 ? `flex-row items-center gap-2.5 px-5 pb-3 ${props.isFirst ? "pt-2" : "pt-6"}`
67 : `flex-row items-center gap-2 px-3 pb-2 ${props.isFirst ? "pt-1" : "pt-5"}`
68 }
69 style={{ minHeight: compact ? 44 : 36 }}
70 >
71 <ProjectFavicon
72 environmentId={props.project.environmentId}
73 size={compact ? 22 : 18}
74 projectTitle={props.project.title}
75 workspaceRoot={props.project.workspaceRoot}
76 />
77 <Text
78 className={
79 compact
80 ? "flex-shrink text-base font-t3-bold text-foreground-muted"
81 : "flex-shrink text-sm font-t3-bold text-foreground-muted"
82 }
83 style={{ letterSpacing: 0.2 }}
84 numberOfLines={1}
85 >
86 {props.title}
87 </Text>
88 <Text
89 className={
90 compact
91 ? "flex-1 text-sm font-t3-medium text-foreground-tertiary"
92 : "flex-1 text-xs font-t3-medium text-foreground-tertiary"
93 }
94 >
95 {props.threadCount}
96 </Text>
97 <SymbolView
98 name={props.collapsed ? "chevron.right" : "chevron.down"}
99 size={compact ? 13 : 11}
100 tintColor={iconSubtleColor}
101 type="monochrome"
102 weight="semibold"
103 />
104 </View>
105 </Pressable>
106 );
107});
108
109/* ─── Show more / show less row ──────────────────────────────────────── */
110
111export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: {
112 readonly variant: ThreadListVariant;
113 readonly hiddenCount: number;
114 readonly canShowLess: boolean;
115 readonly groupKey: string;
116 readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;
117}) {
118 const iconSubtleColor = useThemeColor("--color-icon-subtle");
119 const showsMore = props.hiddenCount > 0;
120 const compact = props.variant === "compact";
121 const { groupKey, onGroupAction } = props;
122 const handleShowMore = useCallback(
123 () => onGroupAction(groupKey, "show-more"),
124 [groupKey, onGroupAction],
125 );
126 const handleShowLess = useCallback(
127 () => onGroupAction(groupKey, "show-less"),
128 [groupKey, onGroupAction],
129 );
130
131 const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => (
132 <Pressable
133 accessibilityRole="button"
134 accessibilityLabel={label === "Show more" ? "Show more threads" : "Show fewer threads"}
135 className="rounded-full bg-subtle"
136 hitSlop={6}
137 onPress={onPress}
138 style={({ pressed }) => ({
139 opacity: pressed ? 0.6 : 1,
140 paddingHorizontal: compact ? 14 : 12,
141 paddingVertical: compact ? 7 : 6,
142 borderCurve: "continuous",
143 })}
144 >
145 <View className="flex-row items-center gap-1.5">
146 <SymbolView
147 name={icon}
148 size={10}
149 tintColor={iconSubtleColor}
150 type="monochrome"
151 weight="semibold"
152 />
153 <Text
154 className={
155 compact
156 ? "text-sm font-t3-medium text-foreground-muted"
157 : "text-xs font-t3-medium text-foreground-muted"
158 }
159 >
160 {label}
161 </Text>
162 </View>
163 </Pressable>
164 );
165
166 return (
167 <View
168 className={
169 compact ? "flex-row items-center gap-2.5 bg-screen" : "flex-row items-center gap-2"
170 }
171 style={{
172 paddingLeft: compact ? THREAD_LIST_COMPACT_INSET : 12,
173 paddingRight: compact ? 18 : 12,
174 paddingVertical: compact ? 12 : 8,
175 }}
176 >
177 {showsMore ? button("Show more", "chevron.down", handleShowMore) : null}
178 {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null}
179 </View>
180 );
181});
182
183/* ─── Thread row ─────────────────────────────────────────────────────── */
184
185const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [
186 { id: "archive", title: "Archive", image: "archivebox" },
187 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
188];
189
190export const ThreadListRow = memo(function ThreadListRow(props: {
191 readonly variant: ThreadListVariant;
192 readonly thread: EnvironmentThreadShell;
193 readonly environmentLabel: string | null;
194 readonly projectCwd: string | null;
195 readonly isLast: boolean;
196 /** Sidebar only: the thread currently open in the detail pane. */
197 readonly selected?: boolean;
198 /** Defaults to window width minus compact margins. */
199 readonly fullSwipeWidth?: number;
200 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
201 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
202 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
203 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
204 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
205 readonly simultaneousSwipeGesture?: ComponentProps<
206 typeof ThreadSwipeable
207 >["simultaneousWithExternalGesture"];
208}) {
209 const { width: windowWidth } = useWindowDimensions();
210 const compact = props.variant === "compact";
211 const selected = props.selected === true;
212 // Recycling-safe: resets when the list container is reused for another
213 // thread, so a hover highlight can't leak across rows.
214 const [hovered, setHovered] = useRecyclingState(false);
215
216 const separatorColor = useThemeColor("--color-separator");
217 const iconSubtleColor = useThemeColor("--color-icon-subtle");
218 const screenColor = useThemeColor("--color-screen");
219 const drawerColor = useThemeColor("--color-drawer");
220 const foregroundColor = useThemeColor("--color-foreground");
221 const mutedColor = useThemeColor("--color-foreground-muted");
222 const pressedBackgroundColor = useThemeColor("--color-subtle");
223 const selectedBackgroundColor = useThemeColor("--color-user-bubble");
224 const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground");
225 const selectedMutedColor = useThemeColor("--color-user-bubble-foreground-muted");
226
227 const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props;
228 const status = resolveThreadStatus(thread);
229 const pr = useThreadPr(thread, props.projectCwd);
230 const timestamp = relativeTime(
231 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
232 );
233 const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string =>
234 Boolean(part),
235 );
236
237 const backgroundColor = compact ? screenColor : drawerColor;
238 const effectiveForeground = selected ? selectedForegroundColor : foregroundColor;
239 const effectiveMuted = selected ? selectedMutedColor : mutedColor;
240 const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor;
241 const effectiveStatus =
242 selected && status
243 ? { ...status, pillClassName: "bg-white/20", textClassName: "text-white" }
244 : status;
245
246 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
247 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
248 const primaryAction = useMemo(
249 () => ({
250 accessibilityLabel: `Archive ${thread.title}`,
251 icon: "archivebox" as const,
252 label: "Archive",
253 onPress: handleArchive,
254 }),
255 [handleArchive, thread.title],
256 );
257 const handleMenuAction = useCallback(
258 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
259 if (nativeEvent.event === "archive") handleArchive();
260 if (nativeEvent.event === "delete") handleDelete();
261 },
262 [handleArchive, handleDelete],
263 );
264
265 const statusPill = effectiveStatus ? (
266 <View
267 className={effectiveStatus.pillClassName}
268 style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }}
269 >
270 <Text className={`text-3xs font-t3-bold ${effectiveStatus.textClassName}`}>
271 {effectiveStatus.label}
272 </Text>
273 </View>
274 ) : null;
275
276 const subtitleRow =
277 subtitleParts.length > 0 || pr !== null ? (
278 <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}>
279 {subtitleParts.length > 0 ? (
280 <>
281 <SymbolView
282 name="arrow.triangle.branch"
283 size={10}
284 tintColor={compact ? iconSubtleColor : effectiveMuted}
285 type="monochrome"
286 />
287 <Text
288 className={compact ? "text-sm text-foreground-muted" : "text-xs"}
289 numberOfLines={1}
290 style={compact ? { flexShrink: 1 } : { flexShrink: 1, color: effectiveMuted }}
291 >
292 {subtitleParts.join(" · ")}
293 </Text>
294 </>
295 ) : null}
296 {pr !== null ? (
297 <Text
298 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${
299 selected ? "text-white" : pr.textClassName
300 }`}
301 >
302 {pr.label}
303 </Text>
304 ) : null}
305 </View>
306 ) : null;
307
308 const rowContent = (close: () => void) =>
309 compact ? (
310 <Pressable
311 accessibilityHint="Swipe left for archive and delete actions"
312 accessibilityLabel={thread.title}
313 accessibilityRole="button"
314 className="bg-screen"
315 onPress={() => {
316 close();
317 onSelectThread(thread);
318 }}
319 style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
320 >
321 <View
322 style={{
323 paddingLeft: THREAD_LIST_COMPACT_INSET,
324 paddingRight: 18,
325 paddingTop: 10,
326 }}
327 >
328 <View
329 style={{
330 gap: 3,
331 borderBottomWidth: props.isLast ? 0 : 1,
332 borderBottomColor: separatorColor,
333 paddingBottom: 10,
334 }}
335 >
336 <View className="flex-row items-center justify-between gap-2">
337 <Text className="flex-1 text-lg font-t3-bold text-foreground" numberOfLines={1}>
338 {thread.title}
339 </Text>
340 <View className="flex-row items-center gap-2">
341 {statusPill}
342 <Text
343 className="text-base text-foreground-tertiary"
344 style={{ fontVariant: ["tabular-nums"] }}
345 >
346 {timestamp}
347 </Text>
348 <SymbolView
349 name="chevron.right"
350 size={13}
351 tintColor={iconSubtleColor}
352 type="monochrome"
353 />
354 </View>
355 </View>
356 {subtitleRow}
357 </View>
358 </View>
359 </Pressable>
360 ) : (
361 <Pressable
362 accessibilityHint="Opens the thread"
363 accessibilityLabel={thread.title}
364 accessibilityRole="button"
365 accessibilityState={{ selected }}
366 onHoverIn={() => setHovered(true)}
367 onHoverOut={() => setHovered(false)}
368 onPress={() => {
369 close();
370 onSelectThread(thread);
371 }}
372 style={({ pressed }) => ({
373 backgroundColor: selected
374 ? selectedBackgroundColor
375 : pressed || hovered
376 ? effectivePressedBackground
377 : backgroundColor,
378 borderRadius: SIDEBAR_ROW_RADIUS,
379 cursor: "pointer",
380 minHeight: 64,
381 justifyContent: "center",
382 paddingHorizontal: 12,
383 paddingVertical: 10,
384 })}
385 >
386 <View style={{ gap: 3 }}>
387 <View className="flex-row items-center justify-between gap-2">
388 <Text
389 className="flex-1 text-base font-t3-medium"
390 numberOfLines={1}
391 style={{ color: effectiveForeground }}
392 >
393 {thread.title}
394 </Text>
395 <View className="flex-row items-center gap-2">
396 {statusPill}
397 <Text
398 className="text-xs"
399 numberOfLines={1}
400 style={{ color: effectiveMuted, fontVariant: ["tabular-nums"] }}
401 >
402 {timestamp}
403 </Text>
404 </View>
405 </View>
406 {subtitleRow}
407 </View>
408 </Pressable>
409 );
410
411 return (
412 <ThreadSwipeable
413 backgroundColor={backgroundColor}
414 containerStyle={
415 compact ? undefined : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }
416 }
417 enableTrackpadSwipe
418 fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}
419 onDelete={handleDelete}
420 onSwipeableClose={props.onSwipeableClose}
421 onSwipeableWillOpen={props.onSwipeableWillOpen}
422 primaryAction={primaryAction}
423 resetKey={`${thread.environmentId}:${thread.id}`}
424 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
425 threadTitle={thread.title}
426 >
427 {(close) => (
428 // Messages-style row actions: a real UIContextMenuInteraction on
429 // long-press / pointer right-click, with the row as the zoom preview.
430 // Requires the patched @react-native-menu (see
431 // patches/@react-native-menu__menu@2.0.0.patch): in long-press mode
432 // the interaction is hosted by the component view and the underlying
433 // UIButton passes touches through, so row taps keep working.
434 <ControlPillMenu
435 actions={THREAD_ROW_MENU_ACTIONS}
436 onPressAction={handleMenuAction}
437 shouldOpenOnLongPress
438 >
439 {rowContent(close)}
440 </ControlPillMenu>
441 )}
442 </ThreadSwipeable>
443 );
444});
apps/mobile/src/features/threads/thread-navigation-groups.test.ts created+111
...@@ -0,0 +1,111 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import { buildThreadNavigationGroups } from "./thread-navigation-groups";
9
10const environmentId = EnvironmentId.make("environment-1");
11
12function makeProject(input: Pick<EnvironmentProject, "id" | "title">): EnvironmentProject {
13 return {
14 environmentId,
15 workspaceRoot: `/workspaces/${input.id}`,
16 repositoryIdentity: null,
17 defaultModelSelection: null,
18 scripts: [],
19 createdAt: "2026-06-01T00:00:00.000Z",
20 updatedAt: "2026-06-01T00:00:00.000Z",
21 ...input,
22 };
23}
24
25function makeThread(
26 input: Pick<EnvironmentThreadShell, "id" | "projectId" | "title"> &
27 Partial<EnvironmentThreadShell>,
28): EnvironmentThreadShell {
29 return {
30 environmentId,
31 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
32 runtimeMode: "full-access",
33 interactionMode: "default",
34 branch: null,
35 worktreePath: null,
36 latestTurn: null,
37 createdAt: "2026-06-01T00:00:00.000Z",
38 updatedAt: "2026-06-01T00:00:00.000Z",
39 archivedAt: null,
40 session: null,
41 latestUserMessageAt: null,
42 hasPendingApprovals: false,
43 hasPendingUserInput: false,
44 hasActionableProposedPlan: false,
45 ...input,
46 };
47}
48
49describe("buildThreadNavigationGroups", () => {
50 const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" });
51 const threads = [
52 makeThread({
53 id: ThreadId.make("older"),
54 projectId: project.id,
55 title: "Fix reconnect flow",
56 updatedAt: "2026-06-02T00:00:00.000Z",
57 }),
58 makeThread({
59 id: ThreadId.make("newer"),
60 projectId: project.id,
61 title: "Build adaptive sidebar",
62 updatedAt: "2026-06-03T00:00:00.000Z",
63 }),
64 ];
65
66 it("sorts each group by recent activity", () => {
67 expect(
68 buildThreadNavigationGroups({ projects: [project], threads })[0]?.threads.map(
69 (thread) => thread.id,
70 ),
71 ).toEqual(["newer", "older"]);
72 });
73
74 it("matches thread titles without dropping their group", () => {
75 const groups = buildThreadNavigationGroups({
76 projects: [project],
77 threads,
78 searchQuery: "reconnect",
79 });
80
81 expect(groups).toHaveLength(1);
82 expect(groups[0]?.threads.map((thread) => thread.id)).toEqual(["older"]);
83 });
84
85 it("keeps every thread when the project title matches", () => {
86 expect(
87 buildThreadNavigationGroups({
88 projects: [project],
89 threads,
90 searchQuery: "t3 code",
91 })[0]?.threads.map((thread) => thread.id),
92 ).toEqual(["newer", "older"]);
93 });
94
95 it("excludes archived threads from the navigation sidebar", () => {
96 const archived = makeThread({
97 id: ThreadId.make("archived"),
98 projectId: project.id,
99 title: "Archived work",
100 archivedAt: "2026-06-04T00:00:00.000Z",
101 updatedAt: "2026-06-04T00:00:00.000Z",
102 });
103
104 expect(
105 buildThreadNavigationGroups({
106 projects: [project],
107 threads: [...threads, archived],
108 })[0]?.threads.map((thread) => thread.id),
109 ).toEqual(["newer", "older"]);
110 });
111});
apps/mobile/src/features/threads/thread-navigation-groups.ts created+64
...@@ -0,0 +1,64 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import * as Arr from "effect/Array";
6import * as Order from "effect/Order";
7
8import { groupProjectsByRepository } from "../../lib/repositoryGroups";
9
10export interface ThreadNavigationGroup {
11 readonly key: string;
12 readonly title: string;
13 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
14}
15
16const threadActivityOrder = Order.mapInput(
17 Order.Struct({
18 activityAt: Order.flip(Order.Number),
19 title: Order.String,
20 }),
21 (thread: EnvironmentThreadShell) => ({
22 activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),
23 title: thread.title,
24 }),
25);
26
27export function buildThreadNavigationGroups(input: {
28 readonly projects: ReadonlyArray<EnvironmentProject>;
29 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
30 readonly searchQuery?: string;
31}): ReadonlyArray<ThreadNavigationGroup> {
32 const query = input.searchQuery?.trim().toLocaleLowerCase() ?? "";
33 const activeThreads = input.threads.filter((thread) => thread.archivedAt === null);
34
35 return groupProjectsByRepository({ ...input, threads: activeThreads }).flatMap((group) => {
36 const threads = Arr.sort(
37 group.projects.flatMap((projectGroup) => projectGroup.threads),
38 threadActivityOrder,
39 );
40 const title = group.projects[0]?.project.title ?? group.title;
41 const groupMatches =
42 query.length === 0 ||
43 title.toLocaleLowerCase().includes(query) ||
44 group.title.toLocaleLowerCase().includes(query) ||
45 group.projects.some((projectGroup) =>
46 projectGroup.project.title.toLocaleLowerCase().includes(query),
47 );
48 const matchingThreads = groupMatches
49 ? threads
50 : threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));
51
52 if (query.length > 0 && matchingThreads.length === 0) {
53 return [];
54 }
55
56 return [
57 {
58 key: group.key,
59 title,
60 threads: matchingThreads,
61 },
62 ];
63 });
64}
apps/mobile/src/features/threads/thread-work-log.tsx+2-5
...@@ -138,10 +138,7 @@ export function ThreadWorkLog(props: {...@@ -138,10 +138,7 @@ export function ThreadWorkLog(props: {
138 />138 />
139 </View>139 </View>
140140
141 <Text141 <Text className="min-w-0 flex-1 text-xs text-foreground" numberOfLines={1}>
142 className="min-w-0 flex-1 text-xs leading-4 text-foreground"
143 numberOfLines={1}
144 >
145 <Text142 <Text
146 className={cn(143 className={cn(
147 "font-t3-medium text-foreground",144 "font-t3-medium text-foreground",
...@@ -202,7 +199,7 @@ export function ThreadWorkLog(props: {...@@ -202,7 +199,7 @@ export function ThreadWorkLog(props: {
202 >199 >
203 <Text200 <Text
204 selectable201 selectable
205 className="text-2xs leading-[17px] text-foreground-muted"202 className="text-2xs leading-normal text-foreground-muted"
206 style={{ fontFamily: "ui-monospace" }}203 style={{ fontFamily: "ui-monospace" }}
207 >204 >
208 {row.fullDetail}205 {row.fullDetail}
apps/mobile/src/features/threads/threadContentPresentation.ts+7-1
...@@ -32,7 +32,13 @@ export function projectThreadContentPresentation(input: {...@@ -32,7 +32,13 @@ export function projectThreadContentPresentation(input: {
32 detail: input.detailError,32 detail: input.detailError,
33 };33 };
34 }34 }
35 if (input.connectionState === "connected") {35 if (
36 input.connectionState === "connected" ||
37 input.connectionState === "connecting" ||
38 input.connectionState === "reconnecting"
39 ) {
40 // Messages will arrive once the (re)connection completes — present as
41 // loading; the composer's connection pill reports the connection phase.
36 return { kind: "loading" };42 return { kind: "loading" };
37 }43 }
38 return {44 return {
apps/mobile/src/features/threads/threadPresentation.ts+103-18
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { StatusTone } from "../../components/StatusPill";1import type { StatusTone } from "../../components/StatusPill";
2import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts";
2import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";3import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
34
4export function threadSortValue(thread: EnvironmentThreadShell): number {5export function threadSortValue(thread: EnvironmentThreadShell): number {
...@@ -6,39 +7,123 @@ export function threadSortValue(thread: EnvironmentThreadShell): number {...@@ -6,39 +7,123 @@ export function threadSortValue(thread: EnvironmentThreadShell): number {
6 return Number.isNaN(candidate) ? 0 : candidate;7 return Number.isNaN(candidate) ? 0 : candidate;
7}8}
89
9export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone {10export type ThreadStatusKind =
10 const status = thread.session?.status;11 | "pending-approval"
11 if (status === "running") {12 | "awaiting-input"
13 | "working"
14 | "connecting"
15 | "error"
16 | "plan-ready";
17
18export interface ThreadStatusPresentation extends StatusTone {
19 readonly kind: ThreadStatusKind;
20 /** Foreground color for the leading status icon. */
21 readonly iconColor: string;
22 /** Background color for the leading status icon circle. */
23 readonly iconBackground: string;
24 /** Whether the indicator represents in-flight activity. */
25 readonly pulse: boolean;
26}
27
28/** Neutral icon colors for threads with no actionable status. */
29export const THREAD_STATUS_NEUTRAL_ICON = {
30 iconColor: "#8e8e93",
31 iconBackground: "rgba(142,142,147,0.22)",
32} as const;
33
34function isLatestTurnSettled(
35 latestTurn: OrchestrationLatestTurn | null,
36 session: OrchestrationSession | null,
37): boolean {
38 if (!latestTurn?.startedAt) return false;
39 if (!latestTurn.completedAt) return false;
40 if (!session) return true;
41 return session.status !== "running";
42}
43
44/**
45 * Resolves the user-facing status of a thread, in priority order. Returns
46 * `null` for quiescent threads so rows stay free of "Idle"-style noise.
47 * Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts.
48 */
49export function resolveThreadStatus(
50 thread: EnvironmentThreadShell,
51): ThreadStatusPresentation | null {
52 if (thread.hasPendingApprovals) {
53 return {
54 kind: "pending-approval",
55 label: "Needs Approval",
56 pillClassName: "bg-amber-500/12 dark:bg-amber-500/16",
57 textClassName: "text-amber-700 dark:text-amber-300",
58 iconColor: "#ff9f0a",
59 iconBackground: "rgba(255,159,10,0.22)",
60 pulse: false,
61 };
62 }
63
64 if (thread.hasPendingUserInput) {
12 return {65 return {
13 label: "Running",66 kind: "awaiting-input",
14 pillClassName: "bg-orange-500/12 dark:bg-orange-500/16",67 label: "Awaiting Input",
15 textClassName: "text-orange-700 dark:text-orange-300",68 pillClassName: "bg-indigo-500/12 dark:bg-indigo-500/16",
69 textClassName: "text-indigo-700 dark:text-indigo-300",
70 iconColor: "#5e5ce6",
71 iconBackground: "rgba(94,92,230,0.22)",
72 pulse: false,
16 };73 };
17 }74 }
18 if (status === "ready") {75
76 if (thread.session?.status === "running") {
19 return {77 return {
20 label: "Ready",78 kind: "working",
21 pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16",79 label: "Working",
22 textClassName: "text-emerald-700 dark:text-emerald-300",80 pillClassName: "bg-sky-500/12 dark:bg-sky-500/16",
81 textClassName: "text-sky-700 dark:text-sky-300",
82 iconColor: "#0a84ff",
83 iconBackground: "rgba(10,132,255,0.22)",
84 pulse: true,
23 };85 };
24 }86 }
25 if (status === "starting") {87
88 if (thread.session?.status === "starting") {
26 return {89 return {
27 label: "Starting",90 kind: "connecting",
91 label: "Connecting",
28 pillClassName: "bg-sky-500/12 dark:bg-sky-500/16",92 pillClassName: "bg-sky-500/12 dark:bg-sky-500/16",
29 textClassName: "text-sky-700 dark:text-sky-300",93 textClassName: "text-sky-700 dark:text-sky-300",
94 iconColor: "#0a84ff",
95 iconBackground: "rgba(10,132,255,0.22)",
96 pulse: true,
30 };97 };
31 }98 }
32 if (status === "error") {99
100 if (thread.session?.status === "error" || thread.latestTurn?.state === "error") {
33 return {101 return {
102 kind: "error",
34 label: "Error",103 label: "Error",
35 pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",104 pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
36 textClassName: "text-rose-700 dark:text-rose-300",105 textClassName: "text-rose-700 dark:text-rose-300",
106 iconColor: "#ff453a",
107 iconBackground: "rgba(255,69,58,0.22)",
108 pulse: false,
37 };109 };
38 }110 }
39 return {111
40 label: "Idle",112 const hasPlanReadyPrompt =
41 pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16",113 thread.interactionMode === "plan" &&
42 textClassName: "text-neutral-600 dark:text-neutral-300",114 isLatestTurnSettled(thread.latestTurn, thread.session) &&
43 };115 thread.hasActionableProposedPlan;
116 if (hasPlanReadyPrompt) {
117 return {
118 kind: "plan-ready",
119 label: "Plan Ready",
120 pillClassName: "bg-violet-500/12 dark:bg-violet-500/16",
121 textClassName: "text-violet-700 dark:text-violet-300",
122 iconColor: "#bf5af2",
123 iconBackground: "rgba(191,90,242,0.22)",
124 pulse: false,
125 };
126 }
127
128 return null;
44}129}
apps/mobile/src/lib/adaptive-navigation.test.ts created+68
...@@ -0,0 +1,68 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 isBaseThreadRoute,
5 resolveFileSelectionNavigationAction,
6 resolveThreadSelectionNavigationAction,
7} from "./adaptive-navigation";
8
9describe("isBaseThreadRoute", () => {
10 it("recognizes only the thread detail route", () => {
11 expect(isBaseThreadRoute("/threads/environment/thread")).toBe(true);
12 expect(isBaseThreadRoute("/threads/environment/thread/")).toBe(true);
13 expect(isBaseThreadRoute("/threads/environment/thread/files")).toBe(false);
14 expect(isBaseThreadRoute("/threads/environment/thread/review")).toBe(false);
15 });
16});
17
18describe("resolveThreadSelectionNavigationAction", () => {
19 it("updates params when a persistent sidebar selects a peer thread", () => {
20 expect(
21 resolveThreadSelectionNavigationAction({
22 usesSplitView: true,
23 pathname: "/threads/environment/thread",
24 }),
25 ).toBe("set-params");
26 });
27
28 it("replaces nested thread content when a persistent sidebar selects a peer", () => {
29 expect(
30 resolveThreadSelectionNavigationAction({
31 usesSplitView: true,
32 pathname: "/threads/environment/thread/files/path",
33 }),
34 ).toBe("replace");
35 });
36
37 it("pushes from Home so the back stack survives collapsing to compact", () => {
38 expect(
39 resolveThreadSelectionNavigationAction({
40 usesSplitView: true,
41 pathname: "/",
42 }),
43 ).toBe("push");
44 });
45
46 it("pushes compact list selections onto the native stack", () => {
47 expect(
48 resolveThreadSelectionNavigationAction({
49 usesSplitView: false,
50 pathname: "/threads/environment/thread",
51 }),
52 ).toBe("push");
53 });
54});
55
56describe("resolveFileSelectionNavigationAction", () => {
57 it("replaces the wide file browser with the selected preview", () => {
58 expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: true })).toBe(
59 "replace",
60 );
61 });
62
63 it("pushes a preview above the compact file browser", () => {
64 expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: false })).toBe(
65 "push",
66 );
67 });
68});
apps/mobile/src/lib/adaptive-navigation.ts created+35
...@@ -0,0 +1,35 @@
1export type AdaptiveNavigationAction = "push" | "replace" | "set-params";
2
3const BASE_THREAD_ROUTE_PATTERN = /^\/threads\/[^/]+\/[^/]+\/?$/;
4
5export function isBaseThreadRoute(pathname: string): boolean {
6 return BASE_THREAD_ROUTE_PATTERN.test(pathname);
7}
8
9/**
10 * A persistent sidebar selects a peer destination in place. A compact list
11 * drills into a new destination so the native back stack remains available.
12 * From Home the selection pushes (never replaces) so Home stays beneath the
13 * thread — collapsing back to a compact width keeps a sane back stack.
14 */
15export function resolveThreadSelectionNavigationAction(input: {
16 readonly usesSplitView: boolean;
17 readonly pathname: string;
18}): AdaptiveNavigationAction {
19 if (!input.usesSplitView || input.pathname === "/") {
20 return "push";
21 }
22
23 return isBaseThreadRoute(input.pathname) ? "set-params" : "replace";
24}
25
26/**
27 * On regular-width layouts, the file browser and preview occupy one workspace
28 * destination. Replacing the browser route keeps a single back step to chat.
29 * Compact layouts retain the browser as the previous stack screen.
30 */
31export function resolveFileSelectionNavigationAction(input: {
32 readonly hasPersistentFileInspector: boolean;
33}): AdaptiveNavigationAction {
34 return input.hasPersistentFileInspector ? "replace" : "push";
35}
apps/mobile/src/lib/appearancePreferences.test.ts created+142
...@@ -0,0 +1,142 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 DEFAULT_BASE_FONT_SIZE,
5 deriveCodeFontSize,
6 deriveTerminalFontSize,
7 normalizeBaseFontSize,
8 normalizeCodeFontSize,
9 normalizeCodeWordBreak,
10 resolveAppearance,
11 resolveAppearancePreferences,
12 resolveMarkdownFontSizes,
13 resolveMobileCodeSurface,
14 resolveNativeMarkdownTypography,
15 resolveTextScaleVariables,
16 stepBaseFontSize,
17 stepCodeFontSize,
18 stepTerminalFontSize,
19} from "./appearancePreferences";
20
21describe("appearancePreferences", () => {
22 it("resolves defaults for empty stored preferences", () => {
23 expect(resolveAppearancePreferences({})).toEqual({
24 baseFontSize: DEFAULT_BASE_FONT_SIZE,
25 terminalFontSize: null,
26 codeFontSize: null,
27 codeWordBreak: false,
28 });
29 });
30
31 it("migrates the legacy markdownFontSize key to baseFontSize", () => {
32 expect(resolveAppearancePreferences({ markdownFontSize: 18 }).baseFontSize).toBe(18);
33 expect(
34 resolveAppearancePreferences({ baseFontSize: 16, markdownFontSize: 18 }).baseFontSize,
35 ).toBe(16);
36 });
37
38 it("keeps explicit overrides and treats missing values as automatic", () => {
39 const preferences = resolveAppearancePreferences({ terminalFontSize: 12, codeFontSize: 14 });
40 expect(preferences.terminalFontSize).toBe(12);
41 expect(preferences.codeFontSize).toBe(14);
42 expect(resolveAppearancePreferences({ terminalFontSize: null }).terminalFontSize).toBe(null);
43 });
44
45 it("derives terminal and code sizes from the base size when not overridden", () => {
46 const appearance = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 15 }));
47 expect(appearance.terminalFontSize).toBe(10);
48 expect(appearance.codeFontSize).toBe(11);
49 expect(appearance.isTerminalFontSizeCustom).toBe(false);
50 expect(appearance.isCodeFontSizeCustom).toBe(false);
51
52 const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 }));
53 expect(scaled.terminalFontSize).toBe(deriveTerminalFontSize(22));
54 expect(scaled.codeFontSize).toBe(deriveCodeFontSize(22));
55 expect(scaled.terminalFontSize).toBeGreaterThan(10);
56 expect(scaled.codeFontSize).toBeGreaterThan(11);
57 });
58
59 it("applies explicit overrides over derived values", () => {
60 const appearance = resolveAppearance(
61 resolveAppearancePreferences({ baseFontSize: 22, terminalFontSize: 8, codeFontSize: 9 }),
62 );
63 expect(appearance.terminalFontSize).toBe(8);
64 expect(appearance.codeFontSize).toBe(9);
65 expect(appearance.isTerminalFontSizeCustom).toBe(true);
66 expect(appearance.isCodeFontSizeCustom).toBe(true);
67 });
68
69 it("clamps base and code font sizes", () => {
70 expect(normalizeBaseFontSize(4)).toBe(11);
71 expect(normalizeBaseFontSize(30)).toBe(22);
72 expect(normalizeCodeFontSize(4)).toBe(8);
73 expect(normalizeCodeFontSize(30)).toBe(18);
74 });
75
76 it("steps font sizes within bounds", () => {
77 expect(stepTerminalFontSize(6, -1)).toBe(6);
78 expect(stepBaseFontSize(11, -1)).toBe(11);
79 expect(stepCodeFontSize(8, -1)).toBe(8);
80 expect(stepBaseFontSize(15, 1)).toBe(16);
81 });
82
83 it("scales markdown typography from the base size", () => {
84 expect(resolveMarkdownFontSizes(15)).toMatchObject({
85 m: 15,
86 h1: 20,
87 bodyLineHeight: 22,
88 codeBlockFontSize: 12,
89 codeBlockLineHeight: 18,
90 });
91 });
92
93 it("scales code surface geometry from the code font size", () => {
94 expect(resolveMobileCodeSurface(11)).toMatchObject({
95 fontSize: 11,
96 rowHeight: 20,
97 });
98 });
99
100 it("defaults code word break to false", () => {
101 expect(normalizeCodeWordBreak(undefined)).toBe(false);
102 expect(normalizeCodeWordBreak(true)).toBe(true);
103 });
104
105 it("returns the authored text scale at the 16pt default", () => {
106 expect(DEFAULT_BASE_FONT_SIZE).toBe(16);
107
108 const variables = resolveTextScaleVariables(DEFAULT_BASE_FONT_SIZE);
109 expect(variables["--text-base"]).toBe(16);
110 expect(variables["--text-base--line-height"]).toBe(23);
111 expect(variables["--text-sm"]).toBe(14);
112 expect(variables["--text-sm--line-height"]).toBe(19);
113 expect(variables["--text-lg"]).toBe(18);
114 expect(variables["--text-3xl"]).toBe(30);
115 });
116
117 it("scales every text variable proportionally with the base size", () => {
118 const smallerVariables = resolveTextScaleVariables(15);
119 expect(smallerVariables["--text-base"]).toBe(15);
120 expect(smallerVariables["--text-sm"]).toBe(13);
121
122 const variables = resolveTextScaleVariables(20);
123 expect(variables["--text-base"]).toBe(20);
124 expect(variables["--text-base--line-height"]).toBe(29);
125 expect(variables["--text-sm"]).toBe(18);
126 expect(variables["--text-xs"]).toBe(16);
127 expect(variables["--text-lg"]).toBe(23);
128
129 const smaller = resolveTextScaleVariables(11);
130 expect(smaller["--text-base"]).toBe(11);
131 expect(smaller["--text-3xs"]).toBeGreaterThanOrEqual(8);
132 expect(smaller["--text-3xs--line-height"]).toBeGreaterThanOrEqual(10);
133 });
134
135 it("derives native markdown typography from the base size", () => {
136 expect(resolveNativeMarkdownTypography(22)).toEqual({
137 fontSize: 22,
138 lineHeight: 32,
139 headingFontSizes: [29, 26, 23, 21, 21, 21],
140 });
141 });
142});
apps/mobile/src/lib/appearancePreferences.ts created+245
...@@ -0,0 +1,245 @@
1import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography";
2import {
3 DEFAULT_TERMINAL_FONT_SIZE,
4 MAX_TERMINAL_FONT_SIZE,
5 MIN_TERMINAL_FONT_SIZE,
6 TERMINAL_FONT_SIZE_STEP,
7 normalizeTerminalFontSize,
8} from "../features/terminal/terminalPreferences";
9
10export const DEFAULT_BASE_FONT_SIZE = MOBILE_TYPOGRAPHY.body.fontSize;
11export const MIN_BASE_FONT_SIZE = 11;
12export const MAX_BASE_FONT_SIZE = 22;
13export const BASE_FONT_SIZE_STEP = 1;
14
15export const DEFAULT_CODE_FONT_SIZE = MOBILE_CODE_SURFACE.fontSize;
16export const MIN_CODE_FONT_SIZE = 8;
17export const MAX_CODE_FONT_SIZE = 18;
18export const CODE_FONT_SIZE_STEP = 1;
19
20/**
21 * User-configurable appearance preferences as stored. `null` overrides mean
22 * "automatic": the value is derived from the base font size.
23 */
24export interface AppearancePreferences {
25 readonly baseFontSize: number;
26 readonly terminalFontSize: number | null;
27 readonly codeFontSize: number | null;
28 readonly codeWordBreak: boolean;
29}
30
31/** Effective appearance values after applying base-size derivation. */
32export interface ResolvedAppearance {
33 readonly baseFontSize: number;
34 readonly terminalFontSize: number;
35 readonly codeFontSize: number;
36 readonly codeWordBreak: boolean;
37 readonly isTerminalFontSizeCustom: boolean;
38 readonly isCodeFontSizeCustom: boolean;
39}
40
41export interface ResolvedMobileCodeSurface {
42 readonly fontSize: number;
43 readonly lineNumberFontSize: number;
44 readonly rowHeight: number;
45 readonly gutterWidth: number;
46 readonly codePadding: number;
47 readonly textVerticalInset: number;
48}
49
50export interface ResolvedMarkdownFontSizes {
51 readonly s: number;
52 readonly m: number;
53 readonly h1: number;
54 readonly h2: number;
55 readonly h3: number;
56 readonly h4: number;
57 readonly h5: number;
58 readonly h6: number;
59 readonly bodyLineHeight: number;
60 readonly codeBlockFontSize: number;
61 readonly codeBlockLineHeight: number;
62}
63
64export interface NativeMarkdownTypography {
65 readonly fontSize: number;
66 readonly lineHeight: number;
67 readonly headingFontSizes: readonly [number, number, number, number, number, number];
68}
69
70export function normalizeBaseFontSize(value: number | null | undefined): number {
71 if (typeof value !== "number" || !Number.isFinite(value)) {
72 return DEFAULT_BASE_FONT_SIZE;
73 }
74
75 return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value)));
76}
77
78export function normalizeCodeFontSize(value: number | null | undefined): number {
79 if (typeof value !== "number" || !Number.isFinite(value)) {
80 return DEFAULT_CODE_FONT_SIZE;
81 }
82
83 return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value)));
84}
85
86export function normalizeCodeWordBreak(value: boolean | null | undefined): boolean {
87 return value === true;
88}
89
90/** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */
91export function deriveTerminalFontSize(baseFontSize: number): number {
92 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
93 return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2);
94}
95
96/** Code/diff size derived from base: 12pt at base 16. */
97export function deriveCodeFontSize(baseFontSize: number): number {
98 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
99 return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale));
100}
101
102interface StoredAppearancePreferences {
103 readonly baseFontSize?: number | null | undefined;
104 /** Legacy key from before base font size existed; migrated to baseFontSize. */
105 readonly markdownFontSize?: number | null | undefined;
106 readonly terminalFontSize?: number | null | undefined;
107 readonly codeFontSize?: number | null | undefined;
108 readonly codeWordBreak?: boolean | null | undefined;
109}
110
111export function resolveAppearancePreferences(
112 stored: StoredAppearancePreferences | null | undefined,
113): AppearancePreferences {
114 return {
115 baseFontSize: normalizeBaseFontSize(stored?.baseFontSize ?? stored?.markdownFontSize),
116 terminalFontSize:
117 typeof stored?.terminalFontSize === "number" && Number.isFinite(stored.terminalFontSize)
118 ? normalizeTerminalFontSize(stored.terminalFontSize)
119 : null,
120 codeFontSize:
121 typeof stored?.codeFontSize === "number" && Number.isFinite(stored.codeFontSize)
122 ? normalizeCodeFontSize(stored.codeFontSize)
123 : null,
124 codeWordBreak: normalizeCodeWordBreak(stored?.codeWordBreak),
125 };
126}
127
128export function resolveAppearance(preferences: AppearancePreferences): ResolvedAppearance {
129 return {
130 baseFontSize: preferences.baseFontSize,
131 terminalFontSize:
132 preferences.terminalFontSize ?? deriveTerminalFontSize(preferences.baseFontSize),
133 codeFontSize: preferences.codeFontSize ?? deriveCodeFontSize(preferences.baseFontSize),
134 codeWordBreak: preferences.codeWordBreak,
135 isTerminalFontSizeCustom: preferences.terminalFontSize !== null,
136 isCodeFontSizeCustom: preferences.codeFontSize !== null,
137 };
138}
139
140export function resolveMobileCodeSurface(codeFontSize: number): ResolvedMobileCodeSurface {
141 const fontSize = normalizeCodeFontSize(codeFontSize);
142 const scale = fontSize / DEFAULT_CODE_FONT_SIZE;
143
144 return {
145 fontSize,
146 lineNumberFontSize: Math.max(8, Math.round(MOBILE_CODE_SURFACE.lineNumberFontSize * scale)),
147 rowHeight: Math.max(14, Math.round(MOBILE_CODE_SURFACE.rowHeight * scale)),
148 gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,
149 codePadding: MOBILE_CODE_SURFACE.codePadding,
150 textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,
151 };
152}
153
154export function resolveMarkdownFontSizes(baseFontSize: number): ResolvedMarkdownFontSizes {
155 const m = normalizeBaseFontSize(baseFontSize);
156 const scale = m / DEFAULT_BASE_FONT_SIZE;
157 const codeBlockFontSize = Math.max(10, Math.round(13 * scale));
158
159 return {
160 s: Math.max(10, Math.round(14 * scale)),
161 m,
162 h1: Math.max(16, Math.round(21 * scale)),
163 h2: Math.max(14, Math.round(19 * scale)),
164 h3: Math.max(13, Math.round(17 * scale)),
165 h4: Math.max(12, Math.round(15 * scale)),
166 h5: Math.max(12, Math.round(15 * scale)),
167 h6: Math.max(12, Math.round(15 * scale)),
168 bodyLineHeight: Math.max(18, Math.round(MOBILE_TYPOGRAPHY.body.lineHeight * scale)),
169 codeBlockFontSize,
170 codeBlockLineHeight: codeBlockFontSize + 6,
171 };
172}
173
174/**
175 * Maps the Uniwind `--text-*` theme variables (see global.css) to the
176 * MOBILE_TYPOGRAPHY roles they were authored from. Keep in sync with both.
177 */
178const TEXT_SCALE_VARIABLE_ROLES = {
179 "--text-3xs": MOBILE_TYPOGRAPHY.micro,
180 "--text-2xs": MOBILE_TYPOGRAPHY.caption,
181 "--text-xs": MOBILE_TYPOGRAPHY.label,
182 "--text-sm": MOBILE_TYPOGRAPHY.footnote,
183 "--text-base": MOBILE_TYPOGRAPHY.body,
184 "--text-lg": MOBILE_TYPOGRAPHY.headline,
185 "--text-xl": MOBILE_TYPOGRAPHY.title,
186 "--text-2xl": MOBILE_TYPOGRAPHY.largeTitle,
187 "--text-3xl": MOBILE_TYPOGRAPHY.display,
188} as const;
189
190/**
191 * Scaled values for every `--text-*` size and line-height variable, ready to
192 * pass to `Uniwind.updateCSSVariables`. All className-based text (`text-sm`,
193 * `text-base`, ...) re-resolves live when these are injected.
194 */
195export function resolveTextScaleVariables(baseFontSize: number): Record<string, number> {
196 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
197 const variables: Record<string, number> = {};
198
199 for (const [name, role] of Object.entries(TEXT_SCALE_VARIABLE_ROLES)) {
200 variables[name] = Math.max(8, Math.round(role.fontSize * scale));
201 variables[`${name}--line-height`] = Math.max(10, Math.round(role.lineHeight * scale));
202 }
203
204 return variables;
205}
206
207export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography {
208 const fontSizes = resolveMarkdownFontSizes(baseFontSize);
209 return {
210 fontSize: fontSizes.m,
211 lineHeight: fontSizes.bodyLineHeight,
212 headingFontSizes: [
213 fontSizes.h1,
214 fontSizes.h2,
215 fontSizes.h3,
216 fontSizes.h4,
217 fontSizes.h5,
218 fontSizes.h6,
219 ],
220 };
221}
222
223export function stepBaseFontSize(current: number, direction: -1 | 1): number {
224 const next = direction === -1 ? current - BASE_FONT_SIZE_STEP : current + BASE_FONT_SIZE_STEP;
225 return normalizeBaseFontSize(next);
226}
227
228export function stepTerminalFontSize(current: number, direction: -1 | 1): number {
229 const next =
230 direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP;
231 return normalizeTerminalFontSize(next);
232}
233
234export function stepCodeFontSize(current: number, direction: -1 | 1): number {
235 const next = direction === -1 ? current - CODE_FONT_SIZE_STEP : current + CODE_FONT_SIZE_STEP;
236 return normalizeCodeFontSize(next);
237}
238
239export {
240 DEFAULT_TERMINAL_FONT_SIZE,
241 MAX_TERMINAL_FONT_SIZE,
242 MIN_TERMINAL_FONT_SIZE,
243 TERMINAL_FONT_SIZE_STEP,
244 normalizeTerminalFontSize,
245};
apps/mobile/src/lib/layout.test.ts created+343
...@@ -0,0 +1,343 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 constrainAuxiliaryPaneWidth,
5 constrainPrimarySidebarWidth,
6 deriveCenteredContentHorizontalPadding,
7 deriveFileInspectorPaneLayout,
8 deriveLayout,
9 deriveStableFormSheetDetent,
10 deriveWorkspacePaneLayout,
11 SPLIT_LAYOUT_MIN_HEIGHT,
12 SPLIT_LAYOUT_MIN_WIDTH,
13} from "./layout";
14
15describe("resizable pane constraints", () => {
16 it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => {
17 expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430);
18 expect(constrainPrimarySidebarWidth(430, 744)).toBe(384);
19 expect(constrainPrimarySidebarWidth(100, 1_366)).toBe(280);
20 });
21
22 it("preserves a useful main pane while constraining a trailing pane", () => {
23 expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440);
24 expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340);
25 expect(constrainAuxiliaryPaneWidth({ preferredWidth: 100, availableWidth: 1_100 })).toBe(260);
26 });
27});
28
29describe("deriveCenteredContentHorizontalPadding", () => {
30 it("keeps the minimum padding while the viewport fits the reading width", () => {
31 expect(
32 deriveCenteredContentHorizontalPadding({
33 viewportWidth: 744,
34 maxContentWidth: 960,
35 minimumPadding: 20,
36 }),
37 ).toBe(20);
38 });
39
40 it("centers only the content inside a wider full-width scroll host", () => {
41 expect(
42 deriveCenteredContentHorizontalPadding({
43 viewportWidth: 1_032,
44 maxContentWidth: 960,
45 minimumPadding: 20,
46 }),
47 ).toBe(56);
48 });
49
50 it("supports unconstrained compact content", () => {
51 expect(
52 deriveCenteredContentHorizontalPadding({
53 viewportWidth: 430,
54 maxContentWidth: null,
55 minimumPadding: 16,
56 }),
57 ).toBe(16);
58 });
59});
60
61describe("deriveLayout", () => {
62 it.each([
63 { name: "small iPhone portrait", width: 375, height: 667 },
64 { name: "large iPhone portrait", width: 430, height: 932 },
65 { name: "small iPhone landscape", width: 667, height: 375 },
66 { name: "large iPhone landscape", width: 932, height: 430 },
67 { name: "short wide window", width: 1_024, height: 599 },
68 { name: "narrow tall window", width: 719, height: 1_024 },
69 ])("keeps a $name in the compact shell", ({ width, height }) => {
70 expect(deriveLayout({ width, height })).toEqual({
71 variant: "compact",
72 usesSplitView: false,
73 listPaneWidth: null,
74 shellPadding: 0,
75 });
76 });
77
78 it.each([
79 { name: "small tablet portrait", width: 744, height: 1_133 },
80 { name: "tablet landscape", width: 1_024, height: 768 },
81 { name: "large resizable window", width: 1_366, height: 1_024 },
82 { name: "foldable-sized window", width: 800, height: 700 },
83 ])("uses the split shell for a $name", ({ width, height }) => {
84 expect(deriveLayout({ width, height })).toMatchObject({
85 variant: "split",
86 usesSplitView: true,
87 });
88 });
89
90 it("switches only after both space requirements are met", () => {
91 expect(
92 deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant,
93 ).toBe("split");
94 expect(
95 deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH - 1, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant,
96 ).toBe("compact");
97 expect(
98 deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT - 1 }).variant,
99 ).toBe("compact");
100 });
101
102 it("keeps the sidebar within usable native-column bounds", () => {
103 expect(deriveLayout({ width: 720, height: 1_000 }).listPaneWidth).toBe(280);
104 expect(deriveLayout({ width: 1_024, height: 768 }).listPaneWidth).toBe(328);
105 expect(deriveLayout({ width: 1_600, height: 1_000 }).listPaneWidth).toBe(380);
106 });
107});
108
109describe("deriveWorkspacePaneLayout", () => {
110 it("keeps the auxiliary pane out of a standard iPad detail column", () => {
111 const layout = deriveLayout({ width: 1_194, height: 834 });
112
113 expect(
114 deriveWorkspacePaneLayout({
115 layout,
116 viewportWidth: 1_194,
117 primarySidebarPreferredVisible: true,
118 auxiliaryPanePreferredVisible: true,
119 }),
120 ).toEqual({
121 primarySidebarVisible: true,
122 primarySidebarSuppressedByAuxiliary: false,
123 contentPaneWidth: 814,
124 supportsAuxiliaryPane: false,
125 auxiliaryPaneVisible: false,
126 auxiliaryPaneWidth: null,
127 });
128 });
129
130 it("offers an auxiliary pane when maximizing a standard iPad landscape window", () => {
131 const layout = deriveLayout({ width: 1_194, height: 834 });
132
133 expect(
134 deriveWorkspacePaneLayout({
135 layout,
136 viewportWidth: 1_194,
137 primarySidebarPreferredVisible: false,
138 auxiliaryPanePreferredVisible: true,
139 }),
140 ).toEqual({
141 primarySidebarVisible: false,
142 primarySidebarSuppressedByAuxiliary: false,
143 contentPaneWidth: 1_194,
144 supportsAuxiliaryPane: true,
145 auxiliaryPaneVisible: true,
146 auxiliaryPaneWidth: 320,
147 });
148 });
149
150 it("prioritizes a trailing file inspector over the thread sidebar at medium widths", () => {
151 const layout = deriveLayout({ width: 1_024, height: 1_366 });
152
153 expect(
154 deriveWorkspacePaneLayout({
155 layout,
156 viewportWidth: 1_024,
157 primarySidebarPreferredVisible: true,
158 auxiliaryPanePreferredVisible: true,
159 auxiliaryPaneRole: "inspector",
160 }),
161 ).toEqual({
162 primarySidebarVisible: false,
163 primarySidebarSuppressedByAuxiliary: true,
164 contentPaneWidth: 1_024,
165 supportsAuxiliaryPane: true,
166 auxiliaryPaneVisible: true,
167 auxiliaryPaneWidth: 260,
168 });
169 });
170
171 it("keeps threads, content, and the file inspector visible in a large landscape window", () => {
172 const layout = deriveLayout({ width: 1_366, height: 1_024 });
173
174 expect(
175 deriveWorkspacePaneLayout({
176 layout,
177 viewportWidth: 1_366,
178 primarySidebarPreferredVisible: true,
179 auxiliaryPanePreferredVisible: true,
180 auxiliaryPaneRole: "inspector",
181 }),
182 ).toEqual({
183 primarySidebarVisible: true,
184 primarySidebarSuppressedByAuxiliary: false,
185 contentPaneWidth: 986,
186 supportsAuxiliaryPane: true,
187 auxiliaryPaneVisible: true,
188 auxiliaryPaneWidth: 276,
189 });
190 });
191
192 it("keeps an explicitly hidden thread sidebar hidden when the file inspector is visible", () => {
193 const layout = deriveLayout({ width: 1_024, height: 1_366 });
194
195 expect(
196 deriveWorkspacePaneLayout({
197 layout,
198 viewportWidth: 1_024,
199 primarySidebarPreferredVisible: false,
200 auxiliaryPanePreferredVisible: true,
201 auxiliaryPaneRole: "inspector",
202 }),
203 ).toMatchObject({
204 primarySidebarVisible: false,
205 primarySidebarSuppressedByAuxiliary: false,
206 auxiliaryPaneVisible: true,
207 });
208 });
209
210 it("restores the thread sidebar when the file inspector is hidden", () => {
211 const layout = deriveLayout({ width: 1_024, height: 1_366 });
212
213 expect(
214 deriveWorkspacePaneLayout({
215 layout,
216 viewportWidth: 1_024,
217 primarySidebarPreferredVisible: true,
218 auxiliaryPanePreferredVisible: false,
219 auxiliaryPaneRole: "inspector",
220 }),
221 ).toMatchObject({
222 primarySidebarVisible: true,
223 primarySidebarSuppressedByAuxiliary: false,
224 auxiliaryPaneVisible: false,
225 });
226 });
227
228 it("keeps file navigation on the native stack below the inspector breakpoint", () => {
229 const layout = deriveLayout({ width: 744, height: 1_133 });
230
231 expect(deriveFileInspectorPaneLayout({ layout, viewportWidth: 744 })).toEqual({
232 supported: false,
233 width: null,
234 });
235 expect(
236 deriveWorkspacePaneLayout({
237 layout,
238 viewportWidth: 744,
239 primarySidebarPreferredVisible: true,
240 auxiliaryPanePreferredVisible: true,
241 auxiliaryPaneRole: "inspector",
242 }),
243 ).toMatchObject({
244 primarySidebarVisible: true,
245 supportsAuxiliaryPane: false,
246 auxiliaryPaneVisible: false,
247 });
248 });
249
250 it("supports three visible columns in a sufficiently large window", () => {
251 const layout = deriveLayout({ width: 1_366, height: 1_024 });
252
253 expect(
254 deriveWorkspacePaneLayout({
255 layout,
256 viewportWidth: 1_366,
257 primarySidebarPreferredVisible: true,
258 auxiliaryPanePreferredVisible: true,
259 }),
260 ).toMatchObject({
261 primarySidebarVisible: true,
262 contentPaneWidth: 986,
263 supportsAuxiliaryPane: true,
264 auxiliaryPaneVisible: true,
265 auxiliaryPaneWidth: 276,
266 });
267 });
268
269 it("uses a preferred inspector width when all three panes still fit", () => {
270 const layout = deriveLayout({ width: 1_366, height: 1_024 });
271
272 expect(
273 deriveWorkspacePaneLayout({
274 layout,
275 viewportWidth: 1_366,
276 primarySidebarPreferredVisible: true,
277 auxiliaryPanePreferredVisible: true,
278 auxiliaryPaneRole: "inspector",
279 auxiliaryPanePreferredWidth: 420,
280 }),
281 ).toMatchObject({
282 primarySidebarVisible: true,
283 auxiliaryPaneVisible: true,
284 auxiliaryPaneWidth: 420,
285 });
286 });
287
288 it("clamps a preferred supplementary width before squeezing the main pane", () => {
289 const layout = deriveLayout({ width: 1_366, height: 1_024 });
290
291 expect(
292 deriveWorkspacePaneLayout({
293 layout,
294 viewportWidth: 1_366,
295 primarySidebarPreferredVisible: true,
296 auxiliaryPanePreferredVisible: true,
297 auxiliaryPanePreferredWidth: 460,
298 }).auxiliaryPaneWidth,
299 ).toBe(426);
300 });
301
302 it("respects a hidden auxiliary-pane preference", () => {
303 const layout = deriveLayout({ width: 1_366, height: 1_024 });
304
305 expect(
306 deriveWorkspacePaneLayout({
307 layout,
308 viewportWidth: 1_366,
309 primarySidebarPreferredVisible: true,
310 auxiliaryPanePreferredVisible: false,
311 }).auxiliaryPaneVisible,
312 ).toBe(false);
313 });
314
315 it("never exposes workspace panes in compact layouts", () => {
316 const layout = deriveLayout({ width: 430, height: 932 });
317
318 expect(
319 deriveWorkspacePaneLayout({
320 layout,
321 viewportWidth: 430,
322 primarySidebarPreferredVisible: true,
323 auxiliaryPanePreferredVisible: true,
324 }),
325 ).toMatchObject({
326 primarySidebarVisible: false,
327 supportsAuxiliaryPane: false,
328 auxiliaryPaneVisible: false,
329 auxiliaryPaneWidth: null,
330 });
331 });
332});
333
334describe("deriveStableFormSheetDetent", () => {
335 it.each([
336 { height: 1_194, expected: 0.62 },
337 { height: 834, expected: 0.863 },
338 { height: 600, expected: 0.893 },
339 { height: 0, expected: 0.92 },
340 ])("derives a stable sheet detent for height $height", ({ height, expected }) => {
341 expect(deriveStableFormSheetDetent(height)).toBe(expected);
342 });
343});
apps/mobile/src/lib/layout.ts+226-4
...@@ -2,6 +2,33 @@ function clamp(value: number, min: number, max: number): number {...@@ -2,6 +2,33 @@ function clamp(value: number, min: number, max: number): number {
2 return Math.min(Math.max(value, min), max);2 return Math.min(Math.max(value, min), max);
3}3}
44
5/**
6 * Use available space, not device or orientation labels, to choose the shell.
7 *
8 * The height floor deliberately keeps every current iPhone in the compact shell
9 * when it rotates to landscape, while still allowing iPad and foldable-sized
10 * windows to adopt the persistent sidebar as they resize.
11 */
12export const SPLIT_LAYOUT_MIN_WIDTH = 720;
13export const SPLIT_LAYOUT_MIN_HEIGHT = 600;
14
15export const SPLIT_SIDEBAR_MIN_WIDTH = 280;
16export const SPLIT_SIDEBAR_MAX_WIDTH = 460;
17const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380;
18
19export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960;
20export const CHAT_CONTENT_MAX_WIDTH = 960;
21
22export const AUXILIARY_PANE_MIN_WIDTH = 260;
23export const AUXILIARY_PANE_MAX_WIDTH = 480;
24const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320;
25const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820;
26const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560;
27const STABLE_FORM_SHEET_MAX_HEIGHT = 720;
28const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64;
29const STABLE_FORM_SHEET_MIN_DETENT = 0.62;
30const STABLE_FORM_SHEET_MAX_DETENT = 0.92;
31
5export type LayoutVariant = "compact" | "split";32export type LayoutVariant = "compact" | "split";
633
7export interface Layout {34export interface Layout {
...@@ -11,10 +38,25 @@ export interface Layout {...@@ -11,10 +38,25 @@ export interface Layout {
11 readonly shellPadding: number;38 readonly shellPadding: number;
12}39}
1340
41export interface WorkspacePaneLayout {
42 readonly primarySidebarVisible: boolean;
43 readonly primarySidebarSuppressedByAuxiliary: boolean;
44 readonly contentPaneWidth: number;
45 readonly supportsAuxiliaryPane: boolean;
46 readonly auxiliaryPaneVisible: boolean;
47 readonly auxiliaryPaneWidth: number | null;
48}
49
50export interface FileInspectorPaneLayout {
51 readonly supported: boolean;
52 readonly width: number | null;
53}
54
55export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector";
56
14export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout {57export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout {
15 const { width, height } = input;58 const { width, height } = input;
16 const shortestEdge = Math.min(width, height);59 const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT;
17 const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700);
1860
19 if (!wideEnoughForSplit) {61 if (!wideEnoughForSplit) {
20 return {62 return {
...@@ -28,7 +70,187 @@ export function deriveLayout(input: { readonly width: number; readonly height: n...@@ -28,7 +70,187 @@ export function deriveLayout(input: { readonly width: number; readonly height: n
28 return {70 return {
29 variant: "split",71 variant: "split",
30 usesSplitView: true,72 usesSplitView: true,
31 listPaneWidth: clamp(Math.round(width * 0.34), 320, 420),73 listPaneWidth: clamp(
32 shellPadding: width >= 1180 ? 20 : 14,74 Math.round(width * 0.32),
75 SPLIT_SIDEBAR_MIN_WIDTH,
76 SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH,
77 ),
78 shellPadding: 0,
79 };
80}
81
82export function deriveWorkspacePaneLayout(input: {
83 readonly layout: Layout;
84 readonly viewportWidth: number;
85 readonly primarySidebarPreferredVisible: boolean;
86 readonly auxiliaryPanePreferredVisible: boolean;
87 readonly auxiliaryPaneRole?: WorkspaceAuxiliaryPaneRole;
88 readonly auxiliaryPanePreferredWidth?: number;
89}): WorkspacePaneLayout {
90 const viewportWidth = Math.max(0, input.viewportWidth);
91 const auxiliaryPaneRole = input.auxiliaryPaneRole ?? "supplementary";
92 const preferredPrimarySidebarVisible =
93 input.layout.usesSplitView && input.primarySidebarPreferredVisible;
94 const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible
95 ? (input.layout.listPaneWidth ?? 0)
96 : 0;
97
98 if (auxiliaryPaneRole === "inspector") {
99 const fileInspector = deriveFileInspectorPaneLayout({
100 layout: input.layout,
101 viewportWidth,
102 preferredWidth: input.auxiliaryPanePreferredWidth,
103 reservedLeadingWidth: preferredPrimarySidebarWidth,
104 });
105 const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible;
106 const primarySidebarSuppressedByAuxiliary =
107 preferredPrimarySidebarVisible &&
108 auxiliaryPaneVisible &&
109 fileInspector.width !== null &&
110 input.layout.listPaneWidth !== null &&
111 viewportWidth - input.layout.listPaneWidth - fileInspector.width <
112 FILE_INSPECTOR_MIN_MAIN_WIDTH;
113 const primarySidebarVisible =
114 preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary;
115 const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0;
116
117 return {
118 primarySidebarVisible,
119 primarySidebarSuppressedByAuxiliary,
120 contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth),
121 supportsAuxiliaryPane: fileInspector.supported,
122 auxiliaryPaneVisible,
123 auxiliaryPaneWidth: fileInspector.width,
124 };
125 }
126
127 const contentPaneWidth = Math.max(0, viewportWidth - preferredPrimarySidebarWidth);
128 const supportsAuxiliaryPane =
129 input.layout.usesSplitView && contentPaneWidth >= AUXILIARY_PANE_MIN_CONTENT_WIDTH;
130 const auxiliaryPaneVisible = supportsAuxiliaryPane && input.auxiliaryPanePreferredVisible;
131 const defaultAuxiliaryPaneWidth = clamp(
132 Math.round(contentPaneWidth * 0.28),
133 AUXILIARY_PANE_MIN_WIDTH,
134 AUXILIARY_PANE_DEFAULT_MAX_WIDTH,
135 );
136
137 return {
138 primarySidebarVisible: preferredPrimarySidebarVisible,
139 primarySidebarSuppressedByAuxiliary: false,
140 contentPaneWidth,
141 supportsAuxiliaryPane,
142 auxiliaryPaneVisible,
143 auxiliaryPaneWidth: supportsAuxiliaryPane
144 ? constrainAuxiliaryPaneWidth({
145 preferredWidth: input.auxiliaryPanePreferredWidth ?? defaultAuxiliaryPaneWidth,
146 availableWidth: contentPaneWidth,
147 })
148 : null,
149 };
150}
151
152export function deriveFileInspectorPaneLayout(input: {
153 readonly layout: Layout;
154 readonly viewportWidth: number;
155 readonly preferredWidth?: number;
156 readonly reservedLeadingWidth?: number;
157}): FileInspectorPaneLayout {
158 const viewportWidth = Math.max(0, input.viewportWidth);
159 const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth)
160 ? Math.max(0, input.reservedLeadingWidth ?? 0)
161 : 0;
162 const availableContentWidth = Math.max(0, viewportWidth - reservedLeadingWidth);
163 const supported =
164 input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH;
165
166 return {
167 supported,
168 width: supported
169 ? constrainAuxiliaryPaneWidth({
170 preferredWidth:
171 input.preferredWidth ??
172 clamp(
173 Math.round(availableContentWidth * 0.28),
174 AUXILIARY_PANE_MIN_WIDTH,
175 AUXILIARY_PANE_DEFAULT_MAX_WIDTH,
176 ),
177 availableWidth: availableContentWidth,
178 })
179 : null,
33 };180 };
34}181}
182
183/** Keep a user-selected sidebar width useful as a window is resized. */
184export function constrainPrimarySidebarWidth(
185 preferredWidth: number,
186 viewportWidth = Number.POSITIVE_INFINITY,
187): number {
188 const safeWidth = Number.isFinite(preferredWidth) ? preferredWidth : SPLIT_SIDEBAR_MIN_WIDTH;
189 const viewportMax = Number.isFinite(viewportWidth)
190 ? Math.max(SPLIT_SIDEBAR_MIN_WIDTH, viewportWidth - 360)
191 : SPLIT_SIDEBAR_MAX_WIDTH;
192 return clamp(
193 Math.round(safeWidth),
194 SPLIT_SIDEBAR_MIN_WIDTH,
195 Math.min(SPLIT_SIDEBAR_MAX_WIDTH, viewportMax),
196 );
197}
198
199/**
200 * Keep an auxiliary pane within native-feeling bounds without squeezing its
201 * neighboring content below a usable reading/editor width.
202 */
203export function constrainAuxiliaryPaneWidth(input: {
204 readonly preferredWidth: number;
205 readonly availableWidth: number;
206}): number {
207 const safePreferredWidth = Number.isFinite(input.preferredWidth)
208 ? input.preferredWidth
209 : AUXILIARY_PANE_MIN_WIDTH;
210 const availableWidth = Number.isFinite(input.availableWidth)
211 ? Math.max(0, input.availableWidth)
212 : 0;
213 const maxWidth = Math.max(
214 AUXILIARY_PANE_MIN_WIDTH,
215 Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH),
216 );
217 return clamp(Math.round(safePreferredWidth), AUXILIARY_PANE_MIN_WIDTH, maxWidth);
218}
219
220export function deriveCenteredContentHorizontalPadding(input: {
221 readonly viewportWidth: number;
222 readonly maxContentWidth: number | null;
223 readonly minimumPadding: number;
224}): number {
225 const viewportWidth = Number.isFinite(input.viewportWidth) ? Math.max(0, input.viewportWidth) : 0;
226 const minimumPadding = Number.isFinite(input.minimumPadding)
227 ? Math.max(0, input.minimumPadding)
228 : 0;
229
230 if (
231 input.maxContentWidth === null ||
232 !Number.isFinite(input.maxContentWidth) ||
233 input.maxContentWidth <= 0
234 ) {
235 return minimumPadding;
236 }
237
238 return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2);
239}
240
241export function deriveStableFormSheetDetent(containerHeight: number): number {
242 if (!Number.isFinite(containerHeight) || containerHeight <= 0) {
243 return STABLE_FORM_SHEET_MAX_DETENT;
244 }
245
246 const targetHeight = Math.min(
247 STABLE_FORM_SHEET_MAX_HEIGHT,
248 Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN),
249 );
250 const detent = clamp(
251 targetHeight / containerHeight,
252 STABLE_FORM_SHEET_MIN_DETENT,
253 STABLE_FORM_SHEET_MAX_DETENT,
254 );
255 return Math.round(detent * 1_000) / 1_000;
256}
apps/mobile/src/lib/routes.test.ts deleted-45
...@@ -1,45 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3
4import { buildThreadFilesNavigation, buildThreadFilesRoutePath } from "./routes";
5
6const thread = {
7 environmentId: EnvironmentId.make("environment-1"),
8 threadId: ThreadId.make("thread-1"),
9};
10
11describe("thread file routes", () => {
12 it("includes an optional source line in string routes", () => {
13 expect(buildThreadFilesRoutePath(thread, "src/main.ts", 42)).toBe(
14 "/threads/environment-1/thread-1/files/src/main.ts?line=42",
15 );
16 });
17
18 it("encodes each file path segment without encoding separators", () => {
19 expect(buildThreadFilesRoutePath(thread, "docs/My File#1.md")).toBe(
20 "/threads/environment-1/thread-1/files/docs/My%20File%231.md",
21 );
22 });
23
24 it("builds typed navigation params for a file and source line", () => {
25 expect(buildThreadFilesNavigation(thread, "src/main.ts", 42)).toEqual({
26 pathname: "/threads/[environmentId]/[threadId]/files/[...path]",
27 params: {
28 environmentId: "environment-1",
29 threadId: "thread-1",
30 path: ["src", "main.ts"],
31 line: "42",
32 },
33 });
34 });
35
36 it("targets the files index when no file path is provided", () => {
37 expect(buildThreadFilesNavigation(thread)).toEqual({
38 pathname: "/threads/[environmentId]/[threadId]/files",
39 params: {
40 environmentId: "environment-1",
41 threadId: "thread-1",
42 },
43 });
44 });
45});
apps/mobile/src/lib/routes.ts deleted-134
...@@ -1,134 +0,0 @@
1import type { Href, useRouter } from "expo-router";
2import { type EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4
5import type { SelectedThreadRef } from "../state/remote-runtime-types";
6
7type Router = ReturnType<typeof useRouter>;
8
9type ThreadRouteInput =
10 | Pick<SelectedThreadRef, "environmentId" | "threadId">
11 | Pick<EnvironmentThreadShell, "environmentId" | "id">;
12type PlainThreadRouteInput =
13 | {
14 environmentId: EnvironmentId;
15 threadId: ThreadId;
16 }
17 | {
18 environmentId: EnvironmentId;
19 id: ThreadId;
20 };
21
22export function buildThreadRoutePath(input: ThreadRouteInput | PlainThreadRouteInput): string {
23 const environmentId = input.environmentId;
24 const threadId = "threadId" in input ? input.threadId : input.id;
25
26 return `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`;
27}
28
29export function buildThreadReviewRoutePath(
30 input: ThreadRouteInput | PlainThreadRouteInput,
31): string {
32 return `${buildThreadRoutePath(input)}/review`;
33}
34
35export function buildThreadFilesRoutePath(
36 input: ThreadRouteInput | PlainThreadRouteInput,
37 relativePath?: string | null,
38 line?: number | null,
39): string {
40 const basePath = `${buildThreadRoutePath(input)}/files`;
41 if (!relativePath) {
42 return basePath;
43 }
44
45 const pathSegments = relativePath.split("/").filter((segment) => segment.length > 0);
46 if (pathSegments.length === 0) {
47 return basePath;
48 }
49
50 const encodedPath = pathSegments.map(encodeURIComponent).join("/");
51 const lineParam =
52 Number.isFinite(line) && Number(line) > 0 ? `?line=${Math.floor(Number(line))}` : "";
53 return `${basePath}/${encodedPath}${lineParam}`;
54}
55
56export function buildThreadTerminalRoutePath(
57 input: ThreadRouteInput | PlainThreadRouteInput,
58 terminalId?: string | null,
59): string {
60 const basePath = `${buildThreadRoutePath(input)}/terminal`;
61 if (!terminalId) {
62 return basePath;
63 }
64
65 return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`;
66}
67
68/**
69 * Prefer this over {@link buildThreadTerminalRoutePath} with `router.push(string)` — Expo Router
70 * often does not merge query strings into `useLocalSearchParams`, which breaks terminal bootstrap
71 * (`requestedTerminalId` stays null while the UI assumes `default`).
72 */
73export function buildThreadTerminalNavigation(
74 input: ThreadRouteInput | PlainThreadRouteInput,
75 terminalId?: string | null,
76): Href {
77 const environmentId = String(input.environmentId);
78 const threadId = String("threadId" in input ? input.threadId : input.id);
79
80 const params: { environmentId: string; threadId: string; terminalId?: string } = {
81 environmentId,
82 threadId,
83 };
84
85 if (terminalId != null && terminalId !== "") {
86 params.terminalId = terminalId;
87 }
88
89 return {
90 pathname: "/threads/[environmentId]/[threadId]/terminal",
91 params,
92 };
93}
94
95export function buildThreadFilesNavigation(
96 input: ThreadRouteInput | PlainThreadRouteInput,
97 relativePath?: string | null,
98 line?: number | null,
99): Href {
100 const environmentId = String(input.environmentId);
101 const threadId = String("threadId" in input ? input.threadId : input.id);
102 const path = relativePath?.split("/").filter((segment) => segment.length > 0) ?? [];
103
104 if (path.length === 0) {
105 return {
106 pathname: "/threads/[environmentId]/[threadId]/files",
107 params: { environmentId, threadId },
108 };
109 }
110
111 const params: {
112 environmentId: string;
113 threadId: string;
114 path: string[];
115 line?: string;
116 } = { environmentId, threadId, path };
117 if (Number.isFinite(line) && Number(line) > 0) {
118 params.line = String(Math.floor(Number(line)));
119 }
120
121 return {
122 pathname: "/threads/[environmentId]/[threadId]/files/[...path]",
123 params,
124 };
125}
126
127export function dismissRoute(router: Router) {
128 if (router.canGoBack()) {
129 router.back();
130 return;
131 }
132
133 router.replace("/");
134}
apps/mobile/src/lib/storage.ts+26-3
...@@ -59,7 +59,14 @@ export class MobileStorageEncodeError extends Schema.TaggedErrorClass<MobileStor...@@ -59,7 +59,14 @@ export class MobileStorageEncodeError extends Schema.TaggedErrorClass<MobileStor
5959
60export interface Preferences {60export interface Preferences {
61 readonly liveActivitiesEnabled?: boolean;61 readonly liveActivitiesEnabled?: boolean;
62 readonly terminalFontSize?: number;62 readonly baseFontSize?: number;
63 /** Terminal font size override; null/absent means derived from baseFontSize. */
64 readonly terminalFontSize?: number | null;
65 /** Legacy key predating baseFontSize; read once for migration. */
66 readonly markdownFontSize?: number;
67 /** Code/diff font size override; null/absent means derived from baseFontSize. */
68 readonly codeFontSize?: number | null;
69 readonly codeWordBreak?: boolean;
63}70}
6471
65async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {72async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {
...@@ -153,15 +160,31 @@ export async function loadPreferences(): Promise<Preferences> {...@@ -153,15 +160,31 @@ export async function loadPreferences(): Promise<Preferences> {
153160
154 const preferences: {161 const preferences: {
155 liveActivitiesEnabled?: boolean;162 liveActivitiesEnabled?: boolean;
156 terminalFontSize?: number;163 baseFontSize?: number;
164 terminalFontSize?: number | null;
165 markdownFontSize?: number;
166 codeFontSize?: number | null;
167 codeWordBreak?: boolean;
157 } = {};168 } = {};
158169
159 if (typeof parsed.liveActivitiesEnabled === "boolean") {170 if (typeof parsed.liveActivitiesEnabled === "boolean") {
160 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;171 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
161 }172 }
162 if (typeof parsed.terminalFontSize === "number") {173 if (typeof parsed.baseFontSize === "number") {
174 preferences.baseFontSize = parsed.baseFontSize;
175 }
176 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
163 preferences.terminalFontSize = parsed.terminalFontSize;177 preferences.terminalFontSize = parsed.terminalFontSize;
164 }178 }
179 if (typeof parsed.markdownFontSize === "number") {
180 preferences.markdownFontSize = parsed.markdownFontSize;
181 }
182 if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) {
183 preferences.codeFontSize = parsed.codeFontSize;
184 }
185 if (typeof parsed.codeWordBreak === "boolean") {
186 preferences.codeWordBreak = parsed.codeWordBreak;
187 }
165188
166 return preferences;189 return preferences;
167}190}
apps/mobile/src/lib/typography.test.ts+4-7
...@@ -3,21 +3,18 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,21 +3,18 @@ import { describe, expect, it } from "vite-plus/test";
3import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography";3import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography";
44
5describe("mobile typography", () => {5describe("mobile typography", () => {
6 it("uses the intentional compact mobile font scale", () => {6 it("uses the intentional mobile font scale anchored at a 16pt body", () => {
7 expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([7 expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([
8 10, 11, 12, 13, 14, 15, 17, 20, 24, 28,8 11, 12, 13, 14, 16, 18, 21, 26, 30,
9 ]);9 ]);
10 });10 expect(MOBILE_TYPOGRAPHY.body).toEqual({ fontSize: 16, lineHeight: 23 });
11
12 it("uses a compact shared style for editable composer text", () => {
13 expect(MOBILE_TYPOGRAPHY.composer).toEqual({ fontSize: 14, lineHeight: 20 });
14 });11 });
1512
16 it("uses caption-sized code with a compact readable row height", () => {13 it("uses caption-sized code with a compact readable row height", () => {
17 expect(MOBILE_CODE_SURFACE).toMatchObject({14 expect(MOBILE_CODE_SURFACE).toMatchObject({
18 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,15 fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
19 lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize,16 lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize,
20 rowHeight: 20,17 rowHeight: 22,
21 });18 });
22 });19 });
23});20});
apps/mobile/src/lib/typography.ts+10-11
...@@ -1,19 +1,18 @@...@@ -1,19 +1,18 @@
1export const MOBILE_TYPOGRAPHY = {1export const MOBILE_TYPOGRAPHY = {
2 micro: { fontSize: 10, lineHeight: 13 },2 micro: { fontSize: 11, lineHeight: 14 },
3 caption: { fontSize: 11, lineHeight: 15 },3 caption: { fontSize: 12, lineHeight: 16 },
4 label: { fontSize: 12, lineHeight: 16 },4 label: { fontSize: 13, lineHeight: 17 },
5 footnote: { fontSize: 13, lineHeight: 18 },5 footnote: { fontSize: 14, lineHeight: 19 },
6 composer: { fontSize: 14, lineHeight: 20 },6 body: { fontSize: 16, lineHeight: 23 },
7 body: { fontSize: 15, lineHeight: 22 },7 headline: { fontSize: 18, lineHeight: 23 },
8 headline: { fontSize: 17, lineHeight: 22 },8 title: { fontSize: 21, lineHeight: 28 },
9 title: { fontSize: 20, lineHeight: 26 },9 largeTitle: { fontSize: 26, lineHeight: 32 },
10 largeTitle: { fontSize: 24, lineHeight: 30 },10 display: { fontSize: 30, lineHeight: 36 },
11 display: { fontSize: 28, lineHeight: 34 },
12} as const;11} as const;
1312
14/** Shared geometry for dense, horizontally scrolling code surfaces. */13/** Shared geometry for dense, horizontally scrolling code surfaces. */
15export const MOBILE_CODE_SURFACE = {14export const MOBILE_CODE_SURFACE = {
16 rowHeight: 20,15 rowHeight: 22,
17 gutterWidth: 46,16 gutterWidth: 46,
18 codePadding: 7,17 codePadding: 7,
19 textVerticalInset: 2,18 textVerticalInset: 2,
apps/mobile/src/native/StackHeader.tsx created+359
...@@ -0,0 +1,359 @@
1import { useNavigation, type ParamListBase } from "@react-navigation/native";
2import type {
3 NativeStackHeaderItem,
4 NativeStackHeaderItemMenu,
5 NativeStackNavigationOptions,
6 NativeStackNavigationProp,
7} from "@react-navigation/native-stack";
8import {
9 Children,
10 isValidElement,
11 useEffect,
12 useLayoutEffect,
13 useMemo,
14 type ReactElement,
15 type ReactNode,
16} from "react";
17import type { ColorValue } from "react-native";
18
19export {
20 nativeHeaderScrollEdgeEffects,
21 nativeTopScrollEdgeEffect,
22 type NativeHeaderScrollEdgeEffects,
23 type NativeTopScrollEdgeEffect,
24} from "./scrollEdgeEffects";
25
26export type AppNativeStackNavigationOptions = Omit<
27 NativeStackNavigationOptions,
28 "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems"
29> & {
30 readonly headerTintColor?: string | ColorValue;
31 readonly unstable_headerCenterItems?: unknown;
32 readonly unstable_headerLeftItems?: unknown;
33 readonly unstable_headerRightItems?: unknown;
34 readonly unstable_headerSubtitle?: unknown;
35 readonly unstable_headerToolbarItems?: unknown;
36 readonly unstable_navigationItemStyle?: unknown;
37};
38
39function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null {
40 return useNavigation<NativeStackNavigationProp<ParamListBase>>();
41}
42
43function normalizeScreenOptions(
44 options: AppNativeStackNavigationOptions | undefined,
45): NativeStackNavigationOptions | undefined {
46 if (!options) {
47 return options;
48 }
49
50 const normalized = { ...options } as NativeStackNavigationOptions & {
51 unstable_navigationItemStyle?: unknown;
52 unstable_headerCenterItems?: unknown;
53 unstable_headerSubtitle?: unknown;
54 unstable_headerToolbarItems?: unknown;
55 };
56
57 if (normalized.headerTintColor !== undefined) {
58 normalized.headerTintColor = String(normalized.headerTintColor);
59 }
60
61 return normalized as NativeStackNavigationOptions;
62}
63
64export function NativeStackScreenOptions(props: {
65 readonly options?: AppNativeStackNavigationOptions;
66 readonly listeners?: Record<string, (event: never) => void>;
67 readonly name?: string;
68}) {
69 const navigation = useNativeStackNavigation();
70 const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]);
71
72 useLayoutEffect(() => {
73 if (!navigation || !normalizedOptions) {
74 return;
75 }
76 navigation.setOptions(normalizedOptions);
77 }, [navigation, normalizedOptions]);
78
79 useEffect(() => {
80 if (!navigation || !props.listeners) {
81 return;
82 }
83 const subscriptions = Object.entries(props.listeners).map(([eventName, listener]) =>
84 navigation.addListener(eventName as never, listener as never),
85 );
86 return () => {
87 for (const unsubscribe of subscriptions) {
88 unsubscribe();
89 }
90 };
91 }, [navigation, props.listeners]);
92
93 return null;
94}
95
96function labelFromChildren(children: ReactNode): string {
97 const parts: string[] = [];
98 Children.forEach(children, (child) => {
99 if (typeof child === "string" || typeof child === "number") {
100 parts.push(String(child));
101 } else if (isValidElement<{ children?: ReactNode }>(child)) {
102 parts.push(labelFromChildren(child.props.children));
103 }
104 });
105 return parts.join("");
106}
107
108type NativeStackHeaderIcon = NonNullable<
109 Extract<NativeStackHeaderItem, { type: "button" }>["icon"]
110>;
111type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
112 unstable_headerToolbarItems?: () => NativeStackHeaderItem[];
113};
114
115function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
116 if (typeof icon !== "string") {
117 return undefined;
118 }
119 return { type: "sfSymbol", name: icon as never };
120}
121
122type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode };
123
124function elementTypeName(element: ReactElement): string | undefined {
125 const type = element.type;
126 if (typeof type === "function") {
127 return (type as { displayName?: string; name?: string }).displayName ?? type.name;
128 }
129 return undefined;
130}
131
132function convertMenuAction(
133 element: ReactElement<ToolbarElementProps>,
134): NativeStackHeaderItemMenu["menu"]["items"][number] | null {
135 const typeName = elementTypeName(element);
136 if (typeName === "NativeHeaderToolbarMenuAction") {
137 const label = labelFromChildren(element.props.children);
138 return {
139 type: "action",
140 label,
141 description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined,
142 disabled: Boolean(element.props.disabled),
143 icon: iconFromProp(element.props.icon),
144 onPress:
145 typeof element.props.onPress === "function"
146 ? (element.props.onPress as () => void)
147 : () => undefined,
148 state: element.props.isOn === true ? "on" : undefined,
149 destructive: Boolean(element.props.destructive),
150 discoverabilityLabel:
151 typeof element.props.discoverabilityLabel === "string"
152 ? element.props.discoverabilityLabel
153 : undefined,
154 };
155 }
156
157 if (typeName === "NativeHeaderToolbarMenu") {
158 return {
159 type: "submenu",
160 label:
161 typeof element.props.title === "string"
162 ? element.props.title
163 : labelFromChildren(element.props.children),
164 icon: iconFromProp(element.props.icon),
165 inline: Boolean(element.props.inline),
166 items: collectMenuItems(element.props.children),
167 };
168 }
169
170 return null;
171}
172
173function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] {
174 const items: NativeStackHeaderItemMenu["menu"]["items"] = [];
175 Children.forEach(children, (child) => {
176 if (!isValidElement<ToolbarElementProps>(child)) {
177 return;
178 }
179 const item = convertMenuAction(child);
180 if (item) {
181 items.push(item);
182 return;
183 }
184 items.push(...collectMenuItems(child.props.children));
185 });
186 return items;
187}
188
189function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
190 if (!isValidElement<ToolbarElementProps>(child)) {
191 return null;
192 }
193
194 const typeName = elementTypeName(child);
195 if (typeName === "NativeHeaderToolbarButton") {
196 return {
197 type: "button",
198 label: "",
199 accessibilityLabel:
200 typeof child.props.accessibilityLabel === "string"
201 ? child.props.accessibilityLabel
202 : undefined,
203 disabled: Boolean(child.props.disabled),
204 icon: iconFromProp(child.props.icon),
205 onPress:
206 typeof child.props.onPress === "function"
207 ? (child.props.onPress as () => void)
208 : () => undefined,
209 sharesBackground: !child.props.separateBackground,
210 tintColor: child.props.tintColor as ColorValue | undefined,
211 variant: "plain",
212 };
213 }
214
215 if (typeName === "NativeHeaderToolbarMenu") {
216 return {
217 type: "menu",
218 label: typeof child.props.title === "string" ? child.props.title : "",
219 accessibilityLabel:
220 typeof child.props.accessibilityLabel === "string"
221 ? child.props.accessibilityLabel
222 : undefined,
223 disabled: Boolean(child.props.disabled),
224 icon: iconFromProp(child.props.icon),
225 menu: {
226 title: typeof child.props.title === "string" ? child.props.title : undefined,
227 items: collectMenuItems(child.props.children),
228 },
229 sharesBackground: !child.props.separateBackground,
230 tintColor: child.props.tintColor as ColorValue | undefined,
231 variant: "plain",
232 };
233 }
234
235 if (typeName === "NativeHeaderToolbarSpacer") {
236 return {
237 type: "spacing",
238 spacing: typeof child.props.width === "number" ? child.props.width : 8,
239 };
240 }
241
242 return null;
243}
244
245function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {
246 const items: NativeStackHeaderItem[] = [];
247 Children.forEach(children, (child) => {
248 const item = convertToolbarChild(child);
249 if (item) {
250 items.push(item);
251 }
252 });
253 return items;
254}
255
256function NativeHeaderToolbarRoot(props: {
257 readonly placement?: "left" | "right" | "bottom";
258 readonly children?: ReactNode;
259}) {
260 const navigation = useNativeStackNavigation();
261 const items = useMemo(() => collectToolbarItems(props.children), [props.children]);
262
263 useEffect(() => {
264 if (!navigation) {
265 return;
266 }
267 if (props.placement === "bottom") {
268 navigation.setOptions({
269 unstable_headerToolbarItems: () => items,
270 } as NativeStackOptionsWithToolbar);
271 return () => {
272 navigation.setOptions({
273 unstable_headerToolbarItems: () => [],
274 } as NativeStackOptionsWithToolbar);
275 };
276 }
277 if (props.placement === "left") {
278 navigation.setOptions({ unstable_headerLeftItems: () => items });
279 return () => {
280 navigation.setOptions({ unstable_headerLeftItems: () => [] });
281 };
282 }
283 navigation.setOptions({ unstable_headerRightItems: () => items });
284 return () => {
285 navigation.setOptions({ unstable_headerRightItems: () => [] });
286 };
287 }, [items, navigation, props.placement]);
288
289 return null;
290}
291
292function NativeHeaderToolbarButton(_props: {
293 readonly accessibilityLabel?: string;
294 readonly disabled?: boolean;
295 readonly icon?: string;
296 readonly onPress?: () => void;
297 readonly separateBackground?: boolean;
298 readonly tintColor?: ColorValue;
299}) {
300 return null;
301}
302NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton";
303
304function NativeHeaderToolbarMenu(_props: {
305 readonly accessibilityLabel?: string;
306 readonly children?: ReactNode;
307 readonly disabled?: boolean;
308 readonly icon?: string;
309 readonly inline?: boolean;
310 readonly separateBackground?: boolean;
311 readonly tintColor?: ColorValue;
312 readonly title?: string;
313}) {
314 return null;
315}
316NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu";
317
318function NativeHeaderToolbarMenuAction(_props: {
319 readonly children?: ReactNode;
320 readonly destructive?: boolean;
321 readonly disabled?: boolean;
322 readonly discoverabilityLabel?: string;
323 readonly icon?: string;
324 readonly isOn?: boolean;
325 readonly onPress?: () => void;
326 readonly subtitle?: string;
327}) {
328 return null;
329}
330NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction";
331
332function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
333 return null;
334}
335NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel";
336
337function NativeHeaderToolbarSpacer(_props: {
338 readonly sharesBackground?: boolean;
339 readonly width?: number;
340}) {
341 return null;
342}
343NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer";
344
345function NativeHeaderToolbarSearchBarSlot() {
346 return null;
347}
348NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot";
349
350export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
351 Button: NativeHeaderToolbarButton,
352 Label: NativeHeaderToolbarLabel,
353 Menu: Object.assign(NativeHeaderToolbarMenu, {
354 Action: NativeHeaderToolbarMenuAction,
355 }),
356 MenuAction: NativeHeaderToolbarMenuAction,
357 SearchBarSlot: NativeHeaderToolbarSearchBarSlot,
358 Spacer: NativeHeaderToolbarSpacer,
359});
apps/mobile/src/native/T3ComposerEditor.ios.tsx+7-3
...@@ -14,8 +14,8 @@ import { Image, StyleSheet } from "react-native";...@@ -14,8 +14,8 @@ import { Image, StyleSheet } from "react-native";
1414
15import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";15import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
16import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";16import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
17import { MOBILE_TYPOGRAPHY } from "../lib/typography";
18import { useThemeColor } from "../lib/useThemeColor";17import { useThemeColor } from "../lib/useThemeColor";
18import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
19import {19import {
20 acknowledgeComposerNativeEvent,20 acknowledgeComposerNativeEvent,
21 isComposerNativeEcho,21 isComposerNativeEcho,
...@@ -69,6 +69,7 @@ interface NativeComposerEditorProps extends ViewProps {...@@ -69,6 +69,7 @@ interface NativeComposerEditorProps extends ViewProps {
69 readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void;69 readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void;
70 readonly onComposerFocus?: () => void;70 readonly onComposerFocus?: () => void;
71 readonly onComposerBlur?: () => void;71 readonly onComposerBlur?: () => void;
72 readonly onComposerSubmit?: () => void;
72}73}
7374
74const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME);75const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME);
...@@ -93,6 +94,7 @@ export function ComposerEditor({...@@ -93,6 +94,7 @@ export function ComposerEditor({
93 onPasteImages,94 onPasteImages,
94 onFocus,95 onFocus,
95 onBlur,96 onBlur,
97 onSubmit,
96 contentInsetVertical = 0,98 contentInsetVertical = 0,
97 ...props99 ...props
98}: ComposerEditorProps) {100}: ComposerEditorProps) {
...@@ -105,6 +107,7 @@ export function ComposerEditor({...@@ -105,6 +107,7 @@ export function ComposerEditor({
105 { eventCount: 0, value: props.value, selection: selection ?? null },107 { eventCount: 0, value: props.value, selection: selection ?? null },
106 ]);108 ]);
107 const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));109 const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
110 const bodyText = useScaledTextRole("body");
108 const textColor = useThemeColor("--color-foreground");111 const textColor = useThemeColor("--color-foreground");
109 const placeholderColor = useThemeColor("--color-placeholder");112 const placeholderColor = useThemeColor("--color-placeholder");
110 const chipBackground = useThemeColor("--color-subtle");113 const chipBackground = useThemeColor("--color-subtle");
...@@ -230,12 +233,12 @@ export function ComposerEditor({...@@ -230,12 +233,12 @@ export function ComposerEditor({
230 fontSize={233 fontSize={
231 typeof resolvedTextStyle.fontSize === "number"234 typeof resolvedTextStyle.fontSize === "number"
232 ? resolvedTextStyle.fontSize235 ? resolvedTextStyle.fontSize
233 : MOBILE_TYPOGRAPHY.composer.fontSize236 : bodyText.fontSize
234 }237 }
235 lineHeight={238 lineHeight={
236 typeof resolvedTextStyle.lineHeight === "number"239 typeof resolvedTextStyle.lineHeight === "number"
237 ? resolvedTextStyle.lineHeight240 ? resolvedTextStyle.lineHeight
238 : MOBILE_TYPOGRAPHY.composer.lineHeight241 : bodyText.lineHeight
239 }242 }
240 contentInsetVertical={contentInsetVertical}243 contentInsetVertical={contentInsetVertical}
241 editable={props.editable ?? true}244 editable={props.editable ?? true}
...@@ -270,6 +273,7 @@ export function ComposerEditor({...@@ -270,6 +273,7 @@ export function ComposerEditor({
270 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}273 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
271 onComposerFocus={onFocus}274 onComposerFocus={onFocus}
272 onComposerBlur={onBlur}275 onComposerBlur={onBlur}
276 onComposerSubmit={onSubmit}
273 />277 />
274 );278 );
275}279}
apps/mobile/src/native/T3ComposerEditor.tsx+3-2
...@@ -2,8 +2,8 @@ import { TextInputWrapper } from "expo-paste-input";...@@ -2,8 +2,8 @@ import { TextInputWrapper } from "expo-paste-input";
2import { useImperativeHandle, useRef } from "react";2import { useImperativeHandle, useRef } from "react";
3import { TextInput, type TextInput as RNTextInput } from "react-native";3import { TextInput, type TextInput as RNTextInput } from "react-native";
44
5import { MOBILE_TYPOGRAPHY } from "../lib/typography";
6import { useThemeColor } from "../lib/useThemeColor";5import { useThemeColor } from "../lib/useThemeColor";
6import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
7import { useNativePaste } from "../lib/useNativePaste";7import { useNativePaste } from "../lib/useNativePaste";
8import type { ComposerEditorProps } from "./T3ComposerEditor.types";8import type { ComposerEditorProps } from "./T3ComposerEditor.types";
99
...@@ -18,6 +18,7 @@ export function ComposerEditor({...@@ -18,6 +18,7 @@ export function ComposerEditor({
18 ...props18 ...props
19}: ComposerEditorProps) {19}: ComposerEditorProps) {
20 const inputRef = useRef<RNTextInput>(null);20 const inputRef = useRef<RNTextInput>(null);
21 const bodyText = useScaledTextRole("body");
21 const foregroundColor = useThemeColor("--color-foreground");22 const foregroundColor = useThemeColor("--color-foreground");
22 const placeholderColor = useThemeColor("--color-placeholder");23 const placeholderColor = useThemeColor("--color-placeholder");
23 const handlePaste = useNativePaste((uris) => onPasteImages?.(uris));24 const handlePaste = useNativePaste((uris) => onPasteImages?.(uris));
...@@ -48,7 +49,7 @@ export function ComposerEditor({...@@ -48,7 +49,7 @@ export function ComposerEditor({
48 minHeight: 0,49 minHeight: 0,
49 color: foregroundColor,50 color: foregroundColor,
50 fontFamily: "DMSans_400Regular",51 fontFamily: "DMSans_400Regular",
51 ...MOBILE_TYPOGRAPHY.composer,52 ...bodyText,
52 paddingVertical: contentInsetVertical,53 paddingVertical: contentInsetVertical,
53 },54 },
54 textStyle,55 textStyle,
apps/mobile/src/native/T3ComposerEditor.types.ts+2
...@@ -35,4 +35,6 @@ export interface ComposerEditorProps {...@@ -35,4 +35,6 @@ export interface ComposerEditorProps {
35 readonly onPasteImages?: (uris: ReadonlyArray<string>) => void;35 readonly onPasteImages?: (uris: ReadonlyArray<string>) => void;
36 readonly onFocus?: () => void;36 readonly onFocus?: () => void;
37 readonly onBlur?: () => void;37 readonly onBlur?: () => void;
38 /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */
39 readonly onSubmit?: () => void;
38}40}
apps/mobile/src/native/T3KeyboardCommands.ios.tsx created+31
...@@ -0,0 +1,31 @@
1import { requireNativeView } from "expo";
2import type { PropsWithChildren } from "react";
3import type { NativeSyntheticEvent, ViewProps } from "react-native";
4
5import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands";
6
7interface NativeKeyboardCommandsProps extends ViewProps, PropsWithChildren {
8 readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>;
9 readonly onCommand: (
10 event: NativeSyntheticEvent<{ readonly command: HardwareKeyboardCommand }>,
11 ) => void;
12}
13
14const NativeKeyboardCommands = requireNativeView<NativeKeyboardCommandsProps>("T3KeyboardCommands");
15
16export function T3KeyboardCommands(
17 props: PropsWithChildren<{
18 readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>;
19 readonly onCommand: (command: HardwareKeyboardCommand) => void;
20 }>,
21) {
22 return (
23 <NativeKeyboardCommands
24 onCommand={(event) => props.onCommand(event.nativeEvent.command)}
25 enabledCommands={props.enabledCommands}
26 style={{ flex: 1 }}
27 >
28 {props.children}
29 </NativeKeyboardCommands>
30 );
31}
apps/mobile/src/native/T3KeyboardCommands.tsx created+13
...@@ -0,0 +1,13 @@
1import type { PropsWithChildren } from "react";
2import { View } from "react-native";
3
4import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands";
5
6export function T3KeyboardCommands(
7 props: PropsWithChildren<{
8 readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>;
9 readonly onCommand: (command: HardwareKeyboardCommand) => void;
10 }>,
11) {
12 return <View style={{ flex: 1 }}>{props.children}</View>;
13}
apps/mobile/src/native/scrollEdgeEffects.test.ts created+29
...@@ -0,0 +1,29 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { nativeHeaderScrollEdgeEffects, nativeTopScrollEdgeEffect } from "./scrollEdgeEffects";
4
5describe("nativeTopScrollEdgeEffect", () => {
6 it("keeps the automatic native treatment on iOS 26", () => {
7 expect(nativeTopScrollEdgeEffect("ios", "26.5")).toBe("automatic");
8 });
9
10 it("keeps UIKit automatic scroll-edge sampling on iOS 27 and later", () => {
11 expect(nativeTopScrollEdgeEffect("ios", "27.0")).toBe("automatic");
12 expect(nativeTopScrollEdgeEffect("ios", 28)).toBe("automatic");
13 });
14
15 it("does not apply the iOS workaround to other platforms", () => {
16 expect(nativeTopScrollEdgeEffect("android", 27)).toBe("automatic");
17 });
18});
19
20describe("nativeHeaderScrollEdgeEffects", () => {
21 it("keeps non-top header edges hidden while applying the platform top effect", () => {
22 expect(nativeHeaderScrollEdgeEffects("ios", "27.0")).toEqual({
23 top: "automatic",
24 bottom: "hidden",
25 left: "hidden",
26 right: "hidden",
27 });
28 });
29});
apps/mobile/src/native/scrollEdgeEffects.ts created+37
...@@ -0,0 +1,37 @@
1// Pure helpers for native header scroll-edge effects. Kept free of
2// react-native / react-navigation imports so they stay unit-testable in node
3// (those packages ship untranspiled Flow syntax).
4
5export type NativeTopScrollEdgeEffect = "automatic" | "soft";
6export type NativeHeaderScrollEdgeEffects = {
7 readonly top: NativeTopScrollEdgeEffect;
8 readonly bottom: "hidden";
9 readonly left: "hidden";
10 readonly right: "hidden";
11};
12
13export function nativeTopScrollEdgeEffect(
14 os: string,
15 _version: number | string,
16): NativeTopScrollEdgeEffect {
17 if (os !== "ios") {
18 return "automatic";
19 }
20
21 // The standalone RNS/Mail spike that matched Messages/GitHub used UIKit's
22 // automatic scroll-edge behavior. Forcing `soft` on iOS 27 makes production
23 // look like a local overlay instead of sampling the app content edge-to-edge.
24 return "automatic";
25}
26
27export function nativeHeaderScrollEdgeEffects(
28 os: string,
29 version: number | string,
30): NativeHeaderScrollEdgeEffects {
31 return {
32 top: nativeTopScrollEdgeEffect(os, version),
33 bottom: "hidden",
34 left: "hidden",
35 right: "hidden",
36 };
37}
apps/mobile/src/state/use-thread-pr.ts created+67
...@@ -0,0 +1,67 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import type { VcsStatusResult } from "@t3tools/contracts";
3import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl";
4
5import { useEnvironmentQuery } from "./query";
6import { vcsEnvironment } from "./vcs";
7
8export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
9
10export interface ThreadPrPresentation {
11 readonly number: number;
12 readonly state: ThreadPr["state"];
13 readonly url: string;
14 /** Compact chip label, e.g. "PR open" / "MR merged". */
15 readonly label: string;
16 readonly textClassName: string;
17}
18
19const PR_STATE_TEXT_CLASS: Record<ThreadPr["state"], string> = {
20 open: "text-emerald-600 dark:text-emerald-400",
21 merged: "text-violet-600 dark:text-violet-400",
22 closed: "text-zinc-500 dark:text-zinc-400",
23};
24
25export function presentThreadPr(
26 pr: ThreadPr,
27 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
28): ThreadPrPresentation {
29 const shortName = resolveChangeRequestPresentation(provider).shortName;
30 return {
31 number: pr.number,
32 state: pr.state,
33 url: pr.url,
34 label: `${shortName} ${pr.state}`,
35 textClassName: PR_STATE_TEXT_CLASS[pr.state],
36 };
37}
38
39/**
40 * Live PR status for a thread's branch. Subscriptions are deduplicated per
41 * (environmentId, cwd) by the atom family, so many rows on the same worktree
42 * or project root share one stream — and virtualization means only visible
43 * rows subscribe at all.
44 */
45export function useThreadPr(
46 thread: EnvironmentThreadShell,
47 projectCwd: string | null,
48): ThreadPrPresentation | null {
49 const cwd = thread.worktreePath ?? projectCwd;
50 const gitStatus = useEnvironmentQuery(
51 thread.branch !== null && cwd !== null
52 ? vcsEnvironment.status({
53 environmentId: thread.environmentId,
54 input: { cwd },
55 })
56 : null,
57 );
58
59 const status = gitStatus.data;
60 if (status === null || thread.branch === null || status.refName !== thread.branch) {
61 return null;
62 }
63 if (!status.pr) {
64 return null;
65 }
66 return presentThreadPr(status.pr, status.sourceControlProvider);
67}
apps/mobile/src/state/use-thread-selection.ts+101-20
...@@ -1,12 +1,25 @@...@@ -1,12 +1,25 @@
1import { useLocalSearchParams } from "expo-router";1import { useRoute, type RouteProp } from "@react-navigation/native";
2import { useMemo } from "react";2import { useMemo, useRef } from "react";
3import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts";3import {
4 EnvironmentId,
5 type OrchestrationThread,
6 ThreadId,
7 type ScopedProjectRef,
8 type ScopedThreadRef,
9} from "@t3tools/contracts";
10import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
11import * as Option from "effect/Option";
412
5import { useProject, useThreadShell } from "../state/entities";13import { useProject, useThreadShell } from "../state/entities";
14import { useEnvironmentThread } from "../state/threads";
6import {15import {
7 useRemoteEnvironmentRuntime,16 useRemoteEnvironmentRuntime,
8 useSavedRemoteConnection,17 useSavedRemoteConnection,
9} from "./use-remote-environment-registry";18} from "./use-remote-environment-registry";
19type ThreadSelectionRouteParams = {
20 readonly environmentId?: string | string[];
21 readonly threadId?: string | string[];
22};
1023
11function firstRouteParam(value: string | string[] | undefined): string | null {24function firstRouteParam(value: string | string[] | undefined): string | null {
12 if (Array.isArray(value)) {25 if (Array.isArray(value)) {
...@@ -16,14 +29,48 @@ function firstRouteParam(value: string | string[] | undefined): string | null {...@@ -16,14 +29,48 @@ function firstRouteParam(value: string | string[] | undefined): string | null {
16 return value ?? null;29 return value ?? null;
17}30}
1831
19export function useThreadSelection() {32function latestUserMessageAt(thread: OrchestrationThread): OrchestrationThread["updatedAt"] | null {
20 const params = useLocalSearchParams<{33 for (let index = thread.messages.length - 1; index >= 0; index -= 1) {
21 environmentId?: string | string[];34 const message = thread.messages[index];
22 threadId?: string | string[];35 if (message?.role === "user") {
23 }>();36 return message.createdAt;
24 const selectedThreadRef = useMemo(() => {37 }
25 const environmentId = firstRouteParam(params.environmentId);38 }
26 const threadId = firstRouteParam(params.threadId);39
40 return null;
41}
42
43function threadDetailToShell(
44 environmentId: EnvironmentId,
45 thread: OrchestrationThread,
46): EnvironmentThreadShell {
47 return {
48 environmentId,
49 id: thread.id,
50 projectId: thread.projectId,
51 title: thread.title,
52 modelSelection: thread.modelSelection,
53 runtimeMode: thread.runtimeMode,
54 interactionMode: thread.interactionMode,
55 branch: thread.branch,
56 worktreePath: thread.worktreePath,
57 latestTurn: thread.latestTurn,
58 createdAt: thread.createdAt,
59 updatedAt: thread.updatedAt,
60 archivedAt: thread.archivedAt,
61 session: thread.session,
62 latestUserMessageAt: latestUserMessageAt(thread),
63 hasPendingApprovals: false,
64 hasPendingUserInput: false,
65 hasActionableProposedPlan: false,
66 };
67}
68
69function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefined) {
70 const routeParams = params ?? {};
71 const routeThreadRef = useMemo<ScopedThreadRef | null>(() => {
72 const environmentId = firstRouteParam(routeParams.environmentId);
73 const threadId = firstRouteParam(routeParams.threadId);
27 if (!environmentId || !threadId) {74 if (!environmentId || !threadId) {
28 return null;75 return null;
29 }76 }
...@@ -32,8 +79,26 @@ export function useThreadSelection() {...@@ -32,8 +79,26 @@ export function useThreadSelection() {
32 environmentId: EnvironmentId.make(environmentId),79 environmentId: EnvironmentId.make(environmentId),
33 threadId: ThreadId.make(threadId),80 threadId: ThreadId.make(threadId),
34 };81 };
35 }, [params.environmentId, params.threadId]);82 }, [routeParams.environmentId, routeParams.threadId]);
36 const selectedThread = useThreadShell(selectedThreadRef);83 const lastRouteThreadRef = useRef<ScopedThreadRef | null>(null);
84 if (routeThreadRef !== null) {
85 lastRouteThreadRef.current = routeThreadRef;
86 }
87 const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current;
88 const selectedThreadShell = useThreadShell(selectedThreadRef);
89 const selectedThreadDetailState = useEnvironmentThread(
90 selectedThreadRef?.environmentId ?? null,
91 selectedThreadRef?.threadId ?? null,
92 );
93 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
94 const selectedThread = useMemo(
95 () =>
96 selectedThreadShell ??
97 (selectedThreadRef !== null && selectedThreadDetail !== null
98 ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail)
99 : null),
100 [selectedThreadDetail, selectedThreadRef, selectedThreadShell],
101 );
37 const selectedProjectRef = useMemo<ScopedProjectRef | null>(102 const selectedProjectRef = useMemo<ScopedProjectRef | null>(
38 () =>103 () =>
39 selectedThread === null104 selectedThread === null
...@@ -49,11 +114,27 @@ export function useThreadSelection() {...@@ -49,11 +114,27 @@ export function useThreadSelection() {
49 const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId);114 const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId);
50 const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId);115 const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId);
51116
52 return {117 return useMemo(
53 selectedThreadRef,118 () => ({
54 selectedThread,119 selectedThreadRef,
55 selectedThreadProject,120 selectedThread,
56 selectedEnvironmentConnection,121 selectedThreadProject,
57 selectedEnvironmentRuntime,122 selectedEnvironmentConnection,
58 };123 selectedEnvironmentRuntime,
124 }),
125 [
126 selectedEnvironmentConnection,
127 selectedEnvironmentRuntime,
128 selectedThread,
129 selectedThreadProject,
130 selectedThreadRef,
131 ],
132 );
133}
134
135type ThreadSelectionState = ReturnType<typeof useResolvedThreadSelection>;
136
137export function useThreadSelection(): ThreadSelectionState {
138 const route = useRoute<RouteProp<Record<string, ThreadSelectionRouteParams | undefined>>>();
139 return useResolvedThreadSelection(route.params);
59}140}
experiments/messages-glass-lab/MessagesGlassLab.xcodeproj/project.pbxproj created+285
...@@ -0,0 +1,285 @@
1// !$*UTF8*$!
2{
3 archiveVersion = 1;
4 classes = {
5 };
6 objectVersion = 77;
7 objects = {
8
9/* Begin PBXBuildFile section */
10 8A01A0012D10000100A00001 /* MessagesGlassLabApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A01A0002D10000100A00001 /* MessagesGlassLabApp.swift */; };
11 8A01A0032D10000100A00001 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A01A0022D10000100A00001 /* ContentView.swift */; };
12/* End PBXBuildFile section */
13
14/* Begin PBXFileReference section */
15 8A01A0002D10000100A00001 /* MessagesGlassLabApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesGlassLabApp.swift; sourceTree = "<group>"; };
16 8A01A0022D10000100A00001 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
17 8A01A0102D10000100A00001 /* MessagesGlassLab.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MessagesGlassLab.app; sourceTree = BUILT_PRODUCTS_DIR; };
18/* End PBXFileReference section */
19
20 8A01A0202D10000100A00001 /* MessagesGlassLab */ = {
21 isa = PBXGroup;
22 children = (
23 8A01A0002D10000100A00001 /* MessagesGlassLabApp.swift */,
24 8A01A0022D10000100A00001 /* ContentView.swift */,
25 );
26 path = MessagesGlassLab;
27 sourceTree = "<group>";
28 };
29
30/* Begin PBXFrameworksBuildPhase section */
31 8A01A0112D10000100A00001 /* Frameworks */ = {
32 isa = PBXFrameworksBuildPhase;
33 buildActionMask = 2147483647;
34 files = (
35 );
36 runOnlyForDeploymentPostprocessing = 0;
37 };
38/* End PBXFrameworksBuildPhase section */
39
40/* Begin PBXGroup section */
41 8A01A0302D10000100A00001 = {
42 isa = PBXGroup;
43 children = (
44 8A01A0202D10000100A00001 /* MessagesGlassLab */,
45 8A01A0312D10000100A00001 /* Products */,
46 );
47 sourceTree = "<group>";
48 };
49 8A01A0312D10000100A00001 /* Products */ = {
50 isa = PBXGroup;
51 children = (
52 8A01A0102D10000100A00001 /* MessagesGlassLab.app */,
53 );
54 name = Products;
55 sourceTree = "<group>";
56 };
57/* End PBXGroup section */
58
59/* Begin PBXNativeTarget section */
60 8A01A0402D10000100A00001 /* MessagesGlassLab */ = {
61 isa = PBXNativeTarget;
62 buildConfigurationList = 8A01A0502D10000100A00001 /* Build configuration list for PBXNativeTarget "MessagesGlassLab" */;
63 buildPhases = (
64 8A01A0412D10000100A00001 /* Sources */,
65 8A01A0112D10000100A00001 /* Frameworks */,
66 8A01A0422D10000100A00001 /* Resources */,
67 );
68 buildRules = (
69 );
70 dependencies = (
71 );
72 name = MessagesGlassLab;
73 packageProductDependencies = (
74 );
75 productName = MessagesGlassLab;
76 productReference = 8A01A0102D10000100A00001 /* MessagesGlassLab.app */;
77 productType = "com.apple.product-type.application";
78 };
79/* End PBXNativeTarget section */
80
81/* Begin PBXProject section */
82 8A01A0602D10000100A00001 /* Project object */ = {
83 isa = PBXProject;
84 attributes = {
85 BuildIndependentTargetsInParallel = 1;
86 LastSwiftUpdateCheck = 2700;
87 LastUpgradeCheck = 2700;
88 TargetAttributes = {
89 8A01A0402D10000100A00001 = {
90 CreatedOnToolsVersion = 27.0;
91 };
92 };
93 };
94 buildConfigurationList = 8A01A0612D10000100A00001 /* Build configuration list for PBXProject "MessagesGlassLab" */;
95 compatibilityVersion = "Xcode 16.0";
96 developmentRegion = en;
97 hasScannedForEncodings = 0;
98 knownRegions = (
99 en,
100 Base,
101 );
102 mainGroup = 8A01A0302D10000100A00001;
103 minimizedProjectReferenceProxies = 1;
104 preferredProjectObjectVersion = 77;
105 productRefGroup = 8A01A0312D10000100A00001 /* Products */;
106 projectDirPath = "";
107 projectRoot = "";
108 targets = (
109 8A01A0402D10000100A00001 /* MessagesGlassLab */,
110 );
111 };
112/* End PBXProject section */
113
114/* Begin PBXResourcesBuildPhase section */
115 8A01A0422D10000100A00001 /* Resources */ = {
116 isa = PBXResourcesBuildPhase;
117 buildActionMask = 2147483647;
118 files = (
119 );
120 runOnlyForDeploymentPostprocessing = 0;
121 };
122/* End PBXResourcesBuildPhase section */
123
124/* Begin PBXSourcesBuildPhase section */
125 8A01A0412D10000100A00001 /* Sources */ = {
126 isa = PBXSourcesBuildPhase;
127 buildActionMask = 2147483647;
128 files = (
129 8A01A0012D10000100A00001 /* MessagesGlassLabApp.swift in Sources */,
130 8A01A0032D10000100A00001 /* ContentView.swift in Sources */,
131 );
132 runOnlyForDeploymentPostprocessing = 0;
133 };
134/* End PBXSourcesBuildPhase section */
135
136/* Begin XCBuildConfiguration section */
137 8A01A0622D10000100A00001 /* Debug */ = {
138 isa = XCBuildConfiguration;
139 buildSettings = {
140 ALWAYS_SEARCH_USER_PATHS = NO;
141 CLANG_ENABLE_MODULES = YES;
142 CLANG_ENABLE_OBJC_ARC = YES;
143 CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
144 CLANG_WARN_BOOL_CONVERSION = YES;
145 CLANG_WARN_COMMA = YES;
146 CLANG_WARN_CONSTANT_CONVERSION = YES;
147 CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
148 CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
149 CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
150 CLANG_WARN_EMPTY_BODY = YES;
151 CLANG_WARN_ENUM_CONVERSION = YES;
152 CLANG_WARN_INFINITE_RECURSION = YES;
153 CLANG_WARN_INT_CONVERSION = YES;
154 CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
155 CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
156 CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
157 CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
158 CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
159 CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
160 CLANG_WARN_STRICT_PROTOTYPES = YES;
161 CLANG_WARN_SUSPICIOUS_MOVE = YES;
162 CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
163 CLANG_WARN_UNREACHABLE_CODE = YES;
164 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
165 COPY_PHASE_STRIP = NO;
166 DEBUG_INFORMATION_FORMAT = dwarf;
167 ENABLE_STRICT_OBJC_MSGSEND = YES;
168 ENABLE_TESTABILITY = YES;
169 GCC_C_LANGUAGE_STANDARD = gnu17;
170 GCC_DYNAMIC_NO_PIC = NO;
171 GCC_NO_COMMON_BLOCKS = YES;
172 GCC_OPTIMIZATION_LEVEL = 0;
173 GCC_PREPROCESSOR_DEFINITIONS = (
174 "DEBUG=1",
175 "$(inherited)",
176 );
177 GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
178 GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
179 GCC_WARN_UNDECLARED_SELECTOR = YES;
180 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
181 GCC_WARN_UNUSED_FUNCTION = YES;
182 GCC_WARN_UNUSED_VARIABLE = YES;
183 IPHONEOS_DEPLOYMENT_TARGET = 27.0;
184 MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
185 MTL_FAST_MATH = YES;
186 ONLY_ACTIVE_ARCH = YES;
187 SDKROOT = iphoneos;
188 SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
189 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
190 SWIFT_VERSION = 6.0;
191 };
192 name = Debug;
193 };
194 8A01A0632D10000100A00001 /* Release */ = {
195 isa = XCBuildConfiguration;
196 buildSettings = {
197 ALWAYS_SEARCH_USER_PATHS = NO;
198 CLANG_ENABLE_MODULES = YES;
199 CLANG_ENABLE_OBJC_ARC = YES;
200 COPY_PHASE_STRIP = NO;
201 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
202 ENABLE_NS_ASSERTIONS = NO;
203 GCC_C_LANGUAGE_STANDARD = gnu17;
204 IPHONEOS_DEPLOYMENT_TARGET = 27.0;
205 MTL_FAST_MATH = YES;
206 SDKROOT = iphoneos;
207 SWIFT_COMPILATION_MODE = wholemodule;
208 SWIFT_VERSION = 6.0;
209 VALIDATE_PRODUCT = YES;
210 };
211 name = Release;
212 };
213 8A01A0512D10000100A00001 /* Debug */ = {
214 isa = XCBuildConfiguration;
215 buildSettings = {
216 ASSETCATALOG_COMPILER_APPICON_NAME = "";
217 CODE_SIGNING_ALLOWED = NO;
218 CODE_SIGNING_REQUIRED = NO;
219 CODE_SIGN_STYLE = Automatic;
220 DEVELOPMENT_TEAM = "";
221 GENERATE_INFOPLIST_FILE = YES;
222 INFOPLIST_KEY_CFBundleDisplayName = "Messages Glass Lab";
223 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
224 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
225 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
226 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
227 INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
228 IPHONEOS_DEPLOYMENT_TARGET = 27.0;
229 PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.messagesglasslab;
230 PRODUCT_NAME = "$(TARGET_NAME)";
231 SWIFT_EMIT_LOC_STRINGS = YES;
232 SWIFT_VERSION = 6.0;
233 TARGETED_DEVICE_FAMILY = "1,2";
234 };
235 name = Debug;
236 };
237 8A01A0522D10000100A00001 /* Release */ = {
238 isa = XCBuildConfiguration;
239 buildSettings = {
240 ASSETCATALOG_COMPILER_APPICON_NAME = "";
241 CODE_SIGNING_ALLOWED = NO;
242 CODE_SIGNING_REQUIRED = NO;
243 CODE_SIGN_STYLE = Automatic;
244 DEVELOPMENT_TEAM = "";
245 GENERATE_INFOPLIST_FILE = YES;
246 INFOPLIST_KEY_CFBundleDisplayName = "Messages Glass Lab";
247 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
248 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
249 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
250 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
251 INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
252 IPHONEOS_DEPLOYMENT_TARGET = 27.0;
253 PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.messagesglasslab;
254 PRODUCT_NAME = "$(TARGET_NAME)";
255 SWIFT_EMIT_LOC_STRINGS = YES;
256 SWIFT_VERSION = 6.0;
257 TARGETED_DEVICE_FAMILY = "1,2";
258 };
259 name = Release;
260 };
261/* End XCBuildConfiguration section */
262
263/* Begin XCConfigurationList section */
264 8A01A0612D10000100A00001 /* Build configuration list for PBXProject "MessagesGlassLab" */ = {
265 isa = XCConfigurationList;
266 buildConfigurations = (
267 8A01A0622D10000100A00001 /* Debug */,
268 8A01A0632D10000100A00001 /* Release */,
269 );
270 defaultConfigurationIsVisible = 0;
271 defaultConfigurationName = Debug;
272 };
273 8A01A0502D10000100A00001 /* Build configuration list for PBXNativeTarget "MessagesGlassLab" */ = {
274 isa = XCConfigurationList;
275 buildConfigurations = (
276 8A01A0512D10000100A00001 /* Debug */,
277 8A01A0522D10000100A00001 /* Release */,
278 );
279 defaultConfigurationIsVisible = 0;
280 defaultConfigurationName = Debug;
281 };
282/* End XCConfigurationList section */
283 };
284 rootObject = 8A01A0602D10000100A00001 /* Project object */;
285}
experiments/messages-glass-lab/MessagesGlassLab/ContentView.swift created+386
...@@ -0,0 +1,386 @@
1import SwiftUI
2
3struct LabThread: Identifiable, Hashable {
4 let id = UUID()
5 let title: String
6 let subtitle: String
7 let time: String
8 let initials: String
9 let tint: Color
10 let preview: String
11}
12
13private let threads: [LabThread] = [
14 .init(title: "Markdown rendering test", subtitle: "t3code · Julius’s Mac mini", time: "14h", initials: "MD", tint: .blue, preview: "Renderer stress test, terminal snippets, code blocks, and a very long markdown transcript."),
15 .init(title: "iPad rectly text correction", subtitle: "Julius’s Mac mini · main", time: "16h", initials: "IP", tint: .purple, preview: "Fix iPad layout, search behavior, hardware keyboard, and trackpad scrolling."),
16 .init(title: "Preview Webview Persists Off Panel", subtitle: "codething-mvp · Julius’s MacBook Pro", time: "22m", initials: "PW", tint: .teal, preview: "The browser preview should not leak outside the active panel when switching threads."),
17 .init(title: "Add file preview action buttons", subtitle: "codex/connection-preview", time: "10d", initials: "FP", tint: .orange, preview: "Open files at exact lines and expose copy/open actions in the renderer."),
18 .init(title: "Investigate v2 pipeline slowdown", subtitle: "codex-turn-runner", time: "1d", initials: "V2", tint: .gray, preview: "Compare orchestration traces and find why streamed events are delayed under load."),
19 .init(title: "Fix dark-mode header glass", subtitle: "t3code/ipad-responsive-mobile-layout", time: "2h", initials: "DG", tint: .indigo, preview: "Compare dark scroll-edge material against Messages and Mail, then map the behavior back to React Native Screens."),
20 .init(title: "Magic Keyboard sidebar scroll", subtitle: "mobile/input-polish", time: "3h", initials: "MK", tint: .cyan, preview: "Trackpad scrolling should remain fluid while swipe actions still work for touch gestures."),
21 .init(title: "Terminal tab key routing", subtitle: "terminal/native-pty", time: "5h", initials: "⌘", tint: .green, preview: "Hardware Tab should go to the terminal session instead of escaping to the thread search field."),
22 .init(title: "Diff renderer split inspector", subtitle: "review-diff/native", time: "8h", initials: "Δ", tint: .red, preview: "Keep file navigation, sticky headers, and selected hunk state stable in a three-column iPad layout."),
23 .init(title: "Composer glass affordance", subtitle: "composer/liquid-glass", time: "9h", initials: "CG", tint: .pink, preview: "Prototype a bottom composer that feels native without covering too much content while scrolling."),
24 .init(title: "Search placement audit", subtitle: "navigation/native-search", time: "11h", initials: "SP", tint: .mint, preview: "Validate whether search belongs in the bottom toolbar on iPhone and the sidebar chrome on iPad."),
25 .init(title: "Thread row density pass", subtitle: "home/messages-list", time: "12h", initials: "TR", tint: .brown, preview: "Tune row height, separators, preview text, chevrons, and status glyphs to match native list rhythm."),
26 .init(title: "Files navigator polish", subtitle: "files/inspector", time: "1d", initials: "FN", tint: .yellow, preview: "Make the file explorer feel like an iPad side inspector instead of a cramped web sidebar."),
27 .init(title: "Toolbar grouping experiment", subtitle: "native-toolbar-glass", time: "2d", initials: "TB", tint: .blue.opacity(0.7), preview: "Compare separate glass buttons with merged toolbar groups and spacing behavior."),
28 .init(title: "Scroll-edge fade comparison", subtitle: "swiftui/messages-lab", time: "3d", initials: "SE", tint: .purple.opacity(0.8), preview: "Record scroll positions to see where native headers start becoming visible over content."),
29 .init(title: "iPad sidebar selection state", subtitle: "split-view/sidebar", time: "4d", initials: "SS", tint: .teal.opacity(0.75), preview: "Find the right selected-row treatment for dark and light mode in a persistent sidebar."),
30 .init(title: "Preview webview panel bug", subtitle: "preview/browser", time: "5d", initials: "WB", tint: .orange.opacity(0.8), preview: "Ensure preview browser surfaces stay clipped to the active detail pane during navigation."),
31 .init(title: "Keyboard shortcuts overlay", subtitle: "hardware-keyboard", time: "6d", initials: "KS", tint: .gray.opacity(0.9), preview: "Expose discoverable commands and keep focus behavior aligned with iPadOS keyboard conventions."),
32 .init(title: "Thread loading skeleton", subtitle: "mobile/perceived-performance", time: "1w", initials: "LS", tint: .green.opacity(0.75), preview: "Replace jarring empty states with native-feeling loading rows while snapshots hydrate."),
33 .init(title: "Connection recovery UX", subtitle: "lan-pairing", time: "2w", initials: "CR", tint: .red.opacity(0.75), preview: "Make reconnect banners and retry affordances less intrusive during scroll and composition."),
34]
35
36struct ContentView: View {
37 @Environment(\.horizontalSizeClass) private var horizontalSizeClass
38 @State private var searchText = ""
39 @State private var selectedThread: LabThread? = threads[0]
40
41 var body: some View {
42 if horizontalSizeClass == .regular {
43 NativeSplitLab(searchText: $searchText, selectedThread: $selectedThread)
44 } else {
45 NativePhoneLab(searchText: $searchText, selectedThread: $selectedThread)
46 }
47 }
48}
49
50private var filteredThreads: [LabThread] {
51 threads
52}
53
54private let glassDebugCodeLines: [String] = [
55 "# Native RNS glass debug route",
56 "",
57 "This screen intentionally avoids Expo Router headers.",
58 "The native header below is owned by react-native-screens.",
59 "",
60 "Expected iOS 26 behavior:",
61 "- At rest: header should feel like app background",
62 "- While scrolled: content should blur behind the header",
63 "- No gray custom overlay",
64 "- No JS blur view",
65 "",
66 "Scroll edge effect should sample actual content:",
67 "const header = { translucent: true }",
68 "const scrollEdgeEffects = { top: 'soft' }",
69 "",
70 "Bright rows below make sampling failures obvious.",
71 "",
72 "node_modules",
73 "/.pnp",
74 ".pnp.*",
75 ".yarn/*",
76 "!.yarn/patches",
77 "!.yarn/plugins",
78 "!.yarn/releases",
79 "!.yarn/versions",
80 "",
81 "# testing",
82 "/coverage",
83 ".convex",
84 "e2e/.local-dev.json",
85 "e2e/playwright-report",
86 "e2e/test-results",
87 "",
88 "# app surfaces",
89 "threads",
90 "terminal",
91 "diff renderer",
92 "file explorer",
93 "composer",
94 "native header",
95 "scroll edge",
96 "liquid glass",
97]
98
99private let glassDebugSwatches: [Color] = [
100 .blue,
101 .green,
102 .orange,
103 .purple,
104 .cyan,
105 .pink,
106]
107
108struct NativePhoneLab: View {
109 @Binding var searchText: String
110 @Binding var selectedThread: LabThread?
111
112 var body: some View {
113 NavigationStack {
114 NativeThreadLab(thread: selectedThread ?? threads[0])
115 }
116 }
117}
118
119struct NativeSplitLab: View {
120 @Binding var searchText: String
121 @Binding var selectedThread: LabThread?
122
123 var body: some View {
124 NavigationSplitView {
125 List(filteredThreads, selection: $selectedThread) { thread in
126 MessageSidebarRow(thread: thread)
127 .tag(thread)
128 }
129 .listStyle(.sidebar)
130 .navigationTitle("Threads")
131 .searchable(text: $searchText, placement: .sidebar, prompt: "Search")
132 .toolbar {
133 ToolbarItemGroup(placement: .topBarTrailing) {
134 Menu {
135 Button("All Threads", systemImage: "tray.full") {}
136 Button("Ready", systemImage: "checkmark.circle") {}
137 Button("Running", systemImage: "bolt.circle") {}
138 } label: {
139 Image(systemName: "line.3.horizontal.decrease")
140 }
141 .buttonStyle(.glass)
142
143 Button {} label: {
144 Image(systemName: "gearshape")
145 }
146 .buttonStyle(.glass)
147
148 Button {} label: {
149 Image(systemName: "square.and.pencil")
150 }
151 .buttonStyle(.glass)
152 }
153 }
154 } detail: {
155 if let selectedThread {
156 NativeThreadLab(thread: selectedThread)
157 } else {
158 ContentUnavailableView("Select a thread", systemImage: "sidebar.left")
159 }
160 }
161 }
162}
163
164struct MessageListRow: View {
165 let thread: LabThread
166
167 var body: some View {
168 HStack(alignment: .top, spacing: 14) {
169 Circle()
170 .fill(thread.tint.gradient)
171 .frame(width: 52, height: 52)
172 .overlay {
173 Text(thread.initials)
174 .font(.headline.weight(.bold))
175 .foregroundStyle(.white)
176 }
177
178 VStack(alignment: .leading, spacing: 4) {
179 HStack {
180 Text(thread.title)
181 .font(.headline.weight(.semibold))
182 .lineLimit(1)
183 Spacer(minLength: 8)
184 Text(thread.time)
185 .foregroundStyle(.secondary)
186 Image(systemName: "chevron.right")
187 .font(.caption.weight(.semibold))
188 .foregroundStyle(.tertiary)
189 }
190
191 Text(thread.preview)
192 .font(.subheadline)
193 .foregroundStyle(.secondary)
194 .lineLimit(2)
195 }
196 .padding(.vertical, 12)
197 }
198 }
199}
200
201struct MessageSidebarRow: View {
202 let thread: LabThread
203
204 var body: some View {
205 VStack(alignment: .leading, spacing: 4) {
206 HStack {
207 Text(thread.title)
208 .font(.headline.weight(.semibold))
209 .lineLimit(1)
210 Spacer()
211 Text(thread.time)
212 .font(.subheadline)
213 .foregroundStyle(.secondary)
214 }
215 Text(thread.subtitle)
216 .font(.subheadline)
217 .foregroundStyle(.secondary)
218 .lineLimit(1)
219 }
220 .padding(.vertical, 6)
221 }
222}
223
224struct NativeThreadLab: View {
225 let thread: LabThread
226 @State private var draft = "Ask the repo agent, or run a command..."
227 @State private var scrollStep = 0
228
229 private let scrollTimer = Timer.publish(every: 2.8, on: .main, in: .common).autoconnect()
230 private let scrollTargets = ["top", "swatches", "top", "code", "card"]
231
232 var body: some View {
233 ScrollViewReader { proxy in
234 ScrollView {
235 LazyVStack(alignment: .leading, spacing: 22) {
236 Color.clear
237 .frame(height: 0)
238 .id("top")
239
240 hero
241 .padding(.top, 8)
242
243 swatches
244 .id("swatches")
245
246 explanationCard
247 .id("card")
248
249 codeCard
250 .id("code")
251 }
252 .padding(.horizontal, 18)
253 .padding(.top, 8)
254 .padding(.bottom, 96)
255 }
256 .background(Color(uiColor: .systemBackground))
257 .onReceive(scrollTimer) { _ in
258 guard !scrollTargets.isEmpty else { return }
259
260 let target = scrollTargets[scrollStep % scrollTargets.count]
261 scrollStep += 1
262
263 withAnimation(.smooth(duration: 1.0)) {
264 proxy.scrollTo(target, anchor: .top)
265 }
266 }
267 }
268 .navigationTitle("RNS Glass")
269 .navigationSubtitle("plain react-native-screens")
270 .toolbar {
271 ToolbarItemGroup(placement: .topBarTrailing) {
272 Button {} label: { Image(systemName: "plus") }
273 .buttonStyle(.glass)
274 Button {} label: { Image(systemName: "magnifyingglass") }
275 .buttonStyle(.glass)
276 }
277 }
278 .safeAreaInset(edge: .bottom) {
279 composer
280 }
281 }
282
283 private var hero: some View {
284 VStack(alignment: .leading, spacing: 10) {
285 Text("plain react-native-screens")
286 .font(.subheadline.weight(.bold))
287 .textCase(.uppercase)
288 .tracking(0.5)
289 .foregroundStyle(.secondary)
290
291 Text("Native scroll-edge glass")
292 .font(.system(size: 48, weight: .heavy, design: .default))
293 .lineLimit(nil)
294 .minimumScaleFactor(0.72)
295
296 Text("This route uses RNS directly. The script scrolls automatically so the native header is captured both at rest and with bright content behind it.")
297 .font(.title3)
298 .foregroundStyle(.secondary)
299 }
300 }
301
302 private var swatches: some View {
303 LazyVGrid(
304 columns: [GridItem(.adaptive(minimum: 60), spacing: 12)],
305 alignment: .leading,
306 spacing: 12
307 ) {
308 ForEach(Array(glassDebugSwatches.enumerated()), id: \.offset) { index, color in
309 Circle()
310 .fill(color.gradient)
311 .frame(width: 60, height: 60)
312 .overlay {
313 Text("\(index + 1)")
314 .font(.title2.weight(.heavy))
315 .foregroundStyle(.white)
316 }
317 }
318 }
319 }
320
321 private var explanationCard: some View {
322 VStack(alignment: .leading, spacing: 10) {
323 Text("What this isolates")
324 .font(.title2.weight(.bold))
325
326 Text("Native transparent header + iOS 26 scroll edge effect. No Expo Router header config, no custom blur overlay, no large title requirement.")
327 .font(.title3)
328 .foregroundStyle(.secondary)
329 }
330 .padding(22)
331 .frame(maxWidth: .infinity, alignment: .leading)
332 .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
333 .overlay {
334 RoundedRectangle(cornerRadius: 28, style: .continuous)
335 .stroke(.separator.opacity(0.35), lineWidth: 0.5)
336 }
337 }
338
339 private var codeCard: some View {
340 VStack(alignment: .leading, spacing: 0) {
341 ForEach(Array((glassDebugCodeLines + glassDebugCodeLines).enumerated()), id: \.offset) { index, line in
342 HStack(alignment: .top, spacing: 16) {
343 Text("\(index + 1)")
344 .foregroundStyle(.secondary)
345 .frame(width: 34, alignment: .trailing)
346
347 Text(line.isEmpty ? " " : line)
348 .frame(maxWidth: .infinity, alignment: .leading)
349 }
350 .font(.system(size: 16, design: .monospaced))
351 .lineSpacing(4)
352 .padding(.horizontal, 16)
353 .padding(.vertical, 3)
354 }
355 }
356 .padding(.vertical, 14)
357 .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
358 .overlay {
359 RoundedRectangle(cornerRadius: 24, style: .continuous)
360 .stroke(.separator.opacity(0.35), lineWidth: 0.5)
361 }
362 }
363
364 private var composer: some View {
365 HStack(spacing: 10) {
366 Text(draft)
367 .foregroundStyle(.secondary)
368 .lineLimit(1)
369 Spacer()
370 Image(systemName: "arrow.up")
371 .font(.headline.weight(.bold))
372 .frame(width: 44, height: 44)
373 .glassEffect(.regular.interactive(), in: Circle())
374 }
375 .padding(.leading, 18)
376 .padding(.trailing, 6)
377 .frame(height: 56)
378 .glassEffect(.clear.interactive(), in: Capsule())
379 .padding(.horizontal)
380 .padding(.vertical, 8)
381 }
382}
383
384#Preview {
385 ContentView()
386}
experiments/messages-glass-lab/MessagesGlassLab/MessagesGlassLabApp.swift created+10
...@@ -0,0 +1,10 @@
1import SwiftUI
2
3@main
4struct MessagesGlassLabApp: App {
5 var body: some Scene {
6 WindowGroup {
7 ContentView()
8 }
9 }
10}
packages/client-runtime/src/state/threadDetail.ts+1-1
...@@ -13,7 +13,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";...@@ -13,7 +13,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
1313
14import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts";14import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts";
15import { scopeThread } from "./models.ts";15import { scopeThread } from "./models.ts";
16import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";16import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts";
17import { parseThreadKey, threadKey } from "./entities.ts";17import { parseThreadKey, threadKey } from "./entities.ts";
18import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";18import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
1919
packages/client-runtime/src/state/threadState.ts created+16
...@@ -0,0 +1,16 @@
1import type { OrchestrationThread } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3
4export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
5
6export interface EnvironmentThreadState {
7 readonly data: Option.Option<OrchestrationThread>;
8 readonly status: EnvironmentThreadStatus;
9 readonly error: Option.Option<string>;
10}
11
12export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {
13 data: Option.none(),
14 status: "empty",
15 error: Option.none(),
16};
packages/client-runtime/src/state/threads.ts+6-14
...@@ -22,20 +22,11 @@ import { parseThreadKey, threadKey } from "./entities.ts";...@@ -22,20 +22,11 @@ import { parseThreadKey, threadKey } from "./entities.ts";
22import { applyThreadDetailEvent } from "./threadReducer.ts";22import { applyThreadDetailEvent } from "./threadReducer.ts";
23import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";23import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
24import { followStreamInEnvironment } from "./runtime.ts";24import { followStreamInEnvironment } from "./runtime.ts";
2525import {
26export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";26 EMPTY_ENVIRONMENT_THREAD_STATE,
2727 type EnvironmentThreadState,
28export interface EnvironmentThreadState {28 type EnvironmentThreadStatus,
29 readonly data: Option.Option<OrchestrationThread>;29} from "./threadState.ts";
30 readonly status: EnvironmentThreadStatus;
31 readonly error: Option.Option<string>;
32}
33
34export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {
35 data: Option.none(),
36 status: "empty",
37 error: Option.none(),
38};
3930
40function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus {31function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus {
41 return Option.isSome(data) ? "cached" : "empty";32 return Option.isSome(data) ? "cached" : "empty";
...@@ -254,3 +245,4 @@ export * from "./threadCommands.ts";...@@ -254,3 +245,4 @@ export * from "./threadCommands.ts";
254export * from "./threadDetail.ts";245export * from "./threadDetail.ts";
255export * from "./threadReducer.ts";246export * from "./threadReducer.ts";
256export * from "./threadShell.ts";247export * from "./threadShell.ts";
248export * from "./threadState.ts";
packages/shared/src/dpop.ts+9-22
...@@ -5,22 +5,20 @@ import * as Option from "effect/Option";...@@ -5,22 +5,20 @@ import * as Option from "effect/Option";
5import * as Result from "effect/Result";5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
77
8import { DpopPublicJwk as DpopPublicJwkSchema, normalizeDpopHtu } from "./dpopCommon.ts";
9import type { DpopPublicJwk as DpopPublicJwkType } from "./dpopCommon.ts";
8import { stableStringify } from "./relaySigning.ts";10import { stableStringify } from "./relaySigning.ts";
911
10const DPOP_TYP = "dpop+jwt";12const DPOP_TYP = "dpop+jwt";
11const DPOP_ALG = "ES256";13const DPOP_ALG = "ES256";
12const DEFAULT_MAX_AGE_SECONDS = 300;14const DEFAULT_MAX_AGE_SECONDS = 300;
1315
14export const DpopPublicJwk = Schema.Struct({16export const DpopPublicJwk = DpopPublicJwkSchema;
15 kty: Schema.Literal("EC"),17export type DpopPublicJwk = DpopPublicJwkType;
16 crv: Schema.Literal("P-256"),18export { normalizeDpopHtu };
17 x: Schema.String.check(Schema.isNonEmpty()),
18 y: Schema.String.check(Schema.isNonEmpty()),
19});
20export type DpopPublicJwk = typeof DpopPublicJwk.Type;
2119
22const DpopJwtHeaderPublicJwk = Schema.Struct({20const DpopJwtHeaderPublicJwk = Schema.Struct({
23 ...DpopPublicJwk.fields,21 ...DpopPublicJwkSchema.fields,
24 d: Schema.optionalKey(Schema.Never),22 d: Schema.optionalKey(Schema.Never),
25});23});
2624
...@@ -68,7 +66,7 @@ function decodeBase64UrlDpopJwtPayload(value: string) {...@@ -68,7 +66,7 @@ function decodeBase64UrlDpopJwtPayload(value: string) {
68 return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value)));66 return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value)));
69}67}
7068
71function dpopThumbprintInput(jwk: DpopPublicJwk): string {69function dpopThumbprintInput(jwk: DpopPublicJwkType): string {
72 return stableStringify({70 return stableStringify({
73 crv: jwk.crv,71 crv: jwk.crv,
74 kty: jwk.kty,72 kty: jwk.kty,
...@@ -77,18 +75,7 @@ function dpopThumbprintInput(jwk: DpopPublicJwk): string {...@@ -77,18 +75,7 @@ function dpopThumbprintInput(jwk: DpopPublicJwk): string {
77 });75 });
78}76}
7977
80export function normalizeDpopHtu(url: string): string | null {78export function computeDpopJwkThumbprint(jwk: DpopPublicJwkType): string {
81 try {
82 const parsed = new URL(url);
83 parsed.hash = "";
84 parsed.search = "";
85 return parsed.toString();
86 } catch {
87 return null;
88 }
89}
90
91export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string {
92 return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk))));79 return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk))));
93}80}
9481
...@@ -96,7 +83,7 @@ export function computeDpopAccessTokenHash(accessToken: string): string {...@@ -96,7 +83,7 @@ export function computeDpopAccessTokenHash(accessToken: string): string {
96 return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken)));83 return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken)));
97}84}
9885
99function publicKeyBytesFromJwk(jwk: DpopPublicJwk): Uint8Array {86function publicKeyBytesFromJwk(jwk: DpopPublicJwkType): Uint8Array {
100 const x = base64UrlToBytes(jwk.x);87 const x = base64UrlToBytes(jwk.x);
101 const y = base64UrlToBytes(jwk.y);88 const y = base64UrlToBytes(jwk.y);
102 if (x.length !== 32 || y.length !== 32) {89 if (x.length !== 32 || y.length !== 32) {
patches/@expo%2Fmetro-config@56.0.13.patch deleted-93
...@@ -1,93 +0,0 @@
1diff --git a/build/serializer/sourceMap.js b/build/serializer/sourceMap.js
2index 4cc9aa4..703dfe0 100644
3--- a/build/serializer/sourceMap.js
4+++ b/build/serializer/sourceMap.js
5@@ -20,6 +20,20 @@ function loadRemapping() {
6 }
7 return _remapping;
8 }
9+let _sourceMapCodec;
10+function loadSourceMapCodec() {
11+ if (!_sourceMapCodec) {
12+ _sourceMapCodec = require('@jridgewell/sourcemap-codec');
13+ }
14+ return _sourceMapCodec;
15+}
16+let _traceMapping;
17+function loadTraceMapping() {
18+ if (!_traceMapping) {
19+ _traceMapping = require('@jridgewell/trace-mapping');
20+ }
21+ return _traceMapping;
22+}
23 let _Generator;
24 function loadGenerator() {
25 if (!_Generator) {
26@@ -199,6 +213,58 @@ function patchMetroSourceMapStringForPackedMaps() {
27 stock.sourceMapString = sourceMapString;
28 stock.sourceMapStringNonBlocking = sourceMapStringNonBlocking;
29 }
30+// Hermes can emit mappings for Metro trailer/debug lines that have no
31+// corresponding Metro source-map line. @jridgewell/remapping assumes every
32+// referenced generated line exists, so strip those invalid bridge segments.
33+function getGeneratedLineCount(map) {
34+ const { TraceMap, decodedMappings } = loadTraceMapping();
35+ return decodedMappings(new TraceMap(map)).length;
36+}
37+function dropInvalidOriginalSegments(map, maxOriginalLine) {
38+ const { TraceMap, decodedMappings } = loadTraceMapping();
39+ const decoded = decodedMappings(new TraceMap(map));
40+ let didChange = false;
41+ const filtered = decoded.map((line) => {
42+ const filteredLine = line.filter((segment) => {
43+ if (segment.length < 4) {
44+ return true;
45+ }
46+ const sourceIndex = segment[1];
47+ const sourceLine = segment[2];
48+ const sourceColumn = segment[3];
49+ return (sourceIndex >= 0 &&
50+ sourceIndex < map.sources.length &&
51+ sourceLine >= 0 &&
52+ sourceLine < maxOriginalLine &&
53+ sourceColumn >= 0);
54+ });
55+ if (filteredLine.length !== line.length) {
56+ didChange = true;
57+ }
58+ return filteredLine;
59+ });
60+ if (!didChange) {
61+ return map;
62+ }
63+ return {
64+ ...map,
65+ mappings: loadSourceMapCodec().encode(filtered),
66+ };
67+}
68+function sanitizeSourceMapsForComposition(maps) {
69+ let sanitized = maps;
70+ for (let index = 1; index < maps.length; index++) {
71+ const maxOriginalLine = getGeneratedLineCount(sanitized[index - 1]);
72+ const next = dropInvalidOriginalSegments(sanitized[index], maxOriginalLine);
73+ if (next !== sanitized[index]) {
74+ if (sanitized === maps) {
75+ sanitized = maps.slice();
76+ }
77+ sanitized[index] = next;
78+ }
79+ }
80+ return sanitized;
81+}
82 // `maps[0]` is the original-most transform; `maps[maps.length - 1]` is
83 // the most recent. Built on `@jridgewell/remapping` instead of mozilla's
84 // `SourceMapConsumer`-based composer.
85@@ -226,7 +292,7 @@ function composeSourceMaps(maps) {
86 return { ...map, ignoreList: map.x_google_ignoreList };
87 });
88 // Metro convention is original-first; remapping is most-recent first.
89- const reversed = normalized.slice().reverse();
90+ const reversed = sanitizeSourceMapsForComposition(normalized).slice().reverse();
91 const composed = loadRemapping()(reversed, () => null);
92 // Re-emit as a plain object — remapping returns a `SourceMap` class
93 // instance, which doesn't round-trip JSON cleanly.
patches/@expo%2Fmetro-config@56.0.14.patch created+93
...@@ -0,0 +1,93 @@
1diff --git a/build/serializer/sourceMap.js b/build/serializer/sourceMap.js
2index 4cc9aa4..703dfe0 100644
3--- a/build/serializer/sourceMap.js
4+++ b/build/serializer/sourceMap.js
5@@ -20,6 +20,20 @@ function loadRemapping() {
6 }
7 return _remapping;
8 }
9+let _sourceMapCodec;
10+function loadSourceMapCodec() {
11+ if (!_sourceMapCodec) {
12+ _sourceMapCodec = require('@jridgewell/sourcemap-codec');
13+ }
14+ return _sourceMapCodec;
15+}
16+let _traceMapping;
17+function loadTraceMapping() {
18+ if (!_traceMapping) {
19+ _traceMapping = require('@jridgewell/trace-mapping');
20+ }
21+ return _traceMapping;
22+}
23 let _Generator;
24 function loadGenerator() {
25 if (!_Generator) {
26@@ -199,6 +213,58 @@ function patchMetroSourceMapStringForPackedMaps() {
27 stock.sourceMapString = sourceMapString;
28 stock.sourceMapStringNonBlocking = sourceMapStringNonBlocking;
29 }
30+// Hermes can emit mappings for Metro trailer/debug lines that have no
31+// corresponding Metro source-map line. @jridgewell/remapping assumes every
32+// referenced generated line exists, so strip those invalid bridge segments.
33+function getGeneratedLineCount(map) {
34+ const { TraceMap, decodedMappings } = loadTraceMapping();
35+ return decodedMappings(new TraceMap(map)).length;
36+}
37+function dropInvalidOriginalSegments(map, maxOriginalLine) {
38+ const { TraceMap, decodedMappings } = loadTraceMapping();
39+ const decoded = decodedMappings(new TraceMap(map));
40+ let didChange = false;
41+ const filtered = decoded.map((line) => {
42+ const filteredLine = line.filter((segment) => {
43+ if (segment.length < 4) {
44+ return true;
45+ }
46+ const sourceIndex = segment[1];
47+ const sourceLine = segment[2];
48+ const sourceColumn = segment[3];
49+ return (sourceIndex >= 0 &&
50+ sourceIndex < map.sources.length &&
51+ sourceLine >= 0 &&
52+ sourceLine < maxOriginalLine &&
53+ sourceColumn >= 0);
54+ });
55+ if (filteredLine.length !== line.length) {
56+ didChange = true;
57+ }
58+ return filteredLine;
59+ });
60+ if (!didChange) {
61+ return map;
62+ }
63+ return {
64+ ...map,
65+ mappings: loadSourceMapCodec().encode(filtered),
66+ };
67+}
68+function sanitizeSourceMapsForComposition(maps) {
69+ let sanitized = maps;
70+ for (let index = 1; index < maps.length; index++) {
71+ const maxOriginalLine = getGeneratedLineCount(sanitized[index - 1]);
72+ const next = dropInvalidOriginalSegments(sanitized[index], maxOriginalLine);
73+ if (next !== sanitized[index]) {
74+ if (sanitized === maps) {
75+ sanitized = maps.slice();
76+ }
77+ sanitized[index] = next;
78+ }
79+ }
80+ return sanitized;
81+}
82 // `maps[0]` is the original-most transform; `maps[maps.length - 1]` is
83 // the most recent. Built on `@jridgewell/remapping` instead of mozilla's
84 // `SourceMapConsumer`-based composer.
85@@ -226,7 +292,7 @@ function composeSourceMaps(maps) {
86 return { ...map, ignoreList: map.x_google_ignoreList };
87 });
88 // Metro convention is original-first; remapping is most-recent first.
89- const reversed = normalized.slice().reverse();
90+ const reversed = sanitizeSourceMapsForComposition(normalized).slice().reverse();
91 const composed = loadRemapping()(reversed, () => null);
92 // Re-emit as a plain object — remapping returns a `SourceMap` class
93 // instance, which doesn't round-trip JSON cleanly.
patches/@react-native-menu__menu@2.0.0.patch created+46
...@@ -0,0 +1,46 @@
1diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift
2index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 100644
3--- a/ios/Shared/MenuViewImplementation.swift
4+++ b/ios/Shared/MenuViewImplementation.swift
5@@ -88,6 +88,41 @@ public class MenuViewImplementation: UIButton {
6
7 self.menu = menu
8 self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress
9+ // In long-press mode the button must not intercept touches: as a
10+ // full-bounds contentView it sits IN FRONT of the React children of
11+ // the hosting component view, which would swallow their taps (e.g. a
12+ // row Pressable wrapped by MenuView). The context-menu interaction is
13+ // hosted by the superview instead (see updateLongPressInteraction) so
14+ // long-press and pointer secondary-click still present the menu, with
15+ // the wrapped content as the zoom preview.
16+ self.isUserInteractionEnabled = !shouldOpenOnLongPress
17+ self.updateLongPressInteraction()
18+ }
19+
20+ private var longPressInteraction: UIContextMenuInteraction?
21+
22+ public override func didMoveToSuperview() {
23+ super.didMoveToSuperview()
24+ self.updateLongPressInteraction()
25+ }
26+
27+ private func updateLongPressInteraction() {
28+ if shouldOpenOnLongPress, let host = self.superview {
29+ if let existing = longPressInteraction, existing.view === host {
30+ return
31+ }
32+ if let existing = longPressInteraction {
33+ existing.view?.removeInteraction(existing)
34+ }
35+ let interaction = UIContextMenuInteraction(delegate: self)
36+ host.addInteraction(interaction)
37+ longPressInteraction = interaction
38+ return
39+ }
40+ if let existing = longPressInteraction {
41+ existing.view?.removeInteraction(existing)
42+ longPressInteraction = nil
43+ }
44 }
45
46 public override func reactSetFrame(_ frame: CGRect) {
patches/@react-navigation%2Fnative-stack@7.17.6.patch created+100
...@@ -0,0 +1,100 @@
1--- a/lib/module/views/useHeaderConfigProps.js
2+++ b/lib/module/views/useHeaderConfigProps.js
3@@ -19,6 +19,12 @@
4 }
5 return item;
6 }
7+ if (item.type === 'mailSearchToolbar') {
8+ return {
9+ ...item,
10+ index
11+ };
12+ }
13 if (item.type === 'button' || item.type === 'menu') {
14 if (item.type === 'menu' && item.menu == null) {
15 throw new Error(`Menu item must have a 'menu' property defined: ${JSON.stringify(item)}`);
16@@ -79,7 +85,7 @@
17 }
18 return processedItem;
19 }
20- throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom' and 'spacing'.`);
21+ throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom', 'spacing' and 'mailSearchToolbar'.`);
22 }).filter(item => item != null);
23 };
24 const transformIcon = icon => {
25@@ -158,8 +164,12 @@
26 headerBack,
27 route,
28 title,
29+ unstable_headerCenterItems: headerCenterItems,
30 unstable_headerLeftItems: headerLeftItems,
31- unstable_headerRightItems: headerRightItems
32+ unstable_headerRightItems: headerRightItems,
33+ unstable_headerSubtitle: headerSubtitle,
34+ unstable_headerToolbarItems: headerToolbarItems,
35+ unstable_navigationItemStyle: navigationItemStyle
36 }) {
37 const {
38 direction
39@@ -255,6 +265,10 @@
40 tintColor,
41 canGoBack
42 });
43+ const centerItems = headerCenterItems?.({
44+ tintColor,
45+ canGoBack
46+ });
47 let rightItems = headerRightItems?.({
48 tintColor,
49 canGoBack
50@@ -264,6 +278,10 @@
51 // So we need to reverse them here to match the order
52 rightItems = [...rightItems].reverse();
53 }
54+ const toolbarItems = headerToolbarItems?.({
55+ tintColor,
56+ canGoBack
57+ });
58 const children = /*#__PURE__*/_jsxs(_Fragment, {
59 children: [Platform.OS === 'ios' ? /*#__PURE__*/_jsxs(_Fragment, {
60 children: [leftItems ? leftItems.map((item, index) => {
61@@ -278,7 +296,15 @@
62 return null;
63 }) : headerLeftElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderLeftView, {
64 children: headerLeftElement
65- }) : null, headerTitleElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, {
66+ }) : null, centerItems ? centerItems.map((item, index) => {
67+ if (item.type === 'custom') {
68+ return /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, {
69+ hidesSharedBackground: item.hidesSharedBackground,
70+ children: item.element
71+ }, index);
72+ }
73+ return null;
74+ }) : headerTitleElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, {
75 children: headerTitleElement
76 }) : null]
77 }) : /*#__PURE__*/_jsxs(_Fragment, {
78@@ -356,6 +382,7 @@
79 largeTitleFontWeight,
80 largeTitleHideShadow: headerLargeTitleShadowVisible === false,
81 title: titleText,
82+ subtitle: headerSubtitle,
83 titleColor,
84 titleFontFamily,
85 titleFontSize,
86@@ -364,9 +391,12 @@
87 disableTopInsetApplication: !headerTopInsetEnabled,
88 translucent: translucent === true,
89 children,
90+ navigationItemStyle,
91+ headerToolbarItems: processBarButtonItems(toolbarItems, colors, fonts),
92+ headerCenterBarButtonItems: processBarButtonItems(centerItems, colors, fonts),
93 headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
94 headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
95 experimental_userInterfaceStyle: dark ? 'dark' : 'light'
96 };
97 }
98-//# sourceMappingURL=useHeaderConfigProps.js.map
99\ No newline at end of file
100+//# sourceMappingURL=useHeaderConfigProps.js.map
patches/expo-modules-jsi@56.0.10.patch created+21
...@@ -0,0 +1,21 @@
1diff --git a/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift b/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift
2index a0e3e24d6070c5ec0a220af3cb56fe3373fe1968..d610cc38dd8dee363ea2f1822aea0c4a492cf825 100644
3--- a/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift
4+++ b/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift
5@@ -215,8 +215,15 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
6 .toOpaque()
7 // Pass a null setter to C++ when the Swift setter is nil so that JS assignment
8 // raises a `jsi::JSError` directly, without crossing the Swift boundary.
9+ if set == nil {
10+ let callbacks = expo.HostObjectCallbacks(
11+ context, getter, nil, propertyNamesGetter, deallocate)
12+ let hostObject = expo.HostObject.makeObject(pointee, consume callbacks)
13+
14+ return JavaScriptObject(self, hostObject)
15+ }
16 let callbacks = expo.HostObjectCallbacks(
17- context, getter, set == nil ? nil : setter, propertyNamesGetter, deallocate)
18+ context, getter, setter, propertyNamesGetter, deallocate)
19 let hostObject = expo.HostObject.makeObject(pointee, consume callbacks)
20
21 return JavaScriptObject(self, hostObject)
patches/react-native-gesture-handler@2.31.2.patch created+124
...@@ -0,0 +1,124 @@
1diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
2index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6770f4a24 100644
3--- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
4+++ b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
5@@ -34,6 +34,7 @@ const Swipeable = props => {
6 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
7 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
8 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
9+ failOffsetY,
10 friction = DEFAULT_FRICTION,
11 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
12 onSwipeableOpenStartDrag,
13@@ -285,11 +286,14 @@ const Swipeable = props => {
14 }).onFinalize(() => {
15 dragStarted.value = false;
16 });
17+ if (failOffsetY !== undefined) {
18+ pan.failOffsetY(failOffsetY);
19+ }
20 Object.entries(relationProps).forEach(([relationName, relation]) => {
21 (0, _utils.applyRelationProp)(pan, relationName, relation);
22 });
23 return pan;
24- }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]);
25+ }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, failOffsetY, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]);
26 (0, _react.useImperativeHandle)(ref, () => swipeableMethods, [swipeableMethods]);
27 const animatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({
28 transform: [{
29diff --git a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
30index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb830493c22 100644
31--- a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
32+++ b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
33@@ -29,6 +29,7 @@ const Swipeable = props => {
34 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
35 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
36 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
37+ failOffsetY,
38 friction = DEFAULT_FRICTION,
39 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
40 onSwipeableOpenStartDrag,
41@@ -280,11 +281,14 @@ const Swipeable = props => {
42 }).onFinalize(() => {
43 dragStarted.value = false;
44 });
45+ if (failOffsetY !== undefined) {
46+ pan.failOffsetY(failOffsetY);
47+ }
48 Object.entries(relationProps).forEach(([relationName, relation]) => {
49 applyRelationProp(pan, relationName, relation);
50 });
51 return pan;
52- }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]);
53+ }, [enabled, hitSlop, enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, failOffsetY, updateElementWidths, relationProps, userDrag, rowState, dragStarted, updateAnimatedEvent, onSwipeableOpenStartDrag, onSwipeableCloseStartDrag, handleRelease]);
54 useImperativeHandle(ref, () => swipeableMethods, [swipeableMethods]);
55 const animatedStyle = useAnimatedStyle(() => ({
56 transform: [{
57diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
58index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a050edbb0 100644
59--- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
60+++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
61@@ -64,6 +64,13 @@ export interface SwipeableProps {
62 * a swipe. The default value is 10.
63 */
64 dragOffsetFromRightEdge?: number;
65+ /**
66+ * Range along the Y axis (in points) that, once exceeded before the swipe
67+ * activates, makes the pan fail. Lets vertically-dominant pans (list
68+ * scrolling, especially trackpad two-finger pans) skip the swipe entirely.
69+ * Passed straight to the underlying Pan gesture's `failOffsetY`.
70+ */
71+ failOffsetY?: number | [start: number, end: number];
72 /**
73 * Value indicating if the swipeable panel can be pulled further than the left
74 * actions panel's width. It is set to true by default as long as the left
75diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
76index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e092ea997 100644
77--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
78+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
79@@ -58,6 +58,7 @@ const Swipeable = (props: SwipeableProps) => {
80 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
81 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
82 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
83+ failOffsetY,
84 friction = DEFAULT_FRICTION,
85 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
86 onSwipeableOpenStartDrag,
87@@ -537,6 +538,10 @@ const Swipeable = (props: SwipeableProps) => {
88 dragStarted.value = false;
89 });
90
91+ if (failOffsetY !== undefined) {
92+ pan.failOffsetY(failOffsetY);
93+ }
94+
95 Object.entries(relationProps).forEach(([relationName, relation]) => {
96 applyRelationProp(
97 pan,
98@@ -552,6 +557,7 @@ const Swipeable = (props: SwipeableProps) => {
99 enableTrackpadTwoFingerGesture,
100 dragOffsetFromRightEdge,
101 dragOffsetFromLeftEdge,
102+ failOffsetY,
103 updateElementWidths,
104 relationProps,
105 userDrag,
106diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
107index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866dc95b144c 100644
108--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
109+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
110@@ -77,6 +77,14 @@ export interface SwipeableProps {
111 */
112 dragOffsetFromRightEdge?: number;
113
114+ /**
115+ * Range along the Y axis (in points) that, once exceeded before the swipe
116+ * activates, makes the pan fail. Lets vertically-dominant pans (list
117+ * scrolling, especially trackpad two-finger pans) skip the swipe entirely.
118+ * Passed straight to the underlying Pan gesture's `failOffsetY`.
119+ */
120+ failOffsetY?: number | [start: number, end: number];
121+
122 /**
123 * Value indicating if the swipeable panel can be pulled further than the left
124 * actions panel's width. It is set to true by default as long as the left
patches/react-native-screens@4.25.2.patch created+1542
...@@ -0,0 +1,1542 @@
1diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h
2index ea5325ea8d17b1ddfa790ff8dab48ce83142d4d3..acd2fd7ceb7162ed300abc4d3c9f0c24f4d63898 100644
3--- a/ios/RNSBarButtonItem.h
4+++ b/ios/RNSBarButtonItem.h
5@@ -13,4 +13,8 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId);
6 menuAction:(RNSBarButtonMenuItemAction)menuAction
7 imageLoader:(RCTImageLoader *)imageLoader;
8
9++ (UIMenu *)initUIMenuWithDict:(NSDictionary<NSString *, id> *)dict
10+ menuAction:(RNSBarButtonMenuItemAction)menuAction
11+ imageLoader:(RCTImageLoader *)imageLoader;
12+
13 @end
14diff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mm
15index 0eb1f09dee82edd99e3e614938233db5cdeaebfe..73298f10807520970f625e166d7f5dd1338206f1 100644
16--- a/ios/RNSBarButtonItem.mm
17+++ b/ios/RNSBarButtonItem.mm
18@@ -110,6 +110,58 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict
19 if (dict[@"accessibilityHint"]) {
20 self.accessibilityHint = dict[@"accessibilityHint"];
21 }
22+#if !TARGET_OS_TV && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
23+ NSNumber *glassEffectNum = dict[@"glassEffect"];
24+ if (@available(iOS 26.0, *)) {
25+ if ([glassEffectNum boolValue]) {
26+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
27+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
28+ UIBackgroundConfiguration *background = configuration.background;
29+ background.strokeWidth = 0.0;
30+ background.strokeColor = UIColor.clearColor;
31+ configuration.background = background;
32+ configuration.contentInsets = NSDirectionalEdgeInsetsZero;
33+ if (title != nil) {
34+ configuration.title = title;
35+ }
36+
37+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
38+ button.translatesAutoresizingMaskIntoConstraints = NO;
39+ button.enabled = self.enabled;
40+ button.accessibilityLabel = self.accessibilityLabel;
41+ button.accessibilityHint = self.accessibilityHint;
42+
43+ CGFloat resolvedWidth = width != nil ? width.doubleValue : 56.0;
44+ [button.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
45+ [button.heightAnchor constraintEqualToConstant:50.0].active = YES;
46+
47+ [[self class] resolveImageFromConfig:dict
48+ imageLoader:imageLoader
49+ completionBlock:^(UIImage *img) {
50+ UIButtonConfiguration *updatedConfiguration = button.configuration;
51+ updatedConfiguration.image = img;
52+ button.configuration = updatedConfiguration;
53+ }];
54+
55+ NSDictionary *menu = dict[@"menu"];
56+ if (menu) {
57+ button.menu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader];
58+ button.showsMenuAsPrimaryAction = YES;
59+ } else {
60+ NSString *buttonId = dict[@"buttonId"];
61+ if (buttonId && action) {
62+ UIAction *pressAction = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull sender) {
63+ action(buttonId);
64+ }];
65+ [button addAction:pressAction forControlEvents:UIControlEventTouchUpInside];
66+ }
67+ }
68+
69+ self.customView = button;
70+ return self;
71+ }
72+ }
73+#endif
74
75 #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000
76 if (@available(tvOS 17.0, *)) {
77diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h
78index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758f22c51d8 100644
79--- a/ios/RNSScreenStackHeaderConfig.h
80+++ b/ios/RNSScreenStackHeaderConfig.h
81@@ -21,6 +21,8 @@
82 NS_ASSUME_NONNULL_BEGIN
83
84 @property (nonatomic, retain) NSString *title;
85+@property (nonatomic, retain) NSString *subtitle;
86+@property (nonatomic, retain) NSString *largeSubtitle;
87 @property (nonatomic, retain) NSString *titleFontFamily;
88 @property (nonatomic, retain) NSNumber *titleFontSize;
89 @property (nonatomic, retain) NSString *titleFontWeight;
90@@ -45,9 +47,12 @@ NS_ASSUME_NONNULL_BEGIN
91 @property (nonatomic) BOOL backButtonInCustomView;
92 @property (nonatomic) UISemanticContentAttribute direction;
93 @property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode;
94+@property (nonatomic) NSInteger navigationItemStyle;
95 @property (nonatomic) RNSBlurEffectStyle blurEffect;
96 @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerRightBarButtonItems;
97 @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerLeftBarButtonItems;
98+@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerCenterBarButtonItems;
99+@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerToolbarItems;
100 @property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled;
101
102 NS_ASSUME_NONNULL_END
103diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm
104index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644
105--- a/ios/RNSScreenStackHeaderConfig.mm
106+++ b/ios/RNSScreenStackHeaderConfig.mm
107@@ -30,6 +30,20 @@
108 static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17;
109 static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34;
110
111+static NSInteger navigationItemStyleFromCppEquivalent(
112+ react::RNSScreenStackHeaderConfigNavigationItemStyle navigationItemStyle)
113+{
114+ switch (navigationItemStyle) {
115+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Browser:
116+ return UINavigationItemStyleBrowser;
117+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Editor:
118+ return UINavigationItemStyleEditor;
119+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Navigator:
120+ default:
121+ return UINavigationItemStyleNavigator;
122+ }
123+}
124+
125 @interface RCTImageLoader (Private)
126 - (id<RCTImageCache>)imageCache;
127 @end
128@@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string
129 @end
130
131 @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving>
132+#if !TARGET_OS_TV
133+- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items;
134+#endif
135 @end
136
137 @implementation RNSScreenStackHeaderConfig {
138@@ -81,6 +98,7 @@ - (void)initProps
139 self.hidden = YES;
140 _reactSubviews = [NSMutableArray new];
141 _backTitleVisible = YES;
142+ _navigationItemStyle = UINavigationItemStyleNavigator;
143 _blurEffect = RNSBlurEffectStyleNone;
144 }
145
146@@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc
147
148 if (shouldHide) {
149 navitem.title = config.title;
150+ if (@available(iOS 26.0, *)) {
151+ navitem.subtitle = config.subtitle;
152+ navitem.largeSubtitle = config.largeSubtitle;
153+ }
154
155 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items.
156 [navctr setNavigationBarHidden:YES animated:animated];
157@@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc
158 }
159 navitem.largeTitleDisplayMode =
160 config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever;
161+
162+ if (@available(iOS 16.0, *)) {
163+ navitem.style = (UINavigationItemStyle)config.navigationItemStyle;
164+ }
165 #endif
166
167 UINavigationBarAppearance *appearance = [self buildAppearance:vc withConfig:config];
168 navitem.standardAppearance = appearance;
169 navitem.compactAppearance = appearance;
170+ if (@available(iOS 26.0, *)) {
171+ navitem.subtitle = config.subtitle;
172+ navitem.largeSubtitle = config.largeSubtitle;
173+ }
174
175 // appearance does not apply to the tvOS so we need to use lagacy customization
176 #if TARGET_OS_TV
177@@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc
178 // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).
179 // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)
180 navitem.title = config.title;
181- navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
182- withCurrentItems:navitem.leftBarButtonItems];
183- navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
184- withCurrentItems:navitem.rightBarButtonItems];
185+ NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
186+ withCurrentItems:navitem.leftBarButtonItems
187+ navigationItem:navitem];
188+ NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
189+ withCurrentItems:navitem.rightBarButtonItems
190+ navigationItem:navitem];
191+ NSArray<UIBarButtonItem *> *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems
192+ withCurrentItems:@[]
193+ navigationItem:navitem];
194+#if !TARGET_OS_TV
195+ if (@available(iOS 16.0, *)) {
196+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems];
197+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems];
198+ if (@available(iOS 26.0, *)) {
199+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems];
200+ }
201+ navitem.leftBarButtonItems = nil;
202+ navitem.rightBarButtonItems = nil;
203+ } else {
204+ navitem.leftBarButtonItems = leftBarButtonItems;
205+ navitem.rightBarButtonItems = rightBarButtonItems;
206+ }
207+#else
208+ navitem.leftBarButtonItems = leftBarButtonItems;
209+ navitem.rightBarButtonItems = rightBarButtonItems;
210+#endif
211+ NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil;
212+ for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) {
213+ if (toolbarConfig[@"mailSearchToolbar"]) {
214+ mailSearchToolbarConfig = toolbarConfig;
215+ break;
216+ }
217+ }
218+ if (mailSearchToolbarConfig == nil) {
219+ for (NSDictionary<NSString *, id> *rightConfig in config.headerRightBarButtonItems) {
220+ if ([rightConfig[@"bottomMailSearchToolbar"] boolValue]) {
221+ mailSearchToolbarConfig = rightConfig;
222+ break;
223+ }
224+ }
225+ }
226+
227+ static NSInteger const RNSMailSearchToolbarViewTag = 260628;
228+ UIView *chromeHostView = vc.view;
229+ UIView *existingMailSearchToolbar = [chromeHostView viewWithTag:RNSMailSearchToolbarViewTag];
230+ if (existingMailSearchToolbar == nil) {
231+ existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag];
232+ }
233+ [existingMailSearchToolbar removeFromSuperview];
234+
235+ if (mailSearchToolbarConfig != nil) {
236+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
237+ if (@available(iOS 26.0, *)) {
238+ CGFloat horizontalInset = 18.0;
239+ NSNumber *width = mailSearchToolbarConfig[@"width"];
240+ CGFloat hostWidth = chromeHostView.bounds.size.width;
241+ if (hostWidth <= 0.0) {
242+ hostWidth = vc.view.bounds.size.width;
243+ }
244+ if (hostWidth <= 0.0) {
245+ hostWidth = [UIScreen mainScreen].bounds.size.width;
246+ }
247+ CGFloat fallbackWidth = MIN(560.0, MAX(300.0, hostWidth - (horizontalInset * 2.0)));
248+ CGFloat resolvedWidth = width != nil ? width.doubleValue : fallbackWidth;
249+ if (hostWidth > 0.0) {
250+ resolvedWidth = MIN(resolvedWidth, MAX(260.0, hostWidth - 12.0));
251+ }
252+
253+ CGFloat toolbarHeight = 48.0;
254+ CGFloat toolbarRadius = toolbarHeight / 2.0;
255+ CGFloat buttonSize = toolbarHeight;
256+ CGFloat sideButtonReserve = buttonSize + 8.0;
257+ CGFloat searchFieldHeight = 42.0;
258+ CGFloat toolbarBottomSpacing = 4.0;
259+ UIFont *searchFont = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle3];
260+ NSDictionary<NSAttributedStringKey, id> *placeholderAttributes = @{
261+ NSForegroundColorAttributeName : UIColor.secondaryLabelColor,
262+ NSFontAttributeName : searchFont,
263+ };
264+ UIKeyboardLayoutGuide *keyboardLayoutGuide = chromeHostView.keyboardLayoutGuide;
265+
266+ UIView *toolbarHost = [[UIView alloc] init];
267+ toolbarHost.tag = RNSMailSearchToolbarViewTag;
268+ toolbarHost.translatesAutoresizingMaskIntoConstraints = NO;
269+ [chromeHostView addSubview:toolbarHost];
270+ // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard
271+ // layout guide no longer rests at the bottom safe-area edge while the
272+ // keyboard is hidden, which pushed the toolbar offscreen. The required
273+ // resting position is the safe area; the keyboard guide only pulls the
274+ // toolbar up when it actually tracks a visible keyboard.
275+ NSLayoutConstraint *keyboardAvoidConstraint =
276+ [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor
277+ constant:-toolbarBottomSpacing];
278+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh;
279+ NSLayoutConstraint *restingBottomConstraint =
280+ [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
281+ constant:-toolbarBottomSpacing];
282+ restingBottomConstraint.priority = UILayoutPriorityDefaultLow;
283+ [NSLayoutConstraint activateConstraints:@[
284+ [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor],
285+ [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
286+ constant:-toolbarBottomSpacing],
287+ keyboardAvoidConstraint,
288+ restingBottomConstraint,
289+ [toolbarHost.widthAnchor constraintEqualToConstant:resolvedWidth],
290+ [toolbarHost.heightAnchor constraintEqualToConstant:toolbarHeight],
291+ ]];
292+ [chromeHostView bringSubviewToFront:toolbarHost];
293+
294+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];
295+ glassEffect.interactive = YES;
296+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];
297+ glassView.clipsToBounds = YES;
298+ glassView.layer.cornerRadius = toolbarRadius;
299+ glassView.translatesAutoresizingMaskIntoConstraints = NO;
300+ [toolbarHost addSubview:glassView];
301+ BOOL hasFilterButton =
302+ mailSearchToolbarConfig[@"filterButtonId"] != nil || mailSearchToolbarConfig[@"filterMenu"] != nil;
303+ BOOL hasComposeButton =
304+ mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil;
305+ CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0;
306+ CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0;
307+ [NSLayoutConstraint activateConstraints:@[
308+ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset],
309+ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset],
310+ [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
311+ [glassView.heightAnchor constraintEqualToConstant:toolbarHeight],
312+ ]];
313+
314+ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) {
315+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
316+ config->_eventEmitter);
317+ if (eventEmitter && buttonId) {
318+ eventEmitter->onPressHeaderBarButtonItem(
319+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{
320+ .buttonId = std::string([buttonId UTF8String])});
321+ }
322+ };
323+
324+ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) {
325+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
326+ config->_eventEmitter);
327+ if (eventEmitter && menuId) {
328+ eventEmitter->onPressHeaderBarButtonMenuItem(
329+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{
330+ .menuId = std::string([menuId UTF8String])});
331+ }
332+ };
333+
334+ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) =
335+ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) {
336+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
337+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
338+ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"];
339+ configuration.baseForegroundColor = UIColor.labelColor;
340+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
341+ if (menuConfig != nil) {
342+ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig
343+ menuAction:emitMenuPress
344+ imageLoader:config->_imageLoader];
345+ button.showsMenuAsPrimaryAction = YES;
346+ } else if (buttonId != nil) {
347+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
348+ emitButtonPress(buttonId);
349+ }];
350+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
351+ }
352+ return button;
353+ };
354+
355+ BOOL useFallbackSearchField = [mailSearchToolbarConfig[@"useFallbackSearchField"] boolValue];
356+ UISearchBar *searchBar =
357+ !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil;
358+ NSString *placeholder = mailSearchToolbarConfig[@"placeholder"];
359+ if (searchBar != nil) {
360+ if (placeholder != nil) {
361+ searchBar.placeholder = placeholder;
362+ }
363+ searchBar.hidden = NO;
364+ searchBar.alpha = 1.0;
365+ searchBar.userInteractionEnabled = YES;
366+ searchBar.searchBarStyle = UISearchBarStyleMinimal;
367+ searchBar.backgroundImage = [UIImage new];
368+ searchBar.searchTextField.hidden = NO;
369+ searchBar.searchTextField.alpha = 1.0;
370+ searchBar.searchTextField.backgroundColor = UIColor.clearColor;
371+ searchBar.searchTextField.font = searchFont;
372+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;
373+ searchBar.searchTextField.textColor = UIColor.labelColor;
374+ searchBar.searchTextField.tintColor = UIColor.labelColor;
375+ if (placeholder != nil) {
376+ searchBar.searchTextField.attributedPlaceholder =
377+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
378+ }
379+ searchBar.translatesAutoresizingMaskIntoConstraints = NO;
380+ [glassView.contentView addSubview:searchBar];
381+ [NSLayoutConstraint activateConstraints:@[
382+ [searchBar.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:8.0],
383+ [searchBar.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-8.0],
384+ [searchBar.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor],
385+ [searchBar.heightAnchor constraintEqualToConstant:searchFieldHeight],
386+ ]];
387+ } else {
388+ UISearchTextField *searchField = [UISearchTextField new];
389+ if (placeholder != nil) {
390+ searchField.placeholder = placeholder;
391+ searchField.attributedPlaceholder =
392+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
393+ }
394+ NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"];
395+ if (searchTextChangeId != nil) {
396+ [searchField addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
397+ UISearchTextField *field = (UISearchTextField *)action.sender;
398+ emitButtonPress([NSString stringWithFormat:@"%@:%@", searchTextChangeId, field.text ?: @""]);
399+ }]
400+ forControlEvents:UIControlEventEditingChanged];
401+ }
402+ searchField.borderStyle = UITextBorderStyleNone;
403+ searchField.font = searchFont;
404+ searchField.adjustsFontForContentSizeCategory = YES;
405+ searchField.textColor = UIColor.labelColor;
406+ searchField.tintColor = UIColor.labelColor;
407+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
408+ [glassView.contentView addSubview:searchField];
409+ [NSLayoutConstraint activateConstraints:@[
410+ [searchField.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:14.0],
411+ [searchField.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-14.0],
412+ [searchField.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor],
413+ [searchField.heightAnchor constraintEqualToConstant:searchFieldHeight],
414+ ]];
415+ }
416+
417+ if (hasFilterButton) {
418+ UIButton *filterButton = makeGlassButton(
419+ mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
420+ mailSearchToolbarConfig[@"filterButtonId"],
421+ mailSearchToolbarConfig[@"filterMenu"]);
422+ filterButton.translatesAutoresizingMaskIntoConstraints = NO;
423+ [toolbarHost addSubview:filterButton];
424+ [NSLayoutConstraint activateConstraints:@[
425+ [filterButton.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor],
426+ [filterButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
427+ [filterButton.widthAnchor constraintEqualToConstant:buttonSize],
428+ [filterButton.heightAnchor constraintEqualToConstant:buttonSize],
429+ ]];
430+ }
431+
432+ if (hasComposeButton) {
433+ UIButton *composeButton = makeGlassButton(
434+ mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil",
435+ mailSearchToolbarConfig[@"composeButtonId"],
436+ mailSearchToolbarConfig[@"composeMenu"]);
437+ composeButton.translatesAutoresizingMaskIntoConstraints = NO;
438+ [toolbarHost addSubview:composeButton];
439+ [NSLayoutConstraint activateConstraints:@[
440+ [composeButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor],
441+ [composeButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
442+ [composeButton.widthAnchor constraintEqualToConstant:buttonSize],
443+ [composeButton.heightAnchor constraintEqualToConstant:buttonSize],
444+ ]];
445+ }
446+ }
447+#endif
448+ }
449+
450+ NSArray<NSDictionary<NSString *, id> *> *navigationToolbarConfigs = config.headerToolbarItems;
451+ if (mailSearchToolbarConfig != nil) {
452+ navigationToolbarConfigs = @[];
453+ }
454+
455+ NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs
456+ withCurrentItems:@[]
457+ navigationItem:navitem];
458+ if (toolbarItems.count > 0) {
459+ vc.toolbarItems = toolbarItems;
460+ [navctr setToolbarHidden:NO animated:animated];
461+ } else {
462+ vc.toolbarItems = nil;
463+ [navctr setToolbarHidden:YES animated:animated];
464+ }
465
466 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items
467 // (setting nav bar visibility should be done after `navitem.*BarButtonItems`).
468@@ -773,6 +1079,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
469
470 - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts
471 withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems
472+ navigationItem:(UINavigationItem *)navitem
473 {
474 if (dicts.count == 0) {
475 return currentItems;
476@@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
477 [items addObjectsFromArray:currentItems];
478 for (NSUInteger i = 0; i < dicts.count; i++) {
479 NSDictionary *dict = dicts[i];
480- if (dict[@"buttonId"] || dict[@"menu"]) {
481+ if (dict[@"mailSearchToolbar"]) {
482+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
483+ if (@available(iOS 26.0, *)) {
484+ NSNumber *width = dict[@"width"];
485+ CGFloat resolvedWidth = width != nil ? width.doubleValue : MAX(320.0, [UIScreen mainScreen].bounds.size.width - 56.0);
486+ CGFloat resolvedHeight = 58.0;
487+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)];
488+ container.translatesAutoresizingMaskIntoConstraints = NO;
489+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
490+ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES;
491+
492+ UIStackView *stackView = [[UIStackView alloc] initWithFrame:container.bounds];
493+ stackView.axis = UILayoutConstraintAxisHorizontal;
494+ stackView.alignment = UIStackViewAlignmentCenter;
495+ stackView.distribution = UIStackViewDistributionFill;
496+ stackView.spacing = 10.0;
497+ stackView.translatesAutoresizingMaskIntoConstraints = NO;
498+ [container addSubview:stackView];
499+ [NSLayoutConstraint activateConstraints:@[
500+ [stackView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor],
501+ [stackView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor],
502+ [stackView.centerYAnchor constraintEqualToAnchor:container.centerYAnchor],
503+ [stackView.heightAnchor constraintEqualToConstant:50.0],
504+ ]];
505+
506+ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) {
507+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
508+ self->_eventEmitter);
509+ if (eventEmitter && buttonId) {
510+ eventEmitter->onPressHeaderBarButtonItem(
511+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{
512+ .buttonId = std::string([buttonId UTF8String])});
513+ }
514+ };
515+
516+ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) {
517+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
518+ self->_eventEmitter);
519+ if (eventEmitter && menuId) {
520+ eventEmitter->onPressHeaderBarButtonMenuItem(
521+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{
522+ .menuId = std::string([menuId UTF8String])});
523+ }
524+ };
525+
526+ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) =
527+ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) {
528+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
529+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
530+ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"];
531+
532+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
533+ button.translatesAutoresizingMaskIntoConstraints = NO;
534+ [button.widthAnchor constraintEqualToConstant:58.0].active = YES;
535+ [button.heightAnchor constraintEqualToConstant:50.0].active = YES;
536+ if (menuConfig != nil) {
537+ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig
538+ menuAction:emitMenuPress
539+ imageLoader:self->_imageLoader];
540+ button.showsMenuAsPrimaryAction = YES;
541+ } else if (buttonId != nil) {
542+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
543+ emitButtonPress(buttonId);
544+ }];
545+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
546+ }
547+ return button;
548+ };
549+
550+ BOOL hasFilterButton = dict[@"filterButtonId"] != nil || dict[@"filterMenu"] != nil;
551+ BOOL hasComposeButton = dict[@"composeButtonId"] != nil || dict[@"composeMenu"] != nil;
552+
553+ if (hasFilterButton) {
554+ UIButton *filterButton = makeGlassButton(
555+ dict[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
556+ dict[@"filterButtonId"],
557+ dict[@"filterMenu"]);
558+ [stackView addArrangedSubview:filterButton];
559+ }
560+
561+ UISearchBar *searchBar = navitem.searchController != nil ? navitem.searchController.searchBar : [UISearchBar new];
562+ NSString *placeholder = dict[@"placeholder"];
563+ if (placeholder != nil) {
564+ searchBar.placeholder = placeholder;
565+ }
566+ searchBar.searchBarStyle = UISearchBarStyleMinimal;
567+ searchBar.translatesAutoresizingMaskIntoConstraints = NO;
568+ [searchBar.heightAnchor constraintEqualToConstant:50.0].active = YES;
569+ [stackView addArrangedSubview:searchBar];
570+
571+ if (hasComposeButton) {
572+ UIButton *composeButton = makeGlassButton(
573+ dict[@"composeSystemImageName"] ?: @"square.and.pencil",
574+ dict[@"composeButtonId"],
575+ dict[@"composeMenu"]);
576+ [stackView addArrangedSubview:composeButton];
577+ }
578+
579+ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container];
580+ [items addObject:item];
581+ }
582+#endif
583+ } else if (dict[@"searchField"]) {
584+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
585+ if (@available(iOS 26.0, *)) {
586+ NSNumber *width = dict[@"width"];
587+ CGFloat resolvedWidth = width != nil ? width.doubleValue : 280.0;
588+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, 44.0)];
589+ container.translatesAutoresizingMaskIntoConstraints = NO;
590+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
591+ [container.heightAnchor constraintEqualToConstant:44.0].active = YES;
592+
593+ UISearchTextField *searchField = [UISearchTextField new];
594+ NSString *placeholder = dict[@"placeholder"];
595+ if (placeholder != nil) {
596+ searchField.placeholder = placeholder;
597+ }
598+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
599+ [container addSubview:searchField];
600+ [NSLayoutConstraint activateConstraints:@[
601+ [searchField.leadingAnchor constraintEqualToAnchor:container.leadingAnchor],
602+ [searchField.trailingAnchor constraintEqualToAnchor:container.trailingAnchor],
603+ [searchField.centerYAnchor constraintEqualToAnchor:container.centerYAnchor],
604+ [searchField.heightAnchor constraintEqualToConstant:40.0],
605+ ]];
606+
607+ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container];
608+ [items addObject:item];
609+ }
610+#endif
611+ } else if (dict[@"searchBarPlacement"]) {
612+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
613+ if (@available(iOS 26.0, *)) {
614+ NSNumber *width = dict[@"width"];
615+ UIBarButtonItem *item = nil;
616+ if ([dict[@"activatesSearchController"] boolValue]) {
617+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
618+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
619+ configuration.image = [UIImage systemImageNamed:@"magnifyingglass"];
620+ configuration.baseForegroundColor = UIColor.labelColor;
621+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
622+ button.translatesAutoresizingMaskIntoConstraints = NO;
623+ [button.widthAnchor constraintEqualToConstant:56.0].active = YES;
624+ [button.heightAnchor constraintEqualToConstant:56.0].active = YES;
625+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
626+ if (navitem.searchController != nil) {
627+ [navitem.searchController setActive:YES];
628+ [navitem.searchController.searchBar becomeFirstResponder];
629+ }
630+ }];
631+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
632+ item = [[UIBarButtonItem alloc] initWithCustomView:button];
633+ } else if ([dict[@"customView"] boolValue] && navitem.searchController != nil) {
634+ UISearchBar *searchBar = navitem.searchController.searchBar;
635+ CGFloat resolvedWidth = width != nil ? width.doubleValue : searchBar.frame.size.width;
636+ CGFloat resolvedHeight = searchBar.frame.size.height > 0 ? searchBar.frame.size.height : 44.0;
637+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)];
638+ container.translatesAutoresizingMaskIntoConstraints = NO;
639+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
640+ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES;
641+ searchBar.frame = container.bounds;
642+ searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
643+ [container addSubview:searchBar];
644+ item = [[UIBarButtonItem alloc] initWithCustomView:container];
645+ } else {
646+ item = navitem.searchBarPlacementBarButtonItem;
647+ if (width != nil) {
648+ item.width = width.doubleValue;
649+ }
650+ }
651+ NSNumber *index = dict[@"index"];
652+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
653+ if (@available(iOS 26.0, *)) {
654+ NSNumber *sharesBackground = dict[@"sharesBackground"];
655+ if (sharesBackground != nil) {
656+ item.sharesBackground = sharesBackground.boolValue;
657+ }
658+ NSNumber *hidesSharedBackground = dict[@"hidesSharedBackground"];
659+ if (hidesSharedBackground != nil) {
660+ item.hidesSharedBackground = hidesSharedBackground.boolValue;
661+ }
662+ }
663+#endif
664+ if (index != nil && index.integerValue < items.count) {
665+ [items insertObject:item atIndex:index.integerValue];
666+ } else {
667+ [items addObject:item];
668+ }
669+ }
670+#endif
671+ } else if (dict[@"buttonId"] || dict[@"menu"]) {
672 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict
673 action:^(NSString *buttonId) {
674 auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
675@@ -809,11 +1306,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
676 [items addObject:item];
677 }
678 } else if (dict[@"spacing"]) {
679- UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace
680- target:nil
681- action:nil];
682+ BOOL flexible = [dict[@"flexible"] boolValue];
683+ UIBarButtonItem *item = [[UIBarButtonItem alloc]
684+ initWithBarButtonSystemItem:(flexible ? UIBarButtonSystemItemFlexibleSpace : UIBarButtonSystemItemFixedSpace)
685+ target:nil
686+ action:nil];
687 NSNumber *spacingValue = dict[@"spacing"];
688- item.width = [spacingValue doubleValue];
689+ if (!flexible) {
690+ item.width = [spacingValue doubleValue];
691+ }
692 NSNumber *index = dict[@"index"];
693 if (index.integerValue < items.count) {
694 [items insertObject:item atIndex:index.integerValue];
695@@ -825,6 +1326,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
696 return items;
697 }
698
699+#if !TARGET_OS_TV
700+- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items
701+{
702+ if (items.count == 0) {
703+ return @[];
704+ }
705+
706+ NSMutableArray<UIBarButtonItemGroup *> *groups = [NSMutableArray array];
707+ NSMutableArray<UIBarButtonItem *> *sharedRun = [NSMutableArray array];
708+
709+ void (^flushSharedRun)(void) = ^{
710+ if (sharedRun.count == 0) {
711+ return;
712+ }
713+ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil
714+ items:[sharedRun copy]]];
715+ [sharedRun removeAllObjects];
716+ };
717+
718+ for (UIBarButtonItem *item in items) {
719+ BOOL shouldShareBackground = NO;
720+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
721+ if (@available(iOS 26.0, *)) {
722+ shouldShareBackground = item.sharesBackground && !item.hidesSharedBackground;
723+ }
724+#endif
725+
726+ if (shouldShareBackground) {
727+ [sharedRun addObject:item];
728+ continue;
729+ }
730+
731+ flushSharedRun();
732+ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil items:@[ item ]]];
733+ }
734+
735+ flushSharedRun();
736+ return groups;
737+}
738+#endif
739+
740 RNS_IGNORE_SUPER_CALL_BEGIN
741 - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex
742 {
743@@ -1013,6 +1555,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
744 }
745
746 _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);
747+ _subtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.subtitle);
748+ _largeSubtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.largeSubtitle);
749 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {
750 _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);
751 }
752@@ -1038,6 +1582,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
753 _disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
754 _backButtonDisplayMode =
755 [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];
756+ _navigationItemStyle = navigationItemStyleFromCppEquivalent(newScreenProps.navigationItemStyle);
757
758 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {
759 _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle];
760@@ -1084,6 +1629,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
761 _headerRightBarButtonItems = array;
762 }
763
764+ if (newScreenProps.headerCenterBarButtonItems != oldScreenProps.headerCenterBarButtonItems) {
765+ const auto &vec = newScreenProps.headerCenterBarButtonItems;
766+ NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()];
767+ for (const auto &item : vec) {
768+ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item];
769+ if ([dict isKindOfClass:[NSDictionary class]]) {
770+ [array addObject:dict];
771+ }
772+ }
773+ _headerCenterBarButtonItems = array;
774+ }
775+
776+ if (newScreenProps.headerToolbarItems != oldScreenProps.headerToolbarItems) {
777+ const auto &vec = newScreenProps.headerToolbarItems;
778+ NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()];
779+ for (const auto &item : vec) {
780+ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item];
781+ if ([dict isKindOfClass:[NSDictionary class]]) {
782+ [array addObject:dict];
783+ }
784+ }
785+ _headerToolbarItems = array;
786+ }
787+
788 [self updateViewControllerIfNeeded];
789
790 if (needsNavigationControllerLayout) {
791diff --git a/lib/commonjs/components/ScreenStackHeaderConfig.js b/lib/commonjs/components/ScreenStackHeaderConfig.js
792index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809aee5190d91 100644
793--- a/lib/commonjs/components/ScreenStackHeaderConfig.js
794+++ b/lib/commonjs/components/ScreenStackHeaderConfig.js
795@@ -23,18 +23,42 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
796 } = (0, _TopInsetApplicationContext.useTopInsetApplication)(!props.hidden, props.disableTopInsetApplication ?? false);
797 const {
798 headerLeftBarButtonItems,
799- headerRightBarButtonItems
800+ headerRightBarButtonItems,
801+ headerCenterBarButtonItems,
802+ headerToolbarItems
803 } = props;
804 const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerLeftBarButtonItems, 'left') : undefined;
805 const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerRightBarButtonItems, 'right') : undefined;
806- const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length);
807+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'right') : undefined;
808+ const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'right') : undefined;
809+ const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length);
810
811 // Handle bar button item presses
812 const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => {
813- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId);
814+ const buttonId = event.nativeEvent.buttonId;
815+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
816+ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId);
817 if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) {
818 pressedItem.onPress();
819 }
820+ for (const item of allItems) {
821+ if (!item || item.type !== 'mailSearchToolbar') {
822+ continue;
823+ }
824+ if (item.filterButtonId === buttonId) {
825+ item.onFilterPress?.();
826+ return;
827+ }
828+ if (item.composeButtonId === buttonId) {
829+ item.onComposePress?.();
830+ return;
831+ }
832+ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined;
833+ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) {
834+ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length));
835+ return;
836+ }
837+ }
838 } : undefined;
839
840 // Handle bar button menu item presses by deep-searching nested menus
841@@ -56,7 +80,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
842 };
843
844 // Check each bar-button item with a menu
845- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])];
846+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
847 for (const item of allItems) {
848 if (item && item.type === 'menu' && item.menu) {
849 const action = findInMenu(item.menu, event.nativeEvent.menuId);
850@@ -64,6 +88,15 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
851 action.onPress();
852 return;
853 }
854+ } else if (item && item.type === 'mailSearchToolbar') {
855+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean);
856+ for (const toolbarMenu of toolbarMenus) {
857+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
858+ if (action) {
859+ action.onPress();
860+ return;
861+ }
862+ }
863 }
864 }
865 } : undefined;
866@@ -71,6 +104,8 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
867 userInterfaceStyle: props.experimental_userInterfaceStyle,
868 headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems,
869 headerRightBarButtonItems: preparedHeaderRightBarButtonItems,
870+ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems,
871+ headerToolbarItems: preparedHeaderToolbarItems,
872 onPressHeaderBarButtonItem: onPressHeaderBarButtonItem,
873 onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem,
874 ref: ref,
875diff --git a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
876index ab93f62e4a7049d63c6681cecbd6cf8b1d07ce10..b5ea122473b023f84eabdb1914aef09a28c6d525 100644
877--- a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
878+++ b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
879@@ -41,10 +41,31 @@ const prepareMenu = (menu, index, side, path = '') => {
880 };
881 };
882 const prepareHeaderBarButtonItems = (barButtonItems, side) => {
883- return barButtonItems?.map((item, index) => {
884+ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined;
885+ return items?.map((item, index) => {
886 if (item.type === 'spacing') {
887 return item;
888 }
889+ if (item.type === 'searchBarPlacement') {
890+ return {
891+ ...item,
892+ searchBarPlacement: true
893+ };
894+ }
895+ if (item.type === 'searchField') {
896+ return {
897+ ...item,
898+ searchField: true
899+ };
900+ }
901+ if (item.type === 'mailSearchToolbar') {
902+ return {
903+ ...item,
904+ mailSearchToolbar: true,
905+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined,
906+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined
907+ };
908+ }
909 let imageSource, templateSource;
910 if (item.icon?.type === 'imageSource') {
911 imageSource = _reactNative.Image.resolveAssetSource(item.icon.imageSource);
912diff --git a/lib/module/components/ScreenStackHeaderConfig.js b/lib/module/components/ScreenStackHeaderConfig.js
913index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4d20dce2a 100644
914--- a/lib/module/components/ScreenStackHeaderConfig.js
915+++ b/lib/module/components/ScreenStackHeaderConfig.js
916@@ -19,18 +19,42 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
917 } = useTopInsetApplication(!props.hidden, props.disableTopInsetApplication ?? false);
918 const {
919 headerLeftBarButtonItems,
920- headerRightBarButtonItems
921+ headerRightBarButtonItems,
922+ headerCenterBarButtonItems,
923+ headerToolbarItems
924 } = props;
925 const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerLeftBarButtonItems, 'left') : undefined;
926 const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') : undefined;
927- const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length);
928+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'right') : undefined;
929+ const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'right') : undefined;
930+ const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length);
931
932 // Handle bar button item presses
933 const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => {
934- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId);
935+ const buttonId = event.nativeEvent.buttonId;
936+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
937+ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId);
938 if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) {
939 pressedItem.onPress();
940 }
941+ for (const item of allItems) {
942+ if (!item || item.type !== 'mailSearchToolbar') {
943+ continue;
944+ }
945+ if (item.filterButtonId === buttonId) {
946+ item.onFilterPress?.();
947+ return;
948+ }
949+ if (item.composeButtonId === buttonId) {
950+ item.onComposePress?.();
951+ return;
952+ }
953+ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined;
954+ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) {
955+ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length));
956+ return;
957+ }
958+ }
959 } : undefined;
960
961 // Handle bar button menu item presses by deep-searching nested menus
962@@ -52,7 +76,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
963 };
964
965 // Check each bar-button item with a menu
966- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])];
967+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
968 for (const item of allItems) {
969 if (item && item.type === 'menu' && item.menu) {
970 const action = findInMenu(item.menu, event.nativeEvent.menuId);
971@@ -60,6 +84,15 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
972 action.onPress();
973 return;
974 }
975+ } else if (item && item.type === 'mailSearchToolbar') {
976+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean);
977+ for (const toolbarMenu of toolbarMenus) {
978+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
979+ if (action) {
980+ action.onPress();
981+ return;
982+ }
983+ }
984 }
985 }
986 } : undefined;
987@@ -67,6 +100,8 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
988 userInterfaceStyle: props.experimental_userInterfaceStyle,
989 headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems,
990 headerRightBarButtonItems: preparedHeaderRightBarButtonItems,
991+ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems,
992+ headerToolbarItems: preparedHeaderToolbarItems,
993 onPressHeaderBarButtonItem: onPressHeaderBarButtonItem,
994 onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem,
995 ref: ref,
996diff --git a/lib/module/components/helpers/prepareHeaderBarButtonItems.js b/lib/module/components/helpers/prepareHeaderBarButtonItems.js
997index 8a70ffd78617147418d628c03c580bb7ff9a8a72..8dbd0ba2ee61ee54d44cdf286a720ce26af01f26 100644
998--- a/lib/module/components/helpers/prepareHeaderBarButtonItems.js
999+++ b/lib/module/components/helpers/prepareHeaderBarButtonItems.js
1000@@ -35,10 +35,31 @@ const prepareMenu = (menu, index, side, path = '') => {
1001 };
1002 };
1003 export const prepareHeaderBarButtonItems = (barButtonItems, side) => {
1004- return barButtonItems?.map((item, index) => {
1005+ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined;
1006+ return items?.map((item, index) => {
1007 if (item.type === 'spacing') {
1008 return item;
1009 }
1010+ if (item.type === 'searchBarPlacement') {
1011+ return {
1012+ ...item,
1013+ searchBarPlacement: true
1014+ };
1015+ }
1016+ if (item.type === 'searchField') {
1017+ return {
1018+ ...item,
1019+ searchField: true
1020+ };
1021+ }
1022+ if (item.type === 'mailSearchToolbar') {
1023+ return {
1024+ ...item,
1025+ mailSearchToolbar: true,
1026+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined,
1027+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined
1028+ };
1029+ }
1030 let imageSource, templateSource;
1031 if (item.icon?.type === 'imageSource') {
1032 imageSource = Image.resolveAssetSource(item.icon.imageSource);
1033diff --git a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
1034index b7568ecfebd4f4420f2a8d3fec5184d7fc728dd1..41ca1f273f0175929e73105faa463978323837e3 100644
1035--- a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
1036+++ b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
1037@@ -9,6 +9,7 @@ type OnPressHeaderBarButtonMenuItemEvent = Readonly<{
1038 menuId: string;
1039 }>;
1040 type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';
1041+type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
1042 type BlurEffect = 'none' | 'extraLight' | 'light' | 'dark' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark';
1043 type UserInterfaceStyle = 'unspecified' | 'light' | 'dark';
1044 export interface NativeProps extends ViewProps {
1045@@ -32,18 +33,23 @@ export interface NativeProps extends ViewProps {
1046 largeTitleColor?: ColorValue | undefined;
1047 translucent?: boolean | undefined;
1048 title?: string | undefined;
1049+ subtitle?: string | undefined;
1050+ largeSubtitle?: string | undefined;
1051 titleFontFamily?: string | undefined;
1052 titleFontSize?: CT.Int32 | undefined;
1053 titleFontWeight?: string | undefined;
1054 titleColor?: ColorValue | undefined;
1055 disableBackButtonMenu?: boolean | undefined;
1056 backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>;
1057+ navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>;
1058 hideBackButton?: boolean | undefined;
1059 backButtonInCustomView?: boolean | undefined;
1060 blurEffect?: CT.WithDefault<BlurEffect, 'none'>;
1061 topInsetEnabled?: boolean | undefined;
1062 headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined;
1063 headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined;
1064+ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined;
1065+ headerToolbarItems?: CT.UnsafeMixed[] | undefined;
1066 onPressHeaderBarButtonItem?: CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent> | undefined;
1067 onPressHeaderBarButtonMenuItem?: CT.DirectEventHandler<OnPressHeaderBarButtonMenuItemEvent> | undefined;
1068 synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, false>;
1069diff --git a/lib/typescript/types.d.ts b/lib/typescript/types.d.ts
1070index 3b384e03891e38e936f370372a682d73440e7ec2..1e3a966806c8b8b312f75fb367f758efe49dde9d 100644
1071--- a/lib/typescript/types.d.ts
1072+++ b/lib/typescript/types.d.ts
1073@@ -10,6 +10,7 @@ export type SearchBarCommands = {
1074 cancelSearch: () => void;
1075 };
1076 export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal';
1077+export type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
1078 export type StackPresentationTypes = 'push' | 'modal' | 'transparentModal' | 'containedModal' | 'containedTransparentModal' | 'fullScreenModal' | 'formSheet' | 'pageSheet';
1079 export type StackAnimationTypes = 'default' | 'fade' | 'fade_from_bottom' | 'flip' | 'none' | 'simple_push' | 'slide_from_bottom' | 'slide_from_right' | 'slide_from_left' | 'ios_from_right' | 'ios_from_left';
1080 export type BlurEffectTypes = BlurEffect;
1081@@ -631,6 +632,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
1082 * @platform ios
1083 */
1084 backButtonDisplayMode?: BackButtonDisplayMode | undefined;
1085+ /**
1086+ * Controls the UIKit `UINavigationItem.style` used by native iOS headers.
1087+ *
1088+ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls.
1089+ *
1090+ * @platform ios
1091+ */
1092+ navigationItemStyle?: NavigationItemStyle | undefined;
1093 /**
1094 * Array of UIBarButtomItems to the left side of the header.
1095 *
1096@@ -643,6 +652,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
1097 * @platform ios
1098 */
1099 headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined;
1100+ /**
1101+ * Array of UIBarButtonItems to the centered item group in modern iOS headers.
1102+ *
1103+ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets
1104+ * document/editor-style chrome keep primary actions visually centered while
1105+ * leaving trailing search separate.
1106+ *
1107+ * @platform ios
1108+ */
1109+ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined;
1110+ /**
1111+ * Array of UIBarButtonItems to display in the native toolbar attached to
1112+ * the current screen's navigation controller.
1113+ *
1114+ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit
1115+ * render integrated Mail-style search chrome using the attached SearchBar.
1116+ *
1117+ * @platform ios
1118+ */
1119+ headerToolbarItems?: HeaderBarButtonItem[] | undefined;
1120 /**
1121 * When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false.
1122 */
1123@@ -704,6 +733,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
1124 * String that can be displayed in the header as a fallback for `headerTitle`.
1125 */
1126 title?: string | undefined;
1127+ /**
1128+ * String displayed below the compact navigation title on iOS 26+.
1129+ *
1130+ * @platform ios
1131+ */
1132+ subtitle?: string | undefined;
1133+ /**
1134+ * String displayed below the large navigation title on iOS 26+.
1135+ *
1136+ * Falls back to `subtitle` when omitted.
1137+ *
1138+ * @platform ios
1139+ */
1140+ largeSubtitle?: string | undefined;
1141 /**
1142 * Allows for setting text color of the title.
1143 */
1144@@ -1001,6 +1044,11 @@ interface SharedHeaderBarButtonItem {
1145 * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property
1146 */
1147 variant?: 'plain' | 'done' | 'prominent' | undefined;
1148+ /**
1149+ * Render the item with UIButtonConfiguration.glassButtonConfiguration.
1150+ * Only available from iOS 26.0 and later.
1151+ */
1152+ glassEffect?: boolean | undefined;
1153 /**
1154 * The tint color to apply to the item.
1155 *
1156@@ -1145,8 +1193,37 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
1157 export interface HeaderBarButtonItemSpacing {
1158 type: 'spacing';
1159 spacing: number;
1160+ flexible?: boolean | undefined;
1161+}
1162+export interface HeaderBarButtonSearchBarPlacementItem {
1163+ type: 'searchBarPlacement';
1164+ activatesSearchController?: boolean | undefined;
1165+ customView?: boolean | undefined;
1166+ index?: number | undefined;
1167+ width?: number | undefined;
1168+}
1169+export interface HeaderBarButtonSearchFieldItem {
1170+ type: 'searchField';
1171+ placeholder?: string | undefined;
1172+ width?: number | undefined;
1173+}
1174+export interface HeaderBarButtonMailSearchToolbarItem {
1175+ type: 'mailSearchToolbar';
1176+ composeButtonId?: string | undefined;
1177+ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
1178+ composeSystemImageName?: string | undefined;
1179+ filterButtonId?: string | undefined;
1180+ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
1181+ filterSystemImageName?: string | undefined;
1182+ onComposePress?: (() => void) | undefined;
1183+ onFilterPress?: (() => void) | undefined;
1184+ onSearchTextChange?: ((text: string) => void) | undefined;
1185+ placeholder?: string | undefined;
1186+ searchTextChangeId?: string | undefined;
1187+ useFallbackSearchField?: boolean | undefined;
1188+ width?: number | undefined;
1189 }
1190-export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonItemSpacing;
1191+export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonMailSearchToolbarItem | HeaderBarButtonSearchFieldItem | HeaderBarButtonSearchBarPlacementItem | HeaderBarButtonItemSpacing;
1192 /**
1193 * Custom Screen Transition
1194 */
1195diff --git a/src/components/ScreenStackHeaderConfig.tsx b/src/components/ScreenStackHeaderConfig.tsx
1196index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab210aa30f6f 100644
1197--- a/src/components/ScreenStackHeaderConfig.tsx
1198+++ b/src/components/ScreenStackHeaderConfig.tsx
1199@@ -39,7 +39,12 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1200 props.disableTopInsetApplication ?? false,
1201 );
1202
1203- const { headerLeftBarButtonItems, headerRightBarButtonItems } = props;
1204+ const {
1205+ headerLeftBarButtonItems,
1206+ headerRightBarButtonItems,
1207+ headerCenterBarButtonItems,
1208+ headerToolbarItems,
1209+ } = props;
1210
1211 const preparedHeaderLeftBarButtonItems =
1212 headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
1213@@ -49,22 +54,36 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1214 headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
1215 ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right')
1216 : undefined;
1217+ const preparedHeaderCenterBarButtonItems =
1218+ headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
1219+ ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'right')
1220+ : undefined;
1221+ const preparedHeaderToolbarItems =
1222+ headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform
1223+ ? prepareHeaderBarButtonItems(headerToolbarItems, 'right')
1224+ : undefined;
1225 const hasHeaderBarButtonItems =
1226 isHeaderBarButtonsAvailableForCurrentPlatform &&
1227 (preparedHeaderLeftBarButtonItems?.length ||
1228- preparedHeaderRightBarButtonItems?.length);
1229+ preparedHeaderRightBarButtonItems?.length ||
1230+ preparedHeaderCenterBarButtonItems?.length ||
1231+ preparedHeaderToolbarItems?.length);
1232
1233 // Handle bar button item presses
1234 const onPressHeaderBarButtonItem = hasHeaderBarButtonItems
1235 ? (event: NativeSyntheticEvent<{ buttonId: string }>) => {
1236- const pressedItem = [
1237+ const buttonId = event.nativeEvent.buttonId;
1238+ const allItems = [
1239 ...(preparedHeaderLeftBarButtonItems ?? []),
1240 ...(preparedHeaderRightBarButtonItems ?? []),
1241- ].find(
1242+ ...(preparedHeaderCenterBarButtonItems ?? []),
1243+ ...(preparedHeaderToolbarItems ?? []),
1244+ ];
1245+ const pressedItem = allItems.find(
1246 item =>
1247 item &&
1248 'buttonId' in item &&
1249- item.buttonId === event.nativeEvent.buttonId,
1250+ item.buttonId === buttonId,
1251 );
1252 if (
1253 pressedItem &&
1254@@ -73,6 +92,31 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1255 ) {
1256 pressedItem.onPress();
1257 }
1258+ for (const item of allItems) {
1259+ if (!item || item.type !== 'mailSearchToolbar') {
1260+ continue;
1261+ }
1262+ if (item.filterButtonId === buttonId) {
1263+ item.onFilterPress?.();
1264+ return;
1265+ }
1266+ if (item.composeButtonId === buttonId) {
1267+ item.onComposePress?.();
1268+ return;
1269+ }
1270+ const searchTextChangePrefix = item.searchTextChangeId
1271+ ? `${item.searchTextChangeId}:`
1272+ : undefined;
1273+ if (
1274+ searchTextChangePrefix &&
1275+ buttonId.startsWith(searchTextChangePrefix)
1276+ ) {
1277+ item.onSearchTextChange?.(
1278+ buttonId.slice(searchTextChangePrefix.length),
1279+ );
1280+ return;
1281+ }
1282+ }
1283 }
1284 : undefined;
1285
1286@@ -102,6 +146,8 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1287 const allItems = [
1288 ...(preparedHeaderLeftBarButtonItems ?? []),
1289 ...(preparedHeaderRightBarButtonItems ?? []),
1290+ ...(preparedHeaderCenterBarButtonItems ?? []),
1291+ ...(preparedHeaderToolbarItems ?? []),
1292 ];
1293 for (const item of allItems) {
1294 if (item && item.type === 'menu' && item.menu) {
1295@@ -110,6 +156,17 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1296 action.onPress();
1297 return;
1298 }
1299+ } else if (item && item.type === 'mailSearchToolbar') {
1300+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(
1301+ Boolean,
1302+ );
1303+ for (const toolbarMenu of toolbarMenus) {
1304+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
1305+ if (action) {
1306+ action.onPress();
1307+ return;
1308+ }
1309+ }
1310 }
1311 }
1312 }
1313@@ -121,6 +178,8 @@ export const ScreenStackHeaderConfig = React.forwardRef<
1314 userInterfaceStyle={props.experimental_userInterfaceStyle}
1315 headerLeftBarButtonItems={preparedHeaderLeftBarButtonItems}
1316 headerRightBarButtonItems={preparedHeaderRightBarButtonItems}
1317+ headerCenterBarButtonItems={preparedHeaderCenterBarButtonItems}
1318+ headerToolbarItems={preparedHeaderToolbarItems}
1319 onPressHeaderBarButtonItem={onPressHeaderBarButtonItem}
1320 onPressHeaderBarButtonMenuItem={onPressHeaderBarButtonMenuItem}
1321 ref={ref}
1322diff --git a/src/components/helpers/prepareHeaderBarButtonItems.ts b/src/components/helpers/prepareHeaderBarButtonItems.ts
1323index be2c24dfee77f5883b5ab1d7473be80933b5dc6f..0b038d2005b2d51bbb2ab1d7dba7eaccda83d42f 100644
1324--- a/src/components/helpers/prepareHeaderBarButtonItems.ts
1325+++ b/src/components/helpers/prepareHeaderBarButtonItems.ts
1326@@ -50,13 +50,43 @@ const prepareMenu = (
1327 };
1328
1329 export const prepareHeaderBarButtonItems = (
1330- barButtonItems: HeaderBarButtonItem[],
1331+ barButtonItems:
1332+ | HeaderBarButtonItem[]
1333+ | HeaderBarButtonItem
1334+ | null
1335+ | undefined,
1336 side: 'left' | 'right',
1337 ) => {
1338- return barButtonItems?.map((item, index) => {
1339+ const items = Array.isArray(barButtonItems)
1340+ ? barButtonItems
1341+ : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems
1342+ ? [barButtonItems]
1343+ : undefined;
1344+
1345+ return items?.map((item, index) => {
1346 if (item.type === 'spacing') {
1347 return item;
1348 }
1349+ if (item.type === 'searchBarPlacement') {
1350+ return {
1351+ ...item,
1352+ searchBarPlacement: true,
1353+ };
1354+ }
1355+ if (item.type === 'searchField') {
1356+ return {
1357+ ...item,
1358+ searchField: true,
1359+ };
1360+ }
1361+ if (item.type === 'mailSearchToolbar') {
1362+ return {
1363+ ...item,
1364+ mailSearchToolbar: true,
1365+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined,
1366+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined,
1367+ };
1368+ }
1369 let imageSource, templateSource;
1370 if (item.icon?.type === 'imageSource') {
1371 imageSource = Image.resolveAssetSource(item.icon.imageSource);
1372diff --git a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
1373index ba2479de0f49c112470f78c5084059ddd9e2f3e8..8677583a8f8daa4839340467466bfab8d73111ab 100644
1374--- a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
1375+++ b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
1376@@ -14,6 +14,7 @@ type OnPressHeaderBarButtonItemEvent = Readonly<{ buttonId: string }>;
1377 type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ menuId: string }>;
1378
1379 type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';
1380+type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
1381
1382 type BlurEffect =
1383 | 'none'
1384@@ -61,12 +62,15 @@ export interface NativeProps extends ViewProps {
1385 largeTitleColor?: ColorValue | undefined;
1386 translucent?: boolean | undefined;
1387 title?: string | undefined;
1388+ subtitle?: string | undefined;
1389+ largeSubtitle?: string | undefined;
1390 titleFontFamily?: string | undefined;
1391 titleFontSize?: CT.Int32 | undefined;
1392 titleFontWeight?: string | undefined;
1393 titleColor?: ColorValue | undefined;
1394 disableBackButtonMenu?: boolean | undefined;
1395 backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>;
1396+ navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>;
1397 hideBackButton?: boolean | undefined;
1398 backButtonInCustomView?: boolean | undefined;
1399 blurEffect?: CT.WithDefault<BlurEffect, 'none'>;
1400@@ -74,6 +78,8 @@ export interface NativeProps extends ViewProps {
1401 topInsetEnabled?: boolean | undefined;
1402 headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined;
1403 headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined;
1404+ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined;
1405+ headerToolbarItems?: CT.UnsafeMixed[] | undefined;
1406 onPressHeaderBarButtonItem?:
1407 | CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent>
1408 | undefined;
1409diff --git a/src/types.tsx b/src/types.tsx
1410index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e131d21242b 100644
1411--- a/src/types.tsx
1412+++ b/src/types.tsx
1413@@ -26,6 +26,7 @@ export type SearchBarCommands = {
1414 };
1415
1416 export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal';
1417+export type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
1418 export type StackPresentationTypes =
1419 | 'push'
1420 | 'modal'
1421@@ -735,6 +736,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
1422 * @platform ios
1423 */
1424 backButtonDisplayMode?: BackButtonDisplayMode | undefined;
1425+ /**
1426+ * Controls the UIKit `UINavigationItem.style` used by native iOS headers.
1427+ *
1428+ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls.
1429+ *
1430+ * @platform ios
1431+ */
1432+ navigationItemStyle?: NavigationItemStyle | undefined;
1433 /**
1434 * Array of UIBarButtomItems to the left side of the header.
1435 *
1436@@ -747,6 +756,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
1437 * @platform ios
1438 */
1439 headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined;
1440+ /**
1441+ * Array of UIBarButtonItems to the centered item group in modern iOS headers.
1442+ *
1443+ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets
1444+ * document/editor-style chrome keep primary actions visually centered while
1445+ * leaving trailing search separate.
1446+ *
1447+ * @platform ios
1448+ */
1449+ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined;
1450+ /**
1451+ * Array of UIBarButtonItems to display in the native toolbar attached to
1452+ * the current screen's navigation controller.
1453+ *
1454+ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit
1455+ * render integrated Mail-style search chrome using the attached SearchBar.
1456+ *
1457+ * @platform ios
1458+ */
1459+ headerToolbarItems?: HeaderBarButtonItem[] | undefined;
1460 /**
1461 * When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false.
1462 */
1463@@ -808,6 +837,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
1464 * String that can be displayed in the header as a fallback for `headerTitle`.
1465 */
1466 title?: string | undefined;
1467+ /**
1468+ * String displayed below the compact navigation title on iOS 26+.
1469+ *
1470+ * @platform ios
1471+ */
1472+ subtitle?: string | undefined;
1473+ /**
1474+ * String displayed below the large navigation title on iOS 26+.
1475+ *
1476+ * Falls back to `subtitle` when omitted.
1477+ *
1478+ * @platform ios
1479+ */
1480+ largeSubtitle?: string | undefined;
1481 /**
1482 * Allows for setting text color of the title.
1483 */
1484@@ -1125,6 +1168,11 @@ interface SharedHeaderBarButtonItem {
1485 * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property
1486 */
1487 variant?: 'plain' | 'done' | 'prominent' | undefined;
1488+ /**
1489+ * Render the item with UIButtonConfiguration.glassButtonConfiguration.
1490+ * Only available from iOS 26.0 and later.
1491+ */
1492+ glassEffect?: boolean | undefined;
1493 /**
1494 * The tint color to apply to the item.
1495 *
1496@@ -1279,11 +1327,46 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
1497 export interface HeaderBarButtonItemSpacing {
1498 type: 'spacing';
1499 spacing: number;
1500+ flexible?: boolean | undefined;
1501+}
1502+
1503+export interface HeaderBarButtonSearchBarPlacementItem {
1504+ type: 'searchBarPlacement';
1505+ activatesSearchController?: boolean | undefined;
1506+ customView?: boolean | undefined;
1507+ index?: number | undefined;
1508+ width?: number | undefined;
1509+}
1510+
1511+export interface HeaderBarButtonSearchFieldItem {
1512+ type: 'searchField';
1513+ placeholder?: string | undefined;
1514+ width?: number | undefined;
1515+}
1516+
1517+export interface HeaderBarButtonMailSearchToolbarItem {
1518+ type: 'mailSearchToolbar';
1519+ composeButtonId?: string | undefined;
1520+ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
1521+ composeSystemImageName?: string | undefined;
1522+ filterButtonId?: string | undefined;
1523+ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
1524+ filterSystemImageName?: string | undefined;
1525+ onComposePress?: (() => void) | undefined;
1526+ onFilterPress?: (() => void) | undefined;
1527+ onSearchTextChange?: ((text: string) => void) | undefined;
1528+ placeholder?: string | undefined;
1529+ searchTextChangeId?: string | undefined;
1530+ useFallbackSearchField?: boolean | undefined;
1531+ width?: number | undefined;
1532 }
1533
1534 export type HeaderBarButtonItem =
1535 | HeaderBarButtonItemWithAction
1536 | HeaderBarButtonItemWithMenu
1537+ | HeaderBarButtonMailSearchToolbarItem
1538+ | HeaderBarButtonSearchFieldItem
1539+ | HeaderBarButtonSearchBarPlacementItem
1540 | HeaderBarButtonItemSpacing;
1541
1542 /**
pnpm-lock.yaml+742-502
...@@ -35,19 +35,19 @@ catalogs:...@@ -35,19 +35,19 @@ catalogs:
35 version: 0.2.135 version: 0.2.1
3636
37overrides:37overrides:
38 '@clerk/backend': 3.8.3-snapshot.v2026062223415138 '@clerk/backend': 3.8.4
39 '@clerk/clerk-js': 6.21.0-snapshot.v2026062223415139 '@clerk/clerk-js': 6.22.0
40 '@clerk/electron': 0.0.2-snapshot.v20260622234151
41 '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151
42 '@clerk/expo': 3.5.3-snapshot.v20260622234151
43 '@clerk/react': 6.11.0-snapshot.v20260622234151
44 '@clerk/shared': 4.21.0-snapshot.v20260622234151
45 '@clerk/clerk-js>@base-org/account': '-'40 '@clerk/clerk-js>@base-org/account': '-'
46 '@clerk/clerk-js>@coinbase/wallet-sdk': '-'41 '@clerk/clerk-js>@coinbase/wallet-sdk': '-'
47 '@clerk/clerk-js>@solana/wallet-adapter-base': '-'42 '@clerk/clerk-js>@solana/wallet-adapter-base': '-'
48 '@clerk/clerk-js>@solana/wallet-adapter-react': '-'43 '@clerk/clerk-js>@solana/wallet-adapter-react': '-'
49 '@clerk/clerk-js>@solana/wallet-standard': '-'44 '@clerk/clerk-js>@solana/wallet-standard': '-'
50 '@clerk/clerk-js>@wallet-standard/core': '-'45 '@clerk/clerk-js>@wallet-standard/core': '-'
46 '@clerk/electron': 0.0.5
47 '@clerk/electron-passkeys': 0.0.3
48 '@clerk/expo': 3.6.2
49 '@clerk/react': 6.11.1
50 '@clerk/shared': 4.22.0
51 '@effect/atom-react': 4.0.0-beta.7851 '@effect/atom-react': 4.0.0-beta.78
52 '@effect/platform-bun': 4.0.0-beta.7852 '@effect/platform-bun': 4.0.0-beta.78
53 '@effect/platform-node': 4.0.0-beta.7853 '@effect/platform-node': 4.0.0-beta.78
...@@ -56,7 +56,7 @@ overrides:...@@ -56,7 +56,7 @@ overrides:
56 '@effect/sql-sqlite-bun': 4.0.0-beta.7856 '@effect/sql-sqlite-bun': 4.0.0-beta.78
57 '@effect/vitest': 4.0.0-beta.7857 '@effect/vitest': 4.0.0-beta.78
58 '@effect/vitest>vitest': '-'58 '@effect/vitest>vitest': '-'
59 '@expo/metro-config': 56.0.1359 '@expo/metro-config': 56.0.14
60 '@pierre/diffs>@shikijs/transformers': ^4.2.060 '@pierre/diffs>@shikijs/transformers': ^4.2.0
61 '@types/node': 24.12.461 '@types/node': 24.12.4
62 effect: 4.0.0-beta.7862 effect: 4.0.0-beta.78
...@@ -69,21 +69,36 @@ patchedDependencies:...@@ -69,21 +69,36 @@ patchedDependencies:
69 '@effect/vitest@4.0.0-beta.78':69 '@effect/vitest@4.0.0-beta.78':
70 hash: 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f70 hash: 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f
71 path: patches/@effect__vitest@4.0.0-beta.78.patch71 path: patches/@effect__vitest@4.0.0-beta.78.patch
72 '@expo/metro-config@56.0.13':72 '@expo/metro-config@56.0.14':
73 hash: 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff4673 hash: 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46
74 path: patches/@expo%2Fmetro-config@56.0.13.patch74 path: patches/@expo%2Fmetro-config@56.0.14.patch
75 '@ff-labs/fff-node@0.9.4':75 '@ff-labs/fff-node@0.9.4':
76 hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c876 hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8
77 path: patches/@ff-labs__fff-node@0.9.4.patch77 path: patches/@ff-labs__fff-node@0.9.4.patch
78 '@pierre/diffs@1.3.0-beta.5':78 '@pierre/diffs@1.3.0-beta.5':
79 hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a79 hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a
80 path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch80 path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch
81 '@react-native-menu/menu@2.0.0':
82 hash: 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae
83 path: patches/@react-native-menu__menu@2.0.0.patch
84 '@react-navigation/native-stack@7.17.6':
85 hash: 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca
86 path: patches/@react-navigation%2Fnative-stack@7.17.6.patch
81 effect@4.0.0-beta.78:87 effect@4.0.0-beta.78:
82 hash: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c588 hash: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5
83 path: patches/effect@4.0.0-beta.78.patch89 path: patches/effect@4.0.0-beta.78.patch
90 expo-modules-jsi@56.0.10:
91 hash: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f
92 path: patches/expo-modules-jsi@56.0.10.patch
93 react-native-gesture-handler@2.31.2:
94 hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
95 path: patches/react-native-gesture-handler@2.31.2.patch
84 react-native-nitro-modules@0.35.9:96 react-native-nitro-modules@0.35.9:
85 hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b608842267597 hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
86 path: patches/react-native-nitro-modules@0.35.9.patch98 path: patches/react-native-nitro-modules@0.35.9.patch
99 react-native-screens@4.25.2:
100 hash: e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc
101 path: patches/react-native-screens@4.25.2.patch
87102
88importers:103importers:
89104
...@@ -111,11 +126,11 @@ importers:...@@ -111,11 +126,11 @@ importers:
111 apps/desktop:126 apps/desktop:
112 dependencies:127 dependencies:
113 '@clerk/electron':128 '@clerk/electron':
114 specifier: 0.0.2-snapshot.v20260622234151129 specifier: 0.0.5
115 version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)130 version: 0.0.5(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
116 '@clerk/electron-passkeys':131 '@clerk/electron-passkeys':
117 specifier: 0.0.2-snapshot.v20260622234151132 specifier: 0.0.3
118 version: 0.0.2-snapshot.v20260622234151133 version: 0.0.3
119 '@effect/platform-node':134 '@effect/platform-node':
120 specifier: 4.0.0-beta.78135 specifier: 4.0.0-beta.78
121 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6)136 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6)
...@@ -192,22 +207,25 @@ importers:...@@ -192,22 +207,25 @@ importers:
192 dependencies:207 dependencies:
193 '@callstack/liquid-glass':208 '@callstack/liquid-glass':
194 specifier: ^0.7.1209 specifier: ^0.7.1
195 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)210 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
196 '@clerk/expo':211 '@clerk/expo':
197 specifier: 3.5.3-snapshot.v20260622234151212 specifier: 3.6.2
198 version: 3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)))(expo@56.0.8)(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)213 version: 3.6.2(expo-auth-session@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
199 '@effect/atom-react':214 '@effect/atom-react':
200 specifier: 4.0.0-beta.78215 specifier: 4.0.0-beta.78
201 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0)216 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0)
202 '@expo-google-fonts/dm-sans':217 '@expo-google-fonts/dm-sans':
203 specifier: ^0.4.2218 specifier: ^0.4.2
204 version: 0.4.2219 version: 0.4.2
220 '@expo/metro-runtime':
221 specifier: ~56.0.15
222 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
205 '@expo/ui':223 '@expo/ui':
206 specifier: ~56.0.8224 specifier: ~56.0.18
207 version: 56.0.15(961c4aa6f32829b318e3c87ef20ad401)225 version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3)
208 '@legendapp/list':226 '@legendapp/list':
209 specifier: 3.2.0227 specifier: 3.2.0
210 version: 3.2.0(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)228 version: 3.2.0(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
211 '@noble/curves':229 '@noble/curves':
212 specifier: 'catalog:'230 specifier: 'catalog:'
213 version: 1.9.1231 version: 1.9.1
...@@ -219,7 +237,16 @@ importers:...@@ -219,7 +237,16 @@ importers:
219 version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)237 version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
220 '@react-native-menu/menu':238 '@react-native-menu/menu':
221 specifier: ^2.0.0239 specifier: ^2.0.0
222 version: 2.0.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)240 version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
241 '@react-navigation/elements':
242 specifier: 2.9.26
243 version: 2.9.26(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(@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))(@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-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))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
244 '@react-navigation/native':
245 specifier: 7.3.4
246 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
247 '@react-navigation/native-stack':
248 specifier: 7.17.6
249 version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(889fbbb381dfeb79ac64b7284ebf7fa4)
223 '@shikijs/core':250 '@shikijs/core':
224 specifier: 4.2.0251 specifier: 4.2.0
225 version: 4.2.0252 version: 4.2.0
...@@ -240,7 +267,7 @@ importers:...@@ -240,7 +267,7 @@ importers:
240 version: link:../../packages/contracts267 version: link:../../packages/contracts
241 '@t3tools/mobile-markdown-text':268 '@t3tools/mobile-markdown-text':
242 specifier: file:./modules/t3-markdown-text269 specifier: file:./modules/t3-markdown-text
243 version: file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578)270 version: file:apps/mobile/modules/t3-markdown-text(a6e4bd1e9376530ea93dbb7d8a53d32d)
244 '@t3tools/mobile-review-diff-native':271 '@t3tools/mobile-review-diff-native':
245 specifier: file:./modules/t3-review-diff272 specifier: file:./modules/t3-review-diff
246 version: file:apps/mobile/modules/t3-review-diff273 version: file:apps/mobile/modules/t3-review-diff
...@@ -260,80 +287,77 @@ importers:...@@ -260,80 +287,77 @@ importers:
260 specifier: 4.0.0-beta.78287 specifier: 4.0.0-beta.78
261 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)288 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
262 expo:289 expo:
263 specifier: ^56.0.0290 specifier: ~56.0.12
264 version: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)291 version: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
265 expo-asset:292 expo-asset:
266 specifier: ~56.0.15293 specifier: ~56.0.17
267 version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)(typescript@6.0.3)294 version: 56.0.17(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
268 expo-auth-session:295 expo-auth-session:
269 specifier: ~56.0.12296 specifier: ~56.0.14
270 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)297 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
271 expo-build-properties:298 expo-build-properties:
272 specifier: ~56.0.15299 specifier: ~56.0.19
273 version: 56.0.16(expo@56.0.8)300 version: 56.0.19(expo@56.0.12)
274 expo-camera:301 expo-camera:
275 specifier: ~56.0.7302 specifier: ~56.0.8
276 version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)303 version: 56.0.8(@types/emscripten@1.41.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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
277 expo-clipboard:304 expo-clipboard:
278 specifier: ~56.0.3305 specifier: ~56.0.4
279 version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)306 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
280 expo-constants:307 expo-constants:
281 specifier: ~56.0.16308 specifier: ~56.0.18
282 version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))309 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
283 expo-crypto:310 expo-crypto:
284 specifier: ~56.0.4311 specifier: ~56.0.4
285 version: 56.0.4(expo@56.0.8)312 version: 56.0.4(expo@56.0.12)
286 expo-dev-client:313 expo-dev-client:
287 specifier: ~56.0.16314 specifier: ~56.0.20
288 version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))315 version: 56.0.20(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
289 expo-file-system:316 expo-file-system:
290 specifier: ~56.0.7317 specifier: ~56.0.8
291 version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))318 version: 56.0.8(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
292 expo-font:319 expo-font:
293 specifier: ~56.0.5320 specifier: ~56.0.7
294 version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)321 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
295 expo-glass-effect:322 expo-glass-effect:
296 specifier: ~56.0.4323 specifier: ~56.0.4
297 version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)324 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
298 expo-haptics:325 expo-haptics:
299 specifier: ~56.0.3326 specifier: ~56.0.3
300 version: 56.0.3(expo@56.0.8)327 version: 56.0.3(expo@56.0.12)
301 expo-image-picker:328 expo-image-picker:
302 specifier: ~56.0.14329 specifier: ~56.0.18
303 version: 56.0.15(expo@56.0.8)330 version: 56.0.18(expo@56.0.12)
304 expo-linking:331 expo-linking:
305 specifier: ~56.0.12332 specifier: ~56.0.14
306 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)333 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
307 expo-network:334 expo-network:
308 specifier: ~56.0.5335 specifier: ~56.0.5
309 version: 56.0.5(expo@56.0.8)(react@19.2.3)336 version: 56.0.5(expo@56.0.12)(react@19.2.3)
310 expo-notifications:337 expo-notifications:
311 specifier: ~56.0.14338 specifier: ~56.0.18
312 version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)(typescript@6.0.3)339 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
313 expo-paste-input:340 expo-paste-input:
314 specifier: ^0.1.15341 specifier: ^0.1.15
315 version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)342 version: 0.1.15(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
316 expo-router:
317 specifier: ~56.2.7
318 version: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310)
319 expo-secure-store:343 expo-secure-store:
320 specifier: ~56.0.4344 specifier: ~56.0.4
321 version: 56.0.4(expo@56.0.8)345 version: 56.0.4(expo@56.0.12)
322 expo-splash-screen:346 expo-splash-screen:
323 specifier: ~56.0.10347 specifier: ~56.0.10
324 version: 56.0.10(expo@56.0.8)(typescript@6.0.3)348 version: 56.0.10(expo@56.0.12)(typescript@6.0.3)
325 expo-symbols:349 expo-symbols:
326 specifier: ~56.0.5350 specifier: ~56.0.6
327 version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)351 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
328 expo-updates:352 expo-updates:
329 specifier: ~56.0.17353 specifier: ~56.0.19
330 version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)354 version: 56.0.19(expo-dev-client@56.0.20(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
331 expo-web-browser:355 expo-web-browser:
332 specifier: ~56.0.5356 specifier: ~56.0.5
333 version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))357 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
334 expo-widgets:358 expo-widgets:
335 specifier: ~56.0.15359 specifier: ~56.0.19
336 version: 56.0.16(961c4aa6f32829b318e3c87ef20ad401)360 version: 56.0.19(19413efe5eaad64848598eedfe3a0fd3)
337 punycode:361 punycode:
338 specifier: ^2.3.1362 specifier: ^2.3.1
339 version: 2.3.1363 version: 2.3.1
...@@ -345,43 +369,43 @@ importers:...@@ -345,43 +369,43 @@ importers:
345 version: 19.2.3(react@19.2.3)369 version: 19.2.3(react@19.2.3)
346 react-native:370 react-native:
347 specifier: 0.85.3371 specifier: 0.85.3
348 version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)372 version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
349 react-native-gesture-handler:373 react-native-gesture-handler:
350 specifier: ~2.31.1374 specifier: ~2.31.1
351 version: 2.31.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)375 version: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
352 react-native-image-viewing:376 react-native-image-viewing:
353 specifier: ^0.2.2377 specifier: ^0.2.2
354 version: 0.2.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)378 version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
355 react-native-keyboard-controller:379 react-native-keyboard-controller:
356 specifier: 1.21.7380 specifier: 1.21.6
357 version: 1.21.7(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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)381 version: 1.21.6(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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
358 react-native-nitro-markdown:382 react-native-nitro-markdown:
359 specifier: ^0.5.0383 specifier: ^0.5.0
360 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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-svg@15.15.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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)384 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
361 react-native-nitro-modules:385 react-native-nitro-modules:
362 specifier: 0.35.9386 specifier: 0.35.9
363 version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)387 version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
364 react-native-reanimated:388 react-native-reanimated:
365 specifier: 4.3.1389 specifier: 4.3.1
366 version: 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)390 version: 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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
367 react-native-safe-area-context:391 react-native-safe-area-context:
368 specifier: ~5.7.0392 specifier: ~5.7.0
369 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)393 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
370 react-native-screens:394 react-native-screens:
371 specifier: 4.25.2395 specifier: 4.25.2
372 version: 4.25.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)396 version: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
373 react-native-shiki-engine:397 react-native-shiki-engine:
374 specifier: ^0.3.12398 specifier: ^0.3.12
375 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)399 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
376 react-native-svg:400 react-native-svg:
377 specifier: 15.15.4401 specifier: 15.15.4
378 version: 15.15.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)402 version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
379 react-native-webview:403 react-native-webview:
380 specifier: ^13.16.1404 specifier: ^13.16.1
381 version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
382 react-native-worklets:406 react-native-worklets:
383 specifier: 0.8.3407 specifier: 0.8.3
384 version: 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)408 version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
385 shiki:409 shiki:
386 specifier: 4.2.0410 specifier: 4.2.0
387 version: 4.2.0411 version: 4.2.0
...@@ -390,7 +414,7 @@ importers:...@@ -390,7 +414,7 @@ importers:
390 version: 3.6.0414 version: 3.6.0
391 uniwind:415 uniwind:
392 specifier: ^1.6.2416 specifier: ^1.6.2
393 version: 1.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)(tailwindcss@4.3.0)417 version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0)
394 devDependencies:418 devDependencies:
395 '@effect/vitest':419 '@effect/vitest':
396 specifier: 4.0.0-beta.78420 specifier: 4.0.0-beta.78
...@@ -403,7 +427,7 @@ importers:...@@ -403,7 +427,7 @@ importers:
403 version: 19.2.16427 version: 19.2.16
404 babel-preset-expo:428 babel-preset-expo:
405 specifier: ~56.0.0429 specifier: ~56.0.0
406 version: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2)430 version: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2)
407 tailwindcss:431 tailwindcss:
408 specifier: ^4.0.0432 specifier: ^4.0.0
409 version: 4.3.0433 version: 4.3.0
...@@ -481,11 +505,11 @@ importers:...@@ -481,11 +505,11 @@ importers:
481 specifier: ^1.4.1505 specifier: ^1.4.1
482 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)506 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
483 '@clerk/electron':507 '@clerk/electron':
484 specifier: 0.0.2-snapshot.v20260622234151508 specifier: 0.0.5
485 version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)509 version: 0.0.5(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
486 '@clerk/react':510 '@clerk/react':
487 specifier: 6.11.0-snapshot.v20260622234151511 specifier: 6.11.1
488 version: 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)512 version: 6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
489 '@dnd-kit/core':513 '@dnd-kit/core':
490 specifier: ^6.3.1514 specifier: ^6.3.1
491 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)515 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
...@@ -635,8 +659,8 @@ importers:...@@ -635,8 +659,8 @@ importers:
635 infra/relay:659 infra/relay:
636 dependencies:660 dependencies:
637 '@clerk/backend':661 '@clerk/backend':
638 specifier: 3.8.3-snapshot.v20260622234151662 specifier: 3.8.4
639 version: 3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)663 version: 3.8.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
640 '@effect/sql-pg':664 '@effect/sql-pg':
641 specifier: 4.0.0-beta.78665 specifier: 4.0.0-beta.78
642 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))666 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
...@@ -1650,43 +1674,43 @@ packages:...@@ -1650,43 +1674,43 @@ packages:
1650 resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==}1674 resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==}
1651 engines: {node: '>= 20.12.0'}1675 engines: {node: '>= 20.12.0'}
16521676
1653 '@clerk/backend@3.8.3-snapshot.v20260622234151':1677 '@clerk/backend@3.8.4':
1654 resolution: {integrity: sha512-B5goX0n/5pibc4dMQOfMmn4mPq7eqtrbNV20tOqO9qMPzFA+TKAj9SnB0oJvFsZAXKO6+/n16uYPbqeM2PN6CA==}1678 resolution: {integrity: sha512-3G+kEu8kalqQokJ/n0IynhBh2i6TtiilRzuAg5UWMburkK658/elrG0Uqsapd5yKhmkNuvizHwOPWwKAMxP2EQ==}
1655 engines: {node: '>=20.9.0'}1679 engines: {node: '>=20.9.0'}
16561680
1657 '@clerk/clerk-js@6.21.0-snapshot.v20260622234151':1681 '@clerk/clerk-js@6.22.0':
1658 resolution: {integrity: sha512-mRNn6H8GbeEkcCIzZ03WZ9c1Uy8znf70okYmmeJKzK72gsdwnrxEfbu3DYE/5yRbX6lvL7ugWaNTT3DvPEbBzg==}1682 resolution: {integrity: sha512-4kN1J1GHUv7utZn1UunfOi10ZVyHOVY+d7tMGXoBxrckpb4vT/rFYep8uf8Xc6Q5QNEmi1/J0DMxZqnS3xy+ww==}
1659 engines: {node: '>=20.9.0'}1683 engines: {node: '>=20.9.0'}
16601684
1661 '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151':1685 '@clerk/electron-passkeys-darwin-arm64@0.0.3':
1662 resolution: {integrity: sha512-NZjIVGitAf0yyQvs1WXIAYOle9RjGsExpfwje2U20POTnNueFy56M0g39UTFQXJ42saTZ0zauLD8sd0cHqpOjA==}1686 resolution: {integrity: sha512-oAlrd+GLqP0oREP3hNRc6yNZhXGaQgJss9iCWRQqXjpw1yZD1ZU+L+E2Gfg84vMj/NSUhxbU0Djy0RXhmeUwgg==}
1663 cpu: [arm64]1687 cpu: [arm64]
1664 os: [darwin]1688 os: [darwin]
16651689
1666 '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151':1690 '@clerk/electron-passkeys-darwin-x64@0.0.3':
1667 resolution: {integrity: sha512-gGAwinVoIxa9lefCJjhf5D6oA8uq1sXQ/JwqffkBPJrvczYyAv9FYuQ1/ihji3jTplC0/bpTuIrwBkBCzwj5ug==}1691 resolution: {integrity: sha512-0UPAWQEni7o8gjWwd2sP97fPshJmT2w5b8eV/jOPVSP8NqIcFCm5yIFSEx1SfB53LGfzB01A4iEGi2pxiCHqyQ==}
1668 cpu: [x64]1692 cpu: [x64]
1669 os: [darwin]1693 os: [darwin]
16701694
1671 '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151':1695 '@clerk/electron-passkeys-win32-arm64-msvc@0.0.3':
1672 resolution: {integrity: sha512-anBFktMTAF7of37nLQsqQuW489w563J75l4QolWtblKbrBjlwFsEif95uj3vlQj0qWVVRWpAcxD21oD6wdJcwQ==}1696 resolution: {integrity: sha512-bbRvifm9A/gfPkwC1UyMyF9sYGEs0aen7J2kCcaOIW3K0PXcAbHtsR/gnx4dT0bjFdd21vSa2zlIdERvHc1BiA==}
1673 cpu: [arm64]1697 cpu: [arm64]
1674 os: [win32]1698 os: [win32]
16751699
1676 '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151':1700 '@clerk/electron-passkeys-win32-x64-msvc@0.0.3':
1677 resolution: {integrity: sha512-Pq4FOklTJNpyMTJpxY+/gq6CukvWHFJfGYjuz985cPHup1OSsVmuO/GYil36vh7Qbe8vDivT91wNe73DbEOcVw==}1701 resolution: {integrity: sha512-gFaVqKlOKTF2SJ3lMwkZ0oonycQn93p1qSrp5kzzeZBeQ+tGB3gQnjXecrhQy21AzDUyWqVJJ8KrP2HRAwifWg==}
1678 cpu: [x64]1702 cpu: [x64]
1679 os: [win32]1703 os: [win32]
16801704
1681 '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151':1705 '@clerk/electron-passkeys@0.0.3':
1682 resolution: {integrity: sha512-UvBweTg9+FCbygxARV7RvJ4/lcQgLN+gJC6Lj++cLmbxpgRwRAgR9xXRlKV6dVg1p5k81a4DcFNMu/oR6zBdlQ==}1706 resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==}
1683 engines: {node: '>=20.9.0'}1707 engines: {node: '>=20.9.0'}
16841708
1685 '@clerk/electron@0.0.2-snapshot.v20260622234151':1709 '@clerk/electron@0.0.5':
1686 resolution: {integrity: sha512-T0LUJeAPaAZxpk13Q14b9LSVKKio/X/zFo67hgdr35MaOChsn4T6lQ/rEL7laScQBduskcs1yyjr3e9ndy5ojg==}1710 resolution: {integrity: sha512-usPkmcKsSBuqs9zUctVZLlde3Fhu+pYJ+8fHZPL7aPYfupIe0qvsWOgJIb6GXSrEshcBg104MdSzuVT2w7Dzxg==}
1687 engines: {node: '>=20.9.0'}1711 engines: {node: '>=20.9.0'}
1688 peerDependencies:1712 peerDependencies:
1689 '@clerk/electron-passkeys': 0.0.2-snapshot.v202606222341511713 '@clerk/electron-passkeys': 0.0.3
1690 electron: '>=28'1714 electron: '>=28'
1691 electron-store: ^8.2.01715 electron-store: ^8.2.0
1692 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-01716 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
...@@ -1699,11 +1723,11 @@ packages:...@@ -1699,11 +1723,11 @@ packages:
1699 react-dom:1723 react-dom:
1700 optional: true1724 optional: true
17011725
1702 '@clerk/expo@3.5.3-snapshot.v20260622234151':1726 '@clerk/expo@3.6.2':
1703 resolution: {integrity: sha512-qeKTJYA7cTe5oCmRVCT4A2QEPRp2af0ox0VdXzIhWRqRVFNF5bnsZRNKE2e4QnsC16NYLsGHLR5uQGC15qBiug==}1727 resolution: {integrity: sha512-8h4j62YLEtKlAq6eptfwB6jauOy7QQA+lMqRCY00n9PL1oQG7Y+sTGDg6ylX66O/VG2YOsqnBdlZcNUgt3xd2A==}
1704 engines: {node: '>=20.9.0'}1728 engines: {node: '>=20.9.0'}
1705 peerDependencies:1729 peerDependencies:
1706 '@clerk/expo-passkeys': 1.1.9-snapshot.v202606222341511730 '@clerk/expo-passkeys': '>=0.0.6'
1707 expo: '>=53 <57'1731 expo: '>=53 <57'
1708 expo-apple-authentication: '>=7.0.0'1732 expo-apple-authentication: '>=7.0.0'
1709 expo-auth-session: '>=5'1733 expo-auth-session: '>=5'
...@@ -1735,15 +1759,15 @@ packages:...@@ -1735,15 +1759,15 @@ packages:
1735 react-dom:1759 react-dom:
1736 optional: true1760 optional: true
17371761
1738 '@clerk/react@6.11.0-snapshot.v20260622234151':1762 '@clerk/react@6.11.1':
1739 resolution: {integrity: sha512-yF4jQFJqEHAqZCpOtjQ/Kg9yqZlEG6vW2vmVR5kVwgESkpOR1KPJIEMU8o3b6W86RKQai4lt3CWtY+o7CJsDyw==}1763 resolution: {integrity: sha512-iEWckisZa/V3siCFlvGTNg7Yj+kndRx7HKBZ/QMSd6uEU9kd2tZ8Ymvd4GFPCpClwcki4h7jC750FujN656Jqg==}
1740 engines: {node: '>=20.9.0'}1764 engines: {node: '>=20.9.0'}
1741 peerDependencies:1765 peerDependencies:
1742 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-01766 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
1743 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-01767 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
17441768
1745 '@clerk/shared@4.21.0-snapshot.v20260622234151':1769 '@clerk/shared@4.22.0':
1746 resolution: {integrity: sha512-OYu+hO0GHiHwYTwSFe/GCHmuQlyTHyNa7fJ8vrmtUyML09mf+dayRFeUnxgkwOYhGriyq40t1zxdVx53Td73xg==}1770 resolution: {integrity: sha512-GZ56kzUB2UBb8MCF+Eo8WIey+W4RP1tAkyOL+geiHxrQxJlUcgD+xalY2YPJLH8WVFwtOnfIl8KPEo0M0e/DRg==}
1747 engines: {node: '>=20.9.0'}1771 engines: {node: '>=20.9.0'}
1748 peerDependencies:1772 peerDependencies:
1749 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-01773 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
...@@ -2387,8 +2411,8 @@ packages:...@@ -2387,8 +2411,8 @@ packages:
2387 '@expo-google-fonts/material-symbols@0.4.38':2411 '@expo-google-fonts/material-symbols@0.4.38':
2388 resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==}2412 resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==}
23892413
2390 '@expo/cli@56.1.13':2414 '@expo/cli@56.1.16':
2391 resolution: {integrity: sha512-7n5VzlBr7TKW0BgWgpEopWy+v8buPhMvbSEsuXD+bI1YIJBopkfWAub0qTvlc357E8wWOvV5MJXYyoeRvoOjoQ==}2415 resolution: {integrity: sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==}
2392 hasBin: true2416 hasBin: true
2393 peerDependencies:2417 peerDependencies:
2394 expo: '*'2418 expo: '*'
...@@ -2406,9 +2430,15 @@ packages:...@@ -2406,9 +2430,15 @@ packages:
2406 '@expo/config-plugins@56.0.8':2430 '@expo/config-plugins@56.0.8':
2407 resolution: {integrity: sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==}2431 resolution: {integrity: sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==}
24082432
2433 '@expo/config-plugins@56.0.9':
2434 resolution: {integrity: sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==}
2435
2409 '@expo/config-types@56.0.5':2436 '@expo/config-types@56.0.5':
2410 resolution: {integrity: sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==}2437 resolution: {integrity: sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==}
24112438
2439 '@expo/config-types@56.0.6':
2440 resolution: {integrity: sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==}
2441
2412 '@expo/config@56.0.9':2442 '@expo/config@56.0.9':
2413 resolution: {integrity: sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==}2443 resolution: {integrity: sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==}
24142444
...@@ -2437,18 +2467,18 @@ packages:...@@ -2437,18 +2467,18 @@ packages:
2437 resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==}2467 resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==}
2438 engines: {node: '>=20.12.0'}2468 engines: {node: '>=20.12.0'}
24392469
2440 '@expo/expo-modules-macros-plugin@0.0.9':2470 '@expo/expo-modules-macros-plugin@0.2.2':
2441 resolution: {integrity: sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA==}2471 resolution: {integrity: sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg==}
24422472
2443 '@expo/fingerprint@0.19.3':2473 '@expo/fingerprint@0.19.4':
2444 resolution: {integrity: sha512-w9Au2IVrtc0Ct+WRa05DVHGNHXYq6VyPZWuFP+5x055OeZ5q6k5Yg+aJ1gfShmjdOhDftRcsvmWmTdTZlWaTZw==}2474 resolution: {integrity: sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A==}
2445 hasBin: true2475 hasBin: true
24462476
2447 '@expo/image-utils@0.10.1':2477 '@expo/image-utils@0.10.1':
2448 resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==}2478 resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==}
24492479
2450 '@expo/inline-modules@0.0.10':2480 '@expo/inline-modules@0.0.12':
2451 resolution: {integrity: sha512-DKEfq877UTAmL/gOT5aFhlLNDg/EVmTSca7JQMKDGR6mjFSAcrmQf4GJNsi6ztiaqj6cTnIfoSz0hAYdnr6RJQ==}2481 resolution: {integrity: sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==}
24522482
2453 '@expo/json-file@10.2.0':2483 '@expo/json-file@10.2.0':
2454 resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==}2484 resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==}
...@@ -2456,16 +2486,16 @@ packages:...@@ -2456,16 +2486,16 @@ packages:
2456 '@expo/local-build-cache-provider@56.0.8':2486 '@expo/local-build-cache-provider@56.0.8':
2457 resolution: {integrity: sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==}2487 resolution: {integrity: sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==}
24582488
2459 '@expo/log-box@56.0.12':2489 '@expo/log-box@56.0.13':
2460 resolution: {integrity: sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ==}2490 resolution: {integrity: sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ==}
2461 peerDependencies:2491 peerDependencies:
2462 '@expo/dom-webview': ^56.0.52492 '@expo/dom-webview': ^56.0.5
2463 expo: '*'2493 expo: '*'
2464 react: '*'2494 react: '*'
2465 react-native: '*'2495 react-native: '*'
24662496
2467 '@expo/metro-config@56.0.13':2497 '@expo/metro-config@56.0.14':
2468 resolution: {integrity: sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA==}2498 resolution: {integrity: sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA==}
2469 peerDependencies:2499 peerDependencies:
2470 expo: '*'2500 expo: '*'
2471 peerDependenciesMeta:2501 peerDependenciesMeta:
...@@ -2475,10 +2505,10 @@ packages:...@@ -2475,10 +2505,10 @@ packages:
2475 '@expo/metro-file-map@56.0.3':2505 '@expo/metro-file-map@56.0.3':
2476 resolution: {integrity: sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==}2506 resolution: {integrity: sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==}
24772507
2478 '@expo/metro-runtime@56.0.13':2508 '@expo/metro-runtime@56.0.15':
2479 resolution: {integrity: sha512-aMaFa/RPYm2iQoyYOB5q8AxDmWvf4E2yFbZ6rmBIQWaIPDdixGVUlLQeV8DlDAfZ/j+pNYO7l5M+774WbgkTgg==}2509 resolution: {integrity: sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==}
2480 peerDependencies:2510 peerDependencies:
2481 '@expo/log-box': ^56.0.122511 '@expo/log-box': ^56.0.13
2482 expo: '*'2512 expo: '*'
2483 react: '*'2513 react: '*'
2484 react-dom: '*'2514 react-dom: '*'
...@@ -2500,8 +2530,8 @@ packages:...@@ -2500,8 +2530,8 @@ packages:
2500 '@expo/plist@0.7.0':2530 '@expo/plist@0.7.0':
2501 resolution: {integrity: sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==}2531 resolution: {integrity: sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==}
25022532
2503 '@expo/prebuild-config@56.0.14':2533 '@expo/prebuild-config@56.0.16':
2504 resolution: {integrity: sha512-JHdMqR7Mf5ApLC50ZwTL0Z86ezrHOMYwoSHcWT6Pha/+1TcC+/J+i7vjhP06wGXQ2Kvjt74p/3mKg2Pd12KjhQ==}2534 resolution: {integrity: sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==}
25052535
2506 '@expo/require-utils@56.1.3':2536 '@expo/require-utils@56.1.3':
2507 resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==}2537 resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==}
...@@ -2511,15 +2541,15 @@ packages:...@@ -2511,15 +2541,15 @@ packages:
2511 typescript:2541 typescript:
2512 optional: true2542 optional: true
25132543
2514 '@expo/router-server@56.0.12':2544 '@expo/router-server@56.0.14':
2515 resolution: {integrity: sha512-RqKV2/Z8BH/z8l0ngSpG6//5xxJPaF5dTQvSfPQ0nrvCjikGMeIvyj3B9BeLnmZZhxb3gBtXqrj3irAoiIp2aQ==}2545 resolution: {integrity: sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==}
2516 peerDependencies:2546 peerDependencies:
2517 '@expo/metro-runtime': ^56.0.132547 '@expo/metro-runtime': ^56.0.15
2518 expo: '*'2548 expo: '*'
2519 expo-constants: ^56.0.162549 expo-constants: ^56.0.18
2520 expo-font: ^56.0.52550 expo-font: ^56.0.6
2521 expo-router: '*'2551 expo-router: '*'
2522 expo-server: ^56.0.42552 expo-server: ^56.0.5
2523 react: '*'2553 react: '*'
2524 react-dom: '*'2554 react-dom: '*'
2525 react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.12555 react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1
...@@ -2546,8 +2576,8 @@ packages:...@@ -2546,8 +2576,8 @@ packages:
2546 '@expo/sudo-prompt@9.3.2':2576 '@expo/sudo-prompt@9.3.2':
2547 resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==}2577 resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==}
25482578
2549 '@expo/ui@56.0.15':2579 '@expo/ui@56.0.18':
2550 resolution: {integrity: sha512-PFZBzztQGCp2bRFP8wIOb5ntP2ORH2GdQkJMSJcDOd4NldoWMe1pFqv7PdthjNlaaTHHTTHK+RsQrz+M6z6isw==}2580 resolution: {integrity: sha512-2XgH5obigGtXm8zlb/V3g87NSiIcBcJ1xoQOEQYPoExL1DCNsHzaIecTh1XG/f/45ardo4OZNJwpbfYJ9X3qrQ==}
2551 peerDependencies:2581 peerDependencies:
2552 '@babel/core': '*'2582 '@babel/core': '*'
2553 expo: '*'2583 expo: '*'
...@@ -2566,8 +2596,10 @@ packages:...@@ -2566,8 +2596,10 @@ packages:
2566 react-native-worklets:2596 react-native-worklets:
2567 optional: true2597 optional: true
25682598
2569 '@expo/ws-tunnel@1.0.6':2599 '@expo/ws-tunnel@2.0.0':
2570 resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==}2600 resolution: {integrity: sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==}
2601 peerDependencies:
2602 ws: ^8.0.0
25712603
2572 '@expo/xcpretty@4.4.4':2604 '@expo/xcpretty@4.4.4':
2573 resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==}2605 resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==}
...@@ -3842,6 +3874,41 @@ packages:...@@ -3842,6 +3874,41 @@ packages:
3842 '@types/react':3874 '@types/react':
3843 optional: true3875 optional: true
38443876
3877 '@react-navigation/core@7.21.2':
3878 resolution: {integrity: sha512-EsJhQnsL5NuIhXsWF7URijS5hd2AaJUREdq1fOIowlyNNQBA3jCnkGU0DkW7+eI40cQ+GXH8DQw+ci7TefLIxQ==}
3879 peerDependencies:
3880 react: '>= 18.2.0'
3881
3882 '@react-navigation/elements@2.9.26':
3883 resolution: {integrity: sha512-EaHSJf42MjnH5VFL+KZfQIyqJvdQWK9Z27J7WUSuIVX5NXlR925sPmhWf540DX9gD1ny8BfUGPZAuw/PO4tK2w==}
3884 peerDependencies:
3885 '@react-native-masked-view/masked-view': '>= 0.2.0'
3886 '@react-navigation/native': ^7.3.4
3887 react: '>= 18.2.0'
3888 react-native: '*'
3889 react-native-safe-area-context: '>= 4.0.0'
3890 peerDependenciesMeta:
3891 '@react-native-masked-view/masked-view':
3892 optional: true
3893
3894 '@react-navigation/native-stack@7.17.6':
3895 resolution: {integrity: sha512-CnNPnlSGnrxPyHguDp/xDg5w9STMmIoI6IFZe7cpvEeD0GF/LTzvZ0gmq+hPJTm2/KIrnuPx34emCofzpht45g==}
3896 peerDependencies:
3897 '@react-navigation/native': ^7.3.4
3898 react: '>= 18.2.0'
3899 react-native: '*'
3900 react-native-safe-area-context: '>= 4.0.0'
3901 react-native-screens: '>= 4.0.0'
3902
3903 '@react-navigation/native@7.3.4':
3904 resolution: {integrity: sha512-zyAqFLeZuaakerMMnhYqBeGAzJ+WFQUTgezP3FxSlXNwFq5XxnjP28vi2XHGqm4zg4pGWls9Ay4JKshIHUOpwA==}
3905 peerDependencies:
3906 react: '>= 18.2.0'
3907 react-native: '*'
3908
3909 '@react-navigation/routers@7.6.0':
3910 resolution: {integrity: sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==}
3911
3845 '@rolldown/binding-android-arm64@1.0.0-rc.17':3912 '@rolldown/binding-android-arm64@1.0.0-rc.17':
3846 resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}3913 resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
3847 engines: {node: ^20.19.0 || >=22.12.0}3914 engines: {node: ^20.19.0 || >=22.12.0}
...@@ -5456,6 +5523,21 @@ packages:...@@ -5456,6 +5523,21 @@ packages:
5456 expo-widgets:5523 expo-widgets:
5457 optional: true5524 optional: true
54585525
5526 babel-preset-expo@56.0.15:
5527 resolution: {integrity: sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==}
5528 peerDependencies:
5529 '@babel/runtime': ^7.20.0
5530 expo: '*'
5531 expo-widgets: ^56.0.18
5532 react-refresh: '>=0.14.0 <1.0.0'
5533 peerDependenciesMeta:
5534 '@babel/runtime':
5535 optional: true
5536 expo:
5537 optional: true
5538 expo-widgets:
5539 optional: true
5540
5459 badgin@1.2.3:5541 badgin@1.2.3:
5460 resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==}5542 resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==}
54615543
...@@ -6384,26 +6466,26 @@ packages:...@@ -6384,26 +6466,26 @@ packages:
6384 peerDependencies:6466 peerDependencies:
6385 expo: '*'6467 expo: '*'
63866468
6387 expo-asset@56.0.15:6469 expo-asset@56.0.17:
6388 resolution: {integrity: sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ==}6470 resolution: {integrity: sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg==}
6389 peerDependencies:6471 peerDependencies:
6390 expo: '*'6472 expo: '*'
6391 react: '*'6473 react: '*'
6392 react-native: '*'6474 react-native: '*'
63936475
6394 expo-auth-session@56.0.13:6476 expo-auth-session@56.0.14:
6395 resolution: {integrity: sha512-LR8Suq8BHKRFBUcAKTMmZufCcDcr0sQa8rIYit1r7kshrqAy9glIUU4aqHt8tflW/ISN0x1vU+HU8AQaackM0A==}6477 resolution: {integrity: sha512-b6URDBKXVWBjHwypnbCPW6A3PrwYyFqzLXtTrrpTGpmDlsxk7xuz6wIA77sBNziz3hMxt11Nu70iY0fJZYT4jA==}
6396 peerDependencies:6478 peerDependencies:
6397 react: '*'6479 react: '*'
6398 react-native: '*'6480 react-native: '*'
63996481
6400 expo-build-properties@56.0.16:6482 expo-build-properties@56.0.19:
6401 resolution: {integrity: sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==}6483 resolution: {integrity: sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==}
6402 peerDependencies:6484 peerDependencies:
6403 expo: '*'6485 expo: '*'
64046486
6405 expo-camera@56.0.7:6487 expo-camera@56.0.8:
6406 resolution: {integrity: sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw==}6488 resolution: {integrity: sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==}
6407 peerDependencies:6489 peerDependencies:
6408 expo: '*'6490 expo: '*'
6409 react: '*'6491 react: '*'
...@@ -6413,15 +6495,15 @@ packages:...@@ -6413,15 +6495,15 @@ packages:
6413 react-native-web:6495 react-native-web:
6414 optional: true6496 optional: true
64156497
6416 expo-clipboard@56.0.3:6498 expo-clipboard@56.0.4:
6417 resolution: {integrity: sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg==}6499 resolution: {integrity: sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA==}
6418 peerDependencies:6500 peerDependencies:
6419 expo: '*'6501 expo: '*'
6420 react: '*'6502 react: '*'
6421 react-native: '*'6503 react-native: '*'
64226504
6423 expo-constants@56.0.16:6505 expo-constants@56.0.18:
6424 resolution: {integrity: sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA==}6506 resolution: {integrity: sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==}
6425 peerDependencies:6507 peerDependencies:
6426 expo: '*'6508 expo: '*'
6427 react-native: '*'6509 react-native: '*'
...@@ -6431,13 +6513,13 @@ packages:...@@ -6431,13 +6513,13 @@ packages:
6431 peerDependencies:6513 peerDependencies:
6432 expo: '*'6514 expo: '*'
64336515
6434 expo-dev-client@56.0.18:6516 expo-dev-client@56.0.20:
6435 resolution: {integrity: sha512-pTfDcYTOvrs4vCgAaM+vP2OEO93oGkczgGpTAzCY7ZTIvtPhpekJURHBxfOnKvfn97IF3Hk+8J9tMozsNDj0Gw==}6517 resolution: {integrity: sha512-KebW4r8HhIiRrPzs6ZqVhp/so8buyglAO1h4No0Ibr5C2XRnlIoGWCN4zC6rW7IsI3iKUXcofLAQV9OjoxjiwQ==}
6436 peerDependencies:6518 peerDependencies:
6437 expo: '*'6519 expo: '*'
64386520
6439 expo-dev-launcher@56.0.18:6521 expo-dev-launcher@56.0.20:
6440 resolution: {integrity: sha512-7acFJlkAbp3cMz7Uy787todMR/3A/Row2EOPD21RRoetvzJe4DTm9s7RwJ8PDtyNyued9rooD4+Q6rD8ijpTgw==}6522 resolution: {integrity: sha512-cTuC3GkPl9CTwO3CKnVmEm9qoQ0WairhwvTh6qMlg+zr/QU/tdiU++uDBX67hf9+FuxQOkWGp5khFNosT+0cIg==}
6441 peerDependencies:6523 peerDependencies:
6442 expo: '*'6524 expo: '*'
6443 react-native: '*'6525 react-native: '*'
...@@ -6447,8 +6529,8 @@ packages:...@@ -6447,8 +6529,8 @@ packages:
6447 peerDependencies:6529 peerDependencies:
6448 expo: '*'6530 expo: '*'
64496531
6450 expo-dev-menu@56.0.16:6532 expo-dev-menu@56.0.17:
6451 resolution: {integrity: sha512-aVgoe+YGhrQnpwiB5BRI7G+uQnGHMUij32bBnEVdc6eJrVZCStxQlV9NeFbbXxrDhLJt6OSqbCHbLR+XToWUUA==}6533 resolution: {integrity: sha512-OofRkOOZnaDriSav3JDN4NP2lsLt2eOa/Ryptr5nMD62SwnFyK4R6n6PkPVaDU3LSsZqndAJHmN6inS+oziayQ==}
6452 peerDependencies:6534 peerDependencies:
6453 expo: '*'6535 expo: '*'
6454 react-native: '*'6536 react-native: '*'
...@@ -6456,14 +6538,14 @@ packages:...@@ -6456,14 +6538,14 @@ packages:
6456 expo-eas-client@56.0.1:6538 expo-eas-client@56.0.1:
6457 resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==}6539 resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==}
64586540
6459 expo-file-system@56.0.7:6541 expo-file-system@56.0.8:
6460 resolution: {integrity: sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==}6542 resolution: {integrity: sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==}
6461 peerDependencies:6543 peerDependencies:
6462 expo: '*'6544 expo: '*'
6463 react-native: '*'6545 react-native: '*'
64646546
6465 expo-font@56.0.5:6547 expo-font@56.0.7:
6466 resolution: {integrity: sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw==}6548 resolution: {integrity: sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==}
6467 peerDependencies:6549 peerDependencies:
6468 expo: '*'6550 expo: '*'
6469 react: '*'6551 react: '*'
...@@ -6486,8 +6568,8 @@ packages:...@@ -6486,8 +6568,8 @@ packages:
6486 peerDependencies:6568 peerDependencies:
6487 expo: '*'6569 expo: '*'
64886570
6489 expo-image-picker@56.0.15:6571 expo-image-picker@56.0.18:
6490 resolution: {integrity: sha512-7zmrUcqiNKwGGGL114gC+1aaquhmMbaXCNSHjUcoINdBzvIEVMJDm+DNCxkpftEYeb17X3shqInI4lCgJNVO5g==}6572 resolution: {integrity: sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==}
6491 peerDependencies:6573 peerDependencies:
6492 expo: '*'6574 expo: '*'
64936575
...@@ -6500,8 +6582,8 @@ packages:...@@ -6500,8 +6582,8 @@ packages:
6500 expo: '*'6582 expo: '*'
6501 react: '*'6583 react: '*'
65026584
6503 expo-linking@56.0.13:6585 expo-linking@56.0.14:
6504 resolution: {integrity: sha512-38YrpTh6xdiDxmYSDIUffDqev1hIcEggw2fZ3IZhNp2DVLF1xvqsbO6hJD1fuBKN8P34B3Ggc9Yy26fkqdfCOA==}6586 resolution: {integrity: sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ==}
6505 peerDependencies:6587 peerDependencies:
6506 react: '*'6588 react: '*'
6507 react-native: '*'6589 react-native: '*'
...@@ -6511,12 +6593,12 @@ packages:...@@ -6511,12 +6593,12 @@ packages:
6511 peerDependencies:6593 peerDependencies:
6512 expo: '*'6594 expo: '*'
65136595
6514 expo-modules-autolinking@56.0.14:6596 expo-modules-autolinking@56.0.16:
6515 resolution: {integrity: sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg==}6597 resolution: {integrity: sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw==}
6516 hasBin: true6598 hasBin: true
65176599
6518 expo-modules-core@56.0.14:6600 expo-modules-core@56.0.17:
6519 resolution: {integrity: sha512-dl1TlYRm1k7xk9QeAyDoMfFE2p6rNyzHUcH5ArcGwUzO8YKku+Z2tQ8+kG7zLe3OhfMoJcFR/czrFy7vGSVI6w==}6601 resolution: {integrity: sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ==}
6520 peerDependencies:6602 peerDependencies:
6521 react: '*'6603 react: '*'
6522 react-native: '*'6604 react-native: '*'
...@@ -6525,8 +6607,8 @@ packages:...@@ -6525,8 +6607,8 @@ packages:
6525 react-native-worklets:6607 react-native-worklets:
6526 optional: true6608 optional: true
65276609
6528 expo-modules-jsi@56.0.7:6610 expo-modules-jsi@56.0.10:
6529 resolution: {integrity: sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA==}6611 resolution: {integrity: sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA==}
6530 peerDependencies:6612 peerDependencies:
6531 react-native: '*'6613 react-native: '*'
65326614
...@@ -6536,8 +6618,8 @@ packages:...@@ -6536,8 +6618,8 @@ packages:
6536 expo: '*'6618 expo: '*'
6537 react: '*'6619 react: '*'
65386620
6539 expo-notifications@56.0.15:6621 expo-notifications@56.0.18:
6540 resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==}6622 resolution: {integrity: sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==}
6541 peerDependencies:6623 peerDependencies:
6542 expo: '*'6624 expo: '*'
6543 react: '*'6625 react: '*'
...@@ -6550,15 +6632,15 @@ packages:...@@ -6550,15 +6632,15 @@ packages:
6550 react: '*'6632 react: '*'
6551 react-native: '*'6633 react-native: '*'
65526634
6553 expo-router@56.2.8:6635 expo-router@56.2.11:
6554 resolution: {integrity: sha512-l387I/ddPY/5SS+Rfpp1SrRV9gBKevxtPuZod7igMjR6L674QrxEwGiAILRq6AKCSbrP2I0ufKj7e5xz8JqA4Q==}6636 resolution: {integrity: sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ==}
6555 peerDependencies:6637 peerDependencies:
6556 '@expo/log-box': ^56.0.126638 '@expo/log-box': ^56.0.13
6557 '@expo/metro-runtime': ^56.0.136639 '@expo/metro-runtime': ^56.0.15
6558 '@testing-library/react-native': '>= 13.2.0'6640 '@testing-library/react-native': '>= 13.2.0'
6559 expo: '*'6641 expo: '*'
6560 expo-constants: ^56.0.166642 expo-constants: ^56.0.18
6561 expo-linking: ^56.0.136643 expo-linking: ^56.0.14
6562 react: '*'6644 react: '*'
6563 react-dom: '*'6645 react-dom: '*'
6564 react-native: '*'6646 react-native: '*'
...@@ -6587,8 +6669,8 @@ packages:...@@ -6587,8 +6669,8 @@ packages:
6587 peerDependencies:6669 peerDependencies:
6588 expo: '*'6670 expo: '*'
65896671
6590 expo-server@56.0.4:6672 expo-server@56.0.5:
6591 resolution: {integrity: sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA==}6673 resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==}
6592 engines: {node: '>=20.16.0'}6674 engines: {node: '>=20.16.0'}
65936675
6594 expo-splash-screen@56.0.10:6676 expo-splash-screen@56.0.10:
...@@ -6599,8 +6681,8 @@ packages:...@@ -6599,8 +6681,8 @@ packages:
6599 expo-structured-headers@56.0.0:6681 expo-structured-headers@56.0.0:
6600 resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==}6682 resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==}
66016683
6602 expo-symbols@56.0.5:6684 expo-symbols@56.0.6:
6603 resolution: {integrity: sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw==}6685 resolution: {integrity: sha512-BrA81DjcNafdj7gXVhdrExb9LtUiSVyOf/NavyMmDAHgHMY1GqeR5cnn1PSAZeYKnSgQhee/H89XUpAxtog5hg==}
6604 peerDependencies:6686 peerDependencies:
6605 expo: '*'6687 expo: '*'
6606 expo-font: '*'6688 expo-font: '*'
...@@ -6612,8 +6694,8 @@ packages:...@@ -6612,8 +6694,8 @@ packages:
6612 peerDependencies:6694 peerDependencies:
6613 expo: '*'6695 expo: '*'
66146696
6615 expo-updates@56.0.17:6697 expo-updates@56.0.19:
6616 resolution: {integrity: sha512-4005IRQQ7Vpwb01X3NSG0SwY61HTJzT2pZ0OrkeZ6guQE1WKiRfUKisekRgYXTI8sPbxSHUc9aOpIx6yC49XZQ==}6698 resolution: {integrity: sha512-tTSPYO5h8wDA6a+wQ2v/SRdnOdz29x0npGHCv+4Ev31Fz5r05Ii1Wgfh3BlTXNz8mikMReDsZCf6YN71YeQKpw==}
6617 hasBin: true6699 hasBin: true
6618 peerDependencies:6700 peerDependencies:
6619 expo: '*'6701 expo: '*'
...@@ -6630,15 +6712,15 @@ packages:...@@ -6630,15 +6712,15 @@ packages:
6630 expo: '*'6712 expo: '*'
6631 react-native: '*'6713 react-native: '*'
66326714
6633 expo-widgets@56.0.16:6715 expo-widgets@56.0.19:
6634 resolution: {integrity: sha512-EmWVvskad2XlgCk6j9wz/OqSAeaGxjD6x6Wji99TVjjFd+sTD6W3YJbz3QW/BKTVRcll95m6XjJDCa6B9t9f9A==}6716 resolution: {integrity: sha512-D2RWectoEalVdGwXyE2LX3L9T6q6jSKh8jjvk1K3JSE1qOcCVZk+TJtvBUveTq8OoLblkeFMXqQ2fHG+kg655w==}
6635 peerDependencies:6717 peerDependencies:
6636 expo: '*'6718 expo: '*'
6637 react: '*'6719 react: '*'
6638 react-native: '*'6720 react-native: '*'
66396721
6640 expo@56.0.8:6722 expo@56.0.12:
6641 resolution: {integrity: sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==}6723 resolution: {integrity: sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==}
6642 hasBin: true6724 hasBin: true
6643 peerDependencies:6725 peerDependencies:
6644 '@expo/dom-webview': '*'6726 '@expo/dom-webview': '*'
...@@ -8737,8 +8819,8 @@ packages:...@@ -8737,8 +8819,8 @@ packages:
8737 react: '*'8819 react: '*'
8738 react-native: '*'8820 react-native: '*'
87398821
8740 react-native-keyboard-controller@1.21.7:8822 react-native-keyboard-controller@1.21.6:
8741 resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==}8823 resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==}
8742 peerDependencies:8824 peerDependencies:
8743 react: '*'8825 react: '*'
8744 react-native: '*'8826 react-native: '*'
...@@ -9306,6 +9388,12 @@ packages:...@@ -9306,6 +9388,12 @@ packages:
9306 standard-as-callback@2.1.0:9388 standard-as-callback@2.1.0:
9307 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}9389 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
93089390
9391 standard-navigation@0.0.5:
9392 resolution: {integrity: sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==}
9393
9394 standard-navigation@0.0.7:
9395 resolution: {integrity: sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==}
9396
9309 standardwebhooks@1.0.0:9397 standardwebhooks@1.0.0:
9310 resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}9398 resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
93119399
...@@ -10248,7 +10336,8 @@ snapshots:...@@ -10248,7 +10336,8 @@ snapshots:
1024810336
10249 7zip-bin@5.2.0: {}10337 7zip-bin@5.2.0: {}
1025010338
10251 '@adobe/css-tools@4.5.0': {}10339 '@adobe/css-tools@4.5.0':
10340 optional: true
1025210341
10253 '@alcalzone/ansi-tokenize@0.2.5':10342 '@alcalzone/ansi-tokenize@0.2.5':
10254 dependencies:10343 dependencies:
...@@ -11216,10 +11305,10 @@ snapshots:...@@ -11216,10 +11305,10 @@ snapshots:
11216 '@bruits/satteri-win32-x64-msvc@0.9.3':11305 '@bruits/satteri-win32-x64-msvc@0.9.3':
11217 optional: true11306 optional: true
1121811307
11219 '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)':11308 '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
11220 dependencies:11309 dependencies:
11221 react: 19.2.311310 react: 19.2.3
11222 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)11311 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1122311312
11224 '@capsizecss/unpack@4.0.1':11313 '@capsizecss/unpack@4.0.1':
11225 dependencies:11314 dependencies:
...@@ -11248,18 +11337,18 @@ snapshots:...@@ -11248,18 +11337,18 @@ snapshots:
11248 fast-wrap-ansi: 0.2.211337 fast-wrap-ansi: 0.2.2
11249 sisteransi: 1.0.511338 sisteransi: 1.0.5
1125011339
11251 '@clerk/backend@3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':11340 '@clerk/backend@3.8.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
11252 dependencies:11341 dependencies:
11253 '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)11342 '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
11254 standardwebhooks: 1.0.011343 standardwebhooks: 1.0.0
11255 tslib: 2.8.111344 tslib: 2.8.1
11256 transitivePeerDependencies:11345 transitivePeerDependencies:
11257 - react11346 - react
11258 - react-dom11347 - react-dom
1125911348
11260 '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':11349 '@clerk/clerk-js@6.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
11261 dependencies:11350 dependencies:
11262 '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)11351 '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
11263 '@stripe/stripe-js': 5.6.011352 '@stripe/stripe-js': 5.6.0
11264 '@swc/helpers': 0.5.2111353 '@swc/helpers': 0.5.21
11265 '@tanstack/query-core': 5.100.1411354 '@tanstack/query-core': 5.100.14
...@@ -11274,9 +11363,9 @@ snapshots:...@@ -11274,9 +11363,9 @@ snapshots:
11274 - react11363 - react
11275 - react-dom11364 - react-dom
1127611365
11277 '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':11366 '@clerk/clerk-js@6.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
11278 dependencies:11367 dependencies:
11279 '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)11368 '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
11280 '@stripe/stripe-js': 5.6.011369 '@stripe/stripe-js': 5.6.0
11281 '@swc/helpers': 0.5.2111370 '@swc/helpers': 0.5.21
11282 '@tanstack/query-core': 5.100.1411371 '@tanstack/query-core': 5.100.14
...@@ -11291,72 +11380,72 @@ snapshots:...@@ -11291,72 +11380,72 @@ snapshots:
11291 - react11380 - react
11292 - react-dom11381 - react-dom
1129311382
11294 '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151':11383 '@clerk/electron-passkeys-darwin-arm64@0.0.3':
11295 optional: true11384 optional: true
1129611385
11297 '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151':11386 '@clerk/electron-passkeys-darwin-x64@0.0.3':
11298 optional: true11387 optional: true
1129911388
11300 '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151':11389 '@clerk/electron-passkeys-win32-arm64-msvc@0.0.3':
11301 optional: true11390 optional: true
1130211391
11303 '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151':11392 '@clerk/electron-passkeys-win32-x64-msvc@0.0.3':
11304 optional: true11393 optional: true
1130511394
11306 '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151':11395 '@clerk/electron-passkeys@0.0.3':
11307 optionalDependencies:11396 optionalDependencies:
11308 '@clerk/electron-passkeys-darwin-arm64': 0.0.2-snapshot.v2026062223415111397 '@clerk/electron-passkeys-darwin-arm64': 0.0.3
11309 '@clerk/electron-passkeys-darwin-x64': 0.0.2-snapshot.v2026062223415111398 '@clerk/electron-passkeys-darwin-x64': 0.0.3
11310 '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.2-snapshot.v2026062223415111399 '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3
11311 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.2-snapshot.v2026062223415111400 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3
1131211401
11313 '@clerk/electron@0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':11402 '@clerk/electron@0.0.5(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
11314 dependencies:11403 dependencies:
11315 '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)11404 '@clerk/clerk-js': 6.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
11316 '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)11405 '@clerk/react': 6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
11317 '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)11406 '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
11318 electron: 41.5.011407 electron: 41.5.0
11319 react: 19.2.611408 react: 19.2.6
11320 tslib: 2.8.111409 tslib: 2.8.1
11321 optionalDependencies:11410 optionalDependencies:
11322 '@clerk/electron-passkeys': 0.0.2-snapshot.v2026062223415111411 '@clerk/electron-passkeys': 0.0.3
11323 electron-store: 8.2.011412 electron-store: 8.2.0
11324 react-dom: 19.2.6(react@19.2.6)11413 react-dom: 19.2.6(react@19.2.6)
1132511414
11326 '@clerk/expo@3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)))(expo@56.0.8)(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)':11415 '@clerk/expo@3.6.2(expo-auth-session@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
11327 dependencies:11416 dependencies:
11328 '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)11417 '@clerk/clerk-js': 6.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
11329 '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)11418 '@clerk/react': 6.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
11330 '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)11419 '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
11331 base-64: 1.0.011420 base-64: 1.0.0
11332 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)11421 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
11333 react: 19.2.311422 react: 19.2.3
11334 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)11423 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
11335 react-native-url-polyfill: 2.0.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))11424 react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
11336 tslib: 2.8.111425 tslib: 2.8.1
11337 optionalDependencies:11426 optionalDependencies:
11338 expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)11427 expo-auth-session: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
11339 expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))11428 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
11340 expo-crypto: 56.0.4(expo@56.0.8)11429 expo-crypto: 56.0.4(expo@56.0.12)
11341 expo-secure-store: 56.0.4(expo@56.0.8)11430 expo-secure-store: 56.0.4(expo@56.0.12)
11342 expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))11431 expo-web-browser: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
11343 react-dom: 19.2.3(react@19.2.3)11432 react-dom: 19.2.3(react@19.2.3)
1134411433
11345 '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':11434 '@clerk/react@6.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
11346 dependencies:11435 dependencies:
11347 '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)11436 '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
11348 react: 19.2.311437 react: 19.2.3
11349 react-dom: 19.2.3(react@19.2.3)11438 react-dom: 19.2.3(react@19.2.3)
11350 tslib: 2.8.111439 tslib: 2.8.1
1135111440
11352 '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':11441 '@clerk/react@6.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
11353 dependencies:11442 dependencies:
11354 '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)11443 '@clerk/shared': 4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
11355 react: 19.2.611444 react: 19.2.6
11356 react-dom: 19.2.6(react@19.2.6)11445 react-dom: 19.2.6(react@19.2.6)
11357 tslib: 2.8.111446 tslib: 2.8.1
1135811447
11359 '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':11448 '@clerk/shared@4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
11360 dependencies:11449 dependencies:
11361 '@tanstack/query-core': 5.100.1411450 '@tanstack/query-core': 5.100.14
11362 dequal: 2.0.311451 dequal: 2.0.3
...@@ -11366,7 +11455,7 @@ snapshots:...@@ -11366,7 +11455,7 @@ snapshots:
11366 react: 19.2.311455 react: 19.2.3
11367 react-dom: 19.2.3(react@19.2.3)11456 react-dom: 19.2.3(react@19.2.3)
1136811457
11369 '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':11458 '@clerk/shared@4.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
11370 dependencies:11459 dependencies:
11371 '@tanstack/query-core': 5.100.1411460 '@tanstack/query-core': 5.100.14
11372 dequal: 2.0.311461 dequal: 2.0.3
...@@ -11922,29 +12011,29 @@ snapshots:...@@ -11922,29 +12011,29 @@ snapshots:
1192212011
11923 '@expo-google-fonts/material-symbols@0.4.38': {}12012 '@expo-google-fonts/material-symbols@0.4.38': {}
1192412013
11925 '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(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)(typescript@6.0.3)(utf-8-validate@6.0.6)':12014 '@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)':
11926 dependencies:12015 dependencies:
11927 '@expo/code-signing-certificates': 0.0.612016 '@expo/code-signing-certificates': 0.0.6
11928 '@expo/config': 56.0.9(typescript@6.0.3)12017 '@expo/config': 56.0.9(typescript@6.0.3)
11929 '@expo/config-plugins': 56.0.8(typescript@6.0.3)12018 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
11930 '@expo/devcert': 1.2.112019 '@expo/devcert': 1.2.1
11931 '@expo/env': 2.3.012020 '@expo/env': 2.3.0
11932 '@expo/image-utils': 0.10.1(typescript@6.0.3)12021 '@expo/image-utils': 0.10.1(typescript@6.0.3)
11933 '@expo/inline-modules': 0.0.10(typescript@6.0.3)12022 '@expo/inline-modules': 0.0.12(typescript@6.0.3)
11934 '@expo/json-file': 10.2.012023 '@expo/json-file': 10.2.0
11935 '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12024 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
11936 '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)12025 '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
11937 '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)12026 '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6)
11938 '@expo/metro-file-map': 56.0.312027 '@expo/metro-file-map': 56.0.3
11939 '@expo/osascript': 2.6.012028 '@expo/osascript': 2.6.0
11940 '@expo/package-manager': 1.12.112029 '@expo/package-manager': 1.12.1
11941 '@expo/plist': 0.7.012030 '@expo/plist': 0.7.0
11942 '@expo/prebuild-config': 56.0.14(typescript@6.0.3)12031 '@expo/prebuild-config': 56.0.16(typescript@6.0.3)
11943 '@expo/require-utils': 56.1.3(typescript@6.0.3)12032 '@expo/require-utils': 56.1.3(typescript@6.0.3)
11944 '@expo/router-server': 56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)12033 '@expo/router-server': 56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
11945 '@expo/schema-utils': 56.0.112034 '@expo/schema-utils': 56.0.1
11946 '@expo/spawn-async': 1.8.012035 '@expo/spawn-async': 1.8.0
11947 '@expo/ws-tunnel': 1.0.612036 '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))
11948 '@expo/xcpretty': 4.4.412037 '@expo/xcpretty': 4.4.4
11949 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)12038 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
11950 accepts: 1.3.812039 accepts: 1.3.8
...@@ -11957,8 +12046,8 @@ snapshots:...@@ -11957,8 +12046,8 @@ snapshots:
11957 connect: 3.7.012046 connect: 3.7.0
11958 debug: 4.4.312047 debug: 4.4.3
11959 dnssd-advertise: 1.1.412048 dnssd-advertise: 1.1.4
11960 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)12049 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
11961 expo-server: 56.0.412050 expo-server: 56.0.5
11962 fetch-nodeshim: 0.4.1012051 fetch-nodeshim: 0.4.10
11963 getenv: 2.0.012052 getenv: 2.0.0
11964 glob: 13.0.612053 glob: 13.0.6
...@@ -11983,8 +12072,8 @@ snapshots:...@@ -11983,8 +12072,8 @@ snapshots:
11983 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)12072 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
11984 zod: 3.25.7612073 zod: 3.25.76
11985 optionalDependencies:12074 optionalDependencies:
11986 expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310)12075 expo-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f)
11987 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12076 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
11988 transitivePeerDependencies:12077 transitivePeerDependencies:
11989 - '@expo/dom-webview'12078 - '@expo/dom-webview'
11990 - '@expo/metro-runtime'12079 - '@expo/metro-runtime'
...@@ -12021,11 +12110,32 @@ snapshots:...@@ -12021,11 +12110,32 @@ snapshots:
12021 - supports-color12110 - supports-color
12022 - typescript12111 - typescript
1202312112
12113 '@expo/config-plugins@56.0.9(typescript@6.0.3)':
12114 dependencies:
12115 '@expo/config-types': 56.0.6
12116 '@expo/json-file': 10.2.0
12117 '@expo/plist': 0.7.0
12118 '@expo/require-utils': 56.1.3(typescript@6.0.3)
12119 '@expo/sdk-runtime-versions': 1.0.0
12120 chalk: 4.1.2
12121 debug: 4.4.3
12122 getenv: 2.0.0
12123 glob: 13.0.6
12124 semver: 7.8.5
12125 slugify: 1.6.9
12126 xcode: 3.0.1
12127 xml2js: 0.6.0
12128 transitivePeerDependencies:
12129 - supports-color
12130 - typescript
12131
12024 '@expo/config-types@56.0.5': {}12132 '@expo/config-types@56.0.5': {}
1202512133
12134 '@expo/config-types@56.0.6': {}
12135
12026 '@expo/config@56.0.9(typescript@6.0.3)':12136 '@expo/config@56.0.9(typescript@6.0.3)':
12027 dependencies:12137 dependencies:
12028 '@expo/config-plugins': 56.0.8(typescript@6.0.3)12138 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
12029 '@expo/config-types': 56.0.512139 '@expo/config-types': 56.0.5
12030 '@expo/json-file': 10.2.012140 '@expo/json-file': 10.2.0
12031 '@expo/require-utils': 56.1.3(typescript@6.0.3)12141 '@expo/require-utils': 56.1.3(typescript@6.0.3)
...@@ -12046,18 +12156,18 @@ snapshots:...@@ -12046,18 +12156,18 @@ snapshots:
12046 transitivePeerDependencies:12156 transitivePeerDependencies:
12047 - supports-color12157 - supports-color
1204812158
12049 '@expo/devtools@56.0.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)':12159 '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
12050 dependencies:12160 dependencies:
12051 chalk: 4.1.212161 chalk: 4.1.2
12052 optionalDependencies:12162 optionalDependencies:
12053 react: 19.2.312163 react: 19.2.3
12054 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12164 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1205512165
12056 '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)':12166 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
12057 dependencies:12167 dependencies:
12058 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)12168 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
12059 react: 19.2.312169 react: 19.2.3
12060 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12170 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1206112171
12062 '@expo/env@2.3.0':12172 '@expo/env@2.3.0':
12063 dependencies:12173 dependencies:
...@@ -12067,9 +12177,9 @@ snapshots:...@@ -12067,9 +12177,9 @@ snapshots:
12067 transitivePeerDependencies:12177 transitivePeerDependencies:
12068 - supports-color12178 - supports-color
1206912179
12070 '@expo/expo-modules-macros-plugin@0.0.9': {}12180 '@expo/expo-modules-macros-plugin@0.2.2': {}
1207112181
12072 '@expo/fingerprint@0.19.3':12182 '@expo/fingerprint@0.19.4':
12073 dependencies:12183 dependencies:
12074 '@expo/env': 2.3.012184 '@expo/env': 2.3.0
12075 '@expo/spawn-async': 1.8.012185 '@expo/spawn-async': 1.8.0
...@@ -12098,9 +12208,9 @@ snapshots:...@@ -12098,9 +12208,9 @@ snapshots:
12098 - supports-color12208 - supports-color
12099 - typescript12209 - typescript
1210012210
12101 '@expo/inline-modules@0.0.10(typescript@6.0.3)':12211 '@expo/inline-modules@0.0.12(typescript@6.0.3)':
12102 dependencies:12212 dependencies:
12103 '@expo/config-plugins': 56.0.8(typescript@6.0.3)12213 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
12104 transitivePeerDependencies:12214 transitivePeerDependencies:
12105 - supports-color12215 - supports-color
12106 - typescript12216 - typescript
...@@ -12118,16 +12228,16 @@ snapshots:...@@ -12118,16 +12228,16 @@ snapshots:
12118 - supports-color12228 - supports-color
12119 - typescript12229 - typescript
1212012230
12121 '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)':12231 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
12122 dependencies:12232 dependencies:
12123 '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12233 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
12124 anser: 1.4.1012234 anser: 1.4.10
12125 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)12235 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
12126 react: 19.2.312236 react: 19.2.3
12127 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12237 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
12128 stacktrace-parser: 0.1.1112238 stacktrace-parser: 0.1.11
1212912239
12130 '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)':12240 '@expo/metro-config@56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6)':
12131 dependencies:12241 dependencies:
12132 '@babel/code-frame': 7.29.712242 '@babel/code-frame': 7.29.7
12133 '@babel/core': 7.29.712243 '@babel/core': 7.29.7
...@@ -12149,12 +12259,11 @@ snapshots:...@@ -12149,12 +12259,11 @@ snapshots:
12149 hermes-parser: 0.33.312259 hermes-parser: 0.33.3
12150 jsc-safe-url: 0.2.412260 jsc-safe-url: 0.2.4
12151 lightningcss: 1.32.012261 lightningcss: 1.32.0
12152 msgpackr: 2.0.2
12153 picomatch: 4.0.412262 picomatch: 4.0.4
12154 postcss: 8.5.1512263 postcss: 8.5.15
12155 resolve-from: 5.0.012264 resolve-from: 5.0.0
12156 optionalDependencies:12265 optionalDependencies:
12157 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)12266 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
12158 transitivePeerDependencies:12267 transitivePeerDependencies:
12159 - bufferutil12268 - bufferutil
12160 - supports-color12269 - supports-color
...@@ -12172,14 +12281,14 @@ snapshots:...@@ -12172,14 +12281,14 @@ snapshots:
12172 transitivePeerDependencies:12281 transitivePeerDependencies:
12173 - supports-color12282 - supports-color
1217412283
12175 '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(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)':12284 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
12176 dependencies:12285 dependencies:
12177 '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12286 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
12178 anser: 1.4.1012287 anser: 1.4.10
12179 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)12288 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
12180 pretty-format: 29.7.012289 pretty-format: 29.7.0
12181 react: 19.2.312290 react: 19.2.3
12182 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12291 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
12183 stacktrace-parser: 0.1.1112292 stacktrace-parser: 0.1.11
12184 whatwg-fetch: 3.6.2012293 whatwg-fetch: 3.6.20
12185 optionalDependencies:12294 optionalDependencies:
...@@ -12225,16 +12334,16 @@ snapshots:...@@ -12225,16 +12334,16 @@ snapshots:
12225 base64-js: 1.5.112334 base64-js: 1.5.1
12226 xmlbuilder: 15.1.112335 xmlbuilder: 15.1.1
1222712336
12228 '@expo/prebuild-config@56.0.14(typescript@6.0.3)':12337 '@expo/prebuild-config@56.0.16(typescript@6.0.3)':
12229 dependencies:12338 dependencies:
12230 '@expo/config': 56.0.9(typescript@6.0.3)12339 '@expo/config': 56.0.9(typescript@6.0.3)
12231 '@expo/config-plugins': 56.0.8(typescript@6.0.3)12340 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
12232 '@expo/config-types': 56.0.512341 '@expo/config-types': 56.0.6
12233 '@expo/image-utils': 0.10.1(typescript@6.0.3)12342 '@expo/image-utils': 0.10.1(typescript@6.0.3)
12234 '@expo/json-file': 10.2.012343 '@expo/json-file': 10.2.0
12235 '@react-native/normalize-colors': 0.85.312344 '@react-native/normalize-colors': 0.85.3
12236 debug: 4.4.312345 debug: 4.4.3
12237 expo-modules-autolinking: 56.0.14(typescript@6.0.3)12346 expo-modules-autolinking: 56.0.16(typescript@6.0.3)
12238 resolve-from: 5.0.012347 resolve-from: 5.0.0
12239 semver: 7.8.512348 semver: 7.8.5
12240 transitivePeerDependencies:12349 transitivePeerDependencies:
...@@ -12251,17 +12360,17 @@ snapshots:...@@ -12251,17 +12360,17 @@ snapshots:
12251 transitivePeerDependencies:12360 transitivePeerDependencies:
12252 - supports-color12361 - supports-color
1225312362
12254 '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':12363 '@expo/router-server@56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
12255 dependencies:12364 dependencies:
12256 debug: 4.4.312365 debug: 4.4.3
12257 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)12366 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
12258 expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))12367 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
12259 expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12368 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
12260 expo-server: 56.0.412369 expo-server: 56.0.5
12261 react: 19.2.312370 react: 19.2.3
12262 optionalDependencies:12371 optionalDependencies:
12263 '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(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)12372 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
12264 expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310)12373 expo-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f)
12265 react-dom: 19.2.3(react@19.2.3)12374 react-dom: 19.2.3(react@19.2.3)
12266 transitivePeerDependencies:12375 transitivePeerDependencies:
12267 - supports-color12376 - supports-color
...@@ -12276,23 +12385,25 @@ snapshots:...@@ -12276,23 +12385,25 @@ snapshots:
1227612385
12277 '@expo/sudo-prompt@9.3.2': {}12386 '@expo/sudo-prompt@9.3.2': {}
1227812387
12279 '@expo/ui@56.0.15(961c4aa6f32829b318e3c87ef20ad401)':12388 '@expo/ui@56.0.18(19413efe5eaad64848598eedfe3a0fd3)':
12280 dependencies:12389 dependencies:
12281 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)12390 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
12282 react: 19.2.312391 react: 19.2.3
12283 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12392 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
12284 sf-symbols-typescript: 2.2.012393 sf-symbols-typescript: 2.2.0
12285 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)12394 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)
12286 optionalDependencies:12395 optionalDependencies:
12287 '@babel/core': 7.29.712396 '@babel/core': 7.29.7
12288 react-dom: 19.2.3(react@19.2.3)12397 react-dom: 19.2.3(react@19.2.3)
12289 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)12398 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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
12290 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)12399 react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
12291 transitivePeerDependencies:12400 transitivePeerDependencies:
12292 - '@types/react'12401 - '@types/react'
12293 - '@types/react-dom'12402 - '@types/react-dom'
1229412403
12295 '@expo/ws-tunnel@1.0.6': {}12404 '@expo/ws-tunnel@2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))':
12405 dependencies:
12406 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1229612407
12297 '@expo/xcpretty@4.4.4':12408 '@expo/xcpretty@4.4.4':
12298 dependencies:12409 dependencies:
...@@ -12548,13 +12659,13 @@ snapshots:...@@ -12548,13 +12659,13 @@ snapshots:
12548 dependencies:12659 dependencies:
12549 jsbi: 4.3.212660 jsbi: 4.3.2
1255012661
12551 '@legendapp/list@3.2.0(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)':12662 '@legendapp/list@3.2.0(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
12552 dependencies:12663 dependencies:
12553 react: 19.2.312664 react: 19.2.3
12554 use-sync-external-store: 1.6.0(react@19.2.3)12665 use-sync-external-store: 1.6.0(react@19.2.3)
12555 optionalDependencies:12666 optionalDependencies:
12556 react-dom: 19.2.3(react@19.2.3)12667 react-dom: 19.2.3(react@19.2.3)
12557 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12668 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1255812669
12559 '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':12670 '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12560 dependencies:12671 dependencies:
...@@ -13178,6 +13289,7 @@ snapshots:...@@ -13178,6 +13289,7 @@ snapshots:
13178 optionalDependencies:13289 optionalDependencies:
13179 '@types/react': 19.2.1613290 '@types/react': 19.2.16
13180 '@types/react-dom': 19.2.3(@types/react@19.2.16)13291 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13292 optional: true
1318113293
13182 '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)':13294 '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)':
13183 dependencies:13295 dependencies:
...@@ -13218,6 +13330,7 @@ snapshots:...@@ -13218,6 +13330,7 @@ snapshots:
13218 react: 19.2.313330 react: 19.2.3
13219 optionalDependencies:13331 optionalDependencies:
13220 '@types/react': 19.2.1613332 '@types/react': 19.2.16
13333 optional: true
1322113334
13222 '@radix-ui/react-dismissable-layer@1.1.11(@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)':13335 '@radix-ui/react-dismissable-layer@1.1.11(@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)':
13223 dependencies:13336 dependencies:
...@@ -13301,6 +13414,7 @@ snapshots:...@@ -13301,6 +13414,7 @@ snapshots:
13301 optionalDependencies:13414 optionalDependencies:
13302 '@types/react': 19.2.1613415 '@types/react': 19.2.16
13303 '@types/react-dom': 19.2.3(@types/react@19.2.16)13416 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13417 optional: true
1330413418
13305 '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)':13419 '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)':
13306 dependencies:13420 dependencies:
...@@ -13315,6 +13429,7 @@ snapshots:...@@ -13315,6 +13429,7 @@ snapshots:
13315 react: 19.2.313429 react: 19.2.3
13316 optionalDependencies:13430 optionalDependencies:
13317 '@types/react': 19.2.1613431 '@types/react': 19.2.16
13432 optional: true
1331813433
13319 '@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)':13434 '@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)':
13320 dependencies:13435 dependencies:
...@@ -13331,6 +13446,7 @@ snapshots:...@@ -13331,6 +13446,7 @@ snapshots:
13331 optionalDependencies:13446 optionalDependencies:
13332 '@types/react': 19.2.1613447 '@types/react': 19.2.16
13333 '@types/react-dom': 19.2.3(@types/react@19.2.16)13448 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13449 optional: true
1333413450
13335 '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)':13451 '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)':
13336 dependencies:13452 dependencies:
...@@ -13377,15 +13493,16 @@ snapshots:...@@ -13377,15 +13493,16 @@ snapshots:
13377 prompts: 2.4.213493 prompts: 2.4.2
13378 tinyexec: 1.2.413494 tinyexec: 1.2.4
1337913495
13380 '@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)':13496 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
13381 dependencies:13497 dependencies:
13382 react: 19.2.313498 react: 19.2.3
13383 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)13499 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
13500 optional: true
1338413501
13385 '@react-native-menu/menu@2.0.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)':13502 '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
13386 dependencies:13503 dependencies:
13387 react: 19.2.313504 react: 19.2.3
13388 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)13505 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1338913506
13390 '@react-native/assets-registry@0.85.3': {}13507 '@react-native/assets-registry@0.85.3': {}
1339113508
...@@ -13445,7 +13562,7 @@ snapshots:...@@ -13445,7 +13562,7 @@ snapshots:
13445 tinyglobby: 0.2.1713562 tinyglobby: 0.2.17
13446 yargs: 17.7.213563 yargs: 17.7.2
1344713564
13448 '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)':13565 '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)':
13449 dependencies:13566 dependencies:
13450 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)13567 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
13451 debug: 4.4.313568 debug: 4.4.3
...@@ -13455,7 +13572,7 @@ snapshots:...@@ -13455,7 +13572,7 @@ snapshots:
13455 metro-core: 0.84.413572 metro-core: 0.84.4
13456 semver: 7.8.513573 semver: 7.8.5
13457 optionalDependencies:13574 optionalDependencies:
13458 '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)13575 '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)
13459 transitivePeerDependencies:13576 transitivePeerDependencies:
13460 - bufferutil13577 - bufferutil
13461 - supports-color13578 - supports-color
...@@ -13503,7 +13620,7 @@ snapshots:...@@ -13503,7 +13620,7 @@ snapshots:
13503 transitivePeerDependencies:13620 transitivePeerDependencies:
13504 - supports-color13621 - supports-color
1350513622
13506 '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)':13623 '@react-native/metro-config@0.85.3(@babel/core@7.29.7)':
13507 dependencies:13624 dependencies:
13508 '@react-native/js-polyfills': 0.85.313625 '@react-native/js-polyfills': 0.85.3
13509 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7)13626 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7)
...@@ -13511,21 +13628,72 @@ snapshots:...@@ -13511,21 +13628,72 @@ snapshots:
13511 metro-runtime: 0.84.413628 metro-runtime: 0.84.4
13512 transitivePeerDependencies:13629 transitivePeerDependencies:
13513 - '@babel/core'13630 - '@babel/core'
13514 - bufferutil
13515 - supports-color13631 - supports-color
13516 - utf-8-validate
1351713632
13518 '@react-native/normalize-colors@0.85.3': {}13633 '@react-native/normalize-colors@0.85.3': {}
1351913634
13520 '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)':13635 '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
13521 dependencies:13636 dependencies:
13522 invariant: 2.2.413637 invariant: 2.2.4
13523 nullthrows: 1.1.113638 nullthrows: 1.1.1
13524 react: 19.2.313639 react: 19.2.3
13525 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)13640 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
13526 optionalDependencies:13641 optionalDependencies:
13527 '@types/react': 19.2.1613642 '@types/react': 19.2.16
1352813643
13644 '@react-navigation/core@7.21.2(react@19.2.3)':
13645 dependencies:
13646 '@react-navigation/routers': 7.6.0
13647 escape-string-regexp: 4.0.0
13648 fast-deep-equal: 3.1.3
13649 nanoid: 3.3.12
13650 query-string: 7.1.3
13651 react: 19.2.3
13652 react-is: 19.2.7
13653 use-latest-callback: 0.2.6(react@19.2.3)
13654 use-sync-external-store: 1.6.0(react@19.2.3)
13655
13656 '@react-navigation/elements@2.9.26(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(@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))(@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-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))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
13657 dependencies:
13658 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13659 color: 4.2.3
13660 react: 19.2.3
13661 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
13662 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13663 use-latest-callback: 0.2.6(react@19.2.3)
13664 use-sync-external-store: 1.6.0(react@19.2.3)
13665 optionalDependencies:
13666 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13667
13668 '@react-navigation/native-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(889fbbb381dfeb79ac64b7284ebf7fa4)':
13669 dependencies:
13670 '@react-navigation/elements': 2.9.26(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(@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))(@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-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))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13671 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13672 color: 4.2.3
13673 react: 19.2.3
13674 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
13675 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13676 react-native-screens: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13677 sf-symbols-typescript: 2.2.0
13678 warn-once: 0.1.1
13679 transitivePeerDependencies:
13680 - '@react-native-masked-view/masked-view'
13681
13682 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
13683 dependencies:
13684 '@react-navigation/core': 7.21.2(react@19.2.3)
13685 escape-string-regexp: 4.0.0
13686 fast-deep-equal: 3.1.3
13687 nanoid: 3.3.12
13688 react: 19.2.3
13689 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
13690 standard-navigation: 0.0.7
13691 use-latest-callback: 0.2.6(react@19.2.3)
13692
13693 '@react-navigation/routers@7.6.0':
13694 dependencies:
13695 nanoid: 3.3.12
13696
13529 '@rolldown/binding-android-arm64@1.0.0-rc.17':13697 '@rolldown/binding-android-arm64@1.0.0-rc.17':
13530 optional: true13698 optional: true
1353113699
...@@ -13907,15 +14075,15 @@ snapshots:...@@ -13907,15 +14075,15 @@ snapshots:
13907 dependencies:14075 dependencies:
13908 defer-to-connect: 2.0.114076 defer-to-connect: 2.0.1
1390914077
13910 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578)':14078 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(a6e4bd1e9376530ea93dbb7d8a53d32d)':
13911 dependencies:14079 dependencies:
13912 expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)(typescript@6.0.3)14080 expo-asset: 56.0.17(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
13913 expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)14081 expo-clipboard: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13914 expo-haptics: 56.0.3(expo@56.0.8)14082 expo-haptics: 56.0.3(expo@56.0.12)
13915 expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)14083 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13916 react: 19.2.314084 react: 19.2.3
13917 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)14085 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
13918 react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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-svg@15.15.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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)14086 react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1391914087
13920 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {}14088 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {}
1392114089
...@@ -14171,6 +14339,7 @@ snapshots:...@@ -14171,6 +14339,7 @@ snapshots:
14171 dom-accessibility-api: 0.6.314339 dom-accessibility-api: 0.6.3
14172 picocolors: 1.1.114340 picocolors: 1.1.1
14173 redent: 3.0.014341 redent: 3.0.0
14342 optional: true
1417414343
14175 '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':14344 '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
14176 dependencies:14345 dependencies:
...@@ -14983,7 +15152,60 @@ snapshots:...@@ -14983,7 +15152,60 @@ snapshots:
14983 transitivePeerDependencies:15152 transitivePeerDependencies:
14984 - '@babel/core'15153 - '@babel/core'
1498515154
14986 babel-preset-expo@56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2):15155 babel-preset-expo@56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2):
15156 dependencies:
15157 '@babel/generator': 7.29.7
15158 '@babel/helper-module-imports': 7.29.7
15159 '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7)
15160 '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7)
15161 '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7)
15162 '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7)
15163 '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7)
15164 '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7)
15165 '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7)
15166 '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7)
15167 '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7)
15168 '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7)
15169 '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7)
15170 '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7)
15171 '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7)
15172 '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7)
15173 '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7)
15174 '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7)
15175 '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7)
15176 '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7)
15177 '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7)
15178 '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7)
15179 '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7)
15180 '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7)
15181 '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7)
15182 '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7)
15183 '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7)
15184 '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7)
15185 '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7)
15186 '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7)
15187 '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7)
15188 '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7)
15189 '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7)
15190 '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7)
15191 '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7)
15192 '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7)
15193 '@react-native/babel-plugin-codegen': 0.85.3(@babel/core@7.29.7)
15194 babel-plugin-react-compiler: 1.0.0
15195 babel-plugin-react-native-web: 0.21.2
15196 babel-plugin-syntax-hermes-parser: 0.33.3
15197 babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7)
15198 debug: 4.4.3
15199 react-refresh: 0.14.2
15200 optionalDependencies:
15201 '@babel/runtime': 7.29.7
15202 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
15203 expo-widgets: 56.0.19(19413efe5eaad64848598eedfe3a0fd3)
15204 transitivePeerDependencies:
15205 - '@babel/core'
15206 - supports-color
15207
15208 babel-preset-expo@56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2):
14987 dependencies:15209 dependencies:
14988 '@babel/generator': 7.29.715210 '@babel/generator': 7.29.7
14989 '@babel/helper-module-imports': 7.29.715211 '@babel/helper-module-imports': 7.29.7
...@@ -15030,8 +15252,8 @@ snapshots:...@@ -15030,8 +15252,8 @@ snapshots:
15030 react-refresh: 0.14.215252 react-refresh: 0.14.2
15031 optionalDependencies:15253 optionalDependencies:
15032 '@babel/runtime': 7.29.715254 '@babel/runtime': 7.29.7
15033 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)15255 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
15034 expo-widgets: 56.0.16(961c4aa6f32829b318e3c87ef20ad401)15256 expo-widgets: 56.0.19(19413efe5eaad64848598eedfe3a0fd3)
15035 transitivePeerDependencies:15257 transitivePeerDependencies:
15036 - '@babel/core'15258 - '@babel/core'
15037 - supports-color15259 - supports-color
...@@ -15307,7 +15529,8 @@ snapshots:...@@ -15307,7 +15529,8 @@ snapshots:
1530715529
15308 cli-width@4.1.0: {}15530 cli-width@4.1.0: {}
1530915531
15310 client-only@0.0.1: {}15532 client-only@0.0.1:
15533 optional: true
1531115534
15312 cliui@8.0.1:15535 cliui@8.0.1:
15313 dependencies:15536 dependencies:
...@@ -15499,7 +15722,8 @@ snapshots:...@@ -15499,7 +15722,8 @@ snapshots:
1549915722
15500 css-what@6.2.2: {}15723 css-what@6.2.2: {}
1550115724
15502 css.escape@1.5.1: {}15725 css.escape@1.5.1:
15726 optional: true
1550315727
15504 csso@5.0.5:15728 csso@5.0.5:
15505 dependencies:15729 dependencies:
...@@ -15622,7 +15846,8 @@ snapshots:...@@ -15622,7 +15846,8 @@ snapshots:
1562215846
15623 dom-accessibility-api@0.5.16: {}15847 dom-accessibility-api@0.5.16: {}
1562415848
15625 dom-accessibility-api@0.6.3: {}15849 dom-accessibility-api@0.6.3:
15850 optional: true
1562615851
15627 dom-serializer@2.0.0:15852 dom-serializer@2.0.0:
15628 dependencies:15853 dependencies:
...@@ -15926,154 +16151,154 @@ snapshots:...@@ -15926,154 +16151,154 @@ snapshots:
1592616151
15927 expect-type@1.4.0: {}16152 expect-type@1.4.0: {}
1592816153
15929 expo-application@56.0.3(expo@56.0.8):16154 expo-application@56.0.3(expo@56.0.12):
15930 dependencies:16155 dependencies:
15931 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16156 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
1593216157
15933 expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)(typescript@6.0.3):16158 expo-asset@56.0.17(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3):
15934 dependencies:16159 dependencies:
15935 '@expo/image-utils': 0.10.1(typescript@6.0.3)16160 '@expo/image-utils': 0.10.1(typescript@6.0.3)
15936 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16161 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
15937 expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16162 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
15938 react: 19.2.316163 react: 19.2.3
15939 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16164 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
15940 transitivePeerDependencies:16165 transitivePeerDependencies:
15941 - supports-color16166 - supports-color
15942 - typescript16167 - typescript
1594316168
15944 expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):16169 expo-auth-session@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
15945 dependencies:16170 dependencies:
15946 expo-application: 56.0.3(expo@56.0.8)16171 expo-application: 56.0.3(expo@56.0.12)
15947 expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16172 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
15948 expo-crypto: 56.0.4(expo@56.0.8)16173 expo-crypto: 56.0.4(expo@56.0.12)
15949 expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16174 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
15950 expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16175 expo-web-browser: 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
15951 invariant: 2.2.416176 invariant: 2.2.4
15952 react: 19.2.316177 react: 19.2.3
15953 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16178 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
15954 transitivePeerDependencies:16179 transitivePeerDependencies:
15955 - expo16180 - expo
15956 - supports-color16181 - supports-color
1595716182
15958 expo-build-properties@56.0.16(expo@56.0.8):16183 expo-build-properties@56.0.19(expo@56.0.12):
15959 dependencies:16184 dependencies:
15960 '@expo/schema-utils': 56.0.116185 '@expo/schema-utils': 56.0.1
15961 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16186 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
15962 resolve-from: 5.0.016187 resolve-from: 5.0.0
15963 semver: 7.8.516188 semver: 7.8.5
1596416189
15965 expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):16190 expo-camera@56.0.8(@types/emscripten@1.41.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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
15966 dependencies:16191 dependencies:
15967 barcode-detector: 3.2.0(@types/emscripten@1.41.5)16192 barcode-detector: 3.2.0(@types/emscripten@1.41.5)
15968 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16193 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
15969 react: 19.2.316194 react: 19.2.3
15970 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16195 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
15971 transitivePeerDependencies:16196 transitivePeerDependencies:
15972 - '@types/emscripten'16197 - '@types/emscripten'
1597316198
15974 expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):16199 expo-clipboard@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
15975 dependencies:16200 dependencies:
15976 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16201 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
15977 react: 19.2.316202 react: 19.2.3
15978 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16203 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1597916204
15980 expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)):16205 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):
15981 dependencies:16206 dependencies:
15982 '@expo/env': 2.3.016207 '@expo/env': 2.3.0
15983 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16208 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
15984 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16209 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
15985 transitivePeerDependencies:16210 transitivePeerDependencies:
15986 - supports-color16211 - supports-color
1598716212
15988 expo-crypto@56.0.4(expo@56.0.8):16213 expo-crypto@56.0.4(expo@56.0.12):
15989 dependencies:16214 dependencies:
15990 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16215 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
1599116216
15992 expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)):16217 expo-dev-client@56.0.20(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):
15993 dependencies:16218 dependencies:
15994 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16219 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
15995 expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16220 expo-dev-launcher: 56.0.20(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
15996 expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16221 expo-dev-menu: 56.0.17(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
15997 expo-dev-menu-interface: 56.0.1(expo@56.0.8)16222 expo-dev-menu-interface: 56.0.1(expo@56.0.12)
15998 expo-manifests: 56.0.4(expo@56.0.8)16223 expo-manifests: 56.0.4(expo@56.0.12)
15999 expo-updates-interface: 56.0.2(expo@56.0.8)16224 expo-updates-interface: 56.0.2(expo@56.0.12)
16000 transitivePeerDependencies:16225 transitivePeerDependencies:
16001 - react-native16226 - react-native
1600216227
16003 expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)):16228 expo-dev-launcher@56.0.20(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):
16004 dependencies:16229 dependencies:
16005 '@expo/schema-utils': 56.0.116230 '@expo/schema-utils': 56.0.1
16006 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16231 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16007 expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16232 expo-dev-menu: 56.0.17(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
16008 expo-manifests: 56.0.4(expo@56.0.8)16233 expo-manifests: 56.0.4(expo@56.0.12)
16009 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16234 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1601016235
16011 expo-dev-menu-interface@56.0.1(expo@56.0.8):16236 expo-dev-menu-interface@56.0.1(expo@56.0.12):
16012 dependencies:16237 dependencies:
16013 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16238 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
1601416239
16015 expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)):16240 expo-dev-menu@56.0.17(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):
16016 dependencies:16241 dependencies:
16017 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16242 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16018 expo-dev-menu-interface: 56.0.1(expo@56.0.8)16243 expo-dev-menu-interface: 56.0.1(expo@56.0.12)
16019 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16244 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1602016245
16021 expo-eas-client@56.0.1: {}16246 expo-eas-client@56.0.1: {}
1602216247
16023 expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)):16248 expo-file-system@56.0.8(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):
16024 dependencies:16249 dependencies:
16025 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16250 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16026 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16251 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1602716252
16028 expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):16253 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
16029 dependencies:16254 dependencies:
16030 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16255 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16031 fontfaceobserver: 2.3.016256 fontfaceobserver: 2.3.0
16032 react: 19.2.316257 react: 19.2.3
16033 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16258 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1603416259
16035 expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):16260 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
16036 dependencies:16261 dependencies:
16037 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16262 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16038 react: 19.2.316263 react: 19.2.3
16039 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16264 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1604016265
16041 expo-haptics@56.0.3(expo@56.0.8):16266 expo-haptics@56.0.3(expo@56.0.12):
16042 dependencies:16267 dependencies:
16043 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16268 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
1604416269
16045 expo-image-loader@56.0.3(expo@56.0.8):16270 expo-image-loader@56.0.3(expo@56.0.12):
16046 dependencies:16271 dependencies:
16047 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16272 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
1604816273
16049 expo-image-picker@56.0.15(expo@56.0.8):16274 expo-image-picker@56.0.18(expo@56.0.12):
16050 dependencies:16275 dependencies:
16051 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16276 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16052 expo-image-loader: 56.0.3(expo@56.0.8)16277 expo-image-loader: 56.0.3(expo@56.0.12)
1605316278
16054 expo-json-utils@56.0.0: {}16279 expo-json-utils@56.0.0: {}
1605516280
16056 expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3):16281 expo-keep-awake@56.0.3(expo@56.0.12)(react@19.2.3):
16057 dependencies:16282 dependencies:
16058 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16283 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16059 react: 19.2.316284 react: 19.2.3
1606016285
16061 expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):16286 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
16062 dependencies:16287 dependencies:
16063 expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16288 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
16064 invariant: 2.2.416289 invariant: 2.2.4
16065 react: 19.2.316290 react: 19.2.3
16066 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16291 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
16067 transitivePeerDependencies:16292 transitivePeerDependencies:
16068 - expo16293 - expo
16069 - supports-color16294 - supports-color
1607016295
16071 expo-manifests@56.0.4(expo@56.0.8):16296 expo-manifests@56.0.4(expo@56.0.12):
16072 dependencies:16297 dependencies:
16073 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16298 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16074 expo-json-utils: 56.0.016299 expo-json-utils: 56.0.0
1607516300
16076 expo-modules-autolinking@56.0.14(typescript@6.0.3):16301 expo-modules-autolinking@56.0.16(typescript@6.0.3):
16077 dependencies:16302 dependencies:
16078 '@expo/require-utils': 56.1.3(typescript@6.0.3)16303 '@expo/require-utils': 56.1.3(typescript@6.0.3)
16079 '@expo/spawn-async': 1.8.016304 '@expo/spawn-async': 1.8.0
...@@ -16083,66 +16308,66 @@ snapshots:...@@ -16083,66 +16308,66 @@ snapshots:
16083 - supports-color16308 - supports-color
16084 - typescript16309 - typescript
1608516310
16086 expo-modules-core@56.0.14(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):16311 expo-modules-core@56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
16087 dependencies:16312 dependencies:
16088 '@expo/expo-modules-macros-plugin': 0.0.916313 '@expo/expo-modules-macros-plugin': 0.2.2
16089 expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16314 expo-modules-jsi: 56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
16090 invariant: 2.2.416315 invariant: 2.2.4
16091 react: 19.2.316316 react: 19.2.3
16092 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16317 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
16093 optionalDependencies:16318 optionalDependencies:
16094 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)16319 react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1609516320
16096 expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)):16321 expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):
16097 dependencies:16322 dependencies:
16098 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16323 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1609916324
16100 expo-network@56.0.5(expo@56.0.8)(react@19.2.3):16325 expo-network@56.0.5(expo@56.0.12)(react@19.2.3):
16101 dependencies:16326 dependencies:
16102 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16327 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16103 react: 19.2.316328 react: 19.2.3
1610416329
16105 expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)(typescript@6.0.3):16330 expo-notifications@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3):
16106 dependencies:16331 dependencies:
16107 '@expo/image-utils': 0.10.1(typescript@6.0.3)16332 '@expo/image-utils': 0.10.1(typescript@6.0.3)
16108 abort-controller: 3.0.016333 abort-controller: 3.0.0
16109 badgin: 1.2.316334 badgin: 1.2.3
16110 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16335 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16111 expo-application: 56.0.3(expo@56.0.8)16336 expo-application: 56.0.3(expo@56.0.12)
16112 expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16337 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
16113 react: 19.2.316338 react: 19.2.3
16114 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16339 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
16115 transitivePeerDependencies:16340 transitivePeerDependencies:
16116 - supports-color16341 - supports-color
16117 - typescript16342 - typescript
1611816343
16119 expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):16344 expo-paste-input@0.1.15(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
16120 dependencies:16345 dependencies:
16121 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16346 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16122 react: 19.2.316347 react: 19.2.3
16123 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16348 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1612416349
16125 expo-router@56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310):16350 expo-router@56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f):
16126 dependencies:16351 dependencies:
16127 '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16352 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16128 '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(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)16353 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16129 '@expo/schema-utils': 56.0.116354 '@expo/schema-utils': 56.0.1
16130 '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401)16355 '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3)
16131 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3)16356 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3)
16132 '@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)16357 '@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)
16133 '@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)16358 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16134 '@testing-library/jest-dom': 6.9.116359 '@testing-library/jest-dom': 6.9.1
16135 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)16360 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
16136 client-only: 0.0.116361 client-only: 0.0.1
16137 color: 4.2.316362 color: 4.2.3
16138 debug: 4.4.316363 debug: 4.4.3
16139 escape-string-regexp: 4.0.016364 escape-string-regexp: 4.0.0
16140 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16365 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16141 expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16366 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
16142 expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16367 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16143 expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16368 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16144 expo-server: 56.0.416369 expo-server: 56.0.5
16145 expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16370 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16146 fast-deep-equal: 3.1.316371 fast-deep-equal: 3.1.3
16147 invariant: 2.2.416372 invariant: 2.2.4
16148 nanoid: 3.3.1216373 nanoid: 3.3.12
...@@ -16150,18 +16375,19 @@ snapshots:...@@ -16150,18 +16375,19 @@ snapshots:
16150 react: 19.2.316375 react: 19.2.3
16151 react-fast-compare: 3.2.216376 react-fast-compare: 3.2.2
16152 react-is: 19.2.716377 react-is: 19.2.7
16153 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16378 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
16154 react-native-drawer-layout: 4.2.4(0e9729601f58a7a7ae26c76fe6017455)16379 react-native-drawer-layout: 4.2.4(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))(@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-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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16155 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)16380 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16156 react-native-screens: 4.25.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)16381 react-native-screens: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16157 server-only: 0.0.116382 server-only: 0.0.1
16158 sf-symbols-typescript: 2.2.016383 sf-symbols-typescript: 2.2.0
16159 shallowequal: 1.1.016384 shallowequal: 1.1.0
16385 standard-navigation: 0.0.5
16160 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)16386 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)
16161 optionalDependencies:16387 optionalDependencies:
16162 react-dom: 19.2.3(react@19.2.3)16388 react-dom: 19.2.3(react@19.2.3)
16163 react-native-gesture-handler: 2.31.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)16389 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16164 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)16390 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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16165 transitivePeerDependencies:16391 transitivePeerDependencies:
16166 - '@babel/core'16392 - '@babel/core'
16167 - '@testing-library/dom'16393 - '@testing-library/dom'
...@@ -16170,18 +16396,19 @@ snapshots:...@@ -16170,18 +16396,19 @@ snapshots:
16170 - expo-font16396 - expo-font
16171 - react-native-worklets16397 - react-native-worklets
16172 - supports-color16398 - supports-color
16399 optional: true
1617316400
16174 expo-secure-store@56.0.4(expo@56.0.8):16401 expo-secure-store@56.0.4(expo@56.0.12):
16175 dependencies:16402 dependencies:
16176 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16403 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
1617716404
16178 expo-server@56.0.4: {}16405 expo-server@56.0.5: {}
1617916406
16180 expo-splash-screen@56.0.10(expo@56.0.8)(typescript@6.0.3):16407 expo-splash-screen@56.0.10(expo@56.0.12)(typescript@6.0.3):
16181 dependencies:16408 dependencies:
16182 '@expo/config-plugins': 56.0.8(typescript@6.0.3)16409 '@expo/config-plugins': 56.0.8(typescript@6.0.3)
16183 '@expo/image-utils': 0.10.1(typescript@6.0.3)16410 '@expo/image-utils': 0.10.1(typescript@6.0.3)
16184 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16411 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16185 xml2js: 0.6.016412 xml2js: 0.6.0
16186 transitivePeerDependencies:16413 transitivePeerDependencies:
16187 - supports-color16414 - supports-color
...@@ -16189,20 +16416,20 @@ snapshots:...@@ -16189,20 +16416,20 @@ snapshots:
1618916416
16190 expo-structured-headers@56.0.0: {}16417 expo-structured-headers@56.0.0: {}
1619116418
16192 expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):16419 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
16193 dependencies:16420 dependencies:
16194 '@expo-google-fonts/material-symbols': 0.4.3816421 '@expo-google-fonts/material-symbols': 0.4.38
16195 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16422 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16196 expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16423 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16197 react: 19.2.316424 react: 19.2.3
16198 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16425 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
16199 sf-symbols-typescript: 2.2.016426 sf-symbols-typescript: 2.2.0
1620016427
16201 expo-updates-interface@56.0.2(expo@56.0.8):16428 expo-updates-interface@56.0.2(expo@56.0.12):
16202 dependencies:16429 dependencies:
16203 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16430 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
1620416431
16205 expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):16432 expo-updates@56.0.19(expo-dev-client@56.0.20(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
16206 dependencies:16433 dependencies:
16207 '@expo/code-signing-certificates': 0.0.616434 '@expo/code-signing-certificates': 0.0.6
16208 '@expo/plist': 0.7.016435 '@expo/plist': 0.7.0
...@@ -16210,35 +16437,35 @@ snapshots:...@@ -16210,35 +16437,35 @@ snapshots:
16210 arg: 4.1.316437 arg: 4.1.3
16211 chalk: 4.1.216438 chalk: 4.1.2
16212 debug: 4.4.316439 debug: 4.4.3
16213 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16440 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16214 expo-eas-client: 56.0.116441 expo-eas-client: 56.0.1
16215 expo-manifests: 56.0.4(expo@56.0.8)16442 expo-manifests: 56.0.4(expo@56.0.12)
16216 expo-structured-headers: 56.0.016443 expo-structured-headers: 56.0.0
16217 expo-updates-interface: 56.0.2(expo@56.0.8)16444 expo-updates-interface: 56.0.2(expo@56.0.12)
16218 getenv: 2.0.016445 getenv: 2.0.0
16219 glob: 13.0.616446 glob: 13.0.6
16220 ignore: 5.3.216447 ignore: 5.3.2
16221 nullthrows: 1.1.116448 nullthrows: 1.1.1
16222 react: 19.2.316449 react: 19.2.3
16223 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16450 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
16224 resolve-from: 5.0.016451 resolve-from: 5.0.0
16225 optionalDependencies:16452 optionalDependencies:
16226 expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16453 expo-dev-client: 56.0.20(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
16227 transitivePeerDependencies:16454 transitivePeerDependencies:
16228 - supports-color16455 - supports-color
1622916456
16230 expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)):16457 expo-web-browser@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):
16231 dependencies:16458 dependencies:
16232 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16459 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16233 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16460 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1623416461
16235 expo-widgets@56.0.16(961c4aa6f32829b318e3c87ef20ad401):16462 expo-widgets@56.0.19(19413efe5eaad64848598eedfe3a0fd3):
16236 dependencies:16463 dependencies:
16237 '@expo/plist': 0.7.016464 '@expo/plist': 0.7.0
16238 '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401)16465 '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3)
16239 expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d)16466 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16240 react: 19.2.316467 react: 19.2.3
16241 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16468 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
16242 transitivePeerDependencies:16469 transitivePeerDependencies:
16243 - '@babel/core'16470 - '@babel/core'
16244 - '@types/react'16471 - '@types/react'
...@@ -16247,37 +16474,37 @@ snapshots:...@@ -16247,37 +16474,37 @@ snapshots:
16247 - react-native-reanimated16474 - react-native-reanimated
16248 - react-native-worklets16475 - react-native-worklets
1624916476
16250 expo@56.0.8(63f7aade424ad9e7b1154b679fa2a14d):16477 expo@56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6):
16251 dependencies:16478 dependencies:
16252 '@babel/runtime': 7.29.716479 '@babel/runtime': 7.29.7
16253 '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(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)(typescript@6.0.3)(utf-8-validate@6.0.6)16480 '@expo/cli': 56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
16254 '@expo/config': 56.0.9(typescript@6.0.3)16481 '@expo/config': 56.0.9(typescript@6.0.3)
16255 '@expo/config-plugins': 56.0.8(typescript@6.0.3)16482 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
16256 '@expo/devtools': 56.0.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)16483 '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16257 '@expo/fingerprint': 0.19.316484 '@expo/fingerprint': 0.19.4
16258 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3)16485 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3)
16259 '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16486 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16260 '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)16487 '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
16261 '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)16488 '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6)
16262 '@ungap/structured-clone': 1.3.116489 '@ungap/structured-clone': 1.3.1
16263 babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2)16490 babel-preset-expo: 56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2)
16264 expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)(typescript@6.0.3)16491 expo-asset: 56.0.17(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
16265 expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16492 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
16266 expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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))16493 expo-file-system: 56.0.8(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
16267 expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16494 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16268 expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3)16495 expo-keep-awake: 56.0.3(expo@56.0.12)(react@19.2.3)
16269 expo-modules-autolinking: 56.0.14(typescript@6.0.3)16496 expo-modules-autolinking: 56.0.16(typescript@6.0.3)
16270 expo-modules-core: 56.0.14(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)16497 expo-modules-core: 56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16271 pretty-format: 29.7.016498 pretty-format: 29.7.0
16272 react: 19.2.316499 react: 19.2.3
16273 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16500 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
16274 react-refresh: 0.14.216501 react-refresh: 0.14.2
16275 whatwg-url-minimum: 0.1.216502 whatwg-url-minimum: 0.1.2
16276 optionalDependencies:16503 optionalDependencies:
16277 '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16504 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16278 '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(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)16505 '@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16279 react-dom: 19.2.3(react@19.2.3)16506 react-dom: 19.2.3(react@19.2.3)
16280 react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16507 react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
16281 transitivePeerDependencies:16508 transitivePeerDependencies:
16282 - '@babel/core'16509 - '@babel/core'
16283 - bufferutil16510 - bufferutil
...@@ -16878,7 +17105,8 @@ snapshots:...@@ -16878,7 +17105,8 @@ snapshots:
1687817105
16879 immediate@3.0.6: {}17106 immediate@3.0.6: {}
1688017107
16881 indent-string@4.0.0: {}17108 indent-string@4.0.0:
17109 optional: true
1688217110
16883 indent-string@5.0.0: {}17111 indent-string@5.0.0: {}
1688417112
...@@ -17990,7 +18218,8 @@ snapshots:...@@ -17990,7 +18218,8 @@ snapshots:
1799018218
17991 mimic-response@3.1.0: {}18219 mimic-response@3.1.0: {}
1799218220
17993 min-indent@1.0.1: {}18221 min-indent@1.0.1:
18222 optional: true
1799418223
17995 minimatch@10.2.5:18224 minimatch@10.2.5:
17996 dependencies:18225 dependencies:
...@@ -18659,7 +18888,8 @@ snapshots:...@@ -18659,7 +18888,8 @@ snapshots:
18659 dependencies:18888 dependencies:
18660 react: 19.2.618889 react: 19.2.6
1866118890
18662 react-fast-compare@3.2.2: {}18891 react-fast-compare@3.2.2:
18892 optional: true
1866318893
18664 react-freeze@1.0.4(react@19.2.3):18894 react-freeze@1.0.4(react@19.2.3):
18665 dependencies:18895 dependencies:
...@@ -18698,102 +18928,103 @@ snapshots:...@@ -18698,102 +18928,103 @@ snapshots:
18698 transitivePeerDependencies:18928 transitivePeerDependencies:
18699 - supports-color18929 - supports-color
1870018930
18701 react-native-drawer-layout@4.2.4(0e9729601f58a7a7ae26c76fe6017455):18931 ? react-native-drawer-layout@4.2.4(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))(@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-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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
18702 dependencies:18932 : dependencies:
18703 color: 4.2.318933 color: 4.2.3
18704 react: 19.2.318934 react: 19.2.3
18705 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18935 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
18706 react-native-gesture-handler: 2.31.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)18936 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
18707 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)18937 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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
18708 use-latest-callback: 0.2.6(react@19.2.3)18938 use-latest-callback: 0.2.6(react@19.2.3)
18939 optional: true
1870918940
18710 react-native-gesture-handler@2.31.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):18941 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18711 dependencies:18942 dependencies:
18712 '@egjs/hammerjs': 2.0.1718943 '@egjs/hammerjs': 2.0.17
18713 '@types/react-test-renderer': 19.1.018944 '@types/react-test-renderer': 19.1.0
18714 hoist-non-react-statics: 3.3.218945 hoist-non-react-statics: 3.3.2
18715 invariant: 2.2.418946 invariant: 2.2.4
18716 react: 19.2.318947 react: 19.2.3
18717 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18948 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1871818949
18719 react-native-image-viewing@0.2.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):18950 react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18720 dependencies:18951 dependencies:
18721 react: 19.2.318952 react: 19.2.3
18722 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18953 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1872318954
18724 react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):18955 react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18725 dependencies:18956 dependencies:
18726 react: 19.2.318957 react: 19.2.3
18727 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18958 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1872818959
18729 react-native-keyboard-controller@1.21.7(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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):18960 react-native-keyboard-controller@1.21.6(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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18730 dependencies:18961 dependencies:
18731 react: 19.2.318962 react: 19.2.3
18732 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18963 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
18733 react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18964 react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
18734 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)18965 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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1873518966
18736 react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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-svg@15.15.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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):18967 react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18737 dependencies:18968 dependencies:
18738 react: 19.2.318969 react: 19.2.3
18739 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18970 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
18740 react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18971 react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
18741 optionalDependencies:18972 optionalDependencies:
18742 react-native-svg: 15.15.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)18973 react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1874318974
18744 react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):18975 react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18745 dependencies:18976 dependencies:
18746 react: 19.2.318977 react: 19.2.3
18747 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18978 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1874818979
18749 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):18980 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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18750 dependencies:18981 dependencies:
18751 react: 19.2.318982 react: 19.2.3
18752 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18983 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
18753 react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18984 react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
18754 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)18985 react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
18755 semver: 7.8.518986 semver: 7.8.5
1875618987
18757 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):18988 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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18758 dependencies:18989 dependencies:
18759 react: 19.2.318990 react: 19.2.3
18760 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18991 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1876118992
18762 react-native-screens@4.25.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):18993 react-native-screens@4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18763 dependencies:18994 dependencies:
18764 react: 19.2.318995 react: 19.2.3
18765 react-freeze: 1.0.4(react@19.2.3)18996 react-freeze: 1.0.4(react@19.2.3)
18766 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)18997 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
18767 warn-once: 0.1.118998 warn-once: 0.1.1
1876818999
18769 react-native-shiki-engine@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):19000 react-native-shiki-engine@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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18770 dependencies:19001 dependencies:
18771 '@shikijs/types': 4.3.019002 '@shikijs/types': 4.3.0
18772 '@shikijs/vscode-textmate': 10.0.219003 '@shikijs/vscode-textmate': 10.0.2
18773 react: 19.2.319004 react: 19.2.3
18774 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19005 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1877519006
18776 react-native-svg@15.15.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):19007 react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18777 dependencies:19008 dependencies:
18778 css-select: 5.2.219009 css-select: 5.2.2
18779 css-tree: 1.1.319010 css-tree: 1.1.3
18780 react: 19.2.319011 react: 19.2.3
18781 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19012 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
18782 warn-once: 0.1.119013 warn-once: 0.1.1
1878319014
18784 react-native-url-polyfill@2.0.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)):19015 react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):
18785 dependencies:19016 dependencies:
18786 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19017 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
18787 whatwg-url-without-unicode: 8.0.0-319018 whatwg-url-without-unicode: 8.0.0-3
1878819019
18789 react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):19020 react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18790 dependencies:19021 dependencies:
18791 escape-string-regexp: 4.0.019022 escape-string-regexp: 4.0.0
18792 invariant: 2.2.419023 invariant: 2.2.4
18793 react: 19.2.319024 react: 19.2.3
18794 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19025 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1879519026
18796 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):19027 react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18797 dependencies:19028 dependencies:
18798 '@babel/core': 7.29.719029 '@babel/core': 7.29.7
18799 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7)19030 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7)
...@@ -18805,23 +19036,23 @@ snapshots:...@@ -18805,23 +19036,23 @@ snapshots:
18805 '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7)19036 '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7)
18806 '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7)19037 '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7)
18807 '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7)19038 '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7)
18808 '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)19039 '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)
18809 convert-source-map: 2.0.019040 convert-source-map: 2.0.0
18810 react: 19.2.319041 react: 19.2.3
18811 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19042 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
18812 semver: 7.8.519043 semver: 7.8.5
18813 transitivePeerDependencies:19044 transitivePeerDependencies:
18814 - supports-color19045 - supports-color
1881519046
18816 react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):19047 react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6):
18817 dependencies:19048 dependencies:
18818 '@react-native/assets-registry': 0.85.319049 '@react-native/assets-registry': 0.85.3
18819 '@react-native/codegen': 0.85.3(@babel/core@7.29.7)19050 '@react-native/codegen': 0.85.3(@babel/core@7.29.7)
18820 '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)19051 '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)
18821 '@react-native/gradle-plugin': 0.85.319052 '@react-native/gradle-plugin': 0.85.3
18822 '@react-native/js-polyfills': 0.85.319053 '@react-native/js-polyfills': 0.85.3
18823 '@react-native/normalize-colors': 0.85.319054 '@react-native/normalize-colors': 0.85.3
18824 '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19055 '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
18825 abort-controller: 3.0.019056 abort-controller: 3.0.0
18826 anser: 1.4.1019057 anser: 1.4.10
18827 ansi-regex: 5.0.119058 ansi-regex: 5.0.1
...@@ -18920,6 +19151,7 @@ snapshots:...@@ -18920,6 +19151,7 @@ snapshots:
18920 dependencies:19151 dependencies:
18921 indent-string: 4.0.019152 indent-string: 4.0.0
18922 strip-indent: 3.0.019153 strip-indent: 3.0.0
19154 optional: true
1892319155
18924 redis-errors@1.2.0: {}19156 redis-errors@1.2.0: {}
1892519157
...@@ -19351,7 +19583,8 @@ snapshots:...@@ -19351,7 +19583,8 @@ snapshots:
19351 transitivePeerDependencies:19583 transitivePeerDependencies:
19352 - supports-color19584 - supports-color
1935319585
19354 server-only@0.0.1: {}19586 server-only@0.0.1:
19587 optional: true
1935519588
19356 setimmediate@1.0.5: {}19589 setimmediate@1.0.5: {}
1935719590
...@@ -19359,7 +19592,8 @@ snapshots:...@@ -19359,7 +19592,8 @@ snapshots:
1935919592
19360 sf-symbols-typescript@2.2.0: {}19593 sf-symbols-typescript@2.2.0: {}
1936119594
19362 shallowequal@1.1.0: {}19595 shallowequal@1.1.0:
19596 optional: true
1936319597
19364 sharp@0.34.5:19598 sharp@0.34.5:
19365 dependencies:19599 dependencies:
...@@ -19523,6 +19757,11 @@ snapshots:...@@ -19523,6 +19757,11 @@ snapshots:
1952319757
19524 standard-as-callback@2.1.0: {}19758 standard-as-callback@2.1.0: {}
1952519759
19760 standard-navigation@0.0.5:
19761 optional: true
19762
19763 standard-navigation@0.0.7: {}
19764
19526 standardwebhooks@1.0.0:19765 standardwebhooks@1.0.0:
19527 dependencies:19766 dependencies:
19528 '@stablelib/base64': 1.0.119767 '@stablelib/base64': 1.0.1
...@@ -19585,6 +19824,7 @@ snapshots:...@@ -19585,6 +19824,7 @@ snapshots:
19585 strip-indent@3.0.0:19824 strip-indent@3.0.0:
19586 dependencies:19825 dependencies:
19587 min-indent: 1.0.119826 min-indent: 1.0.1
19827 optional: true
1958819828
19589 strnum@2.3.0: {}19829 strnum@2.3.0: {}
1959019830
...@@ -19870,14 +20110,14 @@ snapshots:...@@ -19870,14 +20110,14 @@ snapshots:
1987020110
19871 universalify@2.0.1: {}20111 universalify@2.0.1: {}
1987220112
19873 uniwind@1.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)(tailwindcss@4.3.0):20113 uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0):
19874 dependencies:20114 dependencies:
19875 '@tailwindcss/node': 4.2.120115 '@tailwindcss/node': 4.2.1
19876 '@tailwindcss/oxide': 4.2.120116 '@tailwindcss/oxide': 4.2.1
19877 culori: 4.0.220117 culori: 4.0.2
19878 lightningcss: 1.30.120118 lightningcss: 1.30.1
19879 react: 19.2.320119 react: 19.2.3
19880 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)20120 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
19881 tailwindcss: 4.3.020121 tailwindcss: 4.3.0
1988220122
19883 unpipe@1.0.0: {}20123 unpipe@1.0.0: {}
pnpm-workspace.yaml+19-19
...@@ -20,13 +20,13 @@ supportedArchitectures:...@@ -20,13 +20,13 @@ supportedArchitectures:
20 - glibc20 - glibc
2121
22catalog:22catalog:
23 "@clerk/backend": 3.8.3-snapshot.v2026062223415123 "@clerk/backend": 3.8.4
24 "@clerk/clerk-js": 6.21.0-snapshot.v2026062223415124 "@clerk/clerk-js": 6.22.0
25 "@clerk/electron": 0.0.2-snapshot.v2026062223415125 "@clerk/electron": 0.0.5
26 "@clerk/electron-passkeys": 0.0.2-snapshot.v2026062223415126 "@clerk/electron-passkeys": 0.0.3
27 "@clerk/expo": 3.5.3-snapshot.v2026062223415127 "@clerk/expo": 3.6.2
28 "@clerk/react": 6.11.0-snapshot.v2026062223415128 "@clerk/react": 6.11.1
29 "@clerk/shared": 4.21.0-snapshot.v2026062223415129 "@clerk/shared": 4.22.0
30 "@effect/atom-react": 4.0.0-beta.7830 "@effect/atom-react": 4.0.0-beta.78
31 "@effect/openapi-generator": 4.0.0-beta.7831 "@effect/openapi-generator": 4.0.0-beta.78
32 "@effect/platform-bun": 4.0.0-beta.7832 "@effect/platform-bun": 4.0.0-beta.78
...@@ -59,22 +59,19 @@ onlyBuiltDependencies:...@@ -59,22 +59,19 @@ onlyBuiltDependencies:
59 - sharp59 - sharp
6060
61overrides:61overrides:
62 # Keep every Clerk consumer on the same snapshot train. Clerk publishes wallet
63 # auth integrations as required dependencies, but T3 Code does not support
64 # wallet auth, so keep that unused dependency tree out of installs.
65 "@clerk/backend": "catalog:"62 "@clerk/backend": "catalog:"
66 "@clerk/clerk-js": "catalog:"63 "@clerk/clerk-js": "catalog:"
67 "@clerk/electron": "catalog:"
68 "@clerk/electron-passkeys": "catalog:"
69 "@clerk/expo": "catalog:"
70 "@clerk/react": "catalog:"
71 "@clerk/shared": "catalog:"
72 "@clerk/clerk-js>@base-org/account": "-"64 "@clerk/clerk-js>@base-org/account": "-"
73 "@clerk/clerk-js>@coinbase/wallet-sdk": "-"65 "@clerk/clerk-js>@coinbase/wallet-sdk": "-"
74 "@clerk/clerk-js>@solana/wallet-adapter-base": "-"66 "@clerk/clerk-js>@solana/wallet-adapter-base": "-"
75 "@clerk/clerk-js>@solana/wallet-adapter-react": "-"67 "@clerk/clerk-js>@solana/wallet-adapter-react": "-"
76 "@clerk/clerk-js>@solana/wallet-standard": "-"68 "@clerk/clerk-js>@solana/wallet-standard": "-"
77 "@clerk/clerk-js>@wallet-standard/core": "-"69 "@clerk/clerk-js>@wallet-standard/core": "-"
70 "@clerk/electron": "catalog:"
71 "@clerk/electron-passkeys": "catalog:"
72 "@clerk/expo": "catalog:"
73 "@clerk/react": "catalog:"
74 "@clerk/shared": "catalog:"
78 "@effect/atom-react": "catalog:"75 "@effect/atom-react": "catalog:"
79 "@effect/platform-bun": "catalog:"76 "@effect/platform-bun": "catalog:"
80 "@effect/platform-node": "catalog:"77 "@effect/platform-node": "catalog:"
...@@ -82,10 +79,8 @@ overrides:...@@ -82,10 +79,8 @@ overrides:
82 "@effect/sql-pg": "catalog:"79 "@effect/sql-pg": "catalog:"
83 "@effect/sql-sqlite-bun": "catalog:"80 "@effect/sql-sqlite-bun": "catalog:"
84 "@effect/vitest": "catalog:"81 "@effect/vitest": "catalog:"
85 # @effect/vitest is patched to use vite-plus/test; do not auto-install its
86 # upstream Vitest peer and create a second Vite/Vitest toolchain.
87 "@effect/vitest>vitest": "-"82 "@effect/vitest>vitest": "-"
88 "@expo/metro-config": 56.0.1383 "@expo/metro-config": 56.0.14
89 "@pierre/diffs>@shikijs/transformers": ^4.2.084 "@pierre/diffs>@shikijs/transformers": ^4.2.0
90 "@types/node": "catalog:"85 "@types/node": "catalog:"
91 effect: "catalog:"86 effect: "catalog:"
...@@ -105,11 +100,16 @@ packageExtensions:...@@ -105,11 +100,16 @@ packageExtensions:
105100
106patchedDependencies:101patchedDependencies:
107 "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch102 "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch
108 "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch103 "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch
109 "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch104 "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch
110 "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch105 "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch
106 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
107 "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch
111 effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch108 effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch
109 expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch
110 react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch
112 react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch111 react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch
112 react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch
113113
114peerDependencyRules:114peerDependencyRules:
115 allowAny:115 allowAny: